feat: migrate from Node.js/PostgreSQL to Cloudflare Workers/D1#140
feat: migrate from Node.js/PostgreSQL to Cloudflare Workers/D1#140
Conversation
Migrate the entire runtime and database stack:
- Runtime: Node.js + @hono/node-server → Cloudflare Workers (wrangler dev)
- Database: PostgreSQL (pg) + drizzle-orm/node-postgres → D1 (SQLite) + drizzle-orm/d1
- WebSockets: @hono/node-ws → hono/cloudflare-workers upgradeWebSocket
- Static assets: @hono/node-server/serve-static → Wrangler [assets] config
- Environment: process.env/dotenv → c.env bindings (.dev.vars + wrangler.toml [vars])
- Session: Lazy middleware wrapper to resolve SESSION_SECRET from c.env at request time
- Auth: Google OAuth credentials read from c.env via createMiddleware wrapper
- Schema: pgTable → sqliteTable, serial → integer().primaryKey({autoIncrement}), etc.
- DB access: Global db singleton → getDb(c.env.DB) factory per request
Removed: docker-compose.yml, PostgreSQL migrations, pg/dotenv/@hono/node-server deps
Added: wrangler.toml, .dev.vars, SQLite migration, seed.sql, wrangler/@cloudflare/workers-types deps
There was a problem hiding this comment.
Pull request overview
Migrate the app runtime from Node.js to Cloudflare Workers (Wrangler) and the database from Postgres to Cloudflare D1 (SQLite), updating routing, WebSockets, and local dev/test tooling accordingly.
Changes:
- Add Workers/D1 configuration (
wrangler.toml) and switch Drizzle todrizzle-orm/d1with SQLite schema + new migration/seed. - Update server entrypoint, WebSocket route, and app/session initialization patterns for Workers
c.envbindings. - Revise build/dev scripts and test mocks to accommodate Workers bindings and asset serving via
public/.
Reviewed changes
Copilot reviewed 43 out of 46 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| wrangler.toml | New Workers + assets + D1 binding config |
| vitest.setup.ts | Remove Postgres/OAuth env setup; keep NODE_ENV |
| tsconfig.prod.json | Add Workers types for prod compilation |
| tsconfig.json | Add Workers types for dev/test typing |
| pnpm-lock.yaml | Dependency graph updates for Wrangler/Workers types |
| playwright.config.ts | Run e2e against wrangler dev |
| package.json | Switch scripts to wrangler + D1 migration/seed + asset build |
| hono/types.ts | Add Workers Bindings; adjust createdAt type |
| hono/testApp.ts | Test app now stubs c.env bindings (DB/NODE_ENV/secret) |
| hono/static/ChatPage.ts | Add JSON typings for API/WS payload parsing |
| hono/routes/ws.ts | Use D1-backed getDb(c.env.DB) and adjust WS handling |
| hono/routes/ws.test.ts | Mock getDb and add env.DB to WS context |
| hono/routes/testAuth.ts | Use c.env instead of process.env for dev-only test auth |
| hono/routes/messages.ts | Use per-request D1 drizzle instance |
| hono/routes/messages.test.ts | Mock getDb; update createdAt to string |
| hono/routes/index.tsx | Use D1 drizzle instance for auto-join logic |
| hono/routes/index.test.tsx | Update Hono env typing to include Bindings |
| hono/routes/emailAuth.ts | Use D1 drizzle instance for user lookup/create |
| hono/routes/emailAuth.test.ts | Mock getDb instead of singleton db |
| hono/routes/channels.ts | Use D1 drizzle instance across channel handlers |
| hono/routes/channels.test.ts | Mock getDb; update createdAt to string + typed JSON |
| hono/routes/auth.ts | Create Google OAuth middleware using c.env bindings |
| hono/index.ts | Workers entrypoint: export default app |
| hono/db/seed.sql | New seed for general channel |
| hono/db/schema.ts | Convert schema to sqliteTable + SQLite defaults/indexes |
| hono/db/migrations/meta/_journal.json | Switch migrations metadata to sqlite + new baseline |
| hono/db/migrations/meta/0000_snapshot.json | New sqlite snapshot for schema |
| hono/db/migrations/0000_certain_omega_red.sql | New single sqlite migration |
| hono/db/migrations/0004_spotty_christian_walker.sql | Remove old Postgres migration |
| hono/db/migrations/0003_narrow_maggott.sql | Remove old Postgres migration |
| hono/db/migrations/0002_bent_maria_hill.sql | Remove old Postgres migration |
| hono/db/migrations/0001_nosy_rawhide_kid.sql | Remove old Postgres migration |
| hono/db/migrations/0000_oval_moon_knight.sql | Remove old Postgres migration |
| hono/db/migrations/meta/0004_snapshot.json | Remove old Postgres snapshot |
| hono/db/migrations/meta/0003_snapshot.json | Remove old Postgres snapshot |
| hono/db/migrations/meta/0002_snapshot.json | Remove old Postgres snapshot |
| hono/db/migrations/meta/0001_snapshot.json | Remove old Postgres snapshot |
| hono/db/index.ts | Replace Postgres pool singleton with D1 getDb() |
| hono/db/config.ts | Remove Postgres connection config |
| drizzle.config.ts | Switch Drizzle Kit dialect to sqlite |
| docker-entrypoint-initdb.d/create-test-db.sql | Remove Postgres test DB init |
| docker-compose.yml | Remove Postgres container setup |
| .gitignore | Ignore public/, .dev.vars, .wrangler/ |
| .env.sample | Update instructions for .dev.vars + Workers env names |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
| onMessage: async (evt: MessageEvent<WSMessageReceive>, ws: WSContext) => { | ||
| if (!user) { | ||
| return; | ||
| } | ||
|
|
||
| if (!clients.has(ws)) { | ||
| clients.set(ws, { userEmail: user.email }); | ||
| } |
There was a problem hiding this comment.
If onOpen doesn't reliably run on Cloudflare Workers, an unauthenticated client will never be closed: onMessage just returns when !user, leaving the socket open. Close the socket with an auth error (e.g. 1008) when user is missing, and consider doing client registration/auth checks in the same place to avoid relying on onOpen.
| "dev": "pnpm run build:assets && wrangler dev", | ||
| "build": "rm -rf dist public && pnpm run build:client && pnpm run build:assets && tsc --project tsconfig.prod.json", | ||
| "build:client": "tsc --project tsconfig.client.json", | ||
| "build:client:watch": "tsc --project tsconfig.client.json --watch", | ||
| "build:assets": "mkdir -p public/components public/static && cp hono/components/*.css public/components/ && cp hono/static/*.js hono/static/*.js.map public/static/", |
There was a problem hiding this comment.
pnpm dev runs build:assets without running build:client, but build:assets copies hono/static/*.js and *.js.map which won't exist on a clean checkout (and cp will fail when globs don't match). Update dev to build/watch the client first (e.g. run build:client or build:client:watch alongside wrangler dev) and/or make build:assets tolerant of missing globs.
| [vars] | ||
| NODE_ENV = "development" |
There was a problem hiding this comment.
[vars] NODE_ENV = "development" in wrangler.toml applies to all environments unless overridden, which would make production run with NODE_ENV=development and unintentionally enable dev-only routes (e.g. /test/login, /debug/session). Move this into an env-specific section (e.g. [env.development.vars]) and ensure production sets NODE_ENV = "production" (or remove it entirely and rely on explicit env config).
| encryptionKey: c.env.SESSION_SECRET || "your-super-secret-key-change-in-production", | ||
| expireAfterSeconds: 3600, | ||
| cookieOptions: { | ||
| httpOnly: true, | ||
| secure: process.env.NODE_ENV === "production", | ||
| secure: c.env.NODE_ENV === "production", | ||
| sameSite: "lax", |
There was a problem hiding this comment.
SESSION_SECRET falls back to a hard-coded default encryption key. If this ever runs outside local dev/tests (or if secrets are misconfigured), sessions become forgeable. Prefer failing fast when c.env.SESSION_SECRET is missing (at least when NODE_ENV === "production") rather than using a default.
See below for a potential fix:
const isProduction = c.env.NODE_ENV === "production";
const sessionSecret = c.env.SESSION_SECRET;
if (!sessionSecret && isProduction) {
throw new Error("SESSION_SECRET must be set in production");
}
let encryptionKey = sessionSecret;
if (!encryptionKey) {
const randomBytes = new Uint8Array(32);
crypto.getRandomValues(randomBytes);
encryptionKey = btoa(String.fromCharCode(...randomBytes));
}
const store = new CookieStore();
cached = sessionMiddleware({
store,
encryptionKey,
expireAfterSeconds: 3600,
cookieOptions: {
httpOnly: true,
secure: isProduction,
| app.route("/", index); | ||
| app.route("/", createWsRoute(upgradeWebSocket, clients)); | ||
| app.route("/", testAuth); |
There was a problem hiding this comment.
testAuth is now always registered. With the current wrangler.toml setting NODE_ENV="development", this would expose the E2E login/logout endpoints in production. Consider registering testAuth only when the request env indicates development (e.g. mount a guard middleware for /test/* that returns 404 unless c.env.NODE_ENV === "development") and ensure production vars cannot accidentally enable it.
) * Initial plan * fix: pass E2E_GMAIL_ACCOUNT as wrangler var to fix e2e tests in CI Agent-Logs-Url: https://github.com/mahata/mlack/sessions/18390311-cf2c-400d-aaca-02ae58821737 Co-authored-by: mahata <23497+mahata@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mahata <23497+mahata@users.noreply.github.com>
) * Initial plan * fix: add db seed step in CI and fix WebSocket close handshake Agent-Logs-Url: https://github.com/mahata/mlack/sessions/f1a795bc-88ea-44a2-b7ee-cec0e9b1ba1e Co-authored-by: mahata <23497+mahata@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mahata <23497+mahata@users.noreply.github.com>
Summary
Migrate the entire runtime and database stack from Node.js + PostgreSQL to Cloudflare Workers + D1 (SQLite).
@hono/node-server) → Cloudflare Workers (wrangler dev)pg+drizzle-orm/node-postgres) → D1 SQLite (drizzle-orm/d1)@hono/node-ws→hono/cloudflare-workersupgradeWebSocket@hono/node-server/serve-static→ Wrangler[assets]config withpublic/directoryprocess.env/dotenv→c.envbindings (.dev.varsfor secrets,wrangler.toml[vars]for non-secrets)Key Changes
Database (Phase 1)
pgTable,serial,text,timestamp) to SQLite types (sqliteTable,integer().primaryKey({autoIncrement}),text,integerfor timestamps)dbsingleton replaced withgetDb(c.env.DB)factory that creates a drizzle instance per-request from the D1 binding0000_certain_omega_red.sql)seed.sqlfor seeding the#generalchanneldocker-compose.ymland PostgreSQL connection configRuntime (Phase 2)
hono/index.ts) simplified toexport default app(Workers convention)hono-sessionsresolves the encryption key at creation time, but Workers only providec.envat request timec.envviacreateMiddlewarewrapper (avoids module-levelprocess.envreads)onOpennever fires on CF Workers — client registration moved to lazy init inonMessagenodejs_compatflag enablesnode:crypto(scrypt,randomBytes,timingSafeEqual) — no changes needed in password hashingDependencies
Removed:
@hono/node-server,@hono/node-ws,ws,@types/ws,dotenv,pg,@types/pgAdded:
wrangler,@cloudflare/workers-typesConfiguration
wrangler.toml: Worker main, assets config, D1 database binding, compatibility flags.dev.vars: Local dev secrets (gitignored)tsconfig.json/tsconfig.prod.json: Added@cloudflare/workers-typesplaywright.config.ts: Updated webServer command forwrangler devKnown Issues
Error auto-joining #generalappears because the mock D1 binding doesn't implementprepare(). Tests still pass — the auto-join is wrapped in try/catch.Verification
All 3 CI gates pass:
tsc --noEmit), client + assets + server all build successfullywrangler dev— login, chat, WebSocket messaging, channel switching all work