Nitro + WebSockets StarterCloudflare

Nitro WebSockets Starter

Live cursors, presence and reactions over one WebSocket, the room a Durable Object.

nitro-websockets

Vercel's Nitro + WebSockets Starter — a Nitro v3 + React app where everyone in the room sees each other's cursors live, with presence and emoji reactions over one WebSocket — running as one Cloudflare Worker whose room is a Durable Object, built by Nitro's own cloudflare_durable preset (in place of the Vercel Functions WebSockets beta), and packaged as an Orun baseline: one command takes a new app from nothing to live on stage and prod, in its own repository, under its own name — in ONE landing.

The template's code is the code: the React page, the wire protocol, and the one defineWebSocketHandler at /api/ws. What the port adds is the preset in vite.config.ts, the Worker's config with the Durable Object, the monorepo wrapper, a test package, the deploy contract, an /api/health route the deploy smoke-tests, and one change the room needs to survive the Durable Object hibernating (below); what it swaps is the platform's name and links in the page. What the baseline adds is everything needed to make a copy of it somebody else's — built the way cirrus-baseline is. See specs/epics/nitro-websockets for the design, the milestones and the as-built status.

The example is MIT-licensed (vercel/examples); its licence ships with the app as apps/web/LICENSE.md.

Instantiating an app

git clone --depth 1 --branch <baseline-tag> https://github.com/sourceplane/nitro-websockets
cd nitro-websockets
orun new --blueprint repo-blueprint.yaml --out ~/apps/acme-room --run-hooks --resume \
  --set reponame=acme-room --set productname="Acme Room" \
  --set githuborg=acme-inc --set subdomain=<workers-dev-subdomain>

Or, once the platform's registry lists it, from the Orun console (Baselines → nitro-websockets → Build). Two phases — 01-product (the repository, its CI and the whole app in one pull request, deployed to stage and prod by one convergence, smoke-tested) and 02-docs (the live deployment recorded) — each landing one pull request in the new repository. BOOTSTRAP.md is the operator guide; docs/phases/ has a page per phase.

The new repository receives the app only — the Worker, its tests, its CI, its tooling, a README of its own and its operating contract — renamed throughout to the app's name, repository and Cloudflare Worker. None of this repository's machinery ships, and nothing that ships names it.

What makes it a baselineWhere
The console card (inputs, secrets, programme)blueprint.yaml
The build document (modules, phases, hooks)repo-blueprint.yaml
This tree's identity, and the bootstrap's task contracts.baseline/
The rename enginetooling/rebrand/rebrand.mjs
The bootstrap's helpers (deferrals, redeploy marker, work-tree strip)tooling/bootstrap/
The deployment-record rendererhooks/render-deployment-docs.sh
The app's README and context pack.baseline/product/README.md, ai/context/
The baseline's tests: contract, leak, offline bootstrap, end to endtesting/, .github/workflows/baseline.yml
Cutting a baseline-vN tag.github/workflows/tag.yml

What is here (the app)

SurfaceWhere
The page — live cursors, presence bar, reactions — the template's React appapps/web/app/
/api/ws — the room: one WebSocket handler, pub/sub over one room topicapps/web/server/api/ws.ts
Each connection's identity, kept with its socket so it survives hibernationapps/web/server/utils/presence.ts
The wire protocol the page and the room shareapps/web/shared/types/realtime.ts
GET /api/health — the site's name and the environment it runs asapps/web/server/api/health.ts
A missing file is a 404, not the pageapps/web/server/middleware/missing-file.ts
The site's name (the rename's seam)apps/web/shared/site.ts
Nitro's config — the template's, plus the cloudflare_durable presetapps/web/vite.config.ts
Worker config (name, compatibility, the Durable Object and its migration, the build output as the Worker and its Static Assets, per-environment vars)apps/web/wrangler.jsonc
Deploy contractapps/web/component.yaml (cloudflare-worker-turbo)
Teststests/web (vitest: the room driven through crossws's own Cloudflare adapter over a stand-in Durable Object, hibernation included; the name seam; the page's text)
The example's licence (MIT)apps/web/LICENSE.md

Getting started

pnpm install
pnpm typecheck
pnpm lint
pnpm test
pnpm build            # vite build → apps/web/.output (the Worker and the page's assets)

Run it with Vite's dev server (Node), or the built Worker under workerd with its Durable Object:

pnpm --filter @app/web dev            # vite dev, http://localhost:3000
pnpm --filter @app/web preview        # wrangler dev over the build, http://localhost:8787
curl http://localhost:8787/api/health # {"status":"ok","name":"Nitro WebSockets Starter","environment":"local"}

Open the page in two tabs and move the cursor in one.

How the room runs on Cloudflare

Every WebSocket upgrade to /api/ws goes to ONE Durable Object, the room (Nitro's cloudflare_durable preset names it $DurableObject, instance server). crossws, Nitro's WebSocket layer, accepts each connection there as a hibernatable WebSocket, so peer.publish reaches every socket in the room, and a quiet room costs nothing while its sockets stay open: the runtime evicts the object and wakes it for the next message.

A wake keeps the sockets and nothing else: module memory and each peer's context are gone. The template kept its roster in a module Map and each peer's identity in peer.context, so after a wake a cursor was dropped (if (!identity) return) and a leave was never announced — measured under wrangler dev, which hibernates an idle room after about a minute. The port keeps the handler and changes where the state lives: each identity is written into its socket's serialized attachment beside crossws's own (peer id, URL, topics), and the roster is read from the room's live sockets. tests/web drives the room through crossws's adapter across a simulated wake.

How it maps to Vercel

VercelHere
Vercel Functions WebSockets beta: the Vercel preset bridges the socket upgrade (crossws/adapters/vercel)Nitro's cloudflare_durable preset: the Worker sends every upgrade to one Durable Object, where crossws's Cloudflare adapter accepts it as a hibernatable WebSocket (vite.config.ts, wrangler.jsonc)
The room's state in the function's memory (a roster Map, peer.context)Each identity in its socket's attachment, the roster read from the live sockets — they survive the Durable Object hibernating, where module memory does not (server/utils/presence.ts)
The SPA and its assets on Vercel's CDNWorkers Static Assets over .output/public; the Worker answers every other page path with the app, and a missing file with a 404
"Deploy with Vercel" and the Vercel WebSockets docs in the pageA link to Cloudflare's WebSockets-on-Durable-Objects guide; the footer still credits the template (vercel/examples)
vercel dev · vercel build · vercel deployvite dev (or wrangler dev over the build) · vite build · wrangler deploy --env <stage|prod>
Deploy on pushOrun deploys <repo>-web-{stage,prod} on merge to main and smoke-tests /api/health and the WebSocket handshake (apps/web/component.yaml)
"Deploy with Vercel" (clone the template)orun new --blueprint repo-blueprint.yaml — a new repository, branded, deployed

CI

WorkflowWhat it runsShips to an app
ci.ymlthe Orun pipeline, and the only workflow an app carries: orun plan --changed, then one job per lane. A pull request runs the verify lanes (the Worker built by Vite and Nitro, type-checked and dry-deployed per environment; the tests' quick-check lane installs and checks the package, it does not run them), except the bootstrap's own landings (orun/BASE-*), where plan alone is the check; a merge deploys stage → prod. It plans nothing while intent.yaml names the placeholder workspace ws_CHANGE_ME, as this repository does. A manual run (Orunbase's Deploy button, or Run workflow on main) plans the environment and components it names under the push-to-main trigger, so they deploy as a merge deploys themyes
baseline.ymlthe baseline's own tiers: contract tests, the blueprint read by the pinned orun, the leak gate, the offline bootstrap, the app built, dry-deployed, served and read route by route, and real WebSocket clients in the roomno
tag.ymlcuts a baseline-vN tag (manual)no