Skip to content

ajmeese7/347am

Repository files navigation

3:47 AM

Between 3 and 5 in the morning, local time, anyone can leave a thought here. In the day, the door is locked.

A small site that only fully exists during the liminal hours of the night. The clock is always ticking; the door only opens for a window.

The original design prototype lives in my design-experiments repository.


Stack

  • Frontend — React 18 + Vite, static SPA, no client-side routing
  • BackendHono, runs on Cloudflare Workers and Node.js with the same route code
  • Database — Cloudflare D1 in production, SQLite (better-sqlite3) for self-hosting
  • Realtime — Server-Sent Events for new posts + the live "awake" counter

The runtime split is at the edges only: src/worker.ts wires up D1, src/node.ts wires up SQLite, and both delegate to src/server/app.ts which is the single source of truth for routes.


Local development

npm install
npm run dev

This starts:

  • Vite on http://localhost:5173 (the frontend, with /api/* proxied)
  • Hono on Node on http://localhost:8787 (the API + a SQLite file at ./data.db)

Open http://localhost:5173. The door is only open between 3:00 and 5:00 local time — to test posting outside that window, change your system clock or temporarily edit OPEN_HOUR / CLOSE_HOUR in src/server/lib/timegate.ts.

Environment variables (Node)

Variable Default Purpose
PORT 8787 Port to listen on
DB_PATH ./data.db SQLite file path
ADMIN_SECRET (unset) Required to call DELETE /api/admin/thoughts/:id
SHOW_PAST_THOUGHTS true When false, the locked state shows nothing (fully ephemeral)
SERVE_STATIC true When false, Node only serves /api/* (use with vite dev)

Production build (single Node process)

npm run build      # builds client -> dist/, server -> dist-server/
ADMIN_SECRET=... node dist-server/node.js

The Node entry serves both the API and the built dist/ SPA from one port.


Deploying to Cloudflare Workers + D1

  1. Create the D1 database and copy the returned database_id into wrangler.toml:

    npx wrangler d1 create 347am
  2. Apply the schema:

    npm run db:init:remote
  3. Deploy the Worker and static assets:

    npm run deploy
  4. Set the admin secret (the Worker now exists, so this attaches cleanly):

    npx wrangler secret put ADMIN_SECRET

wrangler.toml configures the Worker to serve dist/ as static assets and route /api/* through Hono with the D1 binding.


API

Method Path Notes
GET /api/state?offset=<minutes> Bootstrap payload — open/locked, tonight's thoughts, awake count
GET /api/thoughts?date=YYYY-MM-DD Thoughts for a given night
POST /api/thoughts Body: {content, sessionId, utcOffsetMinutes, latitude?}
GET /api/stream SSE — events awake, post, remove
DELETE /api/admin/thoughts/:id Header Authorization: Bearer <ADMIN_SECRET>

The server validates the time gate using utcOffsetMinutes from the client — posts outside 3:00–5:00 local return 403 door is locked. One post per session per 5 minutes.

The latitude attribution is constrained to the form 41° N / 34° S and is optional — the client rounds the user's geolocation to the nearest integer degree (or sends null if they decline).


Project structure

src/
├── client/                 # Vite + React SPA
│   ├── index.html
│   ├── main.tsx
│   ├── App.tsx
│   ├── components/         # Clock, StatusBanner, Feed, Compose, Starfield
│   └── lib/                # api.ts, geo.ts, session.ts
├── server/                 # Hono app — runs anywhere
│   ├── app.ts              # routes
│   ├── db/
│   │   ├── interface.ts    # Database interface
│   │   ├── d1.ts           # Cloudflare D1 implementation
│   │   └── sqlite.ts       # better-sqlite3 implementation
│   └── lib/
│       ├── timegate.ts     # 3-5 AM window math
│       └── broadcast.ts    # In-process SSE pub/sub
├── worker.ts               # Cloudflare Workers entry (binds D1)
└── node.ts                 # Node.js entry (binds SQLite)

Notes

  • The "awake" counter is per-isolate. On Cloudflare a Worker can run in multiple isolates concurrently, so the displayed count is a lower bound on the true global count. That's intentional — it's a vibe, not a metric.
  • Posts are stored permanently. Set SHOW_PAST_THOUGHTS=false to make the experience fully ephemeral on the locked screen.
  • No accounts, no cookies. Sessions are a random ID stored in localStorage, used only for rate limiting.

About

A small site that only fully exists during the liminal hours of the night. The clock is always ticking; the door only opens for a window.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors