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.
- Frontend — React 18 + Vite, static SPA, no client-side routing
- Backend — Hono, 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.
npm install
npm run devThis 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.
| 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) |
npm run build # builds client -> dist/, server -> dist-server/
ADMIN_SECRET=... node dist-server/node.jsThe Node entry serves both the API and the built dist/ SPA from one port.
-
Create the D1 database and copy the returned
database_idintowrangler.toml:npx wrangler d1 create 347am
-
Apply the schema:
npm run db:init:remote
-
Deploy the Worker and static assets:
npm run deploy
-
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.
| 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).
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)
- 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=falseto 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.