Repository navigation
feat: ✨ WebSockets on Cloudflare Workers, Bun and Docker deploy targets, health checks, apps/web on Workers - #919
Conversation
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
React Doctor found no new issues. 🎉 Reviewed by React Doctor for commit |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 23d31506d5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const frameBytes = (data: ArrayBuffer | string): number => | ||
| typeof data === "string" ? data.length * 3 : data.byteLength; |
There was a problem hiding this comment.
Measure the actual UTF-8 size of text frames
On Cloudflare, valid Content Engine updates larger than roughly 1 MiB disconnect the editor even though CONTENT_LIVE_MAX_UPDATE_BYTES permits 2 MiB. Base64 updates are ASCII, so multiplying the JSON string length by three substantially overcounts their size: a 1.5 MiB update produces approximately 2 MiB of JSON but is counted as 6 MiB, exceeding the hub's 4 MiB frame limit and closing the connection with code 1009 before the live handler receives it. This also prevents large document seeds from syncing. Measure the actual UTF-8 byte length instead so updates accepted by the live protocol can reach the handler.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8b755e0f71
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| function HomeContent() { | ||
| const t = useTranslations("app.home"); | ||
| const { data } = useSessionQuery(); | ||
| const user = data?.user ?? null; | ||
| const steps = user ? sharedSteps : [registerStep, ...sharedSteps]; |
There was a problem hiding this comment.
Move the home screen out of the route module
The new Cloudflare home route defines its screen, session query, namespace list and card components directly in the route file. This violates AGENTS.md's explicit topology-only boundary for routes and creates another app-local implementation instead of using the prescribed shared TanStack modules. Extract the screen and its data/namespace wiring into @vitnode/core/tanstack/*, leaving the route declaration as topology.
AGENTS.md reference: AGENTS.md:L31-L31
Useful? React with 👍 / 👎.
| bind(() => { | ||
| void persist(entry); | ||
| }), |
There was a problem hiding this comment.
Return the persistence promise from the bound timer
On Cloudflare, if a document save takes longer than two seconds without other hub activity, this callback returns undefined immediately, so the hub's promise-aware bind implementation calls #end() before persistence finishes. The idle timer then disposes the scoped database with $client.end({ timeout: 0 }), aborting the pending save and leaving the edit unpersisted until another save is triggered. Return the persistence promise so the hub stays busy until it settles; the bound live and refresh sweeper callbacks also discard their asynchronous work and need the same treatment.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c602d9944c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if ( | ||
| !hasRealtimeHostCommand(command.name) && | ||
| this.#registry.connectionCount() === 0 | ||
| ) { | ||
| return null; |
There was a problem hiding this comment.
Load host-command handlers after a cold hibernation wake
On Cloudflare, when a hibernated hub wakes in a fresh isolate with existing sockets, #restore() makes connectionCount() nonzero, but the app bundle and its host-command registrations have not been loaded. This guard therefore falls through to runRealtimeHostCommand(), which throws an unknown-command error; only upgrades and resumed socket messages call #api() to warm the app. If an HTTP request deletes a file or discards a draft before a socket sends another message, the rejected RPC prevents the corresponding listener in content/server/live/socket.ts from reaching patchStoredContentDocuments() or deleteRecord(). Warm the handlers before dispatching, or safely return an empty result when the cold hub has no loaded documents.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6489aafabb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (results.length > 0 && results.every(result => Array.isArray(result))) { | ||
| return results.flat(); |
There was a problem hiding this comment.
Ignore empty-shard results when merging host-command arrays
With Cloudflare sharding enabled, a cold hub with no sockets returns null from #runCommand, while hubs handling the file-deleted command return arrays of patched document rooms. A single null makes this array check fail, so results such as [[], ["room-b"], null] become [], silently dropping the active shard's rooms. The file-deletion listener in content/server/live/socket.ts consequently fails to skip those loaded documents when calling patchStoredContentDocuments, allowing its stale stored-state write to race the hub's document persistence and overwrite newer persisted edits. Filter out null results before concatenating array responses.
Useful? React with 👍 / 👎.
…lth checks
- ws: instance-owned registry and a realtime runtime (ALS scope) behind the
unchanged `wsRegistry`/`realtime` facades; shared handshake lifecycle.
- Cloudflare: one Durable Object realtime hub per installation
(`@vitnode/core/cloudflare/{hub,edge,database}`), Hibernation API with
SQLite room storage, optional sharding (`VITNODE_REALTIME_SHARDS`),
`scopedDatabase`, `runWithVitNodeCloudflare`, and
`vitnode({ webSocket: { transport: "cloudflare" } })`.
- create-vitnode-app: "Where to deploy?" (Node.js, Bun, Cloudflare Workers),
a Cloudflare overlay, a Bun target (Nitro `bun` preset, Hono on
`Bun.serve`), `vitnode.runtime` in package.json, and a production Docker
image (`Dockerfile`, `.dockerignore`, `docker-compose.prod.yml`).
- Starter template: typed app messages and `HrefLink` (23 type errors and
the lint OOM fixed); root route declares `loader` before `head`. The API
template's runtime imports (`@hono/node-server`, `dotenv`,
`lucide-react`) are dependencies.
- Core: `GET /api/health` and `GET /api/health/ready`.
- CLI: `vitnode start` runs `bun`-preset builds on Bun; the runtime follows
`vitnode.runtime`; the build hint names `preview:cloudflare`.
- apps/web: `pnpm build:cloudflare` deploys it as one Worker (bundled and
minified to 6.6 MiB gzipped); `apps/cloudflare` fixture removed in favour
of `scripts/cloudflare-smoke`.
- Wrangler runs from the app folder, so `.dev.vars` loads.
- `workerd`'s install script is declared in `allowBuilds`.
- Docs: Cloudflare WebSockets, Deployments > Cloud > Cloudflare Workers,
Docker, Bun and Health checks pages, plus setup, CLI and contribution
updates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnAiFZHQw1SaeJbt11brzs
200d8ad to
f0cab66
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f0cab66a81
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| VITNODE_WEB_URL: \${VITNODE_WEB_URL:?Set VITNODE_WEB_URL to the site's public origin} | ||
| CRON_SECRET: \${CRON_SECRET:?Set CRON_SECRET to a long random string} |
There was a problem hiding this comment.
Pass the public API origin to the API container
In the generated split-app production stack, VITNODE_API_URL reaches the web build and web container, but the API container receives only *server-env, which omits it. Consequently, CONFIG.api falls back to http://localhost:3000 inside the API even when .env.production contains the correct public origin. This produces unusable absolute file URLs, including private-file download links in api/modules/users/files/routes/list.route.ts and AdminCP links from content/access/server/files.ts. Include the API origin in the API container's runtime environment; setting it as a web build argument does not configure the separate API runtime.
Useful? React with 👍 / 👎.
| volumes: | ||
| - uploads:${uploadsPath(layout)} |
There was a problem hiding this comment.
Persist private local uploads outside the API container
For generated standalone or split API deployments using LocalStorageAdapter, this volume preserves only public/uploads. The adapter writes protected attachments to process.cwd()/storage/private instead (api/adapters/storage/local.ts, lines 41–43 and 92–95), leaving those files in the API container's writable layer. Recreating the container during a deployment therefore permanently deletes private attachments while their database records survive. Add a persistent, writable mount for the private storage directory as well, keeping it separate from publicly served uploads.
Useful? React with 👍 / 👎.
Description
What?
/api/wsnow works on Cloudflare Workers. A Durable Object per installation (the realtime hub) owns every socket. It hibernates when idle and can be split across shards.create-vitnode-appcan generate such an app (--deploy cloudflare), a Bun app (--deploy bun), and a production Docker image (--docker). Every API now answers/api/healthand/api/health/ready.apps/webitself can now deploy to Cloudflare Workers (pnpm build:cloudflare). Node/Bun keep local + Redis delivery, and plugins need no changes.Commits (based on canary
6f1791c1):createWebSocketRegistry()owns all connection, user, device, room and close-listener state.c.get("realtime"), therealtimeimport,wsRegistryand the Content Engine live/refresh servers.wsRegistryandrealtimekeep their API as facades. The Redis wire format is unchanged.@vitnode/core/cloudflare/hub: accepts upgrades the Worker forwards, runs the handshake through the real Hono chain and gives each event a fresh scope.@vitnode/core/cloudflare/edge: Worker-side runtime that sends typed, validated, bounded commands to the hub over DO RPC, kept alive withwaitUntil.runWithVitNodeCloudflare()scopes background work.@vitnode/core/cloudflare/database:scopedDatabase()gives a client per request, or per busy period of a hub.vitnode({ webSocket: { transport: "cloudflare" } }): Nitro middleware plus the hub class exported viaserverEntry.truekeeps crossws.ws/handshake.ts: the handshake and connection lifecycle shared by crossws and the hub.websocket/cloudflare.mdx(split in commit 10); setup, rooms, reconnects and overview updated.VITNODE_REALTIME_HIBERNATION=falseopts out):scopedDatabaseclients are released ~2 s after the hub goes idle.VITNODE_REALTIME_SHARDS, 1–32):--deployflag.copy-of-vitnode-app/cloudflareoverlay:wrangler.jsoncwith the hub binding and SQLite migration;.dev.vars.example;scopedDatabaseAPI config;preview/check:cloudflare/deploy:cloudflarescripts andwrangler.vitnode buildpoints a Worker build at its preview command.apps/cloudflarefixture is removed. Its smoke script and demo routes live inscripts/cloudflare-smoke/, with instructions to run them against a generated app.bun --bun vitnode dev|buildruns Vite/TanStack Start on Bun, and Nitro'sbunpreset produces the server.Bun.serveviaexport default { fetch, websocket }, with/api/wsthroughhono/bun'supgradeWebSocket. There is no build step, and@hono/node-serveris dropped in favour of@types/bun."vitnode": { "runtime": "bun" | "node" }. The CLI's runtime detection reads it first, then falls back to the package manager or lockfile.vitnode startruns abun-preset build on Bun; other platform presets are still refused.global.d.tsnow types the app's own messages next to core's.HrefLink.oxlintno longer runs out of memory; the OOM came from the "excessively deep" types.vitnode startnotes.GET /api/health(liveness):200 {"status":"ok"}, with no I/O.GET /api/health/ready(readiness): the database answersselect 1and a configured Redis answersPING, 2 s each.degraded(200); database down →unavailable(503).Cache-Control: no-store.Dockerfile:app(Nitro bundle only),api(clean production install) andmigrate;HEALTHCHECKthat needs no curl;.dockerignore;docker-compose.prod.yml:migratejob the servers wait for (service_completed_successfully);uploadsvolume for local storage;@hono/node-server,dotenvandlucide-reactare imported at runtime, so they moved todependencies. Before this, a production install (npm ci --omit=dev, any image) could not start the API.pnpm build:cloudflare(NITRO_PRESET=cloudflare-module) builds the site, API and realtime hub as one Worker.preview:cloudflare,check:cloudflareanddeploy:cloudflarerun it with Wrangler.pnpm build/startstay Node.apps/web/wrangler.jsonc:no_bundle: false+minify: true: Nitro uploads every module it wrote, including an orphaned second app bundle with a second copy of every docs page. Bundling from the entry plus minifying brings the Worker from 13.1 MiB to 6.6 MiB gzipped, under the Workers Paid limit of 10 MiB;keep_names: false: esbuild's__namehelper, once minified, broke the inline scripts the server sends as source (theme, scroll restoration) withn is not definedon every Worker-rendered page;assets.html_handling: drop-trailing-slash: prerendered pages keep their URLs instead of 307-ing to/page/.scopedDatabase(one client per process on Node, per request on Workers);sharpcannot load;REDIS_URLstays unset there.vitnode buildnamespreview:cloudflarewhen an app has one.wrangler dev --config .output/server/wrangler.json --env-file .dev.varsloaded none of.dev.vars.--env-filefrom the config's folder and skips a missing file silently. Local runs only worked because the fallbackPOSTGRES_URLmatched the local database.--config: Nitro's.wrangler/deploy/config.jsonredirects it to the generated config, and.dev.varsloads from the app.process.envreads work on Workers. Verified: aPOSTGRES_URLpointing at a missing database → readiness 503.nowseam and an internal-only export removed;vitnode start: a one-item presetSetreplaced by a plain comparison;pnpm@latest.sharp, Redis, in-process cron, migrations);scopedDatabase, local preview with.dev.vars, secrets, migrations and deploy;websocket/cloudflarekeeps the realtime explanation: why a hub, the hub's settings, background pushes, hibernation, sharding, realtime troubleshooting (~2,000 → ~1,400 words).crossws handshake context. The installed crossws 0.4.17
handleDurableUpgrade()drops the upgradecontext, so Nitro's$DurableObjectis not used. The hub runs the handshake itself inside its own execution, using the sameupgradeWebSocketHono helper, and keeps the events in a localVitNodeConnection. Nonode_modulespatches and no private peer fields.Why?
Workers isolates share no memory, and a request cannot reach another request's sockets, so the sockets need one addressable owner per installation. With hibernation, an idle hub stops accruing duration; sharding lifts the single-thread ceiling. The CLI option puts the setup where new apps start. Bun support makes the runtime a choice at creation time, independent of the package manager. One container image covers most hosts (VPS, Railway, Fly.io, Render, Coolify, Kubernetes), and the health endpoints are what their probes and zero-downtime deploys need. Running the VitNode website on Workers exercises the Cloudflare path on a large real app.
Verification
Local workerd: Wrangler 4.153.0, workerd 1.20261023.1, real PostgreSQL 16, against an app generated by
create-vitnode-app --deploy cloudflare, linked to the local packages.scripts/cloudflare-smoke/smoke.mjspassed:VITNODE_REALTIME_SHARDS=3;Covered:
broadcast,sendToUser(via context and via import),sendToUsersandtoRoomfrom HTTP, plus room join/leave, duplicate and forbidden joins;Set-Cookiedelivered on the 101;The generated app's
check:cloudflare(wrangler dry run) passes.Throwaway harnesses also checked:
scheduledhandler delivers throughrunWithVitNodeCloudflare;apps/web on Workers (Wrangler 4.160, local workerd, PostgreSQL 16;
pnpm build:cloudflareprerendered ~790 pages; re-run after commit 9 with.dev.varsloaded — the bindings table listsPOSTGRES_URL):POSTGRES_URLat a missing database →/api/health/ready503; at the real one → 200./,/pl,/docs/dev/setup,/pl/docs/dev/setup,/plugins;/forum,/blog,/login;/docs/search,/llms.txt,/llms-full.txt(2.9 MB) and/docs/….md;/api/health/ready,/api/@vitnode/core/middleware.notifications_statethrough the hub; foreign Origin 403.check:cloudflareuses the redirected config: 6.6 MiB gzipped.pnpm build(node-server) +pnpm startpassed the health, pages, search, sign-in and WebSocket checks.apps/webtscclean.Docs (commit 10):
Bun (Bun 1.x, PostgreSQL 16, apps generated with
--deploy bunand linked to the local packages). Each run below was checked for a page 200, an API 200, a WS pong plusnotifications_statefor a signed-in user, and a foreign Origin → 403:bun --bun vitnode dev;bun --bun vitnode build(nitro.jsonpresetbun), thenvitnode start, which launchesbun .output/server/index.mjs;bun run starton a PATH with no Node at all;vitnode dev(bun --hot src/index.ts): API 200, WS pong, foreign Origin 403;vitnode start(bun src/index.ts): API 200, WS pong, foreign Origin 403;vitnode startforwards SIGTERM, and the Bun server closes cleanly.Starter template: 23 → 0
tscerrors, and type-awareoxlintpasses on a generated app.Docker (Docker 29.9, Compose v5.6). Generated projects were installed against tarballs of the local
@vitnode/core/@vitnode/config. Images were built from the generatedDockerfile, with the sandbox proxy CA added in a test-only copy, and run with the generateddocker-compose.prod.yml.In every layout:
migrateexited 0;healthy;/api/health/readyreturned 200ok.notifications_state; foreign Origin 403bun; WS checks passnpm ci --omit=devpathGenerated Dockerfiles after commit 9 are unchanged in behavior (same
USER,CMD,RUNlines).Readiness and uploads:
degraded/200, then stopping Postgres gaveunavailable/503, and restarting both gaveok. Each change was logged once.Cache-Control: no-store.uploadsvolume (app and API images).The first API image was 2.25 GB; a clean production install brought it to 1.41 GB.
Node and tests:
vite dev(crossws) passes;pnpm test:unitin core: 686 files / 10,643 tests pass (coresrc/apire-run after the health change: 125 files / 1,959 pass);scripts/cli): 311 pass (re-run after commit 9);create-vitnode-apptests: 135 pass (re-run after commit 9).Not done:
.dev.varsloaded.No credentials were available in this environment.
Limitations
wrangler dev, when the hub closes a hibernatable socket from outside that socket's own event (a revocation), the close frame arrives at once but the TCP teardown lags. Not verified on hosted Cloudflare.sharp);pnpm --filter api db:migrate).wranglerto apps/web re-keys many entries with asupports-colorpeer suffix; no versions changed..envitself: aREDIS_URLpointing at a Redis that isn't running makes WS handshakes answer 503 until Redis is up. This is documented on the Bun page.@vitnode/corepulls in UI packages anddrizzle-kit. Filtering the API's install to its own dependencies breaks it, because core importslucide-reactwhile only the web app installs it.lucide-reactis an undeclared peer of core: core imports it in shipped code but lists it only as a dev dependency. The templates install it, but declaring it inpeerDependencieswould be the proper fix (left out: it changes the published contract).migratejob generates missing ones inside the container, where they are lost on the next deploy. This is documented./wsroute..env.example.oxfmt-formatted.🤖 Generated with Claude Code
https://claude.ai/code/session_01CnAiFZHQw1SaeJbt11brzs