Skip to content

feat: ✨ WebSockets on Cloudflare Workers, Bun and Docker deploy targets, health checks, apps/web on Workers - #919

Merged
aXenDeveloper merged 1 commit into
canaryfrom
claude/vitnode-cloudflare-websockets-ju83e8
Nov 13, 2026
Merged

aXenDeveloper merged 1 commit into
canaryfrom
claude/vitnode-cloudflare-websockets-ju83e8

Conversation

@aXenDeveloper

@aXenDeveloper aXenDeveloper commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner

Description

What?

/api/ws now 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-app can generate such an app (--deploy cloudflare), a Bun app (--deploy bun), and a production Docker image (--docker). Every API now answers /api/health and /api/health/ready. apps/web itself 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):

  1. refactor(ws): instance-owned registry and realtime runtime
    • createWebSocketRegistry() owns all connection, user, device, room and close-listener state.
    • A realtime runtime (ALS scope shared across bundles) resolves c.get("realtime"), the realtime import, wsRegistry and the Content Engine live/refresh servers.
    • wsRegistry and realtime keep their API as facades. The Redis wire format is unchanged.
  2. feat(ws): Cloudflare Workers realtime hub
    • @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 with waitUntil. 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 via serverEntry. true keeps crossws.
    • ws/handshake.ts: the handshake and connection lifecycle shared by crossws and the hub.
  3. docs(ws)
    • New page websocket/cloudflare.mdx (split in commit 10); setup, rooms, reconnects and overview updated.
    • A missing binding now answers 503 on upgrade and logs on push, instead of failing every request.
  4. feat(ws): hub hibernation and sharding
    • Hibernation is on by default (VITNODE_REALTIME_HIBERNATION=false opts out):
      • Sockets are accepted with the Hibernation API, and the runtime auto-answers the client heartbeat.
      • Each socket's user and device ride in its attachment; room memberships live in the hub's SQLite storage.
      • The first message after a wake replays the handshake with only the session, device and admin cookies. If the session changed meanwhile, the socket is closed with 1008.
      • scopedDatabase clients are released ~2 s after the hub goes idle.
      • The live-editing and live-refresh sweepers stop once they have no members.
    • Sharding (VITNODE_REALTIME_SHARDS, 1–32):
      • Upgrades land on a random shard.
      • Worker pushes and host commands fan out to every shard.
      • Shards relay their own pushes, presence and host commands to their peers.
  5. feat(create-vitnode-app): ask where to deploy
    • New "Where do you want to deploy?" question and --deploy flag.
    • Cloudflare (Single App only) applies a copy-of-vitnode-app/cloudflare overlay:
      • Workers preset and the Cloudflare WebSocket transport;
      • wrangler.jsonc with the hub binding and SQLite migration;
      • .dev.vars.example;
      • a scopedDatabase API config;
      • preview / check:cloudflare / deploy:cloudflare scripts and wrangler.
    • vitnode build points a Worker build at its preview command.
    • The former apps/cloudflare fixture is removed. Its smoke script and demo routes live in scripts/cloudflare-smoke/, with instructions to run them against a generated app.
  6. feat(create-vitnode-app): Bun deploy target, fix starter template types
    • The deploy question is now asked for every mode. Bun is offered when Bun is installed, and is the default when Bun is the package manager.
      • App: bun --bun vitnode dev|build runs Vite/TanStack Start on Bun, and Nitro's bun preset produces the server.
      • Standalone API: Hono on Bun.serve via export default { fetch, websocket }, with /api/ws through hono/bun's upgradeWebSocket. There is no build step, and @hono/node-server is dropped in favour of @types/bun.
    • Generated packages declare "vitnode": { "runtime": "bun" | "node" }. The CLI's runtime detection reads it first, then falls back to the package manager or lockfile.
    • vitnode start runs a bun-preset build on Bun; other platform presets are still refused.
    • Starter template typing:
      • global.d.ts now types the app's own messages next to core's.
      • The home page links to plugin routes outside the typed route tree with HrefLink.
      • This clears the 23 type errors in the home page and footer. Type-aware oxlint no longer runs out of memory; the OOM came from the "excessively deep" types.
    • Docs: a new Deployments → Bun page, a Bun section in WebSocket setup, the deploy-question row, the runtime-detection rule and vitnode start notes.
  7. feat: production Docker image and /api/health probes
    • Core health endpoints are mounted before the rate limiter and the request context:
      • GET /api/health (liveness): 200 {"status":"ok"}, with no I/O.
      • GET /api/health/ready (readiness): the database answers select 1 and a configured Redis answers PING, 2 s each.
      • Readiness results: Redis down → degraded (200); database down → unavailable (503).
      • Concurrent probes share one check per second, so probes can't become a query flood.
      • Responses carry only the status, with Cache-Control: no-store.
      • Every status change is logged once, naming the dependency that failed.
    • Docker: answering "Docker: yes" now also writes:
      • a production Dockerfile:
        • one workspace build;
        • targets app (Nitro bundle only), api (clean production install) and migrate;
        • Node or Bun runtime images to match the deploy target;
        • non-root, with a HEALTHCHECK that needs no curl;
      • .dockerignore;
      • docker-compose.prod.yml:
        • PostgreSQL and Redis on a private network;
        • a one-off migrate job the servers wait for (service_completed_successfully);
        • an uploads volume for local storage;
        • secrets required from an env file.
      • The generated README gains a deploy section. Nothing is written for Cloudflare.
    • API template fix: @hono/node-server, dotenv and lucide-react are imported at runtime, so they moved to dependencies. Before this, a production install (npm ci --omit=dev, any image) could not start the API.
    • Docs: new Deployments → Docker and Health checks pages; setup, README and self-hosted pages updated.
  8. feat(web): deploy apps/web to Cloudflare Workers
    • Scripts:
      • pnpm build:cloudflare (NITRO_PRESET=cloudflare-module) builds the site, API and realtime hub as one Worker.
      • preview:cloudflare, check:cloudflare and deploy:cloudflare run it with Wrangler.
      • pnpm build / start stay Node.
    • apps/web/wrangler.jsonc:
      • the hub binding and its SQLite migration;
      • 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 __name helper, once minified, broke the inline scripts the server sends as source (theme, scroll restoration) with n is not defined on every Worker-rendered page;
      • assets.html_handling: drop-trailing-slash: prerendered pages keep their URLs instead of 307-ing to /page/.
    • API config:
      • scopedDatabase (one client per process on Node, per request on Workers);
      • image re-encoding is off on Workers, where sharp cannot load;
      • REDIS_URL stays unset there.
    • CLI: vitnode build names preview:cloudflare when an app has one.
    • Docs:
      • a "Shrink the Worker" section (now on the Cloudflare Workers deployment page);
      • "Deploy the website to Cloudflare" in the contribution guide.
  9. refactor: unslop deploy targets; load .dev.vars in Cloudflare previews
    • Bug fix: wrangler dev --config .output/server/wrangler.json --env-file .dev.vars loaded none of .dev.vars.
      • Wrangler resolves --env-file from the config's folder and skips a missing file silently. Local runs only worked because the fallback POSTGRES_URL matched the local database.
      • Wrangler now runs from the app's folder without --config: Nitro's .wrangler/deploy/config.json redirects it to the generated config, and .dev.vars loads from the app.
      • Applied to the apps/web and template scripts, the build hint and the docs.
    • With variables actually loaded, module-level process.env reads work on Workers. Verified: a POSTGRES_URL pointing at a missing database → readiness 503.
      • So the database URL is read once instead of re-read per client.
      • The Stripe and Supabase keys, which are read at module level, do reach the Worker.
    • Cleanup:
      • health: unused now seam and an internal-only export removed;
      • vitnode start: a one-item preset Set replaced by a plain comparison;
      • Docker generator: one-use locals inlined and an unused parameter dropped. A pnpm project whose pnpm version is unknown now fails generation instead of pinning pnpm@latest.
  10. docs: split Cloudflare deployment from the realtime hub page
    • New Deployments → Cloud → Cloudflare Workers how-to, next to Vercel:
      • a "what works, and what does not" table (local storage, sharp, Redis, in-process cron, migrations);
      • build, Wrangler config, scopedDatabase, local preview with .dev.vars, secrets, migrations and deploy;
      • shrinking the Worker, checking the result, deployment troubleshooting.
    • websocket/cloudflare keeps the realtime explanation: why a hub, the hub's settings, background pushes, hibernation, sharding, realtime troubleshooting (~2,000 → ~1,400 words).
    • Deployment links (setup, WebSocket setup, Docker, contribution guide, the generated README) point to the new page.

crossws handshake context. The installed crossws 0.4.17 handleDurableUpgrade() drops the upgrade context, so Nitro's $DurableObject is not used. The hub runs the handshake itself inside its own execution, using the same upgradeWebSocket Hono helper, and keeps the events in a local VitNodeConnection. No node_modules patches 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

Correction (commit 9): the local workerd runs below that predate commit 9 passed .dev.vars in a way Wrangler ignored.

  • They did exercise PostgreSQL, through the app's default POSTGRES_URL, which matched the local database.
  • But VITNODE_WEB_URL, CRON_SECRET and any other variable were unset in those runs.
  • After the fix, apps/web was re-verified with variables loaded (see "apps/web on Workers").
  • The smoke-script runs against the generated template were not repeated.

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.mjs passed:

  • 27/27 with one hub, including an 80 s hibernation round;
  • 24/24 with VITNODE_REALTIME_SHARDS=3;
  • the same suite against the earlier fixture: 27/27 with one hub and with three shards (both with the hibernation round), with sockets spread 2/5/5 across the shards.

Covered:

  • cold first upgrade;
  • foreign Origin → 403;
  • handshake burst → 429, including with a forged hub IP header;
  • client IP identical on HTTP and in the hub;
  • broadcast, sendToUser (via context and via import), sendToUsers and toRoom from HTTP, plus room join/leave, duplicate and forbidden joins;
  • two-editor live editing: presence, convergence, persistence;
  • HTTP draft discard resets the hub's document;
  • revocation per device and per user: no push reaches a revoked socket, which gets the 1008 close frame;
  • handshake Set-Cookie delivered on the 101;
  • reconnect and rejoin;
  • a dropped socket is cleaned up;
  • hibernation: the hub wakes with its users and rooms, the first message replays the handshake, and a socket whose session ended during sleep is closed with 1008.

The generated app's check:cloudflare (wrangler dry run) passes.

Throwaway harnesses also checked:

  • two hubs in one isolate stay isolated;
  • oversized or unknown commands are rejected;
  • a scheduled handler delivers through runWithVitNodeCloudflare;
  • a missing binding gives 503 + log while HTTP keeps working.

apps/web on Workers (Wrangler 4.160, local workerd, PostgreSQL 16; pnpm build:cloudflare prerendered ~790 pages; re-run after commit 9 with .dev.vars loaded — the bindings table lists POSTGRES_URL):

  • Variables really load: POSTGRES_URL at a missing database → /api/health/ready 503; at the real one → 200.
  • HTTP, all 200 with no redirects:
    • prerendered pages in both locales: /, /pl, /docs/dev/setup, /pl/docs/dev/setup, /plugins;
    • Worker-rendered pages: /forum, /blog, /login;
    • docs endpoints: /docs/search, /llms.txt, /llms-full.txt (2.9 MB) and /docs/….md;
    • API: /api/health/ready, /api/@vitnode/core/middleware.
  • Sign-up, sign-in and WebSocket: pong + notifications_state through the hub; foreign Origin 403.
  • Browser (Playwright), no console errors:
    • client-side docs navigation, calling the docs server functions on the Worker;
    • docs search dialog;
    • home → forum;
    • AdminCP sign-in → dashboard → Blog → Articles.
  • check:cloudflare uses the redirected config: 6.6 MiB gzipped.
  • Node build of apps/web unchanged: pnpm build (node-server) + pnpm start passed the health, pages, search, sign-in and WebSocket checks.
  • apps/web tsc clean.

Docs (commit 10):

  • every changed page compiles;
  • every internal link and anchor in the branch's docs resolves;
  • both Cloudflare pages render in the docs dev server at 1280 px and 390 px with no horizontal scroll;
  • the new page's Markdown export works.

Bun (Bun 1.x, PostgreSQL 16, apps generated with --deploy bun and linked to the local packages). Each run below was checked for a page 200, an API 200, a WS pong plus notifications_state for a signed-in user, and a foreign Origin → 403:

  • Single App with bun --bun vitnode dev;
  • Single App with bun --bun vitnode build (nitro.json preset bun), then vitnode start, which launches bun .output/server/index.mjs;
  • Single App started with bun run start on a PATH with no Node at all;
  • Only API with vitnode dev (bun --hot src/index.ts): API 200, WS pong, foreign Origin 403;
  • Only API with vitnode start (bun src/index.ts): API 200, WS pong, foreign Origin 403;
  • vitnode start forwards SIGTERM, and the Bun server closes cleanly.

Starter template: 23 → 0 tsc errors, and type-aware oxlint passes 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 generated Dockerfile, with the sandbox proxy CA added in a test-only copy, and run with the generated docker-compose.prod.yml.

In every layout:

  • migrate exited 0;
  • the servers reported Docker healthy;
  • /api/health/ready returned 200 ok.
Layout Image sizes Extra checks
Single App + Turborepo, Node, pnpm app 460 MB home 200; sign-up/in; WS pong + notifications_state; foreign Origin 403
Monorepo App, Node, pnpm web 400 MB, API 1.41 GB the web container rendered the home page by calling the API at its public URL
Single App, Bun, bun app 385 MB ran as bun; WS checks pass
Only API, Bun, pnpm API 1.14 GB no build step; WS pong; foreign Origin 403
Only API, Node, npm API 1.2 GB npm ci --omit=dev path

Generated Dockerfiles after commit 9 are unchanged in behavior (same USER, CMD, RUN lines).

Readiness and uploads:

  • Stopping Redis gave degraded/200, then stopping Postgres gave unavailable/503, and restarting both gave ok. Each change was logged once.
  • 400 rapid readiness probes all got 200, while a normal route hit 429 after ~300.
  • Responses carry Cache-Control: no-store.
  • Uploads are writable by the non-root user on the uploads volume (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:

  • the smoke script on vite dev (crossws) passes;
  • two Node instances + Redis: cross-instance delivery and revocation;
  • pnpm test:unit in core: 686 files / 10,643 tests pass (core src/api re-run after the health change: 125 files / 1,959 pass);
  • CLI tests (scripts/cli): 311 pass (re-run after commit 9);
  • create-vitnode-app tests: 135 pass (re-run after commit 9).

Not done:

  • a hosted Cloudflare deployment, of a generated app or of apps/web, and WSS with two real browsers;
  • a Docker push to a real registry or platform;
  • re-running the template smoke suite with .dev.vars loaded.

No credentials were available in this environment.

Limitations

  • Close timing (local only): in local 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.
  • What delays hibernation: live editing and live refresh keep the hub awake while used, and the in-memory rate limiter delays hibernation ~60 s after the last handshake.
  • Plugin state: per-socket in-memory state in plugin handlers does not survive a wake; rooms do.
  • Shard cost: every push costs one RPC per shard.
  • Worker size:
    • A generated Worker is about 4.4 MiB gzipped, over the Workers Free plan's 3 MiB limit; the "Shrink the Worker" settings now documented (bundle + minify) take it to about 3.1 MiB.
    • apps/web is 6.6 MiB gzipped (36 MiB raw), so it needs Workers Paid.
    • Cloudflare's startup-time limit for a script this large is only checked on a real deploy; untested here. If it trips, the next lever is keeping compiled docs bodies out of the Worker (every docs page is already prerendered), which takes the reachable docs content (~3.6 MiB gzipped) out.
  • apps/web on Workers:
    • images are stored as uploaded (no sharp);
    • cache and rate limits are per isolate (no Redis);
    • migrations run from Node (pnpm --filter api db:migrate).
  • Lockfile: adding wrangler to apps/web re-keys many entries with a supports-color peer suffix; no versions changed.
  • Bun reads .env itself: a REDIS_URL pointing at a Redis that isn't running makes WS handshakes answer 503 until Redis is up. This is documented on the Bun page.
  • API images stay large (~1.1–1.4 GB). @vitnode/core pulls in UI packages and drizzle-kit. Filtering the API's install to its own dependencies breaks it, because core imports lucide-react while only the web app installs it.
  • lucide-react is 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 in peerDependencies would be the proper fix (left out: it changes the published contract).
  • Migrations must be committed: the migrate job generates missing ones inside the container, where they are lost on the next deploy. This is documented.
  • Pre-existing, untouched:
    • The Node "Only API" template has no /ws route.
    • The API template ships no .env.example.
    • Four existing doc pages are not oxfmt-formatted.
  • File-deletion live patching uses the same host-command path as the discard reset, but was not exercised end to end.
  • Regression tests follow in a separate session, per AGENTS.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CnAiFZHQw1SaeJbt11brzs

@github-actions github-actions Bot added the 💡 Feature A new feature label Nov 11, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-13T19:11:58.696660Z f0cab66 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit f0cab66.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +55 to +56
const frameBytes = (data: ArrayBuffer | string): number =>
typeof data === "string" ? data.length * 3 : data.byteLength;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@github-actions github-actions Bot added 💡 Feature A new feature and removed 💡 Feature A new feature labels Nov 12, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +99 to +103
function HomeContent() {
const t = useTranslations("app.home");
const { data } = useSessionQuery();
const user = data?.user ?? null;
const steps = user ? sharedSteps : [registerStep, ...sharedSteps];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +209 to +211
bind(() => {
void persist(entry);
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@github-actions github-actions Bot added 💡 Feature A new feature and removed 💡 Feature A new feature labels Nov 12, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +759 to +763
if (
!hasRealtimeHostCommand(command.name) &&
this.#registry.connectionCount() === 0
) {
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@aXenDeveloper aXenDeveloper changed the title feat(ws): ✨ VitNode WebSockets on Cloudflare Workers (Durable Object hub) feat(ws): ✨ VitNode WebSockets on Cloudflare Workers (Durable Object hub) + Bun deploy target Nov 12, 2026
@github-actions github-actions Bot added 💡 Feature A new feature and removed 💡 Feature A new feature labels Nov 12, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +161 to +162
if (results.length > 0 && results.every(result => Array.isArray(result))) {
return results.flat();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@aXenDeveloper aXenDeveloper changed the title feat(ws): ✨ VitNode WebSockets on Cloudflare Workers (Durable Object hub) + Bun deploy target feat: ✨ WebSockets on Cloudflare Workers, Bun and Docker deploy targets, health checks Nov 12, 2026
@github-actions github-actions Bot added 💡 Feature A new feature and removed 💡 Feature A new feature labels Nov 12, 2026
@aXenDeveloper aXenDeveloper changed the title feat: ✨ WebSockets on Cloudflare Workers, Bun and Docker deploy targets, health checks feat: ✨ WebSockets on Cloudflare Workers, Bun and Docker deploy targets, health checks, apps/web on Workers Nov 13, 2026
@github-actions github-actions Bot added 💡 Feature A new feature and removed 💡 Feature A new feature labels Nov 13, 2026
…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
@aXenDeveloper
aXenDeveloper force-pushed the claude/vitnode-cloudflare-websockets-ju83e8 branch from 200d8ad to f0cab66 Compare November 13, 2026 19:07

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +363 to +364
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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +319 to +320
volumes:
- uploads:${uploadsPath(layout)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@aXenDeveloper
aXenDeveloper merged commit 467ac0d into canary Nov 13, 2026
7 checks passed
@aXenDeveloper
aXenDeveloper deleted the claude/vitnode-cloudflare-websockets-ju83e8 branch November 13, 2026 19:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💡 Feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants