From f0cab66a815d2f2ed749be14de08a45ea1d01f93 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 19:07:07 +0000 Subject: [PATCH] feat: Cloudflare Workers realtime, Bun and Docker deploy targets, health 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 Claude-Session: https://claude.ai/code/session_01CnAiFZHQw1SaeJbt11brzs --- apps/web/.dev.vars.example | 11 + apps/web/.gitignore | 2 + apps/web/content/docs/dev/cli/build.mdx | 2 +- apps/web/content/docs/dev/cli/dev.mdx | 1 + apps/web/content/docs/dev/contribution.mdx | 12 + apps/web/content/docs/dev/deployments/bun.mdx | 155 +++ .../docs/dev/deployments/cloud/cloudflare.mdx | 246 +++++ .../content/docs/dev/deployments/docker.mdx | 112 ++ .../docs/dev/deployments/health-checks.mdx | 66 ++ .../content/docs/dev/deployments/meta.json | 2 +- .../docs/dev/deployments/self-hosted.mdx | 4 +- apps/web/content/docs/dev/setup.mdx | 3 +- .../content/docs/dev/websocket/cloudflare.mdx | 144 +++ apps/web/content/docs/dev/websocket/index.mdx | 4 +- apps/web/content/docs/dev/websocket/meta.json | 2 +- .../content/docs/dev/websocket/reconnects.mdx | 3 + apps/web/content/docs/dev/websocket/rooms.mdx | 2 +- apps/web/content/docs/dev/websocket/setup.mdx | 32 +- apps/web/package.json | 7 +- apps/web/src/vitnode.api.config.ts | 29 +- apps/web/vite.config.ts | 8 +- apps/web/wrangler.jsonc | 31 + packages/create-vitnode-app/README.md | 3 +- .../copy-of-vitnode-app/api-bun/src/index.ts | 20 +- .../cloudflare/.dev.vars.example | 5 + .../cloudflare/src/vitnode.api.config.ts | 38 + .../cloudflare/vite.config.ts | 28 + .../cloudflare/wrangler.jsonc | 22 + .../copy-of-vitnode-app/root/global.d.ts | 9 +- .../root/src/routes/__root.tsx | 12 +- .../root/src/routes/_main/index.tsx | 20 +- .../src/create/create-docker.ts | 411 ++++++++ .../src/create/create-package-json.ts | 89 +- .../src/create/create-vitnode.ts | 129 ++- .../src/create/package-versions.ts | 1 + .../src/helpers/packages-json.ts | 2 + packages/create-vitnode-app/src/index.ts | 6 + packages/create-vitnode-app/src/questions.ts | 52 +- packages/vitnode/package.json | 1 + .../vitnode/scripts/cli/commands/build.ts | 39 +- .../vitnode/scripts/cli/project/packages.ts | 5 + .../vitnode/scripts/cli/project/runtime.ts | 23 +- .../vitnode/scripts/cli/start/server-entry.ts | 23 +- packages/vitnode/src/api/config.ts | 52 +- packages/vitnode/src/api/lib/health.ts | 129 +++ .../src/cloudflare/cloudflare-workers.d.ts | 24 + packages/vitnode/src/cloudflare/database.ts | 123 +++ packages/vitnode/src/cloudflare/edge.ts | 273 +++++ packages/vitnode/src/cloudflare/hub.ts | 980 ++++++++++++++++++ .../vitnode/src/cloudflare/nitro-entry.ts | 20 + .../src/cloudflare/nitro-middleware.ts | 66 ++ packages/vitnode/src/cloudflare/protocol.ts | 226 ++++ packages/vitnode/src/cloudflare/types.ts | 56 + .../src/content/server/live/documents.ts | 14 +- .../vitnode/src/content/server/live/server.ts | 88 +- .../vitnode/src/content/server/live/socket.ts | 116 ++- .../src/content/server/refresh/publish.ts | 25 +- .../src/content/server/refresh/server.ts | 34 +- .../src/content/server/refresh/socket.ts | 67 +- packages/vitnode/src/framework/vite/env.ts | 11 +- .../vitnode/src/framework/vite/vitnode.ts | 2 +- .../vitnode/src/framework/vite/websocket.ts | 109 +- packages/vitnode/src/ws/create-registry.ts | 256 +++++ packages/vitnode/src/ws/crossws.server.ts | 183 ++-- packages/vitnode/src/ws/handle.ts | 165 +-- packages/vitnode/src/ws/handshake.ts | 248 +++++ packages/vitnode/src/ws/process-runtime.ts | 352 +++++++ packages/vitnode/src/ws/realtime.ts | 108 ++ packages/vitnode/src/ws/registry.ts | 730 ++++--------- packages/vitnode/src/ws/runtime.ts | 361 +++++++ pnpm-lock.yaml | 755 +++++++++----- pnpm-workspace.yaml | 1 + scripts/cloudflare-smoke/README.md | 57 + scripts/cloudflare-smoke/realtime-demo.ts | 153 +++ scripts/cloudflare-smoke/smoke.mjs | 656 ++++++++++++ 75 files changed, 7086 insertions(+), 1170 deletions(-) create mode 100644 apps/web/.dev.vars.example create mode 100644 apps/web/content/docs/dev/deployments/bun.mdx create mode 100644 apps/web/content/docs/dev/deployments/cloud/cloudflare.mdx create mode 100644 apps/web/content/docs/dev/deployments/docker.mdx create mode 100644 apps/web/content/docs/dev/deployments/health-checks.mdx create mode 100644 apps/web/content/docs/dev/websocket/cloudflare.mdx create mode 100644 apps/web/wrangler.jsonc create mode 100644 packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/.dev.vars.example create mode 100644 packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/src/vitnode.api.config.ts create mode 100644 packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/vite.config.ts create mode 100644 packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/wrangler.jsonc create mode 100644 packages/create-vitnode-app/src/create/create-docker.ts create mode 100644 packages/vitnode/src/api/lib/health.ts create mode 100644 packages/vitnode/src/cloudflare/cloudflare-workers.d.ts create mode 100644 packages/vitnode/src/cloudflare/database.ts create mode 100644 packages/vitnode/src/cloudflare/edge.ts create mode 100644 packages/vitnode/src/cloudflare/hub.ts create mode 100644 packages/vitnode/src/cloudflare/nitro-entry.ts create mode 100644 packages/vitnode/src/cloudflare/nitro-middleware.ts create mode 100644 packages/vitnode/src/cloudflare/protocol.ts create mode 100644 packages/vitnode/src/cloudflare/types.ts create mode 100644 packages/vitnode/src/ws/create-registry.ts create mode 100644 packages/vitnode/src/ws/handshake.ts create mode 100644 packages/vitnode/src/ws/process-runtime.ts create mode 100644 packages/vitnode/src/ws/realtime.ts create mode 100644 packages/vitnode/src/ws/runtime.ts create mode 100644 scripts/cloudflare-smoke/README.md create mode 100644 scripts/cloudflare-smoke/realtime-demo.ts create mode 100644 scripts/cloudflare-smoke/smoke.mjs diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example new file mode 100644 index 0000000000..0d28ed51a3 --- /dev/null +++ b/apps/web/.dev.vars.example @@ -0,0 +1,11 @@ +# Secrets for `pnpm preview:cloudflare` (wrangler dev). Copy to `.dev.vars`; +# never commit it. In production set each with `wrangler secret put `. +POSTGRES_URL=postgresql://root:root@127.0.0.1:5432/vitnode +VITNODE_WEB_URL=http://localhost:8787 +CRON_SECRET=your-secure-cron-secret-key +# Optional +SUPABASE_URL= +SUPABASE_SECRET_KEY= +SUPABASE_STORAGE_BUCKET= +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 941c1d6117..91eb1d4688 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -5,6 +5,8 @@ dist-ssr *.local .env* !.env.example +.dev.vars* +!.dev.vars.example .nitro .tanstack .wrangler diff --git a/apps/web/content/docs/dev/cli/build.mdx b/apps/web/content/docs/dev/cli/build.mdx index 98ea9c3653..b23bce3f8a 100644 --- a/apps/web/content/docs/dev/cli/build.mdx +++ b/apps/web/content/docs/dev/cli/build.mdx @@ -135,7 +135,7 @@ vitnode start | Project | Entry it runs | Default port | | -------------- | ----------------------------------------------------------------- | ------------ | -| App | Nitro's server entry, usually `.output/server/index.mjs`, on Node | `3000` | +| App | Nitro's server entry, usually `.output/server/index.mjs`, on Node - or on Bun when it was built with Nitro's `bun` preset | `3000` | | API app (Node) | `dist/index.js` on Node | `8000` | | API app (Bun) | `src/index.ts` on Bun | `8000` | diff --git a/apps/web/content/docs/dev/cli/dev.mdx b/apps/web/content/docs/dev/cli/dev.mdx index c9bdf1835f..f17cce52ec 100644 --- a/apps/web/content/docs/dev/cli/dev.mdx +++ b/apps/web/content/docs/dev/cli/dev.mdx @@ -85,6 +85,7 @@ The port is `--port`, then `PORT`, then `8000`. The CLI passes it to your API as The API runs on Bun, with `bun --hot`, when any of these is true: +- `package.json` declares `"vitnode": { "runtime": "bun" }` (`"node"` there forces Node, whatever else says Bun) - you started the script with Bun (`bun run dev`) - the nearest `packageManager` field says `bun@...` - the nearest lockfile is `bun.lock` or `bun.lockb` diff --git a/apps/web/content/docs/dev/contribution.mdx b/apps/web/content/docs/dev/contribution.mdx index 590546d5c6..f88d7e7bfc 100644 --- a/apps/web/content/docs/dev/contribution.mdx +++ b/apps/web/content/docs/dev/contribution.mdx @@ -67,6 +67,18 @@ Open `http://localhost:3000` for the website, the AdminCP at `/admin` and the li | `apps/api` | The standalone Hono API server | | `plugins/*` | First-party plugins (`@vitnode/blog`, `@vitnode/forum`, `newsroom`) | +### Deploy the website to Cloudflare + +`apps/web` builds for Node by default. `pnpm build:cloudflare` builds the same app as one Cloudflare Worker - site, API and realtime hub - with the settings in `apps/web/wrangler.jsonc`. Build the CLI and plugins first, as in [Run the checks](#run-the-checks), then from `apps/web`: + +```bash +pnpm build:cloudflare +pnpm preview:cloudflare # local workerd, reads .dev.vars (copy .dev.vars.example) +pnpm deploy:cloudflare +``` + +Set `POSTGRES_URL`, `VITNODE_WEB_URL` and `CRON_SECRET` once with `npx wrangler secret put `, plus the Supabase and Stripe keys the site uses. Leave `REDIS_URL` unset. Run migrations from your machine or CI with `pnpm --filter api db:migrate` against the production database before each deploy. Images are stored as uploaded on Workers, because re-encoding needs `sharp`. [Cloudflare Workers](/docs/dev/deployments/cloud/cloudflare) explains the Worker itself. + ## Run the checks Before opening a pull request, run what GitHub Actions runs, from the repository root: diff --git a/apps/web/content/docs/dev/deployments/bun.mdx b/apps/web/content/docs/dev/deployments/bun.mdx new file mode 100644 index 0000000000..0014b8b8a1 --- /dev/null +++ b/apps/web/content/docs/dev/deployments/bun.mdx @@ -0,0 +1,155 @@ +--- +title: Bun +description: Develop, build and run a VitNode app or API on Bun instead of Node.js - TanStack Start through Nitro's bun preset, and Hono on Bun.serve. +icon: Zap +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +VitNode runs on [Bun](https://bun.com) from the first `dev` to production. The TanStack Start app runs Vite on Bun and builds with Nitro's `bun` preset, and a standalone Hono API runs its TypeScript straight on `Bun.serve` - no compile step. Same app, same plugins, one runtime fewer to install on the server. + +## Before you begin + +- [Bun](https://bun.com/docs/installation) installed on your machine and on the server. Node.js is optional: when it is missing, `bun run` stands in for it, so the `vitnode` CLI still runs. +- A PostgreSQL database, as for any VitNode deployment. + +## Create a Bun app + +Answer **Bun** to "Where to deploy?" when you create the app. The option shows up only when Bun is installed, and it is the default when you pick Bun as the package manager. + + + +```bash tab="bun" +bun create vitnode-app@canary --deploy bun +``` + +```bash tab="pnpm" +pnpm create vitnode-app@canary --deploy bun +``` + +```bash tab="npm" +npx create-vitnode-app@canary --deploy bun +``` + + + +Any package manager can install the packages; the runtime is a separate choice. Here is what the generator changes compared with a Node app: + +| Where | Node app | Bun app | +| -------------------- | ---------------------------------- | ----------------------------------------------------- | +| App `dev` / `build` | `vitnode dev`, `vitnode build` | `bun --bun vitnode dev`, `bun --bun vitnode build` | +| App `vite.config.ts` | Nitro's default Node server | `nitro: { preset: "bun" }` | +| API entry | `@hono/node-server` and `ws` | `export default { fetch, websocket }` with `hono/bun` | +| API `build` | `tsc` into `dist` | none - Bun runs `src/index.ts` | +| `package.json` | `"vitnode": { "runtime": "node" }` | `"vitnode": { "runtime": "bun" }` | + +`start` stays `vitnode start` in both. It reads the build's preset (or the API's `vitnode.runtime`) and starts the server with Bun or Node to match. + +## Move an existing app to Bun + + + + +### Build the app for Bun + +Set Nitro's preset in the app's `vite.config.ts`: + +```ts title="vite.config.ts (excerpt)" +vitnode({ + nitro: { + preset: 'bun', + rollupConfig: { external: [/^@sentry\//] }, + }, + webSocket: true, +}) +``` + + + + +### Run Vite on Bun + +Prefix the app's `dev` and `build` scripts with `bun --bun`, so Vite and the SSR code run on Bun rather than on Node: + +```json title="package.json (excerpt)" +{ + "scripts": { + "dev": "bun --bun vitnode dev", + "build": "bun --bun vitnode build", + "start": "vitnode start" + }, + "vitnode": { "runtime": "bun" } +} +``` + + + + +### Serve a standalone API with Bun + +Skip this step for a single app - its API is part of the build above. + +A standalone API exports Bun's server object instead of calling `serve` from `@hono/node-server`. Copy the [WebSocket route for Bun](/docs/dev/websocket/setup#on-bun), drop the API's `build` script and set `"vitnode": { "runtime": "bun" }` in its `package.json`. `vitnode dev` then runs `bun --hot src/index.ts` and `vitnode start` runs `bun src/index.ts`. You can remove `@hono/node-server` and add `@types/bun` to `devDependencies`. + + + + +## Build and run + +Deploy exactly as in [Self-hosted](/docs/dev/deployments/self-hosted), with the same four commands: + + + +```bash tab="bun" +bun install --frozen-lockfile +bun run build +bun run db:migrate +bun start +``` + +```bash tab="pnpm" +pnpm install --frozen-lockfile +pnpm build +pnpm db:migrate +pnpm start +``` + +```bash tab="npm" +npm ci +npm run build +npm run db:migrate +npm start +``` + + + + + Bun loads `.env` before your code starts, so every variable in it applies - + even one you forgot about. A `REDIS_URL` pointing at a Redis that isn't + running makes WebSocket handshakes answer `503` until Redis is up. Comment out + what you don't use. + + +## Check the result + +`vitnode start` prints `Running` once the server accepts connections. To be sure it is Bun answering, list the processes: the server shows up as `bun .output/server/index.mjs` for an app, or `bun src/index.ts` for an API. + +## Related + + + + + + diff --git a/apps/web/content/docs/dev/deployments/cloud/cloudflare.mdx b/apps/web/content/docs/dev/deployments/cloud/cloudflare.mdx new file mode 100644 index 0000000000..122a63b4c4 --- /dev/null +++ b/apps/web/content/docs/dev/deployments/cloud/cloudflare.mdx @@ -0,0 +1,246 @@ +--- +title: Cloudflare Workers +description: Deploy a VitNode Single App to Cloudflare Workers as one Worker - the site, the API and a Durable Object for WebSockets - with Wrangler, secrets, migrations and the size limit. +icon: Cloud +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +A VitNode **Single App** builds into one Cloudflare Worker: the site, the Hono API at `/api/*`, and a Durable Object, the realtime hub, that owns every WebSocket. Pages and the API run at the edge, and Postgres stays where it is, reached over TCP. + +## What works, and what does not + +| Feature | On Workers | Why | +| ---------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------- | +| The site, SSR, prerendered pages, `/api/*` | ✅ Works | One Worker answers every route; prerendered pages are served as static assets. | +| [WebSockets](/docs/dev/websocket) | ✅ Works | The realtime hub owns the sockets. See [Cloudflare WebSockets](/docs/dev/websocket/cloudflare). | +| Postgres | ✅ Works | Through `scopedDatabase`, one client per request. | +| [S3 / R2](/docs/dev/storage/s3-r2), Supabase storage | ✅ Works | Uploads go straight to the bucket. | +| [Local file storage](/docs/dev/storage/local) | ❌ No disk | A Worker has no persistent file system. Use a bucket. | +| Image re-encoding (`storage.image`) | ❌ Unsupported | It needs `sharp`, a native module Workers cannot load. Leave `storage.image` out of the Worker build. | +| Redis | ➖ Not used | Its long-lived connection does not fit a Worker. Cache and rate limits fall back to each isolate's memory. | +| [In-process cron](/docs/dev/cron/node-cron) | ❌ Unsupported | No process stays up. Call the [cron endpoint](/docs/dev/cron/rest-api) from a scheduler instead. | +| Migrations | ➖ From Node | Run `db:migrate` from your machine or CI against the production database. | + +## Before you begin + +- A Single App, the one layout `create-vitnode-app` offers for Workers. +- Workers Paid. A VitNode Worker is above the 3 MiB limit of the Free plan; [Shrink the Worker](#shrink-the-worker) keeps it under the 10 MiB Paid limit. +- A PostgreSQL database that Cloudflare can reach over TCP. +- Wrangler 4 in the app: + + + +```bash tab="bun" +bun add -d wrangler +``` + +```bash tab="pnpm" +pnpm add -D wrangler +``` + +```bash tab="npm" +npm i -D wrangler +``` + + + +A new app gets the first three steps below from the generator: answer **Cloudflare Workers** to "Where do you want to deploy?", or pass `--deploy cloudflare`. Follow them to convert an existing Single App. + +## Deploy it + + + + +### Build for Workers + +Build with the Workers preset and serve `/api/ws` from the realtime hub: + +```ts title="vite.config.ts" +const config = defineConfig(({ command }) => ({ + plugins: [ + vitnode({ + appRoot: import.meta.dirname, + nitro: command === 'build' ? { preset: 'cloudflare-module' } : {}, // [!code highlight] + webSocket: { transport: 'cloudflare' }, // [!code highlight] + }), + tailwindcss(), + tanstackStart(), + viteReact(), + ], +})) +``` + +`vite dev` stays on Node, so local development needs no Worker. A build with `transport: "cloudflare"` and another preset fails with an error that says so. + + + + +### Configure Wrangler + +Create `wrangler.jsonc` next to `vite.config.ts`. The build merges it into `.output/server/wrangler.json` and adds `main` and `assets`: + +```jsonc title="wrangler.jsonc" +{ + "name": "my-vitnode-app", + "compatibility_date": "2026-10-01", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { "name": "VITNODE_REALTIME", "class_name": "VitNodeRealtimeHub" }, + ], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["VitNodeRealtimeHub"] }], + "vars": { "VITNODE_INSTALLATION_ID": "default" }, +} +``` + +The binding name `VITNODE_REALTIME` is fixed. `nodejs_compat` provides `AsyncLocalStorage`, `process.env` and the Node APIs the Postgres driver uses. [Cloudflare WebSockets](/docs/dev/websocket/cloudflare#the-hub-configuration) explains the hub's settings. + + + + +### Give each request its own database client + +A Worker request cannot use a TCP connection another request opened, so a client created once at module load breaks on the second request. Wrap the client in `scopedDatabase`, and leave out `redis`: + +```ts title="src/vitnode.api.config.ts" +import { scopedDatabase } from '@vitnode/core/cloudflare/database' // [!code ++] +import { coreRelations } from '@vitnode/core/database/relations' +import { drizzle } from 'drizzle-orm/postgres-js' + +export const vitNodeApiConfig = buildApiConfig({ + // [!code ++:9] + dbProvider: scopedDatabase(() => + drizzle({ + connection: { + url: process.env.POSTGRES_URL, + max: 1, + }, + relations: coreRelations, + }), + ), + // ...the rest of your config +}) +``` + +On Node (`vite dev`, `db:migrate`) `scopedDatabase` keeps one client per process, so the same config serves both. It closes a Drizzle client on postgres.js or node-postgres; pass `scopedDatabase(create, { dispose })` for another driver. + + + + +### Preview it locally + +Put the variables for local runs in `.dev.vars` in the app's folder, and never commit it: + +```bash title=".dev.vars" +POSTGRES_URL=postgresql://user:password@127.0.0.1:5432/vitnode +VITNODE_WEB_URL=http://localhost:8787 +CRON_SECRET=a-long-random-string +``` + +Run the app's `build` script, then start the Worker in workerd, the runtime Cloudflare uses: + +```bash +npx wrangler dev +``` + +The build leaves `.wrangler/deploy/config.json`, which points Wrangler at the generated config, so run every Wrangler command from the app's folder without `--config`. With `--config .output/server/wrangler.json`, Wrangler looks for `.dev.vars` in `.output/server` and starts without your variables, and without a warning. + + + + +### Set the secrets and migrate + +Store each production secret once: + +```bash +npx wrangler secret put POSTGRES_URL +npx wrangler secret put VITNODE_WEB_URL +npx wrangler secret put CRON_SECRET +``` + +Then apply migrations from your machine or CI, with `POSTGRES_URL` set to the production database. They read the compiled plugins, so run them after the build: + + + +```bash tab="bun" +POSTGRES_URL="postgresql://…" bun run db:migrate +``` + +```bash tab="pnpm" +POSTGRES_URL="postgresql://…" pnpm db:migrate +``` + +```bash tab="npm" +POSTGRES_URL="postgresql://…" npm run db:migrate +``` + + + +Run `db:migrate` before every deploy that changes the schema. + + + + +### Deploy + +Check the bundle and the bindings without uploading anything, then deploy: + +```bash +npx wrangler deploy --dry-run --outdir .output/wrangler-dry-run +npx wrangler deploy +``` + +An app made with `--deploy cloudflare` has these commands as its `preview`, `check:cloudflare` and `deploy:cloudflare` scripts. + + + + +## Shrink the Worker + +By default Wrangler uploads every module the build wrote, including chunks nothing imports, without minifying them. A large app, with many plugins or compiled docs, can pass the 10 MiB Paid limit that way. Let Wrangler bundle from the entry and minify: + +```jsonc title="wrangler.jsonc (excerpt)" +"no_bundle": false, +"minify": true, +"keep_names": false, +``` + +The VitNode website went from 13.1 MiB to 6.6 MiB gzipped. Keep `keep_names` off: some server code sends functions to the browser as source text (the theme and scroll restoration scripts), and esbuild's name-keeping helper turns them into calls to a function the browser does not have, so every page the Worker renders fails with `n is not defined`. + +If the app prerenders pages, also set `"assets": { "html_handling": "drop-trailing-slash" }`. A prerendered page is written as `page/index.html`, and Cloudflare otherwise redirects `/page` to `/page/`. + +## Check the result + +With `npx wrangler dev` running, `http://localhost:8787/api/health/ready` answers `200` with `{"status":"ok"}`. If it answers `503`, the Worker cannot reach `POSTGRES_URL`. `wrangler deploy --dry-run` prints the Worker's size as `Total Upload: … / gzip: …`. To check WebSockets end to end, follow [Cloudflare WebSockets](/docs/dev/websocket/cloudflare#check-the-result). + +## Troubleshooting + +| Symptom | Cause and fix | +| ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| The Worker ignores `.dev.vars`, and the bindings table lists none of its variables | Wrangler ran with `--config .output/server/wrangler.json`. Run `wrangler dev` from the app's folder without it. | +| A Worker-rendered page fails with `n is not defined` in the browser | The Worker is minified with `keep_names` on. Set `"keep_names": false`. | +| `CONNECTION_CLOSED` or `Cannot perform I/O on behalf of a different request` from the database | The client was created once at module load. Use `scopedDatabase`. | +| Image uploads fail with "The image optimization library (sharp) failed to load" | Remove `storage.image` from the Worker's API config. | +| Deploy fails because the script is too large | [Shrink the Worker](#shrink-the-worker), and check that you are on Workers Paid. | + +## Related + + + + + + diff --git a/apps/web/content/docs/dev/deployments/docker.mdx b/apps/web/content/docs/dev/deployments/docker.mdx new file mode 100644 index 0000000000..15dc5bbcad --- /dev/null +++ b/apps/web/content/docs/dev/deployments/docker.mdx @@ -0,0 +1,112 @@ +--- +title: Docker +description: Build a production Docker image of your VitNode app and run it with PostgreSQL, Redis and migrations using Docker Compose. +icon: Container +--- + +`create-vitnode-app` writes a production `Dockerfile` and a `docker-compose.prod.yml` when you answer **Yes** to "Docker". One command then builds the image, runs your migrations and starts the app next to PostgreSQL and Redis. The same image runs on any host that takes containers: a VPS, Railway, Fly.io, Render, Coolify or Kubernetes. + +## Before you begin + +- [Docker](https://docs.docker.com/get-started/get-docker/) with Compose v2 on the machine that builds and runs the image. +- A project created with Docker enabled, and its dependencies installed once, so the lockfile exists. [Cloudflare Workers](/docs/dev/deployments/cloud/cloudflare) projects get no image, because Wrangler deploys them. +- Your migrations committed. The `migrate` job generates any missing ones inside the container, where they are lost on the next deploy. + +## What you get + +| File | What it does | +| ------------------------- | ---------------------------------------------------------------------------------------------------- | +| `Dockerfile` | Builds the whole workspace once, then a small image per server: `app`, `api`, and the `migrate` job. | +| `docker-compose.prod.yml` | PostgreSQL, Redis, the `migrate` job and your server(s). Only the server ports are published. | +| `.dockerignore` | Keeps `.env` files, local database data and build output out of the image. | + +The image follows the runtime you picked: Node.js images for **Node.js**, Bun images for **Bun**. The site image holds only Nitro's server bundle, about 400 MB with the base image; an API image carries its production `node_modules`. + +## Run it with Compose + + + + +### Write the production variables + +Create `.env.production` next to `docker-compose.prod.yml`. Your `.gitignore` already keeps it out of git. + +```bash title=".env.production" +POSTGRES_PASSWORD=change_me_to_a_long_random_password +REDIS_PASSWORD=change_me_to_another_long_random_password +VITNODE_WEB_URL=https://example.com +CRON_SECRET=change_me_to_a_long_random_secret_string + +# 0 when visitors reach the container directly, 1 behind one reverse proxy +VITNODE_TRUSTED_PROXY_HOPS=1 +``` + +A **Monorepo App** (web and API apart) also needs `VITNODE_API_URL`, the API's public address, for example `https://api.example.com`. The web image compiles it in, so set it before you build. + +Compose builds `POSTGRES_URL` and `REDIS_URL` from these values and refuses to start while a required one is missing. `APP_PORT` (default `3000`) and `API_PORT` (default `8000`) change the published ports. + + + + +### Build and start + +Run this in the project's root folder: + +```bash +docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build +``` + +Compose builds the image, starts PostgreSQL and Redis, and runs `migrate`, which applies pending migrations, seeds roles, languages and permissions, then exits. Your server starts only after `migrate` succeeded, so a failed migration never serves traffic. Run the same command for every deploy. + + + + +## Check the result + +List the services: + +```bash +docker compose -f docker-compose.prod.yml --env-file .env.production ps -a +``` + +`migrate` shows **Exited (0)**, and `app` (and `api`) show **healthy** once their [health check](/docs/dev/deployments/health-checks) passes. Open `VITNODE_WEB_URL` and sign up: the first account on a fresh database becomes the administrator. + +If `migrate` exits with an error, read its log with `docker compose -f docker-compose.prod.yml --env-file .env.production logs migrate`. + +## Build images for another host + +Build each target yourself when your platform runs containers but not Compose: + +```bash +docker build --target app -t my-app . +docker build --target migrate -t my-app-migrate . +``` + +A **Monorepo App** or **Only API** project also has `--target api`; pass `--build-arg VITNODE_API_URL=…` when the project has a separate web app. Run `migrate` once per release, before the new version takes traffic. Most platforms call it a release or pre-deploy command. Give every container the variables from step 1, with `POSTGRES_URL` and `REDIS_URL` pointing at your managed database and Redis. + + + Local file storage writes to `public/uploads` inside the container. Compose + mounts the `uploads` volume there; on another host, mount a persistent disk at + that path or switch to [S3 or R2](/docs/dev/storage/s3-r2). Without either, + uploads disappear on the next deploy. + + +## Related + + + + + + diff --git a/apps/web/content/docs/dev/deployments/health-checks.mdx b/apps/web/content/docs/dev/deployments/health-checks.mdx new file mode 100644 index 0000000000..658affb172 --- /dev/null +++ b/apps/web/content/docs/dev/deployments/health-checks.mdx @@ -0,0 +1,66 @@ +--- +title: Health checks +description: Use VitNode's /api/health liveness and /api/health/ready readiness endpoints with Docker, Kubernetes, load balancers and uptime monitors. +icon: HeartPulse +--- + +Every VitNode API answers two health endpoints, so Docker, Kubernetes, a load balancer or an uptime monitor can tell whether an instance is alive and ready for traffic. They are on by default, need no configuration, and work for anonymous callers. + +| Endpoint | Use it for | Answers | +| ----------------------- | ---------- | ------------------------------------------------------------------------------ | +| `GET /api/health` | Liveness | `200 {"status":"ok"}` whenever the process answers HTTP. Touches nothing else. | +| `GET /api/health/ready` | Readiness | `200` with `ok` or `degraded`, `503` with `unavailable`. See below. | + +A **Single App** serves both at its own origin. With a separate API, they live on the API; the web app has no `/api`, so check that its port accepts connections instead. + +## What readiness checks + +`/api/health/ready` asks the database for `select 1` and, when `REDIS_URL` is set, Redis for `PING`. Each gets 2 seconds. + +| Status | HTTP | Meaning | +| ------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ok` | `200` | Everything answered. | +| `degraded` | `200` | Redis did not answer. Pages and the API keep working with in-memory caching, but instances no longer share cache or rate limits, and WebSocket handshakes answer `503` until Redis is back. | +| `unavailable` | `503` | The database did not answer. Stop sending this instance traffic. | + +Redis alone never fails readiness: VitNode works without it, and taking every instance out of rotation because Redis blinked would turn a slower site into an offline one. + +## Which probe goes where + +- **Restart on failure** (Docker `HEALTHCHECK`, a Kubernetes `livenessProbe`): use `/api/health`. Restarting the process cannot fix a database outage, so liveness ignores the database. +- **Route traffic** (a load balancer target check, a Kubernetes `readinessProbe`): use `/api/health/ready`. +- **Alert a human** (an uptime monitor): use `/api/health/ready` and alert on anything but `ok`. + +The [Docker image](/docs/dev/deployments/docker) already ships a `HEALTHCHECK` on `/api/health`. + +## Safe to expose + +The endpoints skip the rate limiter and the session lookup, because probes arrive often, from one address, without cookies. To keep that cheap, concurrent and back-to-back readiness probes share one check per second, so a flood of requests cannot become a flood of database queries. + +Responses carry the status and nothing else: no versions, hostnames or error text. Every response sends `Cache-Control: no-store`, so a CDN never serves a stale answer. When the status changes, the server logs it once with the dependency that failed, for example `Health changed from "ok" to "degraded": Redis did not answer.` + +## Check the result + +With the app running locally, request both endpoints: + +```bash +curl -i http://localhost:3000/api/health +curl -i http://localhost:3000/api/health/ready +``` + +Both answer `200` with `{"status":"ok"}`. Use port `8000` for a separate API. Stop PostgreSQL and the readiness probe answers `503` with `{"status":"unavailable"}` until it is back. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/deployments/meta.json b/apps/web/content/docs/dev/deployments/meta.json index 037bb8502d..c40ddc5e40 100644 --- a/apps/web/content/docs/dev/deployments/meta.json +++ b/apps/web/content/docs/dev/deployments/meta.json @@ -2,5 +2,5 @@ "title": "Deployments", "description": "Deploy a VitNode application to production with cloud or self-hosted infrastructure", "icon": "HardDriveUpload", - "pages": ["self-hosted", "cloud", "prerendering"] + "pages": ["self-hosted", "docker", "bun", "health-checks", "cloud", "prerendering"] } diff --git a/apps/web/content/docs/dev/deployments/self-hosted.mdx b/apps/web/content/docs/dev/deployments/self-hosted.mdx index 637627e670..a8b3ea3ff7 100644 --- a/apps/web/content/docs/dev/deployments/self-hosted.mdx +++ b/apps/web/content/docs/dev/deployments/self-hosted.mdx @@ -10,7 +10,7 @@ VitNode compiles into a standard Node.js server and static assets. This guide bu ## Before you begin -- Node.js `22.22` or newer in the 22 line, `24.11` or newer in the 24 line, or `26+`. +- Node.js `22.22` or newer in the 22 line, `24.11` or newer in the 24 line, or `26+`. Running on Bun instead? Follow [Bun](/docs/dev/deployments/bun) first - the commands below stay the same. - A PostgreSQL database and its connection string. Redis is optional, for shared caching and running more than one instance. - The production `.env` described in [Required environment variables](#required-environment-variables). @@ -216,7 +216,7 @@ For a split deployment without a Turborepo root, start each app from its folder ## Check the result -`start` prints `Running` with the URL once the server accepts connections. Open your domain, sign in with the administrator account, and open the AdminCP Integrations page at `/admin/core/system/integrations` to see which services the deployment has connected. +`start` prints `Running` with the URL once the server accepts connections. Point your load balancer or uptime monitor at [`/api/health/ready`](/docs/dev/deployments/health-checks). Open your domain, sign in with the administrator account, and open the AdminCP Integrations page at `/admin/core/system/integrations` to see which services the deployment has connected. ## Related diff --git a/apps/web/content/docs/dev/setup.mdx b/apps/web/content/docs/dev/setup.mdx index 30d1b13842..1a06561e7b 100644 --- a/apps/web/content/docs/dev/setup.mdx +++ b/apps/web/content/docs/dev/setup.mdx @@ -45,8 +45,9 @@ It asks for a project name (default `my-vitnode`), then these questions. The rec | Package manager | the one you use | The generator offers only the managers you have installed. | | App type | **Single App** | One TanStack Start app with the Hono API mounted at `/api`. **Monorepo App** splits web and API into two apps; **Only API** has no front end. | | Use Turborepo? | **Yes** | Required to create plugins. A Monorepo App always gets it, so the generator skips the question. | +| Where to deploy? | **Node.js**, or **Bun** if you use it | **Bun** runs dev, build and production on Bun; see [Bun](/docs/dev/deployments/bun). **Cloudflare Workers** (Single App only) builds one Worker with a Durable Object for WebSockets; see [Cloudflare Workers](/docs/dev/deployments/cloud/cloudflare). | | Oxlint & Oxfmt? | your choice | Adds lint and format scripts. | -| Docker container? | **Yes**, unless you run PostgreSQL yourself | Adds `docker-compose.yml` with PostgreSQL 17 and Redis on `127.0.0.1`. | +| Docker? | **Yes**, unless you run PostgreSQL yourself | Adds `docker-compose.yml` with PostgreSQL 17 and Redis on `127.0.0.1`, plus a production image; see [Docker](/docs/dev/deployments/docker). | | Git repository? | your choice | | | Install dependencies? | **Yes** | Installs packages and generates the first migrations. | diff --git a/apps/web/content/docs/dev/websocket/cloudflare.mdx b/apps/web/content/docs/dev/websocket/cloudflare.mdx new file mode 100644 index 0000000000..b632029166 --- /dev/null +++ b/apps/web/content/docs/dev/websocket/cloudflare.mdx @@ -0,0 +1,144 @@ +--- +title: Cloudflare +description: How VitNode serves the /api/ws WebSocket on Cloudflare Workers from a Durable Object hub - server push from requests and background jobs, hibernation, sharding and troubleshooting. +icon: Cloud +--- + +On Cloudflare Workers, VitNode serves `/api/ws` from one Durable Object per installation, the **realtime hub**. Server push, rooms, notifications and Content Engine live editing work there as on Node. To deploy an app to Workers, including the hub's binding, follow [Cloudflare Workers](/docs/dev/deployments/cloud/cloudflare); this page explains what the hub does and how to push from code that runs outside a request. + +## Why a hub + +A Worker request runs in an isolate that may not be the one holding a visitor's socket, and it cannot reach sockets that belong to another request. So the sockets have to live somewhere every request can address. VitNode puts all of them in one named Durable Object: + +1. The browser upgrades `/api/ws`. The Worker hands the upgrade to the hub. +2. The hub runs the handshake through your embedded API (Origin check, rate limiter, session cookie), then accepts the socket. +3. An HTTP request that calls `c.get("realtime")` sends the push to the hub over Durable Object RPC, and the hub delivers it to its sockets. + +Plugins do not change: `c.get("realtime")`, `wsRegistry` inside socket handlers, `buildWebSocket` and `useVitNodeWebSocket` work as on Node. Redis is not used for realtime here. The hub owns every socket, so there is nothing to relay. An idle hub [hibernates](#hibernation-and-costs), and a busy installation can [split its sockets across shards](#sharding). + +## The hub configuration + +The deploy guide's `wrangler.jsonc` declares the hub. Three of its settings matter here: + +- **The `VITNODE_REALTIME` binding.** The name is fixed: the Worker finds the hub through it. +- **`VITNODE_INSTALLATION_ID`** names the hub: `vitnode:default` for `"default"`. The upgrade and every push read it from the Worker's own configuration, never from the request, so a visitor cannot pick another installation's hub. Give each installation that shares one script its own value. +- **`new_sqlite_classes`.** The hub keeps room memberships in its SQLite storage so they survive [hibernation](#hibernation-and-costs). A hub without it still works, but never hibernates. + +The handshake route stays as in [WebSocket setup](/docs/dev/websocket/setup): `src/server/vitnode-api.server.ts` mounts `/ws` with `upgradeWebSocket` from `@vitnode/core/ws/crossws.server`. The hub runs that route for each handshake, so a refused Origin answers `403` and a rate-limited visitor `429` before any socket exists, and the `Set-Cookie` of a handshake that changed the session reaches the browser on the `101` response. + +## Push from HTTP and background work + +Route handlers push exactly as on Node. This sends a toast to every open tab of one user, from an ordinary request: + +```ts title="plugins/blog/src/api/modules/posts/routes/publish.route.ts (excerpt)" +import { notificationsChannel } from "@vitnode/core/ws/notifications" + +handler: async c => { + // ...publish the post in the database first + c.get("realtime").sendToUser(authorId, notificationsChannel, { + title: "Your post is live", + type: "success", + }) + + return c.json({ ok: true }) +}, +``` + +All five methods work through the hub: `broadcast`, `sendToUser`, `sendToUsers`, `toRoom` and `closeUserConnections`. A push stays fire-and-forget: it is kept alive with the request's `waitUntil`, and a failure is logged, not thrown and not retried. Core modules that import `realtime` from `@vitnode/core/ws/registry` take the same path, and so do Content Engine resets: a discarded draft unloads the hub's copy of the document before the stored one is deleted. + +Code that runs outside a request or socket event, such as a `scheduled` handler or a queue consumer, has no scope yet. Open one with `runWithVitNodeCloudflare`: + +```ts title="src/worker-jobs.ts" +import { runWithVitNodeCloudflare } from '@vitnode/core/cloudflare/edge' +import type { + CloudflareEnv, + CloudflareExecutionContext, +} from '@vitnode/core/cloudflare/types' + +export const runDigest = async ( + env: CloudflareEnv, + ctx: CloudflareExecutionContext, +) => + await runWithVitNodeCloudflare(env, ctx, async () => { + await sendDailyDigest() // may call realtime and the database + }) +``` + +Without it, a push logs `Realtime was used outside a VitNode execution scope` and is not delivered, and `scopedDatabase` throws the same error. VitNode never falls back to delivering to the sockets of the current isolate, because there are none. + +## Check the result + +1. Build the app and run it with `npx wrangler dev`, as in [Cloudflare Workers](/docs/dev/deployments/cloud/cloudflare#preview-it-locally), then open the site in two browsers, one signed in as an admin and one as a member. +2. In both, the network panel shows `/api/ws` with `101 Switching Protocols`, then `vitnode_ping` and `vitnode_pong` frames. +3. In the admin's AdminCP dashboard, use the **Send a notification** widget with the member's user ID. The member's tab shows the toast; the admin's does not. +4. Sign the member out in another tab of the same browser. Its socket closes with code `1008` (`Session revoked`) and reconnects as a guest. +5. Sign the member in again, leave every tab idle for two minutes so the hub hibernates, then send another notification. It still arrives. + +The VitNode repository runs these checks and more against a generated app: `scripts/cloudflare-smoke/` covers all five realtime methods, rooms, the handshake policy, revocation, reconnects, two-editor live editing and a hibernation round, with one hub or several shards. + +## Hibernation and costs + +Cloudflare bills a Durable Object for the time it is active, so a hub that stayed awake for every open socket would cost the same at 4 a.m. as at noon. By default the hub uses Cloudflare's WebSocket Hibernation API instead: once nothing has happened for a few seconds, the runtime evicts the hub from memory while its sockets stay open, and the hub stops accruing duration until the next event wakes it. + +What keeps working while it sleeps: + +- **Heartbeats.** The runtime answers the client's `vitnode_ping` itself, without waking the hub. +- **Pushes.** A push wakes the hub. Each socket's user and device travel with the socket, and its rooms are kept in the hub's SQLite storage, so `sendToUser`, `toRoom` and revocation reach the right sockets right away. +- **Handlers.** A socket's first message after a wake runs its handshake again through your API, with only its session, device and admin cookies, so its handlers get a fresh `c` that sees the current session. When the session behind the socket is gone or belongs to someone else now, the hub closes it with `1008` and the client reconnects as whoever it is. + +What decides when the hub can sleep: + +- **Live features keep it awake while used.** Content Engine live editing and live refresh keep members, timers and loaded documents in memory, so the hub stays active while anyone uses them and can hibernate about 15 to 20 seconds after the last member leaves. +- **Recent handshakes keep it awake for about a minute.** The rate limiter's in-memory counters expire after 60 seconds, and a pending timer keeps a Durable Object awake. +- **Per-socket state in your own handlers does not survive a wake.** Anything a plugin keeps in memory per socket (a `Map` keyed by `ws`, say) is gone after the hub wakes. Rooms joined with `wsRegistry.join` are restored; rebuild anything else from the database. + +Set `"VITNODE_REALTIME_HIBERNATION": "false"` in `vars` to keep the hub awake with the standard WebSocket API instead. Use it if a plugin cannot rebuild its per-socket state, and expect to pay for duration while any socket is open. + +Hibernation does not make memory durable. A deploy restarts the hub and closes every socket; clients reconnect with backoff, rejoin their rooms and reload state from the database, as described in [Reconnects](/docs/dev/websocket/reconnects). Edited rich text documents are saved to the database a couple of seconds after each change. As on Node, delivery is best effort: a push reaches the sockets that are open when it arrives, nothing is replayed and nothing is delivered twice. + +## Sharding + +One hub is one Durable Object, on one thread. When it cannot keep up, spread the sockets across several hubs: + +```jsonc title="wrangler.jsonc (excerpt)" +"vars": { + "VITNODE_INSTALLATION_ID": "default", + "VITNODE_REALTIME_SHARDS": "4" +} +``` + +Each upgrade lands on a random shard (`vitnode:default:0` to `vitnode:default:3`). Every push from an HTTP request goes to every shard, which delivers to its own sockets; a shard relays the pushes, presence updates and live-editing changes of its own handlers to its peers, the way Node instances share them over Redis. Two editors of one document may sit on different shards and still see each other. + +Sharding is a trade: each push now costs one RPC per shard, and each shard is a Durable Object of its own. Start with one hub, which suits most community sites, and add shards only when the hub's CPU is the limit. VitNode has not measured a socket count per hub, so load-test your expected peak. Changing the shard count moves sockets to new hubs: deploy it like any other configuration change, and clients reconnect. + +## Troubleshooting + +| Symptom | Cause and fix | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/api/ws` answers `503` and the log says the `VITNODE_REALTIME` binding is missing | Add the `durable_objects` binding and the `new_sqlite_classes` migration to `wrangler.jsonc`, rebuild, deploy. HTTP keeps working meanwhile; each push logs the same error. | +| Wrangler warns that no `VitNodeRealtimeHub` class is exported | The build did not use `webSocket: { transport: "cloudflare" }`, or you set your own Nitro `serverEntry`, which replaces VitNode's. | +| Every handshake answers `403` | The page's origin is neither `VITNODE_WEB_URL` nor the Worker's own origin. Set `VITNODE_WEB_URL` to the site's URL. | +| Handshakes answer `429` | The visitor's IP spent the read budget of the [rate limiter](/docs/dev/advanced/rate-limiter). The Worker passes `CF-Connecting-IP` to the hub; a client cannot set it. | +| `Realtime was used outside a VitNode execution scope` | Background code pushed or queried without `runWithVitNodeCloudflare`. | +| The hub never hibernates | Something keeps it busy: an open database connection created outside `scopedDatabase`, a timer in a plugin, an active live-editing or live-refresh member. The log says "cannot hibernate" when the class has no SQLite storage. | +| Sockets close with `1008` after a quiet period | Their session ended while the hub slept; the client reconnects with its current session. Expected. | + +## Related + + + + + + diff --git a/apps/web/content/docs/dev/websocket/index.mdx b/apps/web/content/docs/dev/websocket/index.mdx index 7e445552f1..0a0c5118ea 100644 --- a/apps/web/content/docs/dev/websocket/index.mdx +++ b/apps/web/content/docs/dev/websocket/index.mdx @@ -9,7 +9,7 @@ VitNode provides one shared WebSocket connection at `/api/ws`. Browsers share a ## Before you begin - The `/api/ws` endpoint. A single app turns it on with `webSocket: true`, Vercel included; a standalone API mounts it next to its HTTP server. See [Set up the endpoint](/docs/dev/websocket/setup). -- `REDIS_URL`, only if you run more than one API instance. Without it, pushes reach clients connected to the same instance. +- `REDIS_URL`, only if you run more than one API instance. Without it, pushes reach clients connected to the same instance. On Cloudflare Workers a Durable Object hub owns every socket instead; see [Cloudflare](/docs/dev/websocket/cloudflare). ## Send a toast to a user @@ -122,7 +122,7 @@ Every method returns nothing and does not wait. A client that is not connected m - **Heartbeat, both ways**: The server pings every socket every 30 seconds and closes the ones that stopped answering, so vanished clients do not linger (the standalone API's `ws` server and Nitro's crossws both do). The browser sends its own ping every 25 seconds and reconnects when no reply arrives within 10 seconds, so a half-dead connection after a Wi-Fi drop never pretends to be online. - **No replay**: Messages sent while a socket is down are not queued. A feature that must not miss updates reloads its state when the socket opens again - see [Reconnects and missed messages](/docs/dev/websocket/reconnects). - **Closed on revoke**: A socket is tied to the session and device that opened it. Revoking sessions (signing out a device, changing or resetting a password, an admin revoke) closes those sockets on every instance through `closeUserConnections`, so a revoked device stops receiving the user's pushes. -- **Multiple instances**: With `REDIS_URL` set, `sendToUser`, `broadcast` and `toRoom` are relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance. +- **Multiple instances**: With `REDIS_URL` set, `sendToUser`, `broadcast` and `toRoom` are relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance. On [Cloudflare](/docs/dev/websocket/cloudflare) they go to the installation's hub. ## Related diff --git a/apps/web/content/docs/dev/websocket/meta.json b/apps/web/content/docs/dev/websocket/meta.json index dfa7122ace..57d26f1d27 100644 --- a/apps/web/content/docs/dev/websocket/meta.json +++ b/apps/web/content/docs/dev/websocket/meta.json @@ -2,5 +2,5 @@ "title": "WebSocket", "description": "Push realtime updates over one shared, cookie-authenticated connection", "icon": "Radio", - "pages": ["index", "setup", "rooms", "reconnects"] + "pages": ["index", "setup", "rooms", "reconnects", "cloudflare"] } diff --git a/apps/web/content/docs/dev/websocket/reconnects.mdx b/apps/web/content/docs/dev/websocket/reconnects.mdx index f39d2192b4..56411be549 100644 --- a/apps/web/content/docs/dev/websocket/reconnects.mdx +++ b/apps/web/content/docs/dev/websocket/reconnects.mdx @@ -54,9 +54,12 @@ VitNode's own features follow the same rule: | No `REDIS_URL` | Sockets on the same instance only. Fine for one server. | | `REDIS_URL`, one instance | Same as above, plus a ready check before sockets are accepted. | | `REDIS_URL`, many | Matching sockets on every instance. | +| Cloudflare Workers | Matching sockets in the installation's hub. No Redis involved. | On serverless hosts such as Vercel the HTTP request that sends a message and the socket that should receive it often run on different instances, even within one deployment, so set `REDIS_URL` there. A publish started during a request is kept alive with the request's `waitUntil`, so the instance is not frozen before Redis has it. +On Cloudflare every socket lives in one Durable Object hub, so a push from any request reaches it; see [Cloudflare](/docs/dev/websocket/cloudflare). When the hub restarts, for example on a deploy, its sockets drop and the clients run the restore above. + When the Redis subscription drops and comes back, the instance closes its sockets with code `1012`. The clients reconnect and run the restore above, which picks up whatever was published while the instance could not hear it. While Redis is down, new handshakes on that instance answer `503` and the client keeps retrying. ## Related diff --git a/apps/web/content/docs/dev/websocket/rooms.mdx b/apps/web/content/docs/dev/websocket/rooms.mdx index c3b264bbf5..deaff27a3c 100644 --- a/apps/web/content/docs/dev/websocket/rooms.mdx +++ b/apps/web/content/docs/dev/websocket/rooms.mdx @@ -183,7 +183,7 @@ wsRegistry.onConnectionClose((ws, left) => { | `wsRegistry.roomsOf(ws)` | `string[]` | The rooms of one socket | | `wsRegistry.onConnectionClose(listener)` | unsubscribe function | Calls `listener(ws, left)` when a socket closes | -All `wsRegistry` methods act on the current instance only. +All `wsRegistry` methods act on the runtime handling the current socket event only: this Node/Bun instance, or the installation's [Cloudflare hub](/docs/dev/websocket/cloudflare). On Cloudflare an ordinary HTTP request owns no sockets, so `wsRegistry` throws there; push with `c.get("realtime").toRoom()` instead. A listener registered with `wsRegistry.onConnectionClose` at module load runs in every runtime, the hub included. Room memberships survive the hub's [hibernation](/docs/dev/websocket/cloudflare#hibernation-and-costs); state you keep in memory per socket does not. ## Rooms on several instances diff --git a/apps/web/content/docs/dev/websocket/setup.mdx b/apps/web/content/docs/dev/websocket/setup.mdx index f132bdf157..52e2e379be 100644 --- a/apps/web/content/docs/dev/websocket/setup.mdx +++ b/apps/web/content/docs/dev/websocket/setup.mdx @@ -1,12 +1,12 @@ --- title: Setup -description: Serve VitNode's /api/ws WebSocket from a single TanStack Start app with Nitro, Vercel included, or from a standalone Node API server. +description: Serve VitNode's /api/ws WebSocket from a single TanStack Start app with Nitro, Vercel included, or from a standalone Node or Bun API server. icon: Plug --- -VitNode's WebSocket lives at `/api/ws`. A single app serves it from the same deployment as the site and the HTTP API, through Nitro. A standalone API serves it from its own Node server. Both run the same handshake, protocol and handlers, so plugins and `useVitNodeWebSocket` work the same either way. +VitNode's WebSocket lives at `/api/ws`. A single app serves it from the same deployment as the site and the HTTP API, through Nitro. A standalone API serves it from its own Node or Bun server. Both run the same handshake, protocol and handlers, so plugins and `useVitNodeWebSocket` work the same either way. -New single apps from `create-vitnode-app` already include everything on this page. Follow it to add WebSockets to an older single app. +New single apps from `create-vitnode-app` already include everything on this page. Follow it to add WebSockets to an older single app. On Cloudflare Workers, finish this page and then follow [Cloudflare Workers](/docs/dev/deployments/cloud/cloudflare), where a Durable Object hub owns the sockets. ## Single app @@ -119,15 +119,37 @@ The `ws` server does not ping on its own. Without the heartbeat, a client that v A web app pointed at that API with `VITNODE_API_URL` connects to it on its own; it needs no `webSocket` option and no Nitro route. +### On Bun + +An API that runs on Bun lets Bun upgrade the socket. Use the helpers from `hono/bun` and export Bun's server object instead of calling `serve`: + +```ts title="apps/api/src/index.ts (excerpt)" +import { websocketOriginMiddleware } from "@vitnode/core/api/middlewares/websocket-origin.middleware" +import { handleVitNodeWebSocket } from "@vitnode/core/ws/handle" +import { upgradeWebSocket, websocket } from "hono/bun" + +// `app` is your OpenAPIHono app, already passed to VitNodeAPI({ app, ... }) +app.get("/ws", websocketOriginMiddleware(), upgradeWebSocket(handleVitNodeWebSocket())) + +export default { + fetch: app.fetch, + hostname: process.env.HOST, + port: Number(process.env.PORT ?? 8000), + websocket, +} +``` + +Bun pings idle sockets and drops dead ones on its own, so there is no heartbeat to write. API apps that `create-vitnode-app` generates for Bun ship this file already. + ## Who may connect Both setups apply the same rules before a socket opens: - **Origin**: the configured web and API origins, any extra `origin` you pass, and the handshake's own origin (a page on the same host, such as a preview deployment). A handshake without an `Origin` header is not a browser and is allowed. Anything else gets `403`. - **Session**: the session cookie decides the user. Guests connect too, as `null`; a handler that needs a user checks `c.get("user")` itself, and live editing checks the AdminCP session on every message. -- **Redis**: with `REDIS_URL` set, the instance answers `503` until it is subscribed to the realtime channel, so it never accepts a socket that other instances cannot reach. The client retries. +- **Redis**: with `REDIS_URL` set, the instance answers `503` until it is subscribed to the realtime channel, so it never accepts a socket that other instances cannot reach. The client retries. Cloudflare Workers do not use Redis for this; the hub owns every socket. -A rejected handshake registers nothing. Identity is fixed for the life of a socket. After sign-in or sign-out the client reconnects on its own. Revoking a session (signing out a device, changing a password, an admin revoke) closes that device's sockets on every instance. +A rejected handshake registers nothing. Identity is fixed for the life of a socket. After sign-in or sign-out the client reconnects on its own. Revoking a session (signing out a device, changing a password, an admin revoke) closes that device's sockets on every instance, or in the Cloudflare hub. ## Check the result diff --git a/apps/web/package.json b/apps/web/package.json index 872c8b965b..bb321ce37b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,11 @@ "scripts": { "dev": "pnpm --filter api db:prepare && vitnode dev --port 3000", "build": "vitnode build", + "build:cloudflare": "NITRO_PRESET=cloudflare-module vitnode build", "preview": "vite preview", + "preview:cloudflare": "wrangler dev", + "check:cloudflare": "wrangler deploy --dry-run --outdir .output/wrangler-dry-run", + "deploy:cloudflare": "wrangler deploy", "lint": "oxlint --type-aware", "format": "oxfmt .", "check": "oxfmt --check .", @@ -69,7 +73,8 @@ "oxlint-tsgolint": "^7.0.2006", "tw-animate-css": "^1.4.0", "typescript": "7.1.0-beta", - "vite": "^8.3.6" + "vite": "^8.3.6", + "wrangler": "^4.153.0" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/apps/web/src/vitnode.api.config.ts b/apps/web/src/vitnode.api.config.ts index 3055bec526..caf80b6598 100644 --- a/apps/web/src/vitnode.api.config.ts +++ b/apps/web/src/vitnode.api.config.ts @@ -1,5 +1,6 @@ import { blogApiPlugin } from '@vitnode/blog/config.api' import { FakePaymentAdapter } from '@vitnode/core/api/adapters/payments/fake' +import { scopedDatabase } from '@vitnode/core/cloudflare/database' import { coreRelations } from '@vitnode/core/database/relations' import { buildApiConfig } from '@vitnode/core/vitnode.config' import { forumApiPlugin } from '@vitnode/forum/config.api' @@ -17,6 +18,9 @@ import { vitNodeConfig } from './vitnode.config' config({ quiet: true }) +/** `pnpm build:cloudflare`: the API runs inside a Cloudflare Worker. */ +const ON_WORKERS = globalThis.navigator?.userAgent === 'Cloudflare-Workers' + export const POSTGRES_URL = process.env.POSTGRES_URL ?? 'postgresql://root:root@localhost:5432/vitnode' @@ -40,9 +44,9 @@ export const vitNodeApiConfig = buildApiConfig({ contentModules, frontendPlugins, storage: { - image: { - quality: 85, - }, + // Re-encoding needs sharp, a native module Workers cannot load: there, + // images are stored as uploaded. + image: ON_WORKERS ? undefined : { quality: 85 }, adapter: SupabaseStorageAdapter({ url: process.env.SUPABASE_URL, secretKey: process.env.SUPABASE_SECRET_KEY, @@ -81,10 +85,21 @@ export const vitNodeApiConfig = buildApiConfig({ : []), ], }, - dbProvider: drizzle({ - connection: { url: POSTGRES_URL, onnotice: () => undefined }, - relations: coreRelations, - }), + // One client per process on Node. On Workers a request cannot reuse a + // connection another request opened, so it gets its own, and the realtime + // hub closes its client when idle so it can hibernate. + dbProvider: scopedDatabase(() => + drizzle({ + connection: { + url: POSTGRES_URL, + ...(ON_WORKERS ? { max: 1 } : {}), + onnotice: () => undefined, + }, + relations: coreRelations, + }), + ), + // Leave REDIS_URL unset on Workers: the realtime hub owns every socket, and + // the cache falls back to each isolate's memory. redis: process.env.REDIS_URL ? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD } : undefined, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 67f27e243b..c980416239 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,6 +10,10 @@ import { docsPrerenderPaths } from './src/docs/prerender.ts' import { PRERENDERED_PATHS } from './src/site/prerender.ts' import { vitNodeConfig } from './src/vitnode.config.ts' +// `pnpm build:cloudflare` sets this: one Cloudflare Worker serves the site, +// the API and the realtime hub. Every other build, and `vite dev`, is Node. +const CLOUDFLARE = process.env.NITRO_PRESET === 'cloudflare-module' + const config = defineConfig({ resolve: { alias: [{ find: /^@\//, replacement: `${import.meta.dirname}/src/` }], @@ -20,6 +24,7 @@ const config = defineConfig({ vitnode({ appRoot: import.meta.dirname, nitro: { + ...(CLOUDFLARE ? { preset: 'cloudflare-module' } : {}), prerender: { routes: prerenderRoutes({ i18n: vitNodeConfig.i18n, @@ -27,7 +32,8 @@ const config = defineConfig({ }), }, }, - webSocket: true, + // On Workers, `/api/ws` is the `VitNodeRealtimeHub` Durable Object. + webSocket: CLOUDFLARE ? { transport: 'cloudflare' } : true, }), fumadocsMdx({ index: false }), devtools(), diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc new file mode 100644 index 0000000000..5eef154ca7 --- /dev/null +++ b/apps/web/wrangler.jsonc @@ -0,0 +1,31 @@ +{ + // `pnpm build:cloudflare` merges this file into `.output/server/wrangler.json` + // and adds `main` and `assets`; Wrangler finds that file on its own through + // `.wrangler/deploy/config.json`, and reads `.dev.vars` from this folder. + "name": "vitnode-web", + "compatibility_date": "2026-10-01", + "compatibility_flags": ["nodejs_compat"], + // Nitro uploads every module it wrote, including chunks nothing imports. + // Bundling from the entry keeps only what the Worker reaches, and minifying + // brings it under the Workers Paid size limit (10 MiB gzipped). + "no_bundle": false, + "minify": true, + // Server code that ships functions to the browser as source (the theme and + // scroll scripts) breaks under esbuild's name-keeping helper once minified. + "keep_names": false, + // Prerendered pages are `page/index.html`: serve them at `/page`, as Node + // does, instead of redirecting to `/page/`. + "assets": { "html_handling": "drop-trailing-slash" }, + "durable_objects": { + "bindings": [ + // The realtime hub that owns every WebSocket. The binding name is fixed. + { "name": "VITNODE_REALTIME", "class_name": "VitNodeRealtimeHub" } + ] + }, + // SQLite-backed, so the hub keeps its rooms while it hibernates. + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["VitNodeRealtimeHub"] }], + "vars": { + "VITNODE_INSTALLATION_ID": "default" + }, + "observability": { "enabled": true } +} diff --git a/packages/create-vitnode-app/README.md b/packages/create-vitnode-app/README.md index 094d717651..5611c077fc 100644 --- a/packages/create-vitnode-app/README.md +++ b/packages/create-vitnode-app/README.md @@ -77,8 +77,9 @@ plugins: [myPlugin()]; | `--skip-install` | Skip dependency installation after scaffolding. | | `--skip-git` | Skip initializing a git repository (created on `main` after install). | | `--mode` | Choose `singleApp`, `apiMonorepo`, or `onlyApi`. | +| `--deploy` | `node` (default), `bun`, or `cloudflare` (Single App only). | | `--monorepo` | Create a workspace layout for plugins and multiple applications. | -| `--docker` | Include local Docker services. | +| `--docker` | Include local Docker services and a production image. | | `--plugin` | Create a VitNode plugin package. | ## Linting diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts index 7751797193..c4736260ab 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts @@ -1,6 +1,8 @@ import { OpenAPIHono } from "@hono/zod-openapi"; +import { websocketOriginMiddleware } from "@vitnode/core/api/middlewares/websocket-origin.middleware"; import { VitNodeAPI } from "@vitnode/core/api/config"; -import "hono"; +import { handleVitNodeWebSocket } from "@vitnode/core/ws/handle"; +import { upgradeWebSocket, websocket } from "hono/bun"; import { vitNodeApiConfig } from "./vitnode.api.config.js"; @@ -11,8 +13,22 @@ VitNodeAPI({ vitNodeApiConfig, }); +// VitNode's shared WebSocket at `/api/ws`, upgraded by Bun itself. The +// handshake passes the Origin check and the API's middleware first, so the +// session cookie decides the user. Pass the web app's origin when it is not +// `VITNODE_WEB_URL`. +app.get( + "/ws", + websocketOriginMiddleware(), + upgradeWebSocket(handleVitNodeWebSocket()), +); + +// Bun serves this object: `bun --hot src/index.ts` in development (`vitnode +// dev`), `bun src/index.ts` in production (`vitnode start`). Bun pings idle +// sockets and drops dead ones itself. export default { + fetch: app.fetch, hostname: process.env.HOST, port: Number(process.env.PORT ?? 8000), - fetch: app.fetch, + websocket, }; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/.dev.vars.example b/packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/.dev.vars.example new file mode 100644 index 0000000000..4cdde3bdda --- /dev/null +++ b/packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/.dev.vars.example @@ -0,0 +1,5 @@ +# Secrets for `preview` (wrangler dev). Copy to `.dev.vars`; never commit it. +# In production set them with `wrangler secret put `. +POSTGRES_URL=postgresql://root:root@127.0.0.1:5432/vitnode +VITNODE_WEB_URL=http://localhost:8787 +CRON_SECRET=your-secure-cron-secret-key diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/src/vitnode.api.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/src/vitnode.api.config.ts new file mode 100644 index 0000000000..3e34b5ebd3 --- /dev/null +++ b/packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/src/vitnode.api.config.ts @@ -0,0 +1,38 @@ +import { scopedDatabase } from "@vitnode/core/cloudflare/database"; +import { coreRelations } from "@vitnode/core/database/relations"; +import { buildApiConfig } from "@vitnode/core/vitnode.config"; +import { drizzle } from "drizzle-orm/postgres-js"; + +import { contentModules } from "./content-modules.gen"; +import { frontendPlugins } from "./plugin-inventory.gen"; +import { vitNodeConfig } from "./vitnode.config"; + +export const POSTGRES_URL = + process.env.POSTGRES_URL ?? "postgresql://root:root@localhost:5432/vitnode"; + +export const vitNodeApiConfig = buildApiConfig({ + metadata: { + title: "VitNode", + shortTitle: "VitNode", + }, + plugins: [], + contentModules, + frontendPlugins, + i18n: vitNodeConfig.i18n, + // A Cloudflare Worker request cannot reuse a connection another request + // opened, so the client is created per request - and per busy period of the + // realtime hub, which closes it when idle so it can hibernate. On Node + // (`dev`, migrations) it is one client per process. + dbProvider: scopedDatabase(() => + drizzle({ + connection: { + url: POSTGRES_URL, + max: 1, + onnotice: () => undefined, + }, + relations: coreRelations, + }), + ), + // No `redis`: on Cloudflare the realtime hub owns every socket, so there is + // nothing to relay, and the cache falls back to each isolate's memory. +}); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/vite.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/vite.config.ts new file mode 100644 index 0000000000..3051812c42 --- /dev/null +++ b/packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/vite.config.ts @@ -0,0 +1,28 @@ +import tailwindcss from "@tailwindcss/vite"; +import { devtools } from "@tanstack/devtools-vite"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import viteReact from "@vitejs/plugin-react"; +import { vitnode } from "@vitnode/core/framework/vite"; +import { defineConfig } from "vite"; + +const config = defineConfig(({ command }) => ({ + resolve: { tsconfigPaths: true }, + server: { strictPort: true }, + plugins: [ + vitnode({ + appRoot: import.meta.dirname, + // `vite build` makes one Cloudflare Worker: the site, the API and the + // realtime hub, with `wrangler.jsonc` merged into its config. `vite dev` + // stays on Node. + nitro: command === "build" ? { preset: "cloudflare-module" } : {}, + // `/api/ws` is served by the `VitNodeRealtimeHub` Durable Object. + webSocket: { transport: "cloudflare" }, + }), + devtools(), + tailwindcss(), + tanstackStart(), + viteReact(), + ], +})); + +export default config; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/wrangler.jsonc b/packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/wrangler.jsonc new file mode 100644 index 0000000000..58cdcb5520 --- /dev/null +++ b/packages/create-vitnode-app/copy-of-vitnode-app/cloudflare/wrangler.jsonc @@ -0,0 +1,22 @@ +{ + // Nitro merges this file into `.output/server/wrangler.json` and adds `main` + // and `assets`; Wrangler finds that file on its own through + // `.wrangler/deploy/config.json`, and reads `.dev.vars` from this folder. + "name": "{{WORKER_NAME}}", + "compatibility_date": "2026-10-01", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + // The realtime hub that owns every WebSocket. The binding name is fixed. + { "name": "VITNODE_REALTIME", "class_name": "VitNodeRealtimeHub" } + ] + }, + // SQLite-backed, so the hub keeps its rooms while it hibernates. + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["VitNodeRealtimeHub"] }], + "vars": { + "VITNODE_INSTALLATION_ID": "default" + // Optional: "VITNODE_REALTIME_SHARDS": "4" spreads sockets across four + // hubs; "VITNODE_REALTIME_HIBERNATION": "false" keeps the hub awake. + }, + "observability": { "enabled": true } +} diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/global.d.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/global.d.ts index 9952fb5a90..aaf6253434 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/global.d.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/global.d.ts @@ -1,10 +1,13 @@ /// -import core from '@vitnode/core/locales/en.json' with { type: 'json' } +import core from "@vitnode/core/locales/en.json" with { type: "json" }; +import app from "./src/locales/app/en.json" with { type: "json" }; -declare module 'use-intl' { +declare module "use-intl" { interface AppConfig { - Messages: typeof core + // The app's own strings (`src/locales/app`) and core's. Add a plugin's + // `typeof` here when you use its keys in app code. + Messages: typeof app & typeof core; } } diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx index 55d1ffc6b2..e585cb9081 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx @@ -31,6 +31,12 @@ export const Route = createRootRouteWithContext()({ locale: resolveLocale(publicPathnameOf(location)), }), component: RootComponent, + loader: async ({ context }) => { + await context.queryClient.query({ + ...intlQueryOptions({ locale: context.locale }), + staleTime: "static", + }); + }, head: () => ({ links: [ { href: appCss, rel: "stylesheet" }, @@ -48,12 +54,6 @@ export const Route = createRootRouteWithContext()({ { title: metadata.title }, ], }), - loader: async ({ context }) => { - await context.queryClient.query({ - ...intlQueryOptions({ locale: context.locale }), - staleTime: "static", - }); - }, shellComponent: RootDocument, }); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main/index.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main/index.tsx index 0c935cf433..7fdaf6e95b 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main/index.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main/index.tsx @@ -1,8 +1,7 @@ -import type { RegisteredRouter } from "@tanstack/react-router"; import type { LucideIcon } from "lucide-react"; import type { Messages } from "use-intl"; -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import { LogoVitNode } from "@vitnode/core/components/logo-vitnode"; import { buttonVariants } from "@vitnode/core/components/ui/button"; import { @@ -15,6 +14,7 @@ import { intlQueryOptions, RouteMessages, } from "@vitnode/core/tanstack/i18n"; +import { HrefLink } from "@vitnode/core/tanstack/link"; import { pageHead } from "@vitnode/core/tanstack/metadata"; import { cn } from "cn"; import { @@ -119,16 +119,16 @@ function HomeContent() {
{user ? null : ( - {t("create_account")} - + )} - {t("open_admin")} - +
@@ -208,8 +208,8 @@ function NextStepCard({ external = false, href, icon: Icon, id }: NextStep) { } return ( - className={className} href={href}> + {content} - + ); } diff --git a/packages/create-vitnode-app/src/create/create-docker.ts b/packages/create-vitnode-app/src/create/create-docker.ts new file mode 100644 index 0000000000..362deada96 --- /dev/null +++ b/packages/create-vitnode-app/src/create/create-docker.ts @@ -0,0 +1,411 @@ +import { writeFile } from "fs/promises"; +import { join } from "path"; + +import type { CreateCliReturn } from "../questions.js"; + +import { runtimeOf } from "./create-package-json.js"; + +type Mode = CreateCliReturn["mode"]; +type Runtime = "bun" | "node"; + +const NODE_IMAGE = "node:24-slim"; +const BUN_IMAGE = "oven/bun:1"; +const BUN_RUNTIME_IMAGE = "oven/bun:1-slim"; + +interface DockerLayout { + /** A standalone API, if the project has one. */ + api?: { dir: string }; + /** The TanStack Start app that serves the site, if the project has one. */ + app?: { dir: string; embedsApi: boolean }; + /** The package that owns the schema and runs `db:migrate`. */ + db: string; +} + +export const dockerLayout = (mode: Mode, monorepo: boolean): DockerLayout => { + if (mode === "singleApp") { + const dir = monorepo ? "apps/web" : "."; + + return { app: { dir, embedsApi: true }, db: dir }; + } + if (mode === "onlyApi") { + const dir = monorepo ? "apps/api" : "."; + + return { api: { dir }, db: dir }; + } + + return { + api: { dir: "apps/api" }, + app: { dir: "apps/web", embedsApi: false }, + db: "apps/api", + }; +}; + +const installCommand = (pm: string): string => { + if (pm === "npm") return "npm ci"; + if (pm === "bun") return "bun install --frozen-lockfile"; + + return "pnpm install --frozen-lockfile"; +}; + +/** + * A clean install without devDependencies over the built workspace, so what + * the build compiled (`dist`, plugins) stays and the dev tooling goes. Every + * `node_modules` is removed first: an install over a dev one leaves the dev + * packages behind in pnpm's store folder. + */ +const prodInstallCommand = (pm: string): string => { + const clean = + "find . -name node_modules -type d -prune -exec rm -rf {} + && "; + if (pm === "npm") return "npm ci --omit=dev"; + if (pm === "bun") return `${clean}bun install --production --frozen-lockfile`; + + return `${clean}pnpm install --prod --frozen-lockfile`; +}; + +const storeCache = (pm: string): string => { + if (pm === "npm") return "/root/.npm"; + if (pm === "bun") return "/root/.bun/install/cache"; + + return "/root/.local/share/pnpm/store"; +}; + +/** Where local file storage keeps uploads, relative to the server's folder. */ +const UPLOADS = "public/uploads"; + +/** The folder uploads land in inside the image that serves the API. */ +const uploadsPath = (layout: DockerLayout): string => + layout.api ? `${workdir(layout.api.dir)}/${UPLOADS}` : `/app/${UPLOADS}`; + +const workdir = (dir: string): string => (dir === "." ? "/app" : `/app/${dir}`); + +/** + * A `HEALTHCHECK` that needs nothing but the runtime itself - slim images ship + * no curl. An app or API asks `/api/health`; a web app without an embedded API + * has no such route, so only its port is checked. + */ +const healthcheck = (runtime: Runtime, port: number, http: boolean): string => { + const target = `process.env.PORT||${String(port)}`; + const script = http + ? `fetch('http://127.0.0.1:'+(${target})+'/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))` + : `require('node:net').connect(${target},'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))`; + + return `HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \\ + CMD ["${runtime}", "-e", "${script}"]`; +}; + +/** + * The production `Dockerfile`: one `build` stage for the whole workspace, a + * `migrate` target that applies migrations once per deploy, and one small + * runtime target per server (`app`, `api`). + */ +export const dockerfile = ({ + appName, + layout, + packageManager: pm, + pmVersion, + runtime, +}: { + appName: string; + layout: DockerLayout; + packageManager: string; + pmVersion: string | undefined; + runtime: Runtime; +}): string => { + const needsBun = pm === "bun" || runtime === "bun"; + // A Bun API has no build: Bun runs `src/index.ts` as it is. + const hasBuild = !( + runtime === "bun" && + !layout.app && + layout.api?.dir === "." + ); + const runtimeImage = runtime === "bun" ? BUN_RUNTIME_IMAGE : NODE_IMAGE; + // Both runtime images name their unprivileged user after the binary. + const user = runtime; + + const lines: string[] = [ + "# syntax=docker/dockerfile:1", + "", + "# Production image. Build one target per server:", + ...(layout.app + ? [`# docker build --target app -t ${appName}-app .`] + : []), + ...(layout.api + ? [`# docker build --target api -t ${appName}-api .`] + : []), + "# and run `--target migrate` once per deploy, before the new version starts.", + "# docker-compose.prod.yml does all of it.", + "", + `FROM ${NODE_IMAGE} AS build`, + "WORKDIR /app", + ]; + + if (needsBun) { + lines.push( + `COPY --from=${BUN_IMAGE} /usr/local/bin/bun /usr/local/bin/bun`, + ); + } + if (pm === "pnpm") { + if (pmVersion === undefined) { + throw new Error( + "Install pnpm before generating a pnpm project with Docker: the image pins the pnpm version you have.", + ); + } + lines.push(`RUN npm install -g pnpm@${pmVersion}`); + } + lines.push( + "", + "COPY . .", + `RUN --mount=type=cache,target=${storeCache(pm)} ${installCommand(pm)}`, + ); + + if (layout.app && !layout.app.embedsApi) { + lines.push( + "", + "# The web app calls the API at this URL from the server and from the", + "# browser, so it is compiled in: the API's public address.", + "ARG VITNODE_API_URL", + "ENV VITNODE_API_URL=$VITNODE_API_URL", + ); + } + if (hasBuild) { + lines.push("", "ENV NODE_ENV=production", `RUN ${pm} run build`); + } + + lines.push( + "", + "# Applies pending migrations and seeds what VitNode needs, then exits.", + "FROM build AS migrate", + `WORKDIR ${workdir(layout.db)}`, + "ENV NODE_ENV=production", + `CMD ["${pm}", "run", "db:migrate"]`, + ); + + if (layout.app) { + lines.push( + "", + "# The site: Nitro's server bundle, with no node_modules beside it.", + `FROM ${runtimeImage} AS app`, + "WORKDIR /app", + "ENV NODE_ENV=production PORT=3000", + `COPY --from=build --chown=${user}:${user} ${workdir(layout.app.dir)}/.output ./.output`, + ...(layout.app.embedsApi + ? [ + "# Local file storage writes here; compose mounts a volume on it.", + `RUN mkdir -p ${UPLOADS} && chown -R ${user}:${user} public`, + ] + : []), + `USER ${user}`, + "EXPOSE 3000", + healthcheck(runtime, 3000, layout.app.embedsApi), + `CMD ["${runtime}", ".output/server/index.mjs"]`, + ); + } + + if (layout.api) { + const entry = runtime === "bun" ? "src/index.ts" : "dist/index.js"; + lines.push( + "", + "# The build without devDependencies: what the API needs at runtime.", + "FROM build AS api-deps", + `RUN --mount=type=cache,target=${storeCache(pm)} ${prodInstallCommand(pm)}`, + "", + "# The API runs from the workspace with its production packages.", + `FROM ${runtimeImage} AS api`, + `COPY --from=api-deps --chown=${user}:${user} /app /app`, + `WORKDIR ${workdir(layout.api.dir)}`, + `RUN mkdir -p ${UPLOADS} && chown ${user}:${user} public ${UPLOADS}`, + "ENV NODE_ENV=production PORT=8000", + `USER ${user}`, + "EXPOSE 8000", + healthcheck(runtime, 8000, true), + `CMD ["${runtime}", "${entry}"]`, + ); + } + + return `${lines.join("\n")}\n`; +}; + +/** Keeps secrets, local data and anything the build regenerates out of the image. */ +export const DOCKERIGNORE = `# Never send secrets or local data to the build +.env +.env.* +!.env.example +docker/dev + +# Rebuilt inside the image +**/node_modules +**/.output +**/.nitro +**/.vite +**/.tanstack +**/.turbo +**/dist +**/*.tsbuildinfo + +# Not needed to build +.git +.vscode +**/*.log +Dockerfile +docker-compose*.yml +`; + +/** + * `docker-compose.prod.yml`: PostgreSQL and Redis on a private network, the + * migration job, and the server(s), which start only once migrations + * succeeded. Every secret comes from an env file, never from this file. + */ +export const dockerComposeProd = ({ + appName, + layout, +}: { + appName: string; + layout: DockerLayout; +}): string => { + const split = layout.app !== undefined && !layout.app.embedsApi; + // Every target builds FROM the same stage: the same args share one build. + const build = (target: string): string => + ` build: + context: . + target: ${target}${ + split + ? ` + args: + VITNODE_API_URL: \${VITNODE_API_URL:?Set VITNODE_API_URL to the API's public origin}` + : "" + }`; + const ready = ` depends_on: + migrate: + condition: service_completed_successfully + cache: + condition: service_started`; + + const services: string[] = [ + ` database: + image: postgres:17.5-alpine + restart: unless-stopped + environment: + POSTGRES_USER: \${POSTGRES_USER:-vitnode} + POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + POSTGRES_DB: \${POSTGRES_DB:-vitnode} + volumes: + - database:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U $\${POSTGRES_USER} -d $\${POSTGRES_DB}'] + interval: 5s + timeout: 5s + retries: 10`, + ` cache: + image: redis:8.8-alpine + restart: unless-stopped + command: redis-server --requirepass \${REDIS_PASSWORD:?Set REDIS_PASSWORD}`, + ` # Runs once per \`up\`, then exits; the servers wait for it. + migrate: +${build("migrate")} + environment: *server-env + depends_on: + database: + condition: service_healthy`, + ]; + + if (layout.api) { + services.push(` api: +${build("api")} + image: ${appName}-api + restart: unless-stopped + environment: *server-env + ports: + - '\${API_PORT:-8000}:8000' + volumes: + - uploads:${uploadsPath(layout)} +${ready}`); + } + + if (layout.app) { + services.push(` app: +${build("app")} + image: ${appName}-app + restart: unless-stopped + environment:${ + split + ? ` + # The web app reaches the database only through the API. + VITNODE_WEB_URL: \${VITNODE_WEB_URL:?Set VITNODE_WEB_URL to the site's public origin} + VITNODE_API_URL: \${VITNODE_API_URL:?Set VITNODE_API_URL to the API's public origin}` + : " *server-env" + } + ports: + - '\${APP_PORT:-3000}:3000'${ + split + ? "" + : ` + volumes: + - uploads:${uploadsPath(layout)}` + } +${ + split + ? ` depends_on: + api: + condition: service_healthy` + : ready +}`); + } + + return `# Production stack. Put the variables below in .env.production, then: +# docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build +# PostgreSQL and Redis are not published; only the server ports are. +name: ${appName} + +x-server-env: &server-env + POSTGRES_URL: postgresql://\${POSTGRES_USER:-vitnode}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@database:5432/\${POSTGRES_DB:-vitnode} + REDIS_URL: redis://cache:6379 + REDIS_PASSWORD: \${REDIS_PASSWORD:?Set REDIS_PASSWORD} + 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} + # 0 when visitors reach the container directly, 1 behind one reverse proxy. + VITNODE_TRUSTED_PROXY_HOPS: \${VITNODE_TRUSTED_PROXY_HOPS:-0} + +services: +${services.join("\n\n")} + +volumes: + database: + # Local file storage. Not needed once uploads go to S3, R2 or Supabase. + uploads: +`; +}; + +/** Writes the production Docker files into the project root. */ +export const createDockerFiles = async ({ + appName, + deploy, + mode, + monorepo, + packageManager, + pmVersion, + root, +}: Pick & { + appName: string; + monorepo: boolean; + pmVersion: string | undefined; + root: string; +}): Promise => { + const layout = dockerLayout(mode, monorepo); + await Promise.all([ + writeFile( + join(root, "Dockerfile"), + dockerfile({ + appName, + layout, + packageManager, + pmVersion, + runtime: runtimeOf(deploy), + }), + ), + writeFile(join(root, ".dockerignore"), DOCKERIGNORE), + writeFile( + join(root, "docker-compose.prod.yml"), + dockerComposeProd({ appName, layout }), + ), + ]); +}; diff --git a/packages/create-vitnode-app/src/create/create-package-json.ts b/packages/create-vitnode-app/src/create/create-package-json.ts index 40a9521351..40a32edd95 100644 --- a/packages/create-vitnode-app/src/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/create/create-package-json.ts @@ -2,7 +2,7 @@ import { writeFile } from "fs/promises"; import { join } from "path"; import type { PackageJSON } from "../helpers/packages-json.js"; -import type { CreateCliReturn } from "../questions.js"; +import type { CreateCliReturn, DeployTarget } from "../questions.js"; import { getAvailablePackageManagers } from "../helpers/get-available-package-managers.js"; import { getVitnodePackageVersion } from "../helpers/get-vitnode-package-version.js"; @@ -23,6 +23,20 @@ const paths = (root: string) => ({ web: join(root, "apps", "web"), }); +/** What a generated app or API runs on: `node`, or `bun` for `--deploy bun`. */ +type Runtime = "bun" | "node"; + +export const runtimeOf = (deploy: DeployTarget): Runtime => + deploy === "bun" ? "bun" : "node"; + +/** + * The app's Vite-driven commands. On Bun they run under `bun --bun`, so Vite, + * Nitro and TanStack Start themselves run on Bun, as Bun's TanStack Start + * guide does; `vitnode start` then starts the `bun` preset's output with Bun. + */ +const viteCommand = (command: "build" | "dev", runtime: Runtime): string => + runtime === "bun" ? `bun --bun vitnode ${command}` : `vitnode ${command}`; + /** * Shared blocks */ @@ -125,14 +139,16 @@ export const apiScripts = ( docker: boolean, onlyApi: boolean, appName: string, + runtime: Runtime = pm === "bun" ? "bun" : "node", ) => { return { "db:migrate": "vitnode migrate", "db:prepare": "vitnode db:prepare", - // `vitnode` follows the project's runtime: on Bun it runs `bun --hot` - // and starts `src/index.ts` directly, so there is nothing to build. + // `vitnode` follows the project's runtime (`vitnode.runtime`): on Bun it + // runs `bun --hot` and starts `src/index.ts` directly, so there is + // nothing to build. dev: "vitnode dev", - ...(pm === "bun" ? {} : { build: "vitnode build" }), + ...(runtime === "bun" ? {} : { build: "vitnode build" }), start: "vitnode start", "dev:email": "email dev --dir src/emails", ...i18nScripts, @@ -172,21 +188,31 @@ export const singleAppScripts = ( oxlint: boolean, docker: boolean, appName: string, + deploy: DeployTarget = "node", ) => ({ "db:migrate": "vitnode migrate", "db:prepare": "vitnode db:prepare", - dev: "vitnode dev", + dev: viteCommand("dev", runtimeOf(deploy)), "dev:email": "email dev --dir src/emails", - build: "vitnode build", - start: "vitnode start", + build: viteCommand("build", runtimeOf(deploy)), + // A Cloudflare build is a Worker, not a Node entry: run it in workerd with + // Wrangler instead. `deploy` is a pnpm built-in, hence the longer names. + ...(deploy === "cloudflare" + ? { + preview: "wrangler dev", + "deploy:cloudflare": "wrangler deploy", + "check:cloudflare": + "wrangler deploy --dry-run --outdir .output/wrangler-dry-run", + } + : { start: "vitnode start" }), ...i18nScripts, ...withIf(oxlint, { ...oxlintScripts, ...formatScripts }), ...withIf(docker, { "docker:dev": dockerDevScript(appName) }), }); -export const webScripts = (oxlint: boolean) => ({ - dev: "vitnode dev", - build: "vitnode build", +export const webScripts = (oxlint: boolean, runtime: Runtime = "node") => ({ + dev: viteCommand("dev", runtime), + build: viteCommand("build", runtime), start: "vitnode start", ...i18nScripts, ...withIf(oxlint, oxlintScripts), @@ -204,30 +230,37 @@ const rootDevDeps = (oxlint: boolean) => ({ zod: versionsPackageJson.zod, }); -const apiDeps = { +/** + * What the API imports at runtime - a production install (a Docker image, + * `npm ci --omit=dev`) has nothing else. `@hono/node-server` serves the Node + * API; on Bun, Bun serves it. + */ +const apiDeps = (runtime: Runtime) => ({ + ...(runtime === "bun" ? {} : { "@hono/node-server": "^2.0" }), "@hono/zod-openapi": versionsPackageJson.honoZodOpenapi, "@hono/zod-validator": versionsPackageJson.honoZodValidator, "@vitnode/core": "", // filled dynamically + dotenv: versionsPackageJson.dotenv, "drizzle-kit": versionsPackageJson.drizzleKit, "drizzle-orm": versionsPackageJson.drizzleOrm, hono: versionsPackageJson.hono, + // Core's built-in blocks import their icons, and the API loads them. + "lucide-react": versionsPackageJson.lucide, react: versionsPackageJson.react, "react-dom": versionsPackageJson.reactDom, "react-email": versionsPackageJson.reactEmail, ws: versionsPackageJson.ws, zod: versionsPackageJson.zod, -}; +}); -const apiDevDeps = (pm: string, oxlint: boolean) => ({ - "@hono/node-server": "^2.0", +const apiDevDeps = (oxlint: boolean, runtime: Runtime) => ({ "@react-email/ui": versionsPackageJson.reactEmailUi, - ...(pm === "bun" ? { "@types/bun": versionsPackageJson.typesBun } : {}), + ...(runtime === "bun" ? { "@types/bun": versionsPackageJson.typesBun } : {}), "@types/node": versionsPackageJson.typesNode, "@types/react": versionsPackageJson.typesReact, "@types/react-dom": versionsPackageJson.typesReactDom, cn: versionsPackageJson.cn, "@vitnode/config": "", - dotenv: versionsPackageJson.dotenv, ...withIf(oxlint, oxcDevDeps), "tsc-alias": versionsPackageJson.tscAlias, tsx: versionsPackageJson.tsx, @@ -278,11 +311,14 @@ const tanstackWebDevDeps = { vite: versionsPackageJson.vite, }; -const singleAppDevDeps = (oxlint: boolean) => ({ +const singleAppDevDeps = (oxlint: boolean, deploy: DeployTarget) => ({ ...tanstackWebDevDeps, "@react-email/ui": versionsPackageJson.reactEmailUi, ...withIf(oxlint, oxcDevDeps), turbo: versionsPackageJson.turbo, + ...withIf(deploy === "cloudflare", { + wrangler: versionsPackageJson.wrangler, + }), }); const webDeps = { @@ -301,6 +337,7 @@ const webDevDeps = (oxlint: boolean) => ({ */ export const createPackageJSON = async ({ appName, + deploy = "node", packageManager, root, oxlint, @@ -309,6 +346,7 @@ export const createPackageJSON = async ({ monorepo, }: { appName: string; + deploy?: DeployTarget; docker?: boolean; mode: Mode; monorepo?: boolean; @@ -321,6 +359,7 @@ export const createPackageJSON = async ({ const pmSpec = `${packageManager}@${pmVersions[packageManager]}`; const p = paths(root); + const runtime = runtimeOf(deploy); const isApiMonorepo = mode === "apiMonorepo" || !!monorepo; const isOnlyApi = mode === "onlyApi"; const isSingleApp = mode === "singleApp"; @@ -362,17 +401,17 @@ export const createPackageJSON = async ({ !!docker, mode === "onlyApi", appName, + runtime, ), dependencies: { - ...apiDeps, + ...apiDeps(runtime), "@vitnode/core": vitnodeVersionRange, }, devDependencies: { - ...apiDevDeps(packageManager, oxlint), + ...apiDevDeps(oxlint, runtime), "@vitnode/config": vitnodeVersionRange, - // TS pipeline pieces when not using Bun for dev - ...(packageManager === "bun" ? {} : {}), }, + vitnode: { runtime }, }; // 3) Single app (TanStack Start + the Hono API inside one app) @@ -383,16 +422,17 @@ export const createPackageJSON = async ({ private: true, type: "module", engines, - scripts: singleAppScripts(oxlint, !!docker, appName), + scripts: singleAppScripts(oxlint, !!docker, appName, deploy), dependencies: { ...singleAppDeps, "@vitnode/core": vitnodeVersionRange, }, devDependencies: { - ...singleAppDevDeps(oxlint), + ...singleAppDevDeps(oxlint, deploy), "@vitnode/config": vitnodeVersionRange, }, packageManager: pmSpec, + vitnode: { runtime }, }; await writeJson(join(monorepo ? p.web : p.root, "package.json"), singlePkg); @@ -408,7 +448,7 @@ export const createPackageJSON = async ({ private: true, type: "module", engines, - scripts: webScripts(oxlint), + scripts: webScripts(oxlint, runtime), dependencies: { ...webDeps, "@vitnode/core": vitnodeVersionRange, @@ -417,6 +457,7 @@ export const createPackageJSON = async ({ ...webDevDeps(oxlint), "@vitnode/config": vitnodeVersionRange, }, + vitnode: { runtime }, }; await writeJson(join(p.web, "package.json"), webPkg); diff --git a/packages/create-vitnode-app/src/create/create-vitnode.ts b/packages/create-vitnode-app/src/create/create-vitnode.ts index a238262bfd..bc510e3a81 100644 --- a/packages/create-vitnode-app/src/create/create-vitnode.ts +++ b/packages/create-vitnode-app/src/create/create-vitnode.ts @@ -15,10 +15,12 @@ import color from "picocolors"; import type { InitGitResult } from "../helpers/init-git.js"; import type { CreateCliReturn } from "../questions.js"; +import { getAvailablePackageManagers } from "../helpers/get-available-package-managers.js"; import { initGitRepository } from "../helpers/init-git.js"; import { generateMigrationsVitnode } from "../helpers/init-vitnode.js"; import { installDependencies } from "../helpers/install-dependencies.js"; import { isFolderEmpty } from "../helpers/is-folder-empty.js"; +import { createDockerFiles } from "./create-docker.js"; import { createPackageJSON } from "./create-package-json.js"; const GIT_WARNINGS: Record, string> = { @@ -30,9 +32,39 @@ const GIT_WARNINGS: Record, string> = { 'Git is not installed, so no repository was created. Run "git init" in the project after installing git.', }; +/** + * A Cloudflare Worker name from the project name: lowercase letters, digits + * and dashes, at most 63 characters. + */ +export const workerNameOf = (appName: string): string => + appName + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 63) || "vitnode"; + +/** The nitro options line of the templates' `vite.config.ts`. */ +const NITRO_OPTIONS_LINE = + " nitro: { rollupConfig: { external: [/^@sentry\\//] } },"; + +const BUN_NITRO_OPTIONS = ` nitro: { + // Built for Bun: the server runs on Bun.serve, and \`vitnode start\` + // starts it with Bun. + preset: "bun", + rollupConfig: { external: [/^@sentry\\//] }, + },`; + +const CLOUDFLARE_GITIGNORE = ` +# Cloudflare (Wrangler) +.wrangler +.dev.vars* +!.dev.vars.example +`; + export const createVitNode = async ({ root, appName, + deploy, packageManager, oxlint, git, @@ -112,6 +144,27 @@ export const createVitNode = async ({ await cp(join(templatePath, "api-single-app"), destination, { recursive: true, }); + + // Cloudflare Workers: the Worker preset, the realtime hub's Wrangler + // config and a per-request database client, over the same app. + if (deploy === "cloudflare") { + await cp(join(templatePath, "cloudflare"), destination, { + recursive: true, + }); + const wranglerPath = join(destination, "wrangler.jsonc"); + await writeFile( + wranglerPath, + (await readFile(wranglerPath, "utf-8")).replace( + "{{WORKER_NAME}}", + workerNameOf(appName), + ), + ); + const gitignorePath = join(destination, ".gitignore_template"); + await writeFile( + gitignorePath, + (await readFile(gitignorePath, "utf-8")) + CLOUDFLARE_GITIGNORE, + ); + } } else if (mode === "apiMonorepo") { // Two different destinations, so these genuinely are independent. await Promise.all([ @@ -123,7 +176,7 @@ export const createVitNode = async ({ }), ]); - if (packageManager === "bun") { + if (deploy === "bun") { await cp(join(templatePath, "api-bun"), monorepoStructure.api, { recursive: true, }); @@ -137,7 +190,7 @@ export const createVitNode = async ({ }, ); - if (packageManager === "bun") { + if (deploy === "bun") { await cp( join(templatePath, "api-bun"), monorepo ? monorepoStructure.api : root, @@ -164,6 +217,24 @@ export const createVitNode = async ({ ); } + if (deploy === "bun" && mode !== "onlyApi") { + const viteConfigPath = join( + monorepo || mode === "apiMonorepo" ? monorepoStructure.web : root, + "vite.config.ts", + ); + const viteConfig = await readFile(viteConfigPath, "utf-8"); + const withBunPreset = viteConfig.replace( + NITRO_OPTIONS_LINE, + BUN_NITRO_OPTIONS, + ); + if (withBunPreset === viteConfig) { + throw new Error( + `Could not set Nitro's bun preset in ${viteConfigPath}: the template's nitro options changed.`, + ); + } + await writeFile(viteConfigPath, withBunPreset); + } + if (oxlint) { spinner.text = "Copying Oxlint & Oxfmt files..."; if (monorepo) { @@ -211,6 +282,7 @@ export const createVitNode = async ({ await createPackageJSON({ root, appName, + deploy, packageManager, oxlint, docker, @@ -238,6 +310,20 @@ export const createVitNode = async ({ `${appName}_vitnode_postgres_dev`, ); await writeFile(dockerComposePath, updatedContent); + + // A Worker is deployed with Wrangler, not as a container. + if (deploy !== "cloudflare") { + const pmVersions = await getAvailablePackageManagers(); + await createDockerFiles({ + appName, + deploy, + mode, + monorepo: !!monorepo, + packageManager, + pmVersion: pmVersions[packageManager], + root, + }); + } } // `src/styles.css` points Tailwind at `@vitnode/core`'s compiled components @@ -291,6 +377,45 @@ export const createVitNode = async ({ } readmeContent = readmeContent.replace("{{START_URLS}}", startUrlsText); + if (deploy === "cloudflare") { + const run = packageManager === "npm" ? "npm run" : packageManager; + readmeContent += ` +## Deploy to Cloudflare Workers + +\`${run} dev\` runs the app on Node for development. To run the real Worker, +with WebSockets served by the \`VitNodeRealtimeHub\` Durable Object, copy +\`.dev.vars.example\` to \`.dev.vars\`, then: + +\`\`\`bash +${run} build +${run} preview +\`\`\` + +To deploy, set each secret once with +\`npx wrangler secret put POSTGRES_URL\`, +then run \`${run} check:cloudflare\` (a dry run) and +\`${run} deploy:cloudflare\`. See +[Cloudflare Workers](https://vitnode.com/docs/dev/deployments/cloud/cloudflare) in the docs. +`; + } + if (docker && deploy !== "cloudflare") { + readmeContent += ` +## Deploy with Docker + +\`Dockerfile\` builds the production image and \`docker-compose.prod.yml\` runs +it with PostgreSQL, Redis and a one-off migration job. Put +\`POSTGRES_PASSWORD\`, \`REDIS_PASSWORD\`, \`VITNODE_WEB_URL\` and \`CRON_SECRET\`${ + mode === "apiMonorepo" ? " (and `VITNODE_API_URL`)" : "" + } +in \`.env.production\`, then: + +\`\`\`bash +docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build +\`\`\` + +See [Docker](https://vitnode.com/docs/dev/deployments/docker) in the docs. +`; + } await writeFile(join(root, "README.md"), readmeContent); spinner.text = "Generating migrations..."; diff --git a/packages/create-vitnode-app/src/create/package-versions.ts b/packages/create-vitnode-app/src/create/package-versions.ts index feebf5a9c2..3954e86261 100644 --- a/packages/create-vitnode-app/src/create/package-versions.ts +++ b/packages/create-vitnode-app/src/create/package-versions.ts @@ -43,6 +43,7 @@ export const versionsPackageJson = { viteReact: "^6.2", tailwindVite: "^4.3", nitro: "3.0.261103-rc", + wrangler: "^4.153.0", tslib: "^2.9.1", tsdown: "^0.23.7", vitest: "^5.1", diff --git a/packages/create-vitnode-app/src/helpers/packages-json.ts b/packages/create-vitnode-app/src/helpers/packages-json.ts index fb975bbd31..08f88ec4ac 100644 --- a/packages/create-vitnode-app/src/helpers/packages-json.ts +++ b/packages/create-vitnode-app/src/helpers/packages-json.ts @@ -13,5 +13,7 @@ export interface PackageJSON { scripts?: Record; type?: string; version?: string; + /** Read by the `vitnode` CLI: what runs the app or API. */ + vitnode?: { runtime: "bun" | "node" }; workspaces?: string[]; } diff --git a/packages/create-vitnode-app/src/index.ts b/packages/create-vitnode-app/src/index.ts index 668447aa46..06571e5ef7 100644 --- a/packages/create-vitnode-app/src/index.ts +++ b/packages/create-vitnode-app/src/index.ts @@ -67,6 +67,12 @@ const init = async () => { "What type of app do you want to create?", ).choices(["singleApp", "apiMonorepo", "onlyApi"]), ); + program.addOption( + new Option( + "--deploy ", + "What the app runs on: node, bun, or cloudflare (Single App only).", + ).choices(["node", "bun", "cloudflare"]), + ); program.option("--monorepo", "Create project with monorepo structure."); program.option("--docker", "Initialize with Docker support."); program.option("--plugin", "Create a plugin."); diff --git a/packages/create-vitnode-app/src/questions.ts b/packages/create-vitnode-app/src/questions.ts index 0f862c064f..17fa58f8de 100644 --- a/packages/create-vitnode-app/src/questions.ts +++ b/packages/create-vitnode-app/src/questions.ts @@ -5,7 +5,17 @@ import color from "picocolors"; import { getAvailablePackageManagers } from "./helpers/get-available-package-managers.js"; +/** What the app runs on in production. */ +export type DeployTarget = "bun" | "cloudflare" | "node"; + export interface CreateCliReturn { + /** + * `node`: any Node.js host (a server, Docker, Vercel). `bun`: dev, build and + * production on Bun - TanStack Start with Nitro's `bun` preset, the Hono API + * on `Bun.serve`. `cloudflare`: one Cloudflare Worker with a Durable Object + * hub for WebSockets, Single App only. + */ + deploy: DeployTarget; docker?: boolean; git: boolean; install: boolean; @@ -27,6 +37,7 @@ export const createQuestionsCli = async ( docker: optionsFromProgram.docker, mode: optionsFromProgram.mode, monorepo: optionsFromProgram.monorepo, + deploy: optionsFromProgram.deploy, }; if (!optionsFromProgram.packageManager) { @@ -89,6 +100,45 @@ export const createQuestionsCli = async ( }); } + if (options.deploy === "cloudflare" && options.mode !== "singleApp") { + throw new Error( + 'Cloudflare Workers deployment is only available for the Single App mode. Use "--mode singleApp" or pick "--deploy node" or "--deploy bun".', + ); + } + if (options.deploy === undefined) { + const { bun } = await getAvailablePackageManagers(); + options.deploy = await select({ + message: `Where do you want to ${color.blue("deploy")}?`, + choices: [ + { + name: `Node.js - ${color.blue("a server, Docker or Vercel")}`, + description: + "Runs as a Node.js process. WebSockets through Nitro; several instances share them over Redis.", + value: "node", + }, + { + name: `Bun - ${color.blue("dev, build and production on Bun")}`, + description: bun + ? "TanStack Start built with Nitro's bun preset and the Hono API on Bun.serve. Bun must be installed wherever the app runs." + : "Install Bun (bun.sh) to choose this.", + value: "bun", + disabled: !bun, + }, + ...(options.mode === "singleApp" + ? [ + { + name: `Cloudflare Workers - ${color.blue("one Worker + a Durable Object")}`, + description: + "Builds one Worker with a Durable Object hub for WebSockets. Needs a PostgreSQL database reachable from Cloudflare and a Workers Paid plan.", + value: "cloudflare" as const, + }, + ] + : []), + ], + default: options.packageManager === "bun" && bun ? "bun" : "node", + }); + } + if (optionsFromProgram.oxlint === undefined) { options.oxlint = await confirm({ message: `Would you like to use ${color.blue("Oxlint & Oxfmt")}?`, @@ -97,7 +147,7 @@ export const createQuestionsCli = async ( if (optionsFromProgram.docker === undefined) { options.docker = await confirm({ - message: `Would you like to use ${color.blue("Docker Container")}?`, + message: `Would you like to use ${color.blue("Docker")}? (PostgreSQL and Redis for development, and a production image)`, }); } diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index bf7e0f6a6e..9b62f47862 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -681,6 +681,7 @@ "oxc-parser": "^0.148.0", "picocolors": "^1.1.1", "postgres": "^4.1.0", + "reflect-metadata": "^0.2.2", "rate-limiter-flexible": "^11.3.0", "react-colorful": "^5.8.1", "react-day-picker": "^10.0.2", diff --git a/packages/vitnode/scripts/cli/commands/build.ts b/packages/vitnode/scripts/cli/commands/build.ts index e4744da1e2..01c1647c2b 100644 --- a/packages/vitnode/scripts/cli/commands/build.ts +++ b/packages/vitnode/scripts/cli/commands/build.ts @@ -1,3 +1,6 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + import type { AppBuildOptions } from "../builder/app-build"; import type { LoadTsdown, StepRunner } from "../builder/compiler-build"; import type { MeasuredFile } from "../builder/output-files"; @@ -141,6 +144,34 @@ const startCommand = (project: Project, env: Env): string => { : `${packageManager} start`; }; +/** + * The command that runs a Cloudflare Worker build locally, or `undefined` for + * a build Nitro made for Node: a Worker build writes a Wrangler config next to + * its entry, and `vitnode start` cannot serve it. + */ +const workerPreviewCommand = ( + project: Project, + env: Env, +): string | undefined => { + if (!existsSync(join(project.root, ".output", "server", "wrangler.json"))) { + return undefined; + } + const packageManager = detectPackageManager(project.root, env); + const scripts = readPackageJson(project.root)?.scripts ?? {}; + // An app that builds for Node too keeps `preview` for that and names the + // Worker's own `preview:cloudflare`. + const script = ["preview:cloudflare", "preview"].find( + name => scripts[name] !== undefined, + ); + if (packageManager !== undefined && script !== undefined) { + return packageManager === "npm" + ? `npm run ${script}` + : `${packageManager} ${script}`; + } + + return "wrangler dev"; +}; + export const runBuildCommand = async ( { cwd, env, ui }: CommandContext, options: BuildOptions, @@ -196,7 +227,13 @@ export const runBuildCommand = async ( ui.line(); ui.success(`Built in ${formatDuration(now() - startedAt)}`); - if (project.kind !== "package") { + const worker = + project.kind === "package" ? undefined : workerPreviewCommand(project, env); + if (worker) { + ui.note( + `Run ${ui.colors.command(worker)} to run the Cloudflare Worker locally.`, + ); + } else if (project.kind !== "package") { ui.note( `Run ${ui.colors.command(startCommand(project, env))} to serve it.`, ); diff --git a/packages/vitnode/scripts/cli/project/packages.ts b/packages/vitnode/scripts/cli/project/packages.ts index c985a6d41e..4037c60cea 100644 --- a/packages/vitnode/scripts/cli/project/packages.ts +++ b/packages/vitnode/scripts/cli/project/packages.ts @@ -18,6 +18,11 @@ export interface PackageJson { scripts?: Record; type?: string; version?: string; + /** VitNode's own settings for this project. */ + vitnode?: { + /** What runs the app or API: `node` or `bun`. See `detectRuntime`. */ + runtime?: unknown; + }; workspaces?: string[] | { packages?: string[] }; } diff --git a/packages/vitnode/scripts/cli/project/runtime.ts b/packages/vitnode/scripts/cli/project/runtime.ts index 8695ec1cab..ae6a6befa3 100644 --- a/packages/vitnode/scripts/cli/project/runtime.ts +++ b/packages/vitnode/scripts/cli/project/runtime.ts @@ -1,6 +1,7 @@ import type { Env } from "../context"; import { declaredPackageManager } from "./package-manager"; +import { readPackageJson } from "./packages"; /** * What a standalone API app runs on. @@ -13,16 +14,30 @@ import { declaredPackageManager } from "./package-manager"; export type Runtime = "bun" | "node"; /** - * Bun when the CLI itself runs under Bun, when Bun started the script that - * runs it, or when the project (or its workspace) is a Bun project - a - * `packageManager: "bun@..."` field or a Bun lockfile, whichever the closest - * folder declares first. + * The runtime a project declares in its `package.json` - + * `"vitnode": { "runtime": "bun" }` - which `create-vitnode-app` writes. It + * wins over every guess below, so a project installed with Bun can still run + * on Node and the other way round. + */ +const declaredRuntime = (root: string): Runtime | undefined => { + const runtime = readPackageJson(root)?.vitnode?.runtime; + + return runtime === "bun" || runtime === "node" ? runtime : undefined; +}; + +/** + * The project's declared `vitnode.runtime`, else Bun when the CLI itself runs + * under Bun, when Bun started the script that runs it, or when the project (or + * its workspace) is a Bun project - a `packageManager: "bun@..."` field or a + * Bun lockfile, whichever the closest folder declares first. */ export const detectRuntime = ( root: string, env: Env, versions: NodeJS.ProcessVersions = process.versions, ): Runtime => { + const declared = declaredRuntime(root); + if (declared) return declared; if (versions.bun !== undefined) return "bun"; if (env.npm_config_user_agent?.startsWith("bun/")) return "bun"; diff --git a/packages/vitnode/scripts/cli/start/server-entry.ts b/packages/vitnode/scripts/cli/start/server-entry.ts index ca70d054d7..2dcb4ce2b6 100644 --- a/packages/vitnode/scripts/cli/start/server-entry.ts +++ b/packages/vitnode/scripts/cli/start/server-entry.ts @@ -20,8 +20,10 @@ const NODE_PRESETS = new Set(["node", "node-cluster", "node-server"]); * The file `vitnode start` runs, as the build itself recorded it. * * For an app that is Nitro's `.output/nitro.json`, which names the server - * entry and the preset it was built for - so a build for Vercel is refused - * here with a reason, instead of being started as if it were a Node server. + * entry and the preset it was built for: a `node-server` build runs on Node, a + * `bun` build on Bun, and a build for another platform (Vercel, Cloudflare) is + * refused here with a reason, instead of being started as if it were a Node + * server. * For a standalone API on Node it is the `dist/index.js` its `tsc` build * writes; on Bun it is `src/index.ts`, which Bun runs as it is. */ @@ -59,15 +61,24 @@ export const resolveServerEntry = ( serverEntry?: string; }; - if (manifest.preset !== undefined && !NODE_PRESETS.has(manifest.preset)) { + const preset = manifest.preset; + if (preset !== undefined && preset !== "bun" && !NODE_PRESETS.has(preset)) { throw new ConfigError( - `This build targets the "${manifest.preset}" preset, which that platform runs - not vitnode start.`, - { hint: "Build with the node-server preset to run it yourself." }, + `This build targets the "${preset}" preset, which that platform runs - not vitnode start.`, + { + hint: "Build with the node-server or bun preset to run it yourself.", + }, ); } const entry = join(output, manifest.serverEntry ?? "server/index.mjs"); - if (existsSync(entry)) return { defaultPort: 3000, entry, runtime: "node" }; + if (existsSync(entry)) { + return { + defaultPort: 3000, + entry, + runtime: preset === "bun" ? "bun" : "node", + }; + } } const fallback = join(output, "server", "index.mjs"); diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index e481547ea7..4f732508a2 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -14,6 +14,7 @@ import { createCacheClient } from "@/api/lib/cache-client"; import { clientIpMiddleware } from "@/api/lib/client-ip"; import { collectCronJobs } from "@/api/lib/cron"; import { describeError } from "@/api/lib/error-details"; +import { createReadinessCheck, mountHealthRoutes } from "@/api/lib/health"; import { processSingleton } from "@/api/lib/process-singleton"; import { newBuildPluginApiCore } from "@/api/plugin"; import { @@ -28,7 +29,9 @@ import { pageCacheStoreFor, } from "@/page-cache/store.server"; import { initRealtimePubSub } from "@/ws/registry"; +import { isWorkerdRuntime, realtimeScopeRequirement } from "@/ws/runtime"; +import { runAfterResponse } from "./lib/after-response"; import { assertPluginContentModules, generatedContentModules, @@ -119,8 +122,10 @@ export function VitNodeAPI({ : null; // Bridge realtime (WebSocket) messages across instances via Redis pub/sub, so - // `broadcast`/`sendToUser` reach clients on every instance. No-op without Redis. - initRealtimePubSub(redisClient); + // `broadcast`/`sendToUser` reach clients on every instance. No-op without + // Redis, and never on Cloudflare: its Durable Object hub owns every socket, + // and a long-lived subscriber cannot outlive the request that opened it. + if (!realtimeScopeRequirement()) initRealtimePubSub(redisClient); // A separate API process writes page invalidations to the web app's Redis. // Inside the web app the server entry has registered the store already. @@ -205,6 +210,15 @@ export function VitNodeAPI({ strictTransportSecurity: false, }), ); + // Before the rate limiter and the request context: load balancers and + // orchestrators probe often, without cookies, from one address. + mountHealthRoutes( + app, + createReadinessCheck({ + cacheClient: redisClient, + db: vitNodeApiConfig.dbProvider, + }), + ); app.use("*", clientIpMiddleware); app.use("*", rateLimiter.preflight); app.use( @@ -282,18 +296,36 @@ export function VitNodeAPI({ }); } - plugins.map(root => { - app.route(`/${root.pluginId}`, root.hono); - }); - - registerCronJobs(vitNodeApiConfig.dbProvider, collectCronJobs(plugins)).catch( - (error: unknown) => { + const registerCron = async (): Promise => { + await registerCronJobs( + vitNodeApiConfig.dbProvider, + collectCronJobs(plugins), + ).catch((error: unknown) => { // oxlint-disable-next-line no-console console.warn( `\x1b[34m[VitNode]\x1b[0m \x1b[33mFailed to register cron jobs:\x1b[0m ${describeError(error)}`, ); - }, - ); + }); + }; + + if (isWorkerdRuntime()) { + // A Worker evaluates modules where no I/O is allowed - even one a request + // imports lazily: register on the first request instead, once per isolate. + let registering = false; + app.use(async (c, next) => { + if (!registering) { + registering = true; + runAfterResponse(c, registerCron()); + } + await next(); + }); + } else { + void registerCron(); + } + + plugins.map(root => { + app.route(`/${root.pluginId}`, root.hono); + }); app.onError(async (error, c) => { if (error instanceof HTTPException) { diff --git a/packages/vitnode/src/api/lib/health.ts b/packages/vitnode/src/api/lib/health.ts new file mode 100644 index 0000000000..f67fc94716 --- /dev/null +++ b/packages/vitnode/src/api/lib/health.ts @@ -0,0 +1,129 @@ +import type { Hono } from "hono"; + +import { sql } from "drizzle-orm"; + +import type { VitNodeApiConfig } from "@/vitnode.config"; + +import type { CacheClient } from "./cache"; + +/** Where the probes live, under the API's base path: `/api/health`. */ +const HEALTH_PATH = "/health"; + +/** How long one dependency may take before it counts as down. */ +const CHECK_TIMEOUT_MS = 2000; + +/** + * How long a readiness answer is reused. Probes are unauthenticated and skip + * the rate limiter, so a flood of them must not become a flood of queries: + * concurrent and back-to-back probes share one check. + */ +const READY_CACHE_MS = 1000; + +/** + * `ok` - everything answered. `degraded` - Redis is configured but did not + * answer; VitNode keeps serving (the cache falls back to memory) but instances + * no longer share cache, rate limits or realtime. `unavailable` - the database + * did not answer, so nothing that needs it works. + */ +export type HealthStatus = "degraded" | "ok" | "unavailable"; + +const withTimeout = async (task: Promise): Promise => { + let timer: ReturnType | undefined; + const timeout = new Promise(resolve => { + timer = setTimeout(() => { + resolve(false); + }, CHECK_TIMEOUT_MS); + }); + + try { + return await Promise.race([task.then(() => true), timeout]); + } catch { + return false; + } finally { + clearTimeout(timer); + } +}; + +const logTransition = (from: HealthStatus, to: HealthStatus): void => { + // oxlint-disable-next-line no-console + const log = to === "ok" ? console.info : console.error; + log( + `\x1b[34m[VitNode]\x1b[0m Health changed from "${from}" to "${to}"${ + to === "unavailable" + ? ": the database did not answer" + : to === "degraded" + ? ": Redis did not answer" + : "" + }.`, + ); +}; + +/** + * Builds the readiness check: the database must answer `select 1`, and a + * configured Redis must answer `PING`, each within 2 s. Results are shared for + * a second, and every change of status is logged once, so an operator learns + * which dependency failed without the public response saying so. + */ +export const createReadinessCheck = ({ + cacheClient, + db, +}: { + cacheClient: CacheClient | null; + db: Pick; +}): (() => Promise) => { + let last: HealthStatus = "ok"; + let cached: undefined | { at: number; status: Promise }; + + const check = async (): Promise => { + const [database, redis] = await Promise.all([ + withTimeout(db.execute(sql`select 1`)), + cacheClient ? withTimeout(cacheClient.ping()) : true, + ]); + const status: HealthStatus = !database + ? "unavailable" + : redis + ? "ok" + : "degraded"; + if (status !== last) { + logTransition(last, status); + last = status; + } + + return status; + }; + + return async () => { + if (!cached || Date.now() - cached.at >= READY_CACHE_MS) { + cached = { at: Date.now(), status: check() }; + } + + return await cached.status; + }; +}; + +/** + * Mounts the health probes on the API, before rate limiting and the + * per-request context, so they stay cheap and work for anonymous callers: + * + * - `GET /api/health` - liveness. `200 {"status":"ok"}` whenever the process + * answers HTTP; it touches nothing else. Restart the process when it fails. + * - `GET /api/health/ready` - readiness. `200` with `ok` or `degraded`, `503` + * with `unavailable` when the database does not answer. Stop routing + * traffic to the instance while it fails. + * + * Responses carry the status only - never versions, hosts or error text - and + * are never cached. Called by `VitNodeAPI`. + */ +export const mountHealthRoutes = ( + app: Pick, + check: () => Promise, +): void => { + const noStore = { "Cache-Control": "no-store" }; + + app.get(HEALTH_PATH, c => c.json({ status: "ok" }, 200, noStore)); + app.get(`${HEALTH_PATH}/ready`, async c => { + const status = await check(); + + return c.json({ status }, status === "unavailable" ? 503 : 200, noStore); + }); +}; diff --git a/packages/vitnode/src/cloudflare/cloudflare-workers.d.ts b/packages/vitnode/src/cloudflare/cloudflare-workers.d.ts new file mode 100644 index 0000000000..81c6a5a3e1 --- /dev/null +++ b/packages/vitnode/src/cloudflare/cloudflare-workers.d.ts @@ -0,0 +1,24 @@ +// The parts of the Workers runtime module the hub uses. Kept local so the +// package needs no `@cloudflare/workers-types`; an app that installs them gets +// the full types for its own code. +declare module "cloudflare:workers" { + export abstract class DurableObject { + constructor(ctx: unknown, env: Env); + protected ctx: { + acceptWebSocket: (ws: unknown, tags?: string[]) => void; + getWebSockets: (tag?: string) => unknown[]; + readonly id: { readonly name?: string; toString: () => string }; + setWebSocketAutoResponse: (pair?: unknown) => void; + readonly storage: { + readonly sql?: { + exec: ( + query: string, + ...bindings: unknown[] + ) => { toArray: () => Record[] }; + }; + }; + waitUntil: (promise: Promise) => void; + }; + protected env: Env; + } +} diff --git a/packages/vitnode/src/cloudflare/database.ts b/packages/vitnode/src/cloudflare/database.ts new file mode 100644 index 0000000000..895aa9c06d --- /dev/null +++ b/packages/vitnode/src/cloudflare/database.ts @@ -0,0 +1,123 @@ +import { + currentRealtimeScope, + isWorkerdRuntime, + RealtimeConfigurationError, + realtimeScopeRequirement, +} from "@/ws/runtime"; + +/** + * A database client (or any I/O-bound object) whose real instance belongs to + * the scope that uses it. Use it for `dbProvider` in an app deployed to + * Cloudflare Workers, where a TCP connection opened by one request cannot be + * used by another: + * + * - in a Worker request (or `runWithVitNodeCloudflare`), `create` runs once per + * invocation; + * - in the realtime hub, once per busy period: the hub keeps its client + * across socket events and closes it (`dispose`) when it goes idle, so an + * open connection never keeps it from hibernating; + * - on Node/Bun, outside any scope, once per process, exactly like a client + * created at module load. + * + * Returns a stand-in with the client's own type: every property access is + * forwarded to the instance of the current scope, so code that captured it + * (the API config, a handshake's Hono context) always reaches a client that is + * valid where it runs. Universal, server-only. + * + * @throws {RealtimeConfigurationError} When used on Cloudflare outside a + * scope. + * + * @example + * ```ts + * import { scopedDatabase } from "@vitnode/core/cloudflare/database"; + * import { coreRelations } from "@vitnode/core/database/relations"; + * import { drizzle } from "drizzle-orm/postgres-js"; + * + * dbProvider: scopedDatabase(() => + * drizzle({ + * connection: { url: process.env.POSTGRES_URL, max: 1 }, + * relations: coreRelations, + * }), + * ), + * ``` + */ +/** + * Closes a Drizzle client's driver at once: `end({ timeout: 0 })` drops + * postgres.js connections without waiting, which also ends their pending + * socket reads. A client without `$client.end` is left alone. + */ +const closeDriverClient = async (instance: object): Promise => { + const client: unknown = Reflect.get(instance, "$client"); + if (typeof client !== "object" && typeof client !== "function") return; + if (client === null) return; + const end: unknown = Reflect.get(client, "end"); + if (typeof end === "function") { + await (end as (options: { timeout: number }) => Promise).call( + client, + { timeout: 0 }, + ); + } +}; + +export const scopedDatabase = ( + create: () => T, + { + dispose = closeDriverClient, + }: { + /** + * Closes an instance when its owner goes idle - a realtime hub ~2 s after + * its last event, so an open connection does not keep it from + * hibernating. Defaults to `$client.end({ timeout: 0 })`, which closes a + * Drizzle client on postgres.js or node-postgres. + */ + dispose?: (instance: T) => Promise | void; + } = {}, +): T => { + const key = Symbol("vitnode.scoped-database"); + let processInstance: T | undefined; + + const resolve = (): T => { + const resources = currentRealtimeScope()?.resources; + if (resources) { + const existing = resources.get(key); + if (existing) return existing.instance as T; + const instance = create(); + resources.set(key, { + dispose: async () => { + await dispose(instance); + }, + instance, + }); + + return instance; + } + + const requirement = realtimeScopeRequirement(); + if (requirement || isWorkerdRuntime()) { + throw new RealtimeConfigurationError( + `[VitNode] The database was used outside a VitNode execution scope on Cloudflare Workers. ${requirement?.hint ?? "Use it inside a request, a socket event or runWithVitNodeCloudflare(...)."}`, + ); + } + + return (processInstance ??= create()); + }; + + return new Proxy({} as T, { + get: (_target, property) => { + const instance = resolve(); + const value: unknown = Reflect.get(instance, property, instance); + + return typeof value === "function" + ? (value as (...args: unknown[]) => unknown).bind(instance) + : value; + }, + getPrototypeOf: () => Reflect.getPrototypeOf(resolve()), + has: (_target, property) => Reflect.has(resolve(), property), + ownKeys: () => Reflect.ownKeys(resolve()), + getOwnPropertyDescriptor: (_target, property) => { + const descriptor = Reflect.getOwnPropertyDescriptor(resolve(), property); + + return descriptor ? { ...descriptor, configurable: true } : undefined; + }, + }); +}; diff --git a/packages/vitnode/src/cloudflare/edge.ts b/packages/vitnode/src/cloudflare/edge.ts new file mode 100644 index 0000000000..8a3112bac6 --- /dev/null +++ b/packages/vitnode/src/cloudflare/edge.ts @@ -0,0 +1,273 @@ +import type { RealtimeDeliveryCommand } from "@/ws/realtime"; +import type { + RealtimeRuntime, + RealtimeScope, + ScopedResource, +} from "@/ws/runtime"; + +import { realtimeFromCommands } from "@/ws/realtime"; +import { + createServiceStore, + RealtimeConfigurationError, + requireRealtimeScope, + runInRealtimeScope, +} from "@/ws/runtime"; + +import type { VitNodeHubCommand, VitNodeHubTarget } from "./protocol"; +import type { CloudflareEnv, CloudflareExecutionContext } from "./types"; + +import { + mergeHostResults, + VITNODE_CLIENT_IP_HEADER, + VITNODE_HUB_MAX_BATCH, + VITNODE_HUB_NAME_HEADER, + vitNodeHubTargets, +} from "./protocol"; + +/** How code outside a request or socket event opens a Cloudflare scope. */ +const SCOPE_HINT = + "Requests and socket events get one automatically; wrap scheduled jobs, queue consumers and other background work in runWithVitNodeCloudflare(env, ctx, fn) from @vitnode/core/cloudflare/edge."; + +/** + * Marks this isolate as a Cloudflare deployment: every realtime call must run + * inside a scope from here on, and the Node-only Redis bridge stays off. + * Called when the Cloudflare integration loads. + */ +export const markCloudflareRealtime = (): void => { + requireRealtimeScope({ hint: SCOPE_HINT, name: "Cloudflare Workers" }); +}; + +markCloudflareRealtime(); + +const logError = (message: string, error: unknown): void => { + // oxlint-disable-next-line no-console + console.error(message, error); +}; + +/** + * Splits a batch into commands the hub accepts, so a large `sendToUsers` + * reaches it as several bounded RPCs instead of one it would refuse. + */ +const toHubCommands = ( + command: RealtimeDeliveryCommand, +): VitNodeHubCommand[] => { + if ( + command.type !== "sendToUsers" || + command.messages.length <= VITNODE_HUB_MAX_BATCH + ) { + return [command]; + } + + const chunks: VitNodeHubCommand[] = []; + for ( + let start = 0; + start < command.messages.length; + start += VITNODE_HUB_MAX_BATCH + ) { + chunks.push({ + ...command, + messages: command.messages.slice(start, start + VITNODE_HUB_MAX_BATCH), + }); + } + + return chunks; +}; + +const noop = () => () => {}; + +/** + * Sends `command` to every hub in `targets`, keeping each RPC alive with + * `keepAlive` and logging - never throwing or retrying - a failed one. Shared + * by the Worker runtime and by shards relaying to their peers. + */ +export const dispatchToHubs = ( + targets: VitNodeHubTarget[], + command: VitNodeHubCommand, + keepAlive: (task: Promise) => void, +): void => { + const parts = + command.type === "host" || command.type === "instance" + ? [command] + : toHubCommands(command); + for (const { name, stub } of targets) { + for (const part of parts) { + const task = stub.dispatch(part).catch((error: unknown) => { + logError( + `[VitNode] Realtime ${part.type} did not reach the hub "${name}":`, + error, + ); + }); + keepAlive(task); + } + } +}; + +/** Runs a host command on every hub in `targets` and merges the results. */ +export const runHostCommandOn = async ( + targets: VitNodeHubTarget[], + name: string, + payload: unknown, +): Promise => + mergeHostResults( + await Promise.all( + targets.map( + async ({ stub }) => + await stub.dispatch({ + name, + payload, + type: "host", + } satisfies VitNodeHubCommand), + ), + ), + ); + +/** + * The runtime of an ordinary Worker invocation: it owns no sockets and sends + * every push and host command to the installation's hubs over Durable Object + * RPC - the one hub, or every shard when `VITNODE_REALTIME_SHARDS` is above 1. + * Pushes stay fire-and-forget for the caller - each RPC is handed to + * `keepAlive` (the request's `waitUntil`) and a failure is logged, never + * retried: realtime is best effort, the database is the truth. + * + * A missing `VITNODE_REALTIME` binding does not fail the invocation: each push + * logs a {@link RealtimeConfigurationError} with the fix, and `runOnHost` + * rejects with it. + */ +export const createCloudflareEdgeRuntime = ({ + env, + keepAlive, +}: { + env: CloudflareEnv; + keepAlive: (task: Promise) => void; +}): RealtimeRuntime => { + // Resolved on first use, so a Worker whose binding is missing still serves + // every request; each push then logs the configuration error. + let targets: undefined | VitNodeHubTarget[]; + const hubs = (): VitNodeHubTarget[] => (targets ??= vitNodeHubTargets(env)); + const service = createServiceStore(); + + const send = (command: RealtimeDeliveryCommand): void => { + let resolved: VitNodeHubTarget[]; + try { + resolved = hubs(); + } catch (error) { + logError(`[VitNode] Realtime ${command.type} was not sent:`, error); + + return; + } + dispatchToHubs(resolved, command, keepAlive); + }; + + const runtime: RealtimeRuntime = { + bind: + fn => + (...args) => + runInRealtimeScope({ keepAlive, runtime }, () => fn(...args)), + crossInstance: () => true, + kind: "cloudflare-edge", + onInstanceMessage: noop, + onRemoteRoomMessage: noop, + publishToInstances: () => { + // Only shards tell each other; a Worker invocation has no sockets. + }, + ready: async () => await Promise.resolve(true), + realtime: realtimeFromCommands(send), + registry: null, + runOnHost: async (name, payload) => + await runHostCommandOn(hubs(), name, payload), + service: (key, create) => service(runtime, key, create), + }; + + return runtime; +}; + +/** Opens the scope of one Worker invocation. */ +export const createCloudflareEdgeScope = ({ + ctx, + env, +}: { + ctx?: CloudflareExecutionContext; + env: CloudflareEnv; +}): RealtimeScope => { + const keepAlive = (task: Promise): void => { + ctx?.waitUntil(task); + }; + + return { + keepAlive, + resources: new Map(), + runtime: createCloudflareEdgeRuntime({ env, keepAlive }), + }; +}; + +/** + * Runs `fn` as VitNode Worker code: realtime pushes go to the installation's + * hub and are kept alive with `ctx.waitUntil`, and `scopedDatabase` clients are + * created for this invocation. HTTP requests get this automatically from the + * Nitro middleware; call it yourself in a `scheduled` handler, a queue + * consumer or any other background work that pushes realtime messages. + * Server-only, Cloudflare Workers only. + * + * Without the `VITNODE_REALTIME` binding `fn` still runs; its pushes log a + * configuration error instead of being delivered. + * + * @example + * ```ts + * import { runWithVitNodeCloudflare } from "@vitnode/core/cloudflare/edge"; + * + * export default { + * async scheduled(controller, env, ctx) { + * await runWithVitNodeCloudflare(env, ctx, async () => { + * await runNightlyDigest(); + * }); + * }, + * }; + * ``` + */ +export const runWithVitNodeCloudflare = ( + env: CloudflareEnv, + ctx: CloudflareExecutionContext | undefined, + fn: () => T, +): T => runInRealtimeScope(createCloudflareEdgeScope({ ctx, env }), fn); + +/** `GET` with `Upgrade: websocket`. */ +export const isWebSocketUpgrade = (request: Request): boolean => + request.method === "GET" && + request.headers.get("upgrade")?.toLowerCase() === "websocket"; + +/** + * Hands a WebSocket upgrade to one of the installation's hubs - a random shard + * when there are several - which runs the handshake through the API and + * accepts the socket. The visitor's IP travels in a header the Worker sets + * itself, never one the client could send. Resolves the hub's answer: `101` + * with the socket, or the handshake's own rejection. A missing binding answers + * `503` and logs how to fix it. + */ +export const forwardWebSocketToHub = async ( + request: Request, + env: CloudflareEnv, +): Promise => { + let target: undefined | VitNodeHubTarget; + try { + const targets = vitNodeHubTargets(env); + const [random = 0] = crypto.getRandomValues(new Uint32Array(1)); + target = targets[random % targets.length]; + } catch (error) { + if (!(error instanceof RealtimeConfigurationError)) throw error; + logError("[VitNode] WebSocket upgrade refused:", error); + + return new Response("WebSocket is not configured.", { status: 503 }); + } + if (!target) { + return new Response("WebSocket is not configured.", { status: 503 }); + } + + const headers = new Headers(request.headers); + headers.delete(VITNODE_CLIENT_IP_HEADER); + headers.delete(VITNODE_HUB_NAME_HEADER); + const clientIp = request.headers.get("cf-connecting-ip"); + if (clientIp) headers.set(VITNODE_CLIENT_IP_HEADER, clientIp); + headers.set(VITNODE_HUB_NAME_HEADER, target.name); + + return await target.stub.fetch(new Request(request.url, { headers })); +}; diff --git a/packages/vitnode/src/cloudflare/hub.ts b/packages/vitnode/src/cloudflare/hub.ts new file mode 100644 index 0000000000..30a1bdcbfc --- /dev/null +++ b/packages/vitnode/src/cloudflare/hub.ts @@ -0,0 +1,980 @@ +import type { ExecutionContext } from "hono"; + +import { DurableObject } from "cloudflare:workers"; +import { WSContext } from "hono/ws"; + +import type { InProcessApiHandler } from "@/lib/fetcher/in-process-api.server"; +import type { WebSocketRegistry } from "@/ws/create-registry"; +import type { VitNodeConnection, VitNodeRawSocket } from "@/ws/handshake"; +import type { + RealtimeRuntime, + RealtimeScope, + ScopedResource, +} from "@/ws/runtime"; + +import { getInProcessApi } from "@/lib/fetcher/in-process-api.server"; +import { createWebSocketRegistry } from "@/ws/create-registry"; +import { + connectionIdentityOf, + createVitNodeConnection, + runVitNodeHandshake, +} from "@/ws/handshake"; +import { sharedCloseListeners } from "@/ws/process-runtime"; +import { realtimeFromCommands } from "@/ws/realtime"; +import { + createListenerSet, + createServiceStore, + deliverToRegistry, + disposeScopedResources, + hasRealtimeHostCommand, + runInRealtimeScope, + runRealtimeHostCommand, +} from "@/ws/runtime"; +import { WS_PING_ID, WS_PONG_ID } from "@/ws/types"; + +import type { VitNodeHubCommand, VitNodeHubTarget } from "./protocol"; +import type { CloudflareEnv, CloudflareServerWebSocket } from "./types"; + +import { + dispatchToHubs, + isWebSocketUpgrade, + markCloudflareRealtime, +} from "./edge"; +import { + isVitNodeHibernationEnabled, + mergeHostResults, + parseVitNodeHubCommand, + VITNODE_CLIENT_IP_HEADER, + VITNODE_HUB_MAX_FRAME_BYTES, + VITNODE_HUB_NAME_HEADER, + vitNodeHubTargets, + vitNodeShardCount, +} from "./protocol"; + +markCloudflareRealtime(); + +/** Options of {@link createVitNodeRealtimeHub}. */ +export interface VitNodeRealtimeHubOptions { + /** + * The embedded API that authenticates handshakes. Defaults to the one the + * app registered with `configureInProcessApi`. + */ + api?: () => InProcessApiHandler | undefined; + /** + * Loads the app's server bundle when no API has registered in this isolate + * yet - a hub whose first event is an upgrade, or the first message after a + * hibernation. Receives a plain `GET` of the WebSocket URL; its response is + * ignored. With Nitro pass `request => fetchService("ssr", request)` from + * `nitro/services`; `@vitnode/core/cloudflare/nitro-entry` already does. + */ + warmup?: (request: Request) => Promise; +} + +const logError = (message: string, error: unknown): void => { + // oxlint-disable-next-line no-console + console.error(message, error); +}; + +const frameBytes = (data: ArrayBuffer | string): number => + typeof data === "string" ? data.length * 3 : data.byteLength; + +type WebSocketPairConstructor = new () => { + 0: unknown; + 1: CloudflareServerWebSocket; +}; + +type AutoResponsePairConstructor = new ( + request: string, + response: string, +) => unknown; + +/** The client's heartbeat, answered by the runtime without waking the hub. */ +const PING_FRAME = JSON.stringify({ data: null, id: WS_PING_ID }); +const PONG_FRAME = JSON.stringify({ data: null, id: WS_PONG_ID }); + +/** + * How long a hub waits after its last piece of work before it releases its + * scoped resources (the database connection), so it can hibernate. + */ +const IDLE_RELEASE_MS = 2000; + +/** Headers a replayed handshake needs besides the session cookies. */ +const REPLAY_HEADERS = ["origin", "user-agent", "accept-language"]; + +/** What a hub needs to run a socket's handshake again after hibernating. */ +interface ReplayData { + headers: [string, string][]; + ip?: string; + url: string; +} + +/** + * Kept with each socket by the runtime (at most 2 KiB), so a hub that wakes + * from hibernation knows who every socket belongs to without a database read. + */ +interface SocketAttachment { + deviceId: null | number; + id: string; + replay?: ReplayData; + userId: null | number; + v: 1; +} + +interface SocketRecord { + attachment: SocketAttachment; + /** + * The handlers of the socket's handshake. Missing after a wake until the + * socket's first message replays the handshake. + */ + connection?: VitNodeConnection; + queue: Promise; + raw: CloudflareServerWebSocket; + ws: WSContext; +} + +const isAttachment = (value: unknown): value is SocketAttachment => + typeof value === "object" && + value !== null && + Reflect.get(value, "v") === 1 && + typeof Reflect.get(value, "id") === "string"; + +interface Sql { + exec: ( + query: string, + ...bindings: unknown[] + ) => { toArray: () => Record[] }; +} + +/** + * Room memberships in the hub's own SQLite storage, so rooms survive + * hibernation. Keyed by the socket's attachment id; rows of sockets that are + * gone are dropped on the next wake. + */ +const createRoomStore = (sql: Sql) => { + sql + .exec( + "CREATE TABLE IF NOT EXISTS vitnode_socket_rooms (socket TEXT NOT NULL, room TEXT NOT NULL, member TEXT NOT NULL, PRIMARY KEY (socket, room, member))", + ) + .toArray(); + sql + .exec( + "CREATE TABLE IF NOT EXISTS vitnode_hub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)", + ) + .toArray(); + + return { + add: (socket: string, room: string, member: string): void => { + sql + .exec( + "INSERT OR IGNORE INTO vitnode_socket_rooms (socket, room, member) VALUES (?, ?, ?)", + socket, + room, + member, + ) + .toArray(); + }, + all: (): { member: string; room: string; socket: string }[] => + sql + .exec("SELECT socket, room, member FROM vitnode_socket_rooms") + .toArray() as { member: string; room: string; socket: string }[], + getMeta: (key: string): string | undefined => + sql + .exec("SELECT value FROM vitnode_hub_meta WHERE key = ?", key) + .toArray()[0]?.value as string | undefined, + remove: (socket: string, room: string, member: string): void => { + sql + .exec( + "DELETE FROM vitnode_socket_rooms WHERE socket = ? AND room = ? AND member = ?", + socket, + room, + member, + ) + .toArray(); + }, + removeSocket: (socket: string): void => { + sql + .exec("DELETE FROM vitnode_socket_rooms WHERE socket = ?", socket) + .toArray(); + }, + setMeta: (key: string, value: string): void => { + sql + .exec( + "INSERT OR REPLACE INTO vitnode_hub_meta (key, value) VALUES (?, ?)", + key, + value, + ) + .toArray(); + }, + }; +}; + +type RoomStore = ReturnType; + +/** The public RPC methods of a hub, for typing a stub in app code. */ +export interface VitNodeRealtimeHubRpc { + /** + * Applies one validated command from a Worker of this installation or a + * peer shard: delivery to the hub's sockets, a host command, or a peer's + * instance message. Resolves the host command's JSON result, `null` + * otherwise. + */ + dispatch: (command: unknown) => Promise; +} + +/** + * Creates the Durable Object class that owns the WebSockets of one VitNode + * installation on Cloudflare Workers - the realtime hub, or one shard of it. + * Export the class from the Worker entry under the name the `VITNODE_REALTIME` + * binding's `class_name` gives; `@vitnode/core/cloudflare/nitro-entry` exports + * it as `VitNodeRealtimeHub` for a Nitro app. Cloudflare Workers only. + * + * Each hub instance owns its own socket registry, rooms, Content Engine live + * server, listeners and database clients (`scopedDatabase`), so two hubs in + * one isolate never share state. It: + * + * - accepts `/api/ws` upgrades the Worker forwards: the handshake runs through + * the API's real middleware (Origin check, rate limiter, session cookie), + * a rejected one is answered with its own status before any socket exists, + * and an accepted one gets a `WebSocketPair`, its `Set-Cookie` headers and + * one `WSContext`; + * - runs every socket event in a fresh scope of its own, so `c.get("realtime")`, + * `wsRegistry` and the database resolve to this hub; + * - applies `dispatch` commands from HTTP requests, cron, queues and peer + * shards, delivered locally and never sent anywhere again. + * + * **Hibernation** (default; `VITNODE_REALTIME_HIBERNATION=false` turns it off): + * sockets are accepted with the Hibernation API, so an idle hub is evicted + * from memory while its sockets stay open, and stops accruing duration. The + * runtime answers the client's heartbeat itself. Each socket's user and device + * travel in its attachment and its rooms in the hub's SQLite storage, so + * pushes and rooms work right after a wake. A socket's handlers are rebuilt on + * its first message after a wake by replaying its handshake - with its + * session, device and admin cookies only - and a socket whose session changed + * meanwhile is closed with `1008` so the client reconnects as whoever it is + * now. In-memory state a handler keeps per socket does not survive a wake; + * Content Engine live editing and live refresh keep the hub awake while they + * have members. Needs a SQLite-backed class (`new_sqlite_classes`); without it + * the hub does not hibernate. + * + * **Sharding** (`VITNODE_REALTIME_SHARDS`): every shard relays the pushes, + * instance messages and host commands of its own handlers to its peers, the + * way Node instances share them over Redis. + * + * Sockets and presence live in memory and in the socket attachments only: + * a deploy closes every socket, and clients reconnect with backoff, rejoin + * their rooms and reload durable state from the database. + */ +export const createVitNodeRealtimeHub = ({ + api = getInProcessApi, + warmup, +}: VitNodeRealtimeHubOptions = {}): VitNodeRealtimeHubClass => + class VitNodeRealtimeHub + extends DurableObject + implements VitNodeRealtimeHubRpc + { + constructor(ctx: unknown, env: CloudflareEnv) { + super(ctx, env); + + const sql = this.ctx.storage.sql; + this.#store = sql ? createRoomStore(sql) : null; + this.#hibernate = + isVitNodeHibernationEnabled(env) && this.#store !== null; + if (isVitNodeHibernationEnabled(env) && !this.#store) { + logError( + "[VitNode] The realtime hub cannot hibernate:", + new Error( + 'Its class has no SQLite storage. Declare it under "new_sqlite_classes" in the Wrangler migrations.', + ), + ); + } + this.#name = this.ctx.id.name ?? this.#store?.getMeta("name"); + + const base = createWebSocketRegistry({ sharedCloseListeners }); + this.#registry = this.#persisted(base); + this.#runtime = this.#createRuntime(); + + if (this.#hibernate) { + const Pair = ( + globalThis as typeof globalThis & { + WebSocketRequestResponsePair?: AutoResponsePairConstructor; + } + ).WebSocketRequestResponsePair; + if (Pair) { + this.ctx.setWebSocketAutoResponse(new Pair(PING_FRAME, PONG_FRAME)); + } + this.#restore(base); + } + } + #activity = 0; + readonly #hibernate: boolean; + readonly #idByWs = new WeakMap(); + #idleTimer: ReturnType | undefined; + readonly #instanceListeners = createListenerSet< + [{ data: unknown; origin: string; topic: string }] + >("Realtime hub instance message"); + readonly #keepAlive = (task: Promise): void => { + this.#begin(); + const settled = task.then( + () => { + this.#end(); + }, + () => { + this.#end(); + }, + ); + this.ctx.waitUntil(settled); + }; + #name: string | undefined; + readonly #records = new Map(); + readonly #registry: WebSocketRegistry; + readonly #resources = new Map(); + readonly #roomListeners = + createListenerSet<[room: string, id: string, data: unknown]>( + "Realtime hub room", + ); + readonly #runtime: RealtimeRuntime; + readonly #services = createServiceStore(); + readonly #store: null | RoomStore; + + #warming: Promise | undefined; + + /** Creates the record and `WSContext` of one socket. */ + #adopt( + raw: CloudflareServerWebSocket, + attachment: SocketAttachment, + ): SocketRecord { + const url = attachment.replay?.url ?? ""; + const socket = this.#rawSocket({ raw }, url); + const ws = new WSContext({ + close: socket.close, + protocol: socket.protocol, + raw, + get readyState() { + return raw.readyState as 0 | 1 | 2 | 3; + }, + send: socket.send, + url, + }); + this.#idByWs.set(ws, attachment.id); + const record: SocketRecord = { + attachment, + queue: Promise.resolve(), + raw, + ws, + }; + this.#records.set(raw, record); + + return record; + } + + async #api(url: string): Promise { + const current = api(); + if (current || !warmup) return current; + + this.#warming ??= warmup(new Request(url)) + .catch((error: unknown) => { + logError("[VitNode] Realtime hub warmup error:", error); + }) + .finally(() => { + this.#warming = undefined; + }); + await this.#warming; + + return api(); + } + + /** Work starts: the hub is busy and keeps its resources. */ + #begin(): void { + this.#activity++; + clearTimeout(this.#idleTimer); + this.#idleTimer = undefined; + } + + /** Runs one event's work as busy time. */ + async #busy(work: () => Promise): Promise { + this.#begin(); + try { + return await work(); + } finally { + this.#end(); + } + } + + #close(record: SocketRecord, code: number, reason: string): void { + try { + record.raw.close(code, reason); + } catch { + // Already closed. + } + } + + #createRuntime(): RealtimeRuntime { + const runtime: RealtimeRuntime = { + bind: + fn => + (...args) => { + // Timer callbacks (the live sweeper, document saves) are busy time + // too, so the database stays open while they run. + this.#begin(); + let result: ReturnType; + try { + result = this.#run(() => fn(...args)); + } catch (error) { + this.#end(); + throw error; + } + if (result instanceof Promise) { + result.then( + () => { + this.#end(); + }, + () => { + this.#end(); + }, + ); + } else { + this.#end(); + } + + return result; + }, + crossInstance: () => this.#peers().length > 0, + kind: "cloudflare-hub", + onInstanceMessage: listener => this.#instanceListeners.add(listener), + onRemoteRoomMessage: listener => this.#roomListeners.add(listener), + publishToInstances: (topic, data) => { + const peers = this.#peers(); + if (peers.length === 0) return; + dispatchToHubs( + peers, + { data, origin: this.#name ?? "", topic, type: "instance" }, + this.#keepAlive, + ); + }, + ready: async () => await Promise.resolve(true), + realtime: realtimeFromCommands(command => { + deliverToRegistry(this.#registry, command); + const peers = this.#peers(); + if (peers.length > 0) { + dispatchToHubs(peers, command, this.#keepAlive); + } + }), + registry: this.#registry, + runOnHost: async (name, payload) => { + const local = await runRealtimeHostCommand(runtime, name, payload); + const peers = this.#peers(); + if (peers.length === 0) return local; + + return mergeHostResults([ + local, + ...(await Promise.all( + peers.map( + async ({ stub }) => + await stub.dispatch({ + name, + payload, + type: "host", + } satisfies VitNodeHubCommand), + ), + )), + ]); + }, + service: (key, create) => this.#services(runtime, key, create), + }; + + return runtime; + } + + /** + * Work ends. Once nothing is left, the scoped resources are released + * shortly after - an open database connection keeps a Durable Object from + * hibernating - and created again by the next event that needs them. + */ + #end(): void { + this.#activity = Math.max(0, this.#activity - 1); + if (this.#activity > 0 || this.#idleTimer) return; + this.#idleTimer = setTimeout(() => { + this.#idleTimer = undefined; + if (this.#activity === 0) { + void disposeScopedResources(this.#resources); + } + }, IDLE_RELEASE_MS); + } + + #onClose( + raw: CloudflareServerWebSocket, + code: number, + reason: string, + ): void { + const record = this.#records.get(raw); + if (!record) return; + this.#records.delete(raw); + this.#run(() => { + record.connection?.close({ code, reason }); + // Also for a socket whose handlers never came back after a wake. + this.#registry.remove(record.ws); + }); + try { + raw.close(); + } catch { + // Already closed. + } + } + + #onError(raw: CloudflareServerWebSocket, error: unknown): void { + const record = this.#records.get(raw); + if (!record) return; + this.#records.delete(raw); + this.#run(() => { + if (record.connection) record.connection.error(error); + else logError("WebSocket error:", error); + this.#registry.remove(record.ws); + }); + } + + async #onMessage( + raw: CloudflareServerWebSocket, + data: ArrayBuffer | string, + ): Promise { + const record = this.#records.get(raw); + if (!record) return; + if (frameBytes(data) > VITNODE_HUB_MAX_FRAME_BYTES) { + this.#close(record, 1009, "Message too big"); + + return; + } + + // One message at a time per socket, so a replayed handshake finishes + // before the messages that arrived while it ran. + record.queue = record.queue + .then(async () => { + await this.#run(async () => { + const connection = await this.#resume(record); + connection?.message(data); + }); + }) + .catch((error: unknown) => { + logError("[VitNode] Realtime hub message error:", error); + }); + await record.queue; + } + + /** Other shards of this installation, or none. */ + #peers(): VitNodeHubTarget[] { + let count = 1; + try { + count = vitNodeShardCount(this.env); + } catch (error) { + logError("[VitNode] Realtime hub shard configuration:", error); + } + if (count === 1) return []; + if (!this.#name) { + logError( + "[VitNode] Realtime hub cannot reach its peer shards:", + new Error("It does not know its own name yet."), + ); + + return []; + } + const name = this.#name; + + return vitNodeHubTargets(this.env).filter(target => target.name !== name); + } + + /** Wraps a registry so room changes are written to storage. */ + #persisted(base: WebSocketRegistry): WebSocketRegistry { + const store = this.#store; + if (!store) return base; + const idOf = (ws: WSContext) => this.#idByWs.get(ws); + + return { + ...base, + join: (ws, room, member) => { + const joined = base.join(ws, room, member); + const id = idOf(ws); + if (joined && id) store.add(id, room, member); + + return joined; + }, + leave: (ws, room, member) => { + const left = base.leave(ws, room, member); + const id = idOf(ws); + if (left && id) store.remove(id, room, member); + + return left; + }, + remove: ws => { + base.remove(ws); + const id = idOf(ws); + if (id) store.removeSocket(id); + }, + }; + } + + #rawSocket( + record: Pick, + url: string, + ): VitNodeRawSocket { + const { raw } = record; + + return { + close: (code, reason) => { + raw.close(code, reason); + }, + protocol: raw.protocol ?? undefined, + get readyState() { + return raw.readyState; + }, + send: data => { + raw.send(data); + }, + url, + }; + } + + /** + * After a wake: every socket the runtime kept open goes back into the + * registry with its user, device and rooms. Handlers come back lazily, on + * the socket's next message. + */ + #restore(base: WebSocketRegistry): void { + const sockets = this.ctx.getWebSockets() as CloudflareServerWebSocket[]; + const rooms = new Map(); + for (const row of this.#store?.all() ?? []) { + rooms.set(row.socket, [...(rooms.get(row.socket) ?? []), row]); + } + + const alive = new Set(); + for (const raw of sockets) { + let attachment: unknown; + try { + attachment = raw.deserializeAttachment(); + } catch { + attachment = undefined; + } + if (!isAttachment(attachment)) { + try { + raw.close(1011, "Unknown socket"); + } catch { + // Already gone. + } + continue; + } + const record = this.#adopt(raw, attachment); + alive.add(attachment.id); + base.add(record.ws, attachment.userId, attachment.deviceId); + for (const { member, room } of rooms.get(attachment.id) ?? []) { + base.join(record.ws, room, member); + } + } + + for (const socket of rooms.keys()) { + if (!alive.has(socket)) this.#store?.removeSocket(socket); + } + } + + /** + * Rebuilds a socket's handlers after a wake by running its handshake again + * through the API, with the cookies and headers kept in its attachment. A + * socket whose session changed meanwhile is closed so the client + * reconnects with the session it has now. + */ + async #resume( + record: SocketRecord, + ): Promise { + if (record.connection) return record.connection; + const { deviceId, replay, userId } = record.attachment; + if (!replay) { + this.#close(record, 1012, "Reconnect"); + + return undefined; + } + + const handler = await this.#api(replay.url); + if (!handler) { + this.#close(record, 1013, "Try again later"); + + return undefined; + } + + const accepted = await runVitNodeHandshake( + handler, + new Request(replay.url, { headers: replay.headers }), + { clientAddress: replay.ip, executionCtx: this.#executionCtx }, + ); + if (accepted instanceof Response) { + await accepted.body?.cancel(); + this.#close( + record, + accepted.status === 401 || accepted.status === 403 ? 1008 : 1013, + "Reconnect", + ); + + return undefined; + } + + const identity = connectionIdentityOf(accepted.events); + if ( + identity && + (identity.userId !== userId || identity.deviceId !== deviceId) + ) { + this.#close(record, 1008, "Session changed"); + + return undefined; + } + if (record.connection) return record.connection; + + const connection = createVitNodeConnection(accepted.events); + connection.resume(record.ws, this.#rawSocket(record, replay.url)); + record.connection = connection; + + return connection; + } + + #run(fn: () => T): T { + return runInRealtimeScope(this.#scope(), fn); + } + + async #runCommand(command: VitNodeHubCommand): Promise { + return await this.#run(async () => { + if (command.type === "instance") { + const { data, origin, topic } = command; + this.#instanceListeners.emit({ data, origin, topic }); + + return null; + } + if (command.type !== "host") { + deliverToRegistry(this.#registry, command); + // An HTTP request's room push is, to the hub, what a remote + // instance's is on Node: room owners keep their state in step. + if (command.type === "toRoom") { + this.#roomListeners.emit(command.room, command.id, command.data); + } + + return null; + } + + // A hub whose isolate has not loaded the app owns no sockets and no + // loaded documents, so there is nothing for the command to change. + if ( + !hasRealtimeHostCommand(command.name) && + this.#registry.connectionCount() === 0 + ) { + return null; + } + + return await runRealtimeHostCommand( + this.#runtime, + command.name, + command.payload, + ); + }); + } + + /** A fresh scope for one event, bound to this hub's state. */ + #scope(): RealtimeScope { + return { + keepAlive: this.#keepAlive, + resources: this.#resources, + runtime: this.#runtime, + }; + } + + async #upgrade(request: Request): Promise { + if (!isWebSocketUpgrade(request)) { + return new Response("WebSocket upgrade is required.", { status: 426 }); + } + + const announced = request.headers.get(VITNODE_HUB_NAME_HEADER); + if (!this.#name && announced) { + this.#name = announced; + this.#store?.setMeta("name", announced); + } + + const handler = await this.#api(request.url); + if (!handler) { + return new Response("The VitNode API is not available.", { + status: 503, + }); + } + + const clientAddress = + request.headers.get(VITNODE_CLIENT_IP_HEADER) ?? undefined; + const headers = new Headers(request.headers); + headers.delete(VITNODE_CLIENT_IP_HEADER); + headers.delete(VITNODE_HUB_NAME_HEADER); + + const accepted = await runVitNodeHandshake( + handler, + new Request(request.url, { headers }), + { clientAddress, executionCtx: this.#executionCtx }, + ); + if (accepted instanceof Response) return accepted; + + const Pair = ( + globalThis as typeof globalThis & { + WebSocketPair?: WebSocketPairConstructor; + } + ).WebSocketPair; + if (!Pair) { + return new Response("WebSocketPair is not available.", { + status: 500, + }); + } + const pair = new Pair(); + const client = pair[0]; + const server = pair[1]; + + const identity = connectionIdentityOf(accepted.events); + const attachment: SocketAttachment = { + deviceId: identity?.deviceId ?? null, + id: crypto.randomUUID(), + replay: replayDataOf(request.url, headers, accepted.cookieNames, { + ip: clientAddress, + }), + userId: identity?.userId ?? null, + v: 1, + }; + + if (this.#hibernate) { + this.ctx.acceptWebSocket(server); + try { + server.serializeAttachment(attachment); + } catch { + // Over 2 KiB: after a wake this socket is asked to reconnect. + attachment.replay = undefined; + server.serializeAttachment(attachment); + } + } else { + server.accept(); + server.addEventListener("message", event => { + this.#keepAlive(this.#onMessage(server, event.data)); + }); + server.addEventListener("close", event => { + this.#onClose(server, event.code, event.reason); + }); + server.addEventListener("error", event => { + this.#onError(server, event.error ?? new Error("WebSocket error")); + }); + } + + const record = this.#adopt(server, attachment); + const connection = createVitNodeConnection(accepted.events); + record.connection = connection; + connection.open(this.#rawSocket(record, request.url), server, record.ws); + + const responseHeaders = new Headers(); + for (const cookie of accepted.setCookies) { + responseHeaders.append("set-cookie", cookie); + } + + return new Response(null, { + headers: responseHeaders, + status: 101, + webSocket: client, + } as ResponseInit); + } + + async dispatch(input: unknown): Promise { + const command = parseVitNodeHubCommand(input); + + return await this.#busy(async () => await this.#runCommand(command)); + } + + async fetch(request: Request): Promise { + return await this.#busy( + async () => await this.#run(async () => await this.#upgrade(request)), + ); + } + + /** Hibernation API: a socket closed. */ + webSocketClose( + raw: CloudflareServerWebSocket, + code: number, + reason: string, + ): void { + this.#begin(); + try { + this.#onClose(raw, code, reason); + } finally { + this.#end(); + } + } + + /** Hibernation API: a socket failed. */ + webSocketError(raw: CloudflareServerWebSocket, error: unknown): void { + this.#begin(); + try { + this.#onError(raw, error); + } finally { + this.#end(); + } + } + + /** Hibernation API: a frame from a socket. */ + async webSocketMessage( + raw: CloudflareServerWebSocket, + data: ArrayBuffer | string, + ): Promise { + await this.#busy(async () => { + await this.#onMessage(raw, data); + }); + } + + get #executionCtx(): ExecutionContext { + return { + passThroughOnException: () => undefined, + props: {}, + waitUntil: this.#keepAlive, + }; + } + }; + +/** + * The parts of a handshake to keep for a replay: the URL, the cookies the API + * authenticates with (never the others), a few request headers, the IP. + */ +const replayDataOf = ( + url: string, + headers: Headers, + cookieNames: string[], + { ip }: { ip?: string }, +): ReplayData => { + const keep = new Set(cookieNames); + const cookie = (headers.get("cookie") ?? "") + .split(";") + .map(part => part.trim()) + .filter(part => keep.has(part.slice(0, part.indexOf("=")))) + .join("; "); + const kept: [string, string][] = REPLAY_HEADERS.flatMap(name => { + const value = headers.get(name); + + return value ? [[name, value] as [string, string]] : []; + }); + if (cookie) kept.push(["cookie", cookie]); + + return { headers: kept, ip, url }; +}; + +/** An instance of the realtime hub, as the Workers runtime drives it. */ +export interface VitNodeRealtimeHub extends VitNodeRealtimeHubRpc { + /** Accepts a WebSocket upgrade forwarded by this installation's Worker. */ + fetch: (request: Request) => Promise; + /** Hibernation API handlers, called by the Workers runtime. */ + webSocketClose: ( + ws: CloudflareServerWebSocket, + code: number, + reason: string, + ) => void; + webSocketError: (ws: CloudflareServerWebSocket, error: unknown) => void; + webSocketMessage: ( + ws: CloudflareServerWebSocket, + data: ArrayBuffer | string, + ) => Promise; +} + +/** The class {@link createVitNodeRealtimeHub} returns. */ +export type VitNodeRealtimeHubClass = new ( + ctx: unknown, + env: CloudflareEnv, +) => VitNodeRealtimeHub; diff --git a/packages/vitnode/src/cloudflare/nitro-entry.ts b/packages/vitnode/src/cloudflare/nitro-entry.ts new file mode 100644 index 0000000000..375d9094d3 --- /dev/null +++ b/packages/vitnode/src/cloudflare/nitro-entry.ts @@ -0,0 +1,20 @@ +// Passkeys verify certificates with `@peculiar/x509`, whose DI container checks +// for this polyfill when it loads. Bundled for Workers, the polyfill would run +// after that check; loaded here, it is in place before the app's server bundle +// is imported on the first request. +import "reflect-metadata"; +import { fetchService } from "nitro/services"; + +import { createVitNodeRealtimeHub } from "./hub"; + +/** + * The realtime hub of a Nitro app on Cloudflare Workers: the Durable Object + * class the `VITNODE_REALTIME` binding names. `vitnode({ webSocket: { + * transport: "cloudflare" } })` makes this module Nitro's server entry, so the + * built Worker exports the class; nothing to import yourself. Its first + * upgrade loads the app's server bundle through Nitro, which registers the + * embedded API. Cloudflare Workers only. + */ +export const VitNodeRealtimeHub = createVitNodeRealtimeHub({ + warmup: async request => await fetchService("ssr", request), +}); diff --git a/packages/vitnode/src/cloudflare/nitro-middleware.ts b/packages/vitnode/src/cloudflare/nitro-middleware.ts new file mode 100644 index 0000000000..5840fb6aa3 --- /dev/null +++ b/packages/vitnode/src/cloudflare/nitro-middleware.ts @@ -0,0 +1,66 @@ +import { runInRealtimeScope } from "@/ws/runtime"; + +import type { CloudflareEnv, CloudflareExecutionContext } from "./types"; + +import { + createCloudflareEdgeScope, + forwardWebSocketToHub, + isWebSocketUpgrade, +} from "./edge"; + +const WEBSOCKET_PATH = "/api/ws"; + +interface CloudflareRequest extends Request { + runtime?: { + cloudflare?: { context?: CloudflareExecutionContext; env?: CloudflareEnv }; + }; +} + +interface NitroEvent { + req: CloudflareRequest; + url: URL; +} + +const envOf = (request: CloudflareRequest): CloudflareEnv | undefined => + request.runtime?.cloudflare?.env ?? + (globalThis as typeof globalThis & { __env__?: CloudflareEnv }).__env__; + +/** + * Nitro middleware for an app served by Nitro's `cloudflare-module` preset with + * `vitnode({ webSocket: { transport: "cloudflare" } })`, which registers it. + * + * - `GET /api/ws` with `Upgrade: websocket` goes to the installation's + * realtime hub and nowhere else, so no other adapter competes for it. + * - Every other request runs inside a Cloudflare scope, so `c.get("realtime")`, + * the `realtime` import and `scopedDatabase` resolve to this invocation and + * its hub. + * + * Cloudflare Workers only. + */ +const vitNodeCloudflareMiddleware = async ( + event: NitroEvent, + next: () => unknown, +): Promise => { + const env = envOf(event.req); + if (!env) { + // oxlint-disable-next-line no-console + console.error( + "[VitNode] No Cloudflare bindings on this request; is the app built with Nitro's cloudflare-module preset?", + ); + + return await next(); + } + + if (event.url.pathname === WEBSOCKET_PATH && isWebSocketUpgrade(event.req)) { + return await forwardWebSocketToHub(event.req, env); + } + + const scope = createCloudflareEdgeScope({ + ctx: event.req.runtime?.cloudflare?.context, + env, + }); + + return await runInRealtimeScope(scope, async () => await next()); +}; + +export default vitNodeCloudflareMiddleware; diff --git a/packages/vitnode/src/cloudflare/protocol.ts b/packages/vitnode/src/cloudflare/protocol.ts new file mode 100644 index 0000000000..53566a41cb --- /dev/null +++ b/packages/vitnode/src/cloudflare/protocol.ts @@ -0,0 +1,226 @@ +import { z } from "zod"; + +import type { RealtimeDeliveryCommand } from "@/ws/realtime"; + +import { CONTENT_LIVE_MAX_UPDATE_BYTES } from "@/content/server/live/messages"; +import { RealtimeConfigurationError } from "@/ws/runtime"; + +import type { CloudflareEnv, VitNodeRealtimeHubNamespace } from "./types"; + +/** The Durable Object binding the realtime hub must be declared under. */ +export const VITNODE_REALTIME_BINDING = "VITNODE_REALTIME"; + +/** + * The Worker variable that names this installation's hub. Optional: without + * it every Worker of the script shares the hub named `default`. + */ +export const VITNODE_INSTALLATION_VAR = "VITNODE_INSTALLATION_ID"; + +/** + * Carries the visitor's IP from the Worker to the hub. Set by the Worker from + * `CF-Connecting-IP` after dropping whatever the client sent, and only + * readable by the hub, which no public request can reach. + */ +export const VITNODE_CLIENT_IP_HEADER = "x-vitnode-hub-client-ip"; + +/** The most user messages one `sendToUsers` command carries to the hub. */ +export const VITNODE_HUB_MAX_BATCH = 500; + +/** + * The largest frame the hub accepts from a browser: room for a Content Engine + * live update (base64 inside JSON), and nothing like enough to be a weapon. + */ +export const VITNODE_HUB_MAX_FRAME_BYTES = CONTENT_LIVE_MAX_UPDATE_BYTES * 2; + +/** + * The Worker variable that splits an installation's sockets across several + * hubs (shards), `1` to `32`. Optional, default `1`: one hub. Every push then + * fans out to each shard, so raise it only when one hub's CPU is the limit. + */ +export const VITNODE_SHARDS_VAR = "VITNODE_REALTIME_SHARDS"; + +/** + * The Worker variable that turns hub hibernation off with `"false"`. On by + * default: an idle hub is evicted from memory while its sockets stay open, and + * stops accruing duration charges. + */ +export const VITNODE_HIBERNATION_VAR = "VITNODE_REALTIME_HIBERNATION"; + +/** + * Tells a hub its own name on upgrade, for runtimes whose Durable Object ids + * do not expose it. Set by the Worker only. + */ +export const VITNODE_HUB_NAME_HEADER = "x-vitnode-hub-name"; + +/** The most shards an installation may use. */ +export const VITNODE_MAX_SHARDS = 32; + +const INSTALLATION_ID = /^[\w.-]{1,64}$/; + +const installationOf = (env: CloudflareEnv): string => { + const value = env[VITNODE_INSTALLATION_VAR]; + if (value === undefined || value === "") return "default"; + if (typeof value !== "string" || !INSTALLATION_ID.test(value)) { + throw new RealtimeConfigurationError( + `[VitNode] ${VITNODE_INSTALLATION_VAR} must be 1-64 letters, digits, "_", "." or "-".`, + ); + } + + return value; +}; + +/** + * How many hubs this installation's sockets are spread across: + * `VITNODE_REALTIME_SHARDS`, or `1`. + * + * @throws {RealtimeConfigurationError} When the variable is not a whole number + * from 1 to 32. + */ +export const vitNodeShardCount = (env: CloudflareEnv): number => { + const value = env[VITNODE_SHARDS_VAR]; + if (value === undefined || value === "") return 1; + const count = Number(value); + if (!Number.isInteger(count) || count < 1 || count > VITNODE_MAX_SHARDS) { + throw new RealtimeConfigurationError( + `[VitNode] ${VITNODE_SHARDS_VAR} must be a whole number from 1 to ${VITNODE_MAX_SHARDS}.`, + ); + } + + return count; +}; + +/** Whether hubs hibernate: unless `VITNODE_REALTIME_HIBERNATION` is `"false"`. */ +export const isVitNodeHibernationEnabled = (env: CloudflareEnv): boolean => { + const value = env[VITNODE_HIBERNATION_VAR]; + + return !( + value === false || + (typeof value === "string" && value.toLowerCase() === "false") + ); +}; + +/** + * The Durable Object names of this installation's hubs: `vitnode:` and the + * `VITNODE_INSTALLATION_ID` variable (or `default`), plus `:` when + * `VITNODE_REALTIME_SHARDS` is above 1. Read from the Worker's own + * configuration, never from a request, so a client cannot pick another + * installation's hub. The upgrade route and every dispatch use them. + * + * @throws {RealtimeConfigurationError} When either variable is invalid. + */ +export const vitNodeHubNames = (env: CloudflareEnv): string[] => { + const base = `vitnode:${installationOf(env)}`; + const count = vitNodeShardCount(env); + if (count === 1) return [base]; + + return Array.from({ length: count }, (_, shard) => `${base}:${shard}`); +}; + +/** The single-hub name; the first of {@link vitNodeHubNames}. */ +export const vitNodeHubName = (env: CloudflareEnv): string => + vitNodeHubNames(env)[0] ?? "vitnode:default"; + +const isNamespace = (value: unknown): value is VitNodeRealtimeHubNamespace => + typeof value === "object" && + value !== null && + typeof Reflect.get(value, "idFromName") === "function" && + typeof Reflect.get(value, "get") === "function"; + +/** A hub's name and RPC stub. */ +export interface VitNodeHubTarget { + name: string; + stub: ReturnType; +} + +/** + * The stubs of this installation's hubs, one per shard. + * + * @throws {RealtimeConfigurationError} When the `VITNODE_REALTIME` Durable + * Object binding is missing - realtime then has nowhere to deliver, and + * pretending otherwise would drop every message. + */ +export const vitNodeHubTargets = (env: CloudflareEnv): VitNodeHubTarget[] => { + const namespace = env[VITNODE_REALTIME_BINDING]; + if (!isNamespace(namespace)) { + throw new RealtimeConfigurationError( + `[VitNode] The "${VITNODE_REALTIME_BINDING}" Durable Object binding is missing. Declare it in wrangler.jsonc with class_name "VitNodeRealtimeHub" and a new_sqlite_classes migration - see "Deploy WebSockets on Cloudflare" in the docs.`, + ); + } + + return vitNodeHubNames(env).map(name => ({ + name, + stub: namespace.get(namespace.idFromName(name)), + })); +}; + +/** + * Combines the results of one host command run on every shard: arrays are + * concatenated, otherwise the first result that is not `null` wins. + */ +export const mergeHostResults = (results: unknown[]): unknown => { + if (results.length > 0 && results.every(result => Array.isArray(result))) { + return results.flat(); + } + + return ( + results.find(result => result !== null && result !== undefined) ?? null + ); +}; + +/** One command from a Worker to its hub. */ +export type VitNodeHubCommand = + | RealtimeDeliveryCommand + | { data: unknown; origin: string; topic: string; type: "instance" } + | { name: string; payload: unknown; type: "host" }; + +const id = z.string().min(1).max(256); +const userId = z.number().int().nonnegative(); +const json = z.unknown(); + +const hubCommandSchema = z.discriminatedUnion("type", [ + z.strictObject({ data: json, id, type: z.literal("broadcast") }), + z.strictObject({ + deviceId: z.number().int().nonnegative().nullable(), + type: z.literal("closeUser"), + userId, + }), + z.strictObject({ + data: json, + id, + type: z.literal("sendToUser"), + userId, + }), + z.strictObject({ + id, + messages: z + .array(z.strictObject({ data: json, userId })) + .max(VITNODE_HUB_MAX_BATCH), + type: z.literal("sendToUsers"), + }), + z.strictObject({ + data: json, + id, + room: z.string().min(1).max(512), + type: z.literal("toRoom"), + }), + z.strictObject({ + name: z.string().min(1).max(128), + payload: json, + type: z.literal("host"), + }), + // A shard telling its peers (`publishToInstances`). + z.strictObject({ + data: json, + origin: z.string().min(1).max(256), + topic: z.string().min(1).max(256), + type: z.literal("instance"), + }), +]); + +/** + * Validates a command that crossed the Worker/hub boundary. + * + * @throws {z.ZodError} For anything but a known, bounded command. + */ +export const parseVitNodeHubCommand = (input: unknown): VitNodeHubCommand => + hubCommandSchema.parse(input); diff --git a/packages/vitnode/src/cloudflare/types.ts b/packages/vitnode/src/cloudflare/types.ts new file mode 100644 index 0000000000..d4a1724e03 --- /dev/null +++ b/packages/vitnode/src/cloudflare/types.ts @@ -0,0 +1,56 @@ +/** + * Structural types for the Workers runtime objects VitNode touches, so the + * Node-safe modules of the Cloudflare integration need no + * `@cloudflare/workers-types` and import nothing from `cloudflare:workers`. + */ + +/** A Durable Object id. */ +export interface CloudflareDurableObjectId { + toString: () => string; +} + +/** The RPC surface of the realtime hub, as the Worker sees it through a stub. */ +export interface VitNodeRealtimeHubStub { + /** Applies one command; see `VitNodeHubCommand`. */ + dispatch: (command: unknown) => Promise; + fetch: (request: Request) => Promise; +} + +/** The Durable Object namespace binding of the realtime hub. */ +export interface VitNodeRealtimeHubNamespace { + get: (id: CloudflareDurableObjectId) => VitNodeRealtimeHubStub; + idFromName: (name: string) => CloudflareDurableObjectId; +} + +/** The parts of a Worker's `ExecutionContext` VitNode uses. */ +export interface CloudflareExecutionContext { + waitUntil: (promise: Promise) => void; +} + +/** A Worker's bindings, as VitNode reads them. */ +export type CloudflareEnv = Record; + +/** The server end of a `WebSocketPair`, with the Workers-only `accept()`. */ +export interface CloudflareServerWebSocket { + accept: () => void; + addEventListener: (( + type: "message", + listener: (event: { data: ArrayBuffer | string }) => void, + ) => void) & + (( + type: "close", + listener: (event: { code: number; reason: string }) => void, + ) => void) & + ((type: "error", listener: (event: { error?: unknown }) => void) => void); + close: (code?: number, reason?: string) => void; + /** Hibernation API: the value stored with {@link serializeAttachment}. */ + deserializeAttachment: () => unknown; + readonly protocol?: null | string; + readonly readyState: number; + send: (data: ArrayBuffer | string | Uint8Array) => void; + /** + * Hibernation API: keeps up to 2 KiB of JSON with the socket, readable after + * the hub wakes from hibernation. + */ + serializeAttachment: (value: unknown) => void; +} diff --git a/packages/vitnode/src/content/server/live/documents.ts b/packages/vitnode/src/content/server/live/documents.ts index b7676a6b4b..c461b3ce59 100644 --- a/packages/vitnode/src/content/server/live/documents.ts +++ b/packages/vitnode/src/content/server/live/documents.ts @@ -95,11 +95,14 @@ const asRelayedUpdate = ( }; export const createContentDocuments = ({ + bind = fn => fn, join, leave, relay, send, }: { + /** Re-enters the owning runtime's scope for the persist timer. */ + bind?: (fn: (...args: A) => R) => (...args: A) => R; join: (connection: TConnection, room: string, clientId: string) => void; leave: (connection: TConnection, room: string, clientId: string) => void; relay: (room: string, message: ContentLiveServerMessage) => void; @@ -202,9 +205,12 @@ export const createContentDocuments = ({ const schedulePersist = (entry: LoadedDoc): void => { entry.dirty = true; clearTimeout(entry.persistTimer); - entry.persistTimer = setTimeout(() => { - void persist(entry); - }, CONTENT_DOCUMENT_PERSIST_MS); + entry.persistTimer = setTimeout( + bind(() => { + void persist(entry); + }), + CONTENT_DOCUMENT_PERSIST_MS, + ); unrefTimer(entry.persistTimer); }; @@ -390,6 +396,8 @@ export const createContentDocuments = ({ await closeEntry(entry, clientId); } }, + /** How many documents are loaded or loading. */ + size: (): number => docs.size + loading.size, loaded: (doc: ContentLiveDocRef): undefined | Y.Doc => docs.get(contentLiveDocRoom(doc))?.doc, open: async ({ diff --git a/packages/vitnode/src/content/server/live/server.ts b/packages/vitnode/src/content/server/live/server.ts index 1d9e0242d3..ad809ace57 100644 --- a/packages/vitnode/src/content/server/live/server.ts +++ b/packages/vitnode/src/content/server/live/server.ts @@ -1,6 +1,5 @@ import type { Context } from "hono"; import type { WSContext } from "hono/ws"; -import type * as Y from "yjs"; import { z } from "zod"; @@ -10,6 +9,8 @@ import type { ContentLiveRoomRef, ContentLiveServerMessage, } from "@/content/live/protocol"; +import type { WebSocketRegistry } from "@/ws/create-registry"; +import type { VitNodeRealtime } from "@/ws/realtime"; import { contentAnyIdSchema } from "@/content/ids"; import { @@ -19,6 +20,7 @@ import { contentLiveRoom, } from "@/content/live/protocol"; import { publishToInstances, realtime, wsRegistry } from "@/ws/registry"; +import { currentRealtimeScope } from "@/ws/runtime"; import type { ContentLiveAuthorization } from "./auth"; import type { ContentDocumentStore } from "./documents"; @@ -32,18 +34,11 @@ import { createContentLiveAuthCache, isContentLiveDocValid, } from "./auth"; -import { - createContentDocumentStore, - patchStoredContentDocuments, -} from "./document-store"; +import { createContentDocumentStore } from "./document-store"; import { createContentDocuments } from "./documents"; import { markLiveDocumentFileDeleted } from "./file-references"; import { contentLiveUserLeft } from "./hooks"; -import { - clientIdOf, - contentLiveClientMessageSchema, - encodeContentLiveBytes, -} from "./messages"; +import { clientIdOf, contentLiveClientMessageSchema } from "./messages"; import { createContentLivePresence } from "./presence"; export const CONTENT_LIVE_PRESENCE_TOPIC = `${contentLiveChannel.id}:presence`; @@ -110,9 +105,11 @@ export const createContentLiveServer = ({ createStore = createContentDocumentStore, isDocValid = isContentLiveDocValid, now = Date.now, + bind = fn => fn, publish = snapshot => { publishToInstances(CONTENT_LIVE_PRESENCE_TOPIC, snapshot); }, + realtime: delivery = realtime, registry = wsRegistry, userLeft = contentLiveUserLeft, }: { @@ -121,11 +118,19 @@ export const createContentLiveServer = ({ room: ContentLiveRoomRef, ) => Promise; authTtlMs?: number; + /** + * Re-enters the owning runtime's scope for timer callbacks (the sweeper, the + * document persist timer), so they reach that runtime's database and + * sockets. + */ + bind?: (fn: (...args: A) => R) => (...args: A) => R; createStore?: (c: Context) => ContentDocumentStore; isDocValid?: typeof isContentLiveDocValid; now?: () => number; publish?: (snapshot: ContentLivePresenceSnapshot) => void; - registry?: Pick; + /** Delivery to rooms beyond this server's own sockets. */ + realtime?: Pick; + registry?: Pick; userLeft?: typeof contentLiveUserLeft; } = {}) => { const auth = createContentLiveAuthCache({ authorize, now, ttlMs: authTtlMs }); @@ -151,8 +156,9 @@ export const createContentLiveServer = ({ leave: ({ ws }, room, clientId) => { registry.leave(ws, room, clientId); }, + bind, relay: (room, message) => { - realtime.toRoom(room, contentLiveChannel, message); + delivery.toRoom(room, contentLiveChannel, message); }, send: ({ ws }, message) => { sendTo(ws, message); @@ -219,15 +225,25 @@ export const createContentLiveServer = ({ presence.refresh(); await afterLeft(removed); await documents.retrySeeding(); + // Nothing left to time out or seed here: let the host go idle (a + // Cloudflare hub hibernates only without pending timers). The next join + // starts the sweeper again. + if (presence.localMembers().length === 0 && documents.size() === 0) { + clearInterval(sweeper); + sweeper = undefined; + } }; const startSweeper = (): void => { if (sweeper) return; - sweeper = setInterval(() => { - sweep().catch((error: unknown) => { - logError("Content live sweep error:", error); - }); - }, CONTENT_LIVE_HEARTBEAT_MS); + sweeper = setInterval( + bind(() => { + sweep().catch((error: unknown) => { + logError("Content live sweep error:", error); + }); + }), + CONTENT_LIVE_HEARTBEAT_MS, + ); sweeper.unref?.(); }; @@ -423,9 +439,11 @@ export const createContentLiveServer = ({ ), ); - void Promise.all(work).catch((error: unknown) => { + const settled = Promise.all(work).catch((error: unknown) => { logError("Content live close error:", error); }); + // The host keeps its database and stays awake until this is done. + currentRealtimeScope()?.keepAlive?.(settled); }, onInstanceMessage: ({ data, @@ -454,30 +472,16 @@ export const createContentLiveServer = ({ const reset = resetSchema.safeParse(data); if (reset.success) documents.resetRecord(reset.data.room); }, - onFileDeleted: async ({ - c, - fileId, - }: { - c: Context; - fileId: number; - }): Promise => { - const mutate = (doc: Y.Doc) => markLiveDocumentFileDeleted(doc, fileId); - const loaded = new Set( - documents.patch(mutate).map(ref => contentLiveDocRoom(ref)), - ); - const stored = await patchStoredContentDocuments(c, { - mutate, - skip: ref => loaded.has(contentLiveDocRoom(ref)), - }); - for (const { ref, update } of stored) { - realtime.toRoom(contentLiveDocRoom(ref), contentLiveChannel, { - doc: ref, - from: null, - type: "doc:update", - update: encodeContentLiveBytes(update), - } satisfies ContentLiveServerMessage); - } - }, + /** + * Marks `fileId` deleted in every document loaded here and relays the + * change to its editors. Returns the rooms it patched, so the stored copies + * of those documents are left alone. + */ + patchLoadedFileDeleted: (fileId: number): string[] => + documents + .patch(doc => markLiveDocumentFileDeleted(doc, fileId)) + .map(ref => contentLiveDocRoom(ref)), + /** Unloads the record's documents here and deletes their stored copies. */ onReset: async ({ c, room, diff --git a/packages/vitnode/src/content/server/live/socket.ts b/packages/vitnode/src/content/server/live/socket.ts index 381af850d6..44ada7699a 100644 --- a/packages/vitnode/src/content/server/live/socket.ts +++ b/packages/vitnode/src/content/server/live/socket.ts @@ -1,26 +1,118 @@ +import { z } from "zod"; + +import type { ContentLiveServerMessage } from "@/content/live/protocol"; +import type { RealtimeRuntime } from "@/ws/runtime"; + import { buildWebSocket } from "@/api/lib/websocket"; +import { contentAnyIdSchema } from "@/content/ids"; +import { + contentLiveChannel, + contentLiveDocRoom, +} from "@/content/live/protocol"; import { - onInstanceMessage, - onRemoteRoomMessage, - wsRegistry, -} from "@/ws/registry"; + currentRealtimeRuntime, + registerRealtimeHostCommand, +} from "@/ws/runtime"; +import type { ContentLiveServer } from "./server"; + +import { + createContentDocumentStore, + patchStoredContentDocuments, +} from "./document-store"; +import { markLiveDocumentFileDeleted } from "./file-references"; import { onContentLiveFileDeleted, onContentLiveReset } from "./hooks"; -import { createContentLiveServer } from "./server"; +import { encodeContentLiveBytes } from "./messages"; +import { CONTENT_LIVE_PRESENCE_TOPIC, createContentLiveServer } from "./server"; + +const CONTENT_LIVE_SERVICE = "vitnode.content.live"; +const RESET_COMMAND = "vitnode.content.live.reset"; +const FILE_DELETED_COMMAND = "vitnode.content.live.file-deleted"; + +/** + * The Content Engine live server of the runtime that owns the sockets - one + * per Node/Bun process, one per Cloudflare hub - wired to that runtime's + * registry, delivery and cross-instance messages on first use. + */ +export const contentLiveServerOf = ( + runtime: RealtimeRuntime, +): ContentLiveServer => + runtime.service(CONTENT_LIVE_SERVICE, owner => { + const { registry } = owner; + if (!registry) { + throw new Error( + `[VitNode] The Content Engine live server needs a socket host; the ${owner.kind} runtime owns no sockets.`, + ); + } + const server = createContentLiveServer({ + bind: owner.bind, + publish: snapshot => { + owner.publishToInstances(CONTENT_LIVE_PRESENCE_TOPIC, snapshot); + }, + realtime: owner.realtime, + registry, + }); + registry.onConnectionClose(server.onConnectionClose); + owner.onRemoteRoomMessage(server.onRemoteRoomMessage); + owner.onInstanceMessage(server.onInstanceMessage); + + return server; + }); + +const roomSchema = z.object({ + contentTypeId: z.string().min(1).max(200), + itemId: contentAnyIdSchema, +}); + +// Loaded documents live with the sockets, so a reset or a deleted file that an +// HTTP request handles is applied where they are: in-process on Node/Bun, in +// the hub on Cloudflare. +registerRealtimeHostCommand(RESET_COMMAND, (runtime, payload) => { + const room = roomSchema.parse(payload); + contentLiveServerOf(runtime).documents.resetRecord(room); -const contentLiveServer = createContentLiveServer(); + return null; +}); -wsRegistry.onConnectionClose(contentLiveServer.onConnectionClose); -onRemoteRoomMessage(contentLiveServer.onRemoteRoomMessage); -onInstanceMessage(contentLiveServer.onInstanceMessage); -onContentLiveReset(contentLiveServer.onReset); -onContentLiveFileDeleted(contentLiveServer.onFileDeleted); +registerRealtimeHostCommand(FILE_DELETED_COMMAND, (runtime, payload) => { + const { fileId } = z.object({ fileId: z.number().int() }).parse(payload); + + return contentLiveServerOf(runtime).patchLoadedFileDeleted(fileId); +}); + +onContentLiveReset(async ({ c, room }) => { + // The loaded copy goes first, so it cannot persist itself over the deletion. + await currentRealtimeRuntime().runOnHost(RESET_COMMAND, room); + await createContentDocumentStore(c).deleteRecord(room); +}); + +onContentLiveFileDeleted(async ({ c, fileId }) => { + const runtime = currentRealtimeRuntime(); + const loaded = new Set( + z + .array(z.string()) + .nullable() + .parse(await runtime.runOnHost(FILE_DELETED_COMMAND, { fileId })) ?? [], + ); + const stored = await patchStoredContentDocuments(c, { + mutate: doc => markLiveDocumentFileDeleted(doc, fileId), + skip: ref => loaded.has(contentLiveDocRoom(ref)), + }); + for (const { ref, update } of stored) { + runtime.realtime.toRoom(contentLiveDocRoom(ref), contentLiveChannel, { + doc: ref, + from: null, + type: "doc:update", + update: encodeContentLiveBytes(update), + } satisfies ContentLiveServerMessage); + } +}); export const contentLiveWebSocket = buildWebSocket({ description: "Content Engine live editing: presence and collaborative rich text documents.", id: "live", onMessage: async ({ c, data, ws }) => { - await contentLiveServer.handle({ c, data, ws }); + await contentLiveServerOf(currentRealtimeRuntime()).handle({ c, data, ws }); }, }); diff --git a/packages/vitnode/src/content/server/refresh/publish.ts b/packages/vitnode/src/content/server/refresh/publish.ts index 069b5262a4..f9561c855c 100644 --- a/packages/vitnode/src/content/server/refresh/publish.ts +++ b/packages/vitnode/src/content/server/refresh/publish.ts @@ -17,12 +17,8 @@ import { contentRefreshChannel, contentRefreshRoom, } from "@/content/live/refresh"; -import { - isRealtimePubSubEnabled, - isWebSocketEnabled, - realtime, - wsRegistry, -} from "@/ws/registry"; +import { isWebSocketEnabled, realtime } from "@/ws/registry"; +import { currentRealtimeRuntime } from "@/ws/runtime"; import type { ContentPublicState } from "../page-cache"; @@ -93,10 +89,19 @@ const send = (scope: ContentRefreshScope, signal: ContentRefreshSignal) => { return room; }; -/** Without Redis, nobody outside this instance can be listening. */ -const hasListeners = (scope: ContentRefreshScope): boolean => - isRealtimePubSubEnabled() || - wsRegistry.membersOf(contentRefreshRoom(scope)).length > 0; +/** + * Without Redis, nobody outside this instance can be listening. A runtime that + * owns no sockets (an HTTP request on Cloudflare) cannot tell, so it sends. + */ +const hasListeners = (scope: ContentRefreshScope): boolean => { + const runtime = currentRealtimeRuntime(); + + return ( + runtime.crossInstance() || + !runtime.registry || + runtime.registry.membersOf(contentRefreshRoom(scope)).length > 0 + ); +}; const publicStateOf = ({ action, diff --git a/packages/vitnode/src/content/server/refresh/server.ts b/packages/vitnode/src/content/server/refresh/server.ts index 30c89ac0d4..70d446ef75 100644 --- a/packages/vitnode/src/content/server/refresh/server.ts +++ b/packages/vitnode/src/content/server/refresh/server.ts @@ -9,6 +9,7 @@ import type { ContentRefreshScope, ContentRefreshServerMessage, } from "@/content/live/refresh"; +import type { WebSocketRegistry } from "@/ws/create-registry"; import { contentAnyIdSchema } from "@/content/ids"; import { @@ -89,16 +90,22 @@ const clientIdOf = (data: unknown): string => { */ export const createContentRefreshServer = ({ authorize = authorizeContentRefresh, + bind = fn => fn, now = Date.now, reauthMs = CONTENT_REFRESH_REAUTH_MS, + registry = wsRegistry, shareKey = contentRefreshShareKey, }: { authorize?: ( c: Context, scope: ContentRefreshScope, ) => Promise; + /** Re-enters the owning runtime's scope for the re-authorization sweeper. */ + bind?: (fn: (...args: A) => R) => (...args: A) => R; now?: () => number; reauthMs?: number; + /** The sockets' registry; defaults to the current runtime's. */ + registry?: Pick; shareKey?: (c: Context, scope: ContentRefreshScope) => null | string; } = {}) => { const bySocket = new Map>(); @@ -120,7 +127,7 @@ export const createContentRefreshServer = ({ }; const drop = (membership: Membership, id: string): void => { - wsRegistry.leave(membership.ws, membership.room, id); + registry.leave(membership.ws, membership.room, id); membership.clientIds.delete(id); if (membership.clientIds.size > 0) return; @@ -175,15 +182,24 @@ export const createContentRefreshServer = ({ .flatMap(rooms => [...rooms.values()]) .filter(membership => membership.checkedAt + reauthMs <= now()); if (due.length > 0) await recheck(due); + // No memberships left to re-check: let the host go idle. The next join + // starts the sweeper again. + if (bySocket.size === 0) { + clearInterval(sweeper); + sweeper = undefined; + } }; const startSweeper = (): void => { if (sweeper) return; - sweeper = setInterval(() => { - sweep().catch((error: unknown) => { - logError("Content refresh sweep error:", error); - }); - }, CONTENT_REFRESH_SWEEP_MS); + sweeper = setInterval( + bind(() => { + sweep().catch((error: unknown) => { + logError("Content refresh sweep error:", error); + }); + }), + CONTENT_REFRESH_SWEEP_MS, + ); sweeper.unref?.(); }; @@ -265,7 +281,7 @@ export const createContentRefreshServer = ({ } membership.checkedAt = now(); membership.clientIds.add(message.clientId); - wsRegistry.join(ws, room, message.clientId); + registry.join(ws, room, message.clientId); sendTo(ws, { clientId: message.clientId, room, type: "joined" }); startSweeper(); @@ -328,3 +344,7 @@ export const createContentRefreshServer = ({ sweep, }; }; + +export type ContentRefreshServer = ReturnType< + typeof createContentRefreshServer +>; diff --git a/packages/vitnode/src/content/server/refresh/socket.ts b/packages/vitnode/src/content/server/refresh/socket.ts index 4ddeb61dec..bde189f442 100644 --- a/packages/vitnode/src/content/server/refresh/socket.ts +++ b/packages/vitnode/src/content/server/refresh/socket.ts @@ -1,41 +1,74 @@ import { z } from "zod"; +import type { RealtimeRuntime } from "@/ws/runtime"; + import { buildWebSocket } from "@/api/lib/websocket"; import { CONTENT_REFRESH_ROOM_PREFIX, contentRefreshChannel, } from "@/content/live/refresh"; -import { onRemoteRoomMessage, wsRegistry } from "@/ws/registry"; +import { currentRealtimeRuntime, currentRealtimeScope } from "@/ws/runtime"; + +import type { ContentRefreshServer } from "./server"; import { onContentRefreshRootChanged } from "./publish"; import { createContentRefreshServer } from "./server"; -const contentRefreshServer = createContentRefreshServer(); +const CONTENT_REFRESH_SERVICE = "vitnode.content.refresh"; const threadChangedMessage = z.object({ signal: z.object({ kind: z.literal("thread") }), }); -wsRegistry.onConnectionClose(contentRefreshServer.onConnectionClose); -onContentRefreshRootChanged(contentRefreshServer.revalidateRoom); - -// A root changed on another instance: its members here are checked again too. -onRemoteRoomMessage((room, id, data) => { - if ( - id === contentRefreshChannel.id && - room.startsWith(CONTENT_REFRESH_ROOM_PREFIX) && - threadChangedMessage.safeParse(data).success - ) { - contentRefreshServer.revalidateRoom(room).catch((error: unknown) => { - // oxlint-disable-next-line no-console - console.error("Content refresh revalidation error:", error); +/** + * The live refresh server of the runtime that owns the sockets - one per + * Node/Bun process, one per Cloudflare hub. A root that changed somewhere else + * (another instance, or an HTTP request delivering to the hub) has its room's + * members here checked again too. + */ +export const contentRefreshServerOf = ( + runtime: RealtimeRuntime, +): ContentRefreshServer => + runtime.service(CONTENT_REFRESH_SERVICE, owner => { + const { registry } = owner; + if (!registry) { + throw new Error( + `[VitNode] Content refresh needs a socket host; the ${owner.kind} runtime owns no sockets.`, + ); + } + const server = createContentRefreshServer({ bind: owner.bind, registry }); + registry.onConnectionClose(server.onConnectionClose); + owner.onRemoteRoomMessage((room, id, data) => { + if ( + id === contentRefreshChannel.id && + room.startsWith(CONTENT_REFRESH_ROOM_PREFIX) && + threadChangedMessage.safeParse(data).success + ) { + const task = server.revalidateRoom(room).catch((error: unknown) => { + // oxlint-disable-next-line no-console + console.error("Content refresh revalidation error:", error); + }); + currentRealtimeScope()?.keepAlive?.(task); + } }); - } + + return server; + }); + +// A root changed in this request: the members of its room re-checked where +// they are. Without sockets here (Cloudflare HTTP), the hub does it when the +// room's signal arrives. +onContentRefreshRootChanged(async room => { + const runtime = currentRealtimeRuntime(); + if (!runtime.registry) return; + await contentRefreshServerOf(runtime).revalidateRoom(room); }); export const contentRefreshWebSocket = buildWebSocket({ description: "Content Engine live refresh: authorized subscriptions to AdminCP lists and public threads, answered with small change signals.", id: "refresh", - onMessage: contentRefreshServer.handle, + onMessage: async params => { + await contentRefreshServerOf(currentRealtimeRuntime()).handle(params); + }, }); diff --git a/packages/vitnode/src/framework/vite/env.ts b/packages/vitnode/src/framework/vite/env.ts index a270c207c1..3d7d0d4268 100644 --- a/packages/vitnode/src/framework/vite/env.ts +++ b/packages/vitnode/src/framework/vite/env.ts @@ -2,6 +2,8 @@ import type { Plugin } from "vite"; import { loadEnv } from "vite"; +import type { VitNodeWebSocketOption } from "./websocket"; + const CLIENT_ENV_KEYS = ["VITNODE_API_URL", "VITNODE_WEB_URL"] as const; export interface VitNodeEnvOptions { @@ -18,8 +20,12 @@ export interface VitNodeEnvOptions { * The embedded API still needs its `/ws` handshake route. An app talking to a * separate API (`VITNODE_API_URL`) connects to it regardless. Defaults to * `false`. + * + * `true` serves the socket from the Node/Bun process (Nitro's crossws). + * `{ transport: "cloudflare" }` serves it from a Durable Object hub on + * Cloudflare Workers; see "Deploy WebSockets on Cloudflare" in the docs. */ - webSocket?: boolean; + webSocket?: VitNodeWebSocketOption; } /** @@ -46,7 +52,8 @@ export const vitNodeEnv = ({ for (const [key, value] of Object.entries(env)) { process.env[key] ??= value; } - if (webSocket) process.env.VITNODE_EMBEDDED_WEBSOCKET ??= "true"; + if (webSocket !== false) + process.env.VITNODE_EMBEDDED_WEBSOCKET ??= "true"; return { environments: { diff --git a/packages/vitnode/src/framework/vite/vitnode.ts b/packages/vitnode/src/framework/vite/vitnode.ts index 0dbddddd79..f432432a99 100644 --- a/packages/vitnode/src/framework/vite/vitnode.ts +++ b/packages/vitnode/src/framework/vite/vitnode.ts @@ -71,7 +71,7 @@ export const vitnode = ({ vitNodeTailwindSources({ appRoot }), vitNodePluginRoutes({ appRoot, hostRoutesDir }), vitNodeDevError(), - vitNodeWebSocket({ webSocket }), + vitNodeWebSocket({ nitroPreset: nitro?.preset, webSocket }), ...nitroPlugin({ ...nitro, features: { fastResponse: false, ...nitro?.features }, diff --git a/packages/vitnode/src/framework/vite/websocket.ts b/packages/vitnode/src/framework/vite/websocket.ts index d0fe954ae6..1a1cf59435 100644 --- a/packages/vitnode/src/framework/vite/websocket.ts +++ b/packages/vitnode/src/framework/vite/websocket.ts @@ -4,18 +4,38 @@ import { fileURLToPath } from "node:url"; const WEBSOCKET_ROUTE = "/api/ws"; const WEBSOCKET_ROUTE_MODULE = "@vitnode/core/ws/nitro-route"; +const CLOUDFLARE_MIDDLEWARE_MODULE = + "@vitnode/core/cloudflare/nitro-middleware"; +const CLOUDFLARE_ENTRY_MODULE = "@vitnode/core/cloudflare/nitro-entry"; +const CLOUDFLARE_PRESETS = new Set(["cloudflare-module", "cloudflare_module"]); -interface NitroWebSocketConfig { - nitro: { - features: { websocket: true }; - handlers: { handler: string; route: string }[]; - noExternals: string[]; - }; -} +/** + * Where an app that embeds the API serves `/api/ws`: + * + * - `true` or `{ transport: "node" }` - from the Node/Bun process, through + * Nitro's WebSocket support (crossws). Multiple instances share pushes over + * Redis. + * - `{ transport: "cloudflare" }` - from one Durable Object hub per + * installation, on Nitro's `cloudflare-module` preset. `vite dev` still + * serves the Node transport; build and preview with Wrangler to run the hub. + * - `false` - not at all. + */ +export type VitNodeWebSocketOption = + | boolean + | { transport: "cloudflare" | "node" }; + +const resolveModule = (specifier: string): string => + fileURLToPath(import.meta.resolve(specifier)); const withNitroWebSocket = (): UserConfig => { - const handler = fileURLToPath(import.meta.resolve(WEBSOCKET_ROUTE_MODULE)); - const config: NitroWebSocketConfig = { + const handler = resolveModule(WEBSOCKET_ROUTE_MODULE); + const config: { + nitro: { + features: { websocket: true }; + handlers: { handler: string; route: string }[]; + noExternals: string[]; + }; + } = { nitro: { features: { websocket: true }, handlers: [{ handler, route: WEBSOCKET_ROUTE }], @@ -26,18 +46,75 @@ const withNitroWebSocket = (): UserConfig => { return config; }; +const withCloudflareRealtime = (): UserConfig => { + const middleware = resolveModule(CLOUDFLARE_MIDDLEWARE_MODULE); + const entry = resolveModule(CLOUDFLARE_ENTRY_MODULE); + const config: { + nitro: { + handlers: { handler: string; middleware: true; route: string }[]; + noExternals: string[]; + serverEntry: string; + }; + } = { + nitro: { + // Global, so it wraps every request in a Cloudflare scope; it answers + // `/api/ws` upgrades itself by handing them to the hub. Nitro's own + // WebSocket support stays off: one adapter per upgrade. + handlers: [{ handler: middleware, middleware: true, route: "/**" }], + noExternals: [middleware, entry], + // The preset re-exports the server entry's named exports from the + // Worker, which is how `VitNodeRealtimeHub` reaches the Durable Object + // binding. It has no default export, so it serves no request. + serverEntry: entry, + }, + }; + + return config; +}; + +const transportOf = ( + webSocket: VitNodeWebSocketOption, +): "cloudflare" | "node" | undefined => { + if (webSocket === false) return undefined; + if (webSocket === true) return "node"; + + return webSocket.transport; +}; + /** - * Mounts VitNode's WebSocket at `/api/ws` as a Nitro route and turns on - * Nitro's WebSocket support. The route hands each handshake to the API - * embedded in the app's server bundle, and is always bundled into Nitro's - * output, so it reaches Nitro's runtime in a prerender and in `vite dev` too. - * Does nothing when `webSocket` is off. + * Mounts VitNode's WebSocket at `/api/ws`. With the Node transport it is a + * Nitro route on Nitro's WebSocket support, handing each handshake to the API + * embedded in the app's server bundle. With the Cloudflare transport it is a + * Nitro middleware that forwards upgrades to the realtime hub, plus the hub's + * class exported from the built Worker. Does nothing when `webSocket` is off. + * + * @throws At build time, when the Cloudflare transport is selected without + * Nitro's `cloudflare-module` preset - the hub could not exist at runtime. */ export const vitNodeWebSocket = ({ + nitroPreset, webSocket = false, }: { - webSocket?: boolean; + /** The Nitro preset passed to `vitnode({ nitro: { preset } })`, if any. */ + nitroPreset?: string; + webSocket?: VitNodeWebSocketOption; } = {}): Plugin => ({ - config: () => (webSocket ? withNitroWebSocket() : undefined), + config: (_userConfig, { command }) => { + const transport = transportOf(webSocket); + if (!transport) return undefined; + if (transport === "node") return withNitroWebSocket(); + + // `vite dev` runs Nitro on Node, where no Durable Object exists. + if (command === "serve") return withNitroWebSocket(); + + const preset = nitroPreset ?? process.env.NITRO_PRESET; + if (!preset || !CLOUDFLARE_PRESETS.has(preset)) { + throw new Error( + `[VitNode] vitnode({ webSocket: { transport: "cloudflare" } }) needs Nitro's "cloudflare-module" preset, but the build uses ${preset ? `"${preset}"` : "the default preset"}. Set vitnode({ nitro: { preset: "cloudflare-module" } }) or switch the transport back to true.`, + ); + } + + return withCloudflareRealtime(); + }, name: "vitnode:websocket", }); diff --git a/packages/vitnode/src/ws/create-registry.ts b/packages/vitnode/src/ws/create-registry.ts new file mode 100644 index 0000000000..2ac9d2eb4f --- /dev/null +++ b/packages/vitnode/src/ws/create-registry.ts @@ -0,0 +1,256 @@ +import type { WSContext } from "hono/ws"; + +import type { VitNodeWSMessage } from "./types"; + +/** What a socket left when it closed: one entry per room member it carried. */ +export type WebSocketRoomLeft = { memberId: string; room: string }[]; + +/** Called when a socket that was in a room closes. */ +export type ConnectionCloseListener = ( + ws: WSContext, + left: WebSocketRoomLeft, +) => void; + +/** + * The open WebSocket connections and rooms owned by one realtime host: a + * Node/Bun process, or one Cloudflare Durable Object hub. See + * {@link createWebSocketRegistry}. + */ +export interface WebSocketRegistry { + /** + * Tracks a new connection and the signed-in user who owns it (`null` for a + * guest), with the device of the session that authenticated it. + */ + add: (ws: WSContext, userId: null | number, deviceId?: null | number) => void; + /** Send `{ id, data }` to every open connection. */ + broadcast: (id: string, data: unknown) => void; + /** + * Closes every connection with `code`, so each client reconnects and + * restores its rooms and state. Cleanup runs through each socket's normal + * close handling. + */ + closeAll: (code: number, reason: string) => void; + /** + * Closes a user's connections - only those authenticated from `deviceId` + * when given, else all of them. + */ + closeUser: ( + userId: number, + deviceId: null | number, + code: number, + reason: string, + ) => void; + /** How many connections are open. */ + connectionCount: () => number; + /** + * Adds `memberId` on this socket to `room`; `true` if it was not there yet. + * Check who may join before calling it - a room is only as private as that. + */ + join: (ws: WSContext, room: string, memberId: string) => boolean; + /** Removes `memberId` on this socket from `room`; `true` if it was there. */ + leave: (ws: WSContext, room: string, memberId: string) => boolean; + /** Every member of `room`, with its socket. */ + membersOf: (room: string) => { memberId: string; ws: WSContext }[]; + /** + * Runs `listener` when a socket that was in a room closes, with the rooms + * and members it left. Returns an unsubscribe function. Listener errors are + * logged, not thrown. + */ + onConnectionClose: (listener: ConnectionCloseListener) => () => void; + /** Forgets a closed connection and takes it out of every room. */ + remove: (ws: WSContext) => void; + /** The rooms one socket is in. */ + roomsOf: (ws: WSContext) => string[]; + /** Send `{ id, data }` only to the connections owned by `userId`. */ + sendToUser: (userId: number, id: string, data: unknown) => void; + /** Send `{ id, data }` to every socket in `room`. */ + toRoom: (room: string, id: string, data: unknown) => void; +} + +const logError = (message: string, error: unknown): void => { + // oxlint-disable-next-line no-console + console.error(message, error); +}; + +/** The JSON frame every server push uses. */ +export const encodeWebSocketMessage = (id: string, data: unknown): string => + JSON.stringify({ data, id } satisfies VitNodeWSMessage); + +/** + * Creates an empty {@link WebSocketRegistry}. Each registry owns its own + * connection, user, device, room and close-listener indexes, so two registries + * never see each other's sockets - one per Node/Bun process, one per + * Cloudflare hub. A socket whose send fails is removed. + * + * Server-only. Plugins do not create registries; they use `wsRegistry` from + * `@vitnode/core/ws/registry`, which acts on the registry of the runtime that + * is handling the current socket event. + * + * @param options.sharedCloseListeners Listeners that every registry runs in + * addition to its own - the ones plugins register at module load through + * `wsRegistry.onConnectionClose`. + */ +export const createWebSocketRegistry = ({ + sharedCloseListeners, +}: { + sharedCloseListeners?: ReadonlySet; +} = {}): WebSocketRegistry => { + const connections = new Map(); + // Reverse indexes, so per-user sends and closing a socket touch only what + // belongs to that user/socket instead of scanning every connection or room. + const connectionsByUser = new Map>(); + const deviceBySocket = new Map(); + const roomsBySocket = new Map>(); + const rooms = new Map>>(); + const closeListeners = new Set(); + + const unindexUser = (ws: WSContext, userId: null | number | undefined) => { + if (userId === null || userId === undefined) return; + const sockets = connectionsByUser.get(userId); + if (!sockets) return; + sockets.delete(ws); + if (sockets.size === 0) connectionsByUser.delete(userId); + }; + + const sendRaw = (ws: WSContext, raw: string): void => { + try { + ws.send(raw); + } catch { + registry.remove(ws); + } + }; + + // A failed send removes that socket mid-loop, which Map/Set iteration allows. + const sendRawToAll = (sockets: Iterable, raw: string): void => { + for (const ws of sockets) sendRaw(ws, raw); + }; + + const closeSocket = (ws: WSContext, code: number, reason: string): void => { + try { + ws.close(code, reason); + } catch { + registry.remove(ws); + } + }; + + const registry: WebSocketRegistry = { + add: (ws, userId, deviceId = null) => { + unindexUser(ws, connections.get(ws)); + connections.set(ws, userId); + deviceBySocket.delete(ws); + if (userId === null) return; + if (deviceId !== null) deviceBySocket.set(ws, deviceId); + + const sockets = connectionsByUser.get(userId) ?? new Set(); + connectionsByUser.set(userId, sockets); + sockets.add(ws); + }, + broadcast: (id, data) => { + if (connections.size === 0) return; + sendRawToAll(connections.keys(), encodeWebSocketMessage(id, data)); + }, + closeAll: (code, reason) => { + for (const ws of [...connections.keys()]) closeSocket(ws, code, reason); + }, + closeUser: (userId, deviceId, code, reason) => { + for (const ws of [...(connectionsByUser.get(userId) ?? [])]) { + const socketDevice = deviceBySocket.get(ws); + if ( + deviceId !== null && + socketDevice !== undefined && + socketDevice !== deviceId + ) { + continue; + } + closeSocket(ws, code, reason); + } + }, + connectionCount: () => connections.size, + join: (ws, room, memberId) => { + const sockets = rooms.get(room) ?? new Map>(); + rooms.set(room, sockets); + const members = sockets.get(ws) ?? new Set(); + sockets.set(ws, members); + const joined = roomsBySocket.get(ws) ?? new Set(); + roomsBySocket.set(ws, joined); + joined.add(room); + + if (members.has(memberId)) return false; + members.add(memberId); + + return true; + }, + leave: (ws, room, memberId) => { + const sockets = rooms.get(room); + const members = sockets?.get(ws); + if (!sockets || !members?.delete(memberId)) return false; + + if (members.size === 0) { + sockets.delete(ws); + const joined = roomsBySocket.get(ws); + joined?.delete(room); + if (joined?.size === 0) roomsBySocket.delete(ws); + } + if (sockets.size === 0) rooms.delete(room); + + return true; + }, + membersOf: room => + [...(rooms.get(room) ?? new Map>())].flatMap( + ([ws, members]) => [...members].map(memberId => ({ memberId, ws })), + ), + onConnectionClose: listener => { + closeListeners.add(listener); + + return () => { + closeListeners.delete(listener); + }; + }, + remove: ws => { + unindexUser(ws, connections.get(ws)); + connections.delete(ws); + deviceBySocket.delete(ws); + + const joined = roomsBySocket.get(ws); + roomsBySocket.delete(ws); + if (!joined) return; + + const left: WebSocketRoomLeft = []; + joined.forEach(room => { + const sockets = rooms.get(room); + const members = sockets?.get(ws); + if (!sockets || !members) return; + + members.forEach(memberId => left.push({ memberId, room })); + sockets.delete(ws); + if (sockets.size === 0) rooms.delete(room); + }); + + if (left.length === 0) return; + + for (const listener of [ + ...closeListeners, + ...(sharedCloseListeners ?? []), + ]) { + try { + listener(ws, left); + } catch (error) { + logError("WebSocket close listener error:", error); + } + } + }, + roomsOf: ws => [...(roomsBySocket.get(ws) ?? [])], + sendToUser: (userId, id, data) => { + const sockets = connectionsByUser.get(userId); + if (!sockets) return; + sendRawToAll(sockets, encodeWebSocketMessage(id, data)); + }, + toRoom: (room, id, data) => { + const sockets = rooms.get(room); + if (!sockets) return; + sendRawToAll(sockets.keys(), encodeWebSocketMessage(id, data)); + }, + }; + + return registry; +}; diff --git a/packages/vitnode/src/ws/crossws.server.ts b/packages/vitnode/src/ws/crossws.server.ts index 8f10f2956d..c25ac0fb25 100644 --- a/packages/vitnode/src/ws/crossws.server.ts +++ b/packages/vitnode/src/ws/crossws.server.ts @@ -1,23 +1,18 @@ -import type { WSEvents, WSReadyState } from "hono/ws"; - -import { defineWebSocketHelper, WSContext } from "hono/ws"; +import { defineWebSocketHelper } from "hono/ws"; import type { InProcessApiHandler } from "@/lib/fetcher/in-process-api.server"; import { getInProcessApi } from "@/lib/fetcher/in-process-api.server"; -// `Symbol.for`, not a module-level symbol: the Hono route below runs in the -// app's server bundle and the hooks in Nitro's, and each bundle has its own -// copy of this module. Both read the same key off the same `Request`. -const HANDSHAKE_KEY = Symbol.for("vitnode.ws.crossws.handshake"); +import type { VitNodeConnection, VitNodeHandshakeRequest } from "./handshake"; -const CONTEXT_KEY = "vitnodeWebSocket"; +import { + createVitNodeConnection, + HANDSHAKE_KEY, + runVitNodeHandshake, +} from "./handshake"; -interface Handshake { - events?: WSEvents; -} - -type HandshakeRequest = Request & { [HANDSHAKE_KEY]?: Handshake }; +const CONTEXT_KEY = "vitnodeWebSocket"; /** * The Hono helper for a `/ws` route served through Nitro (crossws). It does not @@ -43,10 +38,26 @@ type HandshakeRequest = Request & { [HANDSHAKE_KEY]?: Handshake }; * ``` */ export const upgradeWebSocket = defineWebSocketHelper((c, events) => { - const handshake = (c.req.raw as HandshakeRequest)[HANDSHAKE_KEY]; + const handshake = (c.req.raw as VitNodeHandshakeRequest)[HANDSHAKE_KEY]; if (!handshake) return c.text("WebSocket upgrade is required.", 426); handshake.events = events; + const authorization = ( + c.get("core") as + | undefined + | { + authorization?: { + adminCookieName?: string; + cookieName?: string; + deviceCookieName?: string; + }; + } + )?.authorization; + handshake.cookieNames = [ + authorization?.cookieName, + authorization?.deviceCookieName, + authorization?.adminCookieName, + ].filter((name): name is string => typeof name === "string"); return c.body(null, 200); }); @@ -103,22 +114,18 @@ export interface VitNodeWebSocketHooksOptions { warmup?: (request: Request) => Promise; } -interface Connection { - closed: boolean; - events: WSEvents; - ws?: WSContext; -} - const logError = (message: string, error: unknown): void => { // oxlint-disable-next-line no-console console.error(message, error); }; -const connectionOf = (peer: VitNodeCrosswsPeer): Connection | undefined => { +const connectionOf = ( + peer: VitNodeCrosswsPeer, +): undefined | VitNodeConnection => { const connection = peer.context[CONTEXT_KEY]; return typeof connection === "object" && connection !== null - ? (connection as Connection) + ? (connection as VitNodeConnection) : undefined; }; @@ -128,37 +135,6 @@ const clientAddressOf = (request: Request): string | undefined => { return typeof ip === "string" && ip.length > 0 ? ip : undefined; }; -const closeEvent = (code?: number, reason?: string): CloseEvent => { - const init = { code, reason, wasClean: code !== undefined && code !== 1006 }; - if (typeof CloseEvent === "function") return new CloseEvent("close", init); - - // Node 22 has no `CloseEvent` global. - return Object.assign(new Event("close"), init) as CloseEvent; -}; - -/** - * Runs the connection's `onClose` exactly once, whether the socket closed, the - * runtime reported an error, or both - so it leaves the registry and every room - * once. - */ -const finish = ( - connection: Connection, - details: { code?: number; reason?: string }, -): void => { - if (connection.closed) return; - connection.closed = true; - if (!connection.ws) return; - - try { - connection.events.onClose?.( - closeEvent(details.code, details.reason), - connection.ws, - ); - } catch (error) { - logError("WebSocket close handler error:", error); - } -}; - /** * crossws hooks that serve VitNode's `/api/ws` from Nitro, for an app that * embeds the API (`configureInProcessApi`). Pass them to @@ -224,22 +200,16 @@ export const createVitNodeWebSocketHooks = ({ return { close: (peer, details) => { - const connection = connectionOf(peer); - if (connection) finish(connection, details); + connectionOf(peer)?.close(details); }, error: (peer, error) => { - logError("WebSocket error:", error); const connection = connectionOf(peer); - if (!connection) return; + if (!connection) { + logError("WebSocket error:", error); - if (connection.ws) { - try { - connection.events.onError?.(new Event("error"), connection.ws); - } catch (handlerError) { - logError("WebSocket error handler error:", handlerError); - } + return; } - finish(connection, { code: 1011, reason: "Internal error" }); + connection.error(error); try { peer.close(1011, "Internal error"); } catch { @@ -247,21 +217,11 @@ export const createVitNodeWebSocketHooks = ({ } }, message: (peer, message) => { - const connection = connectionOf(peer); - if (!connection?.ws || connection.closed) return; - - const data = + connectionOf(peer)?.message( typeof message.rawData === "string" ? message.rawData - : message.arrayBuffer(); - try { - connection.events.onMessage?.( - new MessageEvent("message", { data }), - connection.ws, - ); - } catch (error) { - logError("WebSocket message handler error:", error); - } + : (message.arrayBuffer() as ArrayBuffer), + ); }, open: peer => { const connection = connectionOf(peer); @@ -271,28 +231,22 @@ export const createVitNodeWebSocketHooks = ({ return; } - // One wrapper for the connection's whole life: the registry, the rooms - // and every handler key their state by this object. - connection.ws = new WSContext({ - close: (code, reason) => { - peer.close(code, reason); - }, - protocol: peer.websocket.protocol, - raw: peer, - get readyState() { - return (peer.websocket.readyState ?? 1) as WSReadyState; + connection.open( + { + close: (code, reason) => { + peer.close(code, reason); + }, + protocol: peer.websocket.protocol, + get readyState() { + return peer.websocket.readyState ?? 1; + }, + send: data => { + peer.send(data); + }, + url: peer.request.url, }, - send: data => { - peer.send(data); - }, - url: peer.request.url, - }); - - try { - connection.events.onOpen?.(new Event("open"), connection.ws); - } catch (error) { - logError("WebSocket open handler error:", error); - } + peer, + ); }, upgrade: async request => { const handler = await resolveApi(request); @@ -302,36 +256,19 @@ export const createVitNodeWebSocketHooks = ({ }); } - const handshake: Handshake = {}; - const handshakeRequest: HandshakeRequest = new Request(request.url, { - headers: request.headers, - }); - handshakeRequest[HANDSHAKE_KEY] = handshake; - - const response = await handler(handshakeRequest, { + const accepted = await runVitNodeHandshake(handler, request, { clientAddress: clientAddressOf(request), }); - - if (!handshake.events || !response.ok) { - // Origin refused, rate limited, not ready, ... - the API's own answer. - // crossws sends a returned `Response` back instead of upgrading. - return response.ok - ? new Response("WebSocket upgrade failed.", { status: 500 }) - : response; - } - await response.body?.cancel(); + // crossws sends a returned `Response` back instead of upgrading. + if (accepted instanceof Response) return accepted; return { - context: { - [CONTEXT_KEY]: { - closed: false, - events: handshake.events, - } satisfies Connection, - }, + context: { [CONTEXT_KEY]: createVitNodeConnection(accepted.events) }, // A handshake that refreshed the session passes the cookie on. - headers: response.headers - .getSetCookie() - .map((cookie): [string, string] => ["set-cookie", cookie]), + headers: accepted.setCookies.map((cookie): [string, string] => [ + "set-cookie", + cookie, + ]), }; }, }; diff --git a/packages/vitnode/src/ws/handle.ts b/packages/vitnode/src/ws/handle.ts index 34b2522f8c..368ff23518 100644 --- a/packages/vitnode/src/ws/handle.ts +++ b/packages/vitnode/src/ws/handle.ts @@ -5,18 +5,33 @@ import { HTTPException } from "hono/http-exception"; import type { EnvVitNode } from "@/api/middlewares/global.middleware"; import type { NotificationStateMessage } from "@/lib/notifications/types"; +import type { RealtimeRuntime } from "@/ws/runtime"; import type { VitNodeWSMessage } from "@/ws/types"; import { DeviceModel } from "@/api/models/device"; import { getNotificationState } from "@/api/models/notifications/inbox"; +import { withConnectionIdentity } from "@/ws/handshake"; import { notificationsStateChannel } from "@/ws/notifications"; -import { - markWebSocketEnabled, - waitForRealtimePubSub, - wsRegistry, -} from "@/ws/registry"; +import { markWebSocketEnabled } from "@/ws/registry"; +import { currentRealtimeRuntime, currentRealtimeScope } from "@/ws/runtime"; import { getWebSocketId, WS_PING_ID, WS_PONG_ID } from "@/ws/types"; +const logError = (message: string, error: unknown): void => { + // oxlint-disable-next-line no-console + console.error(message, error); +}; + +/** + * Keeps async socket work alive where the runtime asks for it (a Cloudflare + * hub's `waitUntil`), and logs its failure instead of losing it. + */ +const track = (task: Promise, label: string): void => { + const settled = task.catch((error: unknown) => { + logError(label, error); + }); + currentRealtimeScope()?.keepAlive?.(settled); +}; + const parseMessage = (raw: unknown): undefined | VitNodeWSMessage => { if (typeof raw !== "string") return undefined; @@ -63,7 +78,9 @@ const sendNotificationState = async ( * * The handshake runs through the API's middleware like any request, so the * session cookie decides the user (`null` for a guest) and the Hono context - * stays available to every handler for the connection's lifetime. With Redis + * stays available to every handler for the connection's lifetime. The + * connection belongs to the realtime runtime that accepted the handshake: the + * Node/Bun process, or the installation's Cloudflare hub. With Redis * configured it answers `503` until this instance is subscribed to the * realtime channel, so no socket is accepted that other instances cannot reach. * @@ -71,6 +88,9 @@ const sendNotificationState = async ( * * @param createEvents Optional connection-level handlers. Their `onMessage` * only receives messages that matched no registered socket. + * @param options.runtime The runtime that owns accepted connections. Defaults + * to the one current at the handshake - right for Node/Bun and the Cloudflare + * hub; pass it only to pin connections to a runtime you created yourself. * * @example * ```ts @@ -87,13 +107,16 @@ const sendNotificationState = async ( */ export function handleVitNodeWebSocket( createEvents?: (c: Context) => Promise | WSEvents, + options: { runtime?: () => RealtimeRuntime } = {}, ) { // Called once at boot when the app mounts `/ws`, so the admin integrations // panel can report the WebSocket endpoint as active. markWebSocketEnabled(); + const resolveRuntime = options.runtime ?? currentRealtimeRuntime; return async (c: Context): Promise => { - if (!(await waitForRealtimePubSub())) { + const runtime = resolveRuntime(); + if (!(await runtime.ready())) { throw new HTTPException(503, { message: "Realtime is not ready on this instance yet.", }); @@ -108,69 +131,83 @@ export function handleVitNodeWebSocket( ? null : ((await new DeviceModel(c).getExistingDeviceId())?.id ?? null); - return { - onOpen: (event, ws) => { - // Track the connection so the server can push messages to it. - wsRegistry.add(ws, userId, deviceId); - if (userId !== null) { - sendNotificationState(c, ws, userId).catch((error: unknown) => { - // oxlint-disable-next-line no-console - console.error("WebSocket notification state error:", error); - }); - } - inline?.onOpen?.(event, ws); - }, - onClose: (event, ws) => { - wsRegistry.remove(ws); - inline?.onClose?.(event, ws); - }, - onError: inline?.onError, - onMessage: (event, ws) => { - const message = parseMessage(event.data); - if (message?.id === WS_PING_ID) { - ws.send( - JSON.stringify({ - data: null, - id: WS_PONG_ID, - } satisfies VitNodeWSMessage), - ); - - return; - } - const target = message - ? registered.find( - webSocket => getWebSocketId(webSocket) === message.id, - ) - : undefined; - - if (message && target) { - const send = (data: unknown) => { + return withConnectionIdentity( + { + onOpen: (event, ws) => { + const { registry } = runtime; + if (!registry) { + // Only a runtime that owns sockets may accept one; anything else is + // a misconfigured route. + logError( + "WebSocket opened outside a socket host:", + new Error(`The ${runtime.kind} runtime owns no sockets.`), + ); + ws.close(1011, "Realtime host unavailable"); + + return; + } + // Track the connection so the server can push messages to it. + registry.add(ws, userId, deviceId); + if (userId !== null) { + track( + sendNotificationState(c, ws, userId), + "WebSocket notification state error:", + ); + } + inline?.onOpen?.(event, ws); + }, + onClose: (event, ws) => { + runtime.registry?.remove(ws); + inline?.onClose?.(event, ws); + }, + onError: inline?.onError, + onMessage: (event, ws) => { + const message = parseMessage(event.data); + if (message?.id === WS_PING_ID) { ws.send( JSON.stringify({ - id: message.id, - data, + data: null, + id: WS_PONG_ID, } satisfies VitNodeWSMessage), ); - }; - - try { - Promise.resolve( - target.onMessage({ c, data: message.data, send, ws }), - ).catch((error: unknown) => { - // oxlint-disable-next-line no-console - console.error("WebSocket handler error:", error); - }); - } catch (error) { - // oxlint-disable-next-line no-console - console.error("WebSocket handler error:", error); - } - return; - } + return; + } + const target = message + ? registered.find( + webSocket => getWebSocketId(webSocket) === message.id, + ) + : undefined; + + if (message && target) { + const send = (data: unknown) => { + ws.send( + JSON.stringify({ + id: message.id, + data, + } satisfies VitNodeWSMessage), + ); + }; + + try { + track( + Promise.resolve( + target.onMessage({ c, data: message.data, send, ws }), + ), + "WebSocket handler error:", + ); + } catch (error) { + logError("WebSocket handler error:", error); + } + + return; + } - // Fall back to the connection-level handler for unmatched messages. - inline?.onMessage?.(event, ws); + // Fall back to the connection-level handler for unmatched messages. + inline?.onMessage?.(event, ws); + }, }, - }; + { deviceId, userId }, + ); }; } diff --git a/packages/vitnode/src/ws/handshake.ts b/packages/vitnode/src/ws/handshake.ts new file mode 100644 index 0000000000..7c8a1d2779 --- /dev/null +++ b/packages/vitnode/src/ws/handshake.ts @@ -0,0 +1,248 @@ +import type { ExecutionContext } from "hono"; +import type { WSEvents, WSReadyState } from "hono/ws"; + +import { WSContext } from "hono/ws"; + +import type { InProcessApiHandler } from "@/lib/fetcher/in-process-api.server"; + +/** + * The key the `upgradeWebSocket` helper of `ws/crossws.server` reads the + * handshake from. `Symbol.for`, not a module-level symbol: the Hono route runs + * in the app's server bundle and the code that started the handshake may run + * in Nitro's, and each bundle has its own copy of this module. Both read the + * same key off the same `Request` - inside one execution, never across a + * Worker/Durable Object boundary. + */ +export const HANDSHAKE_KEY = Symbol.for("vitnode.ws.crossws.handshake"); + +/** What the Hono route hands back: the connection's events, once accepted. */ +export interface VitNodeHandshake { + /** + * The names of the cookies that authenticate a handshake - the session, + * device and admin cookies. A host that has to replay the handshake later + * (a hibernated Cloudflare hub) keeps only these. + */ + cookieNames?: string[]; + events?: WSEvents; +} + +const IDENTITY_KEY = Symbol.for("vitnode.ws.connection-identity"); + +/** Who a connection was authenticated as, as `handleVitNodeWebSocket` saw it. */ +export interface VitNodeConnectionIdentity { + deviceId: null | number; + userId: null | number; +} + +/** + * Tags a connection's events with the identity its handshake resolved, so a + * host that replays the handshake can tell whether the session behind the + * socket changed meanwhile. Returns `events`. + */ +export const withConnectionIdentity = ( + events: T, + identity: VitNodeConnectionIdentity, +): T => Object.assign(events, { [IDENTITY_KEY]: identity }); + +/** The identity {@link withConnectionIdentity} tagged `events` with, if any. */ +export const connectionIdentityOf = ( + events: WSEvents, +): undefined | VitNodeConnectionIdentity => + (events as WSEvents & { [IDENTITY_KEY]?: VitNodeConnectionIdentity })[ + IDENTITY_KEY + ]; + +export type VitNodeHandshakeRequest = Request & { + [HANDSHAKE_KEY]?: VitNodeHandshake; +}; + +/** An accepted handshake: the connection's events and the headers to answer with. */ +export interface VitNodeAcceptedHandshake { + /** See {@link VitNodeHandshake.cookieNames}; empty when the route set none. */ + cookieNames: string[]; + events: WSEvents; + /** `Set-Cookie` values the handshake produced - a refreshed session. */ + setCookies: string[]; +} + +/** + * Runs a WebSocket handshake through the embedded API as a `GET` of the same + * URL, so the Origin check, the rate limiter, the session cookie and every + * `c.get(...)` model behave exactly as for any request. Resolves the accepted + * handshake, or the API's own rejection `Response` (`403`, `429`, `503`, ...) + * - nothing is registered for a rejected one. Server-only. + * + * The route must use `upgradeWebSocket` from `@vitnode/core/ws/crossws.server`. + */ +export const runVitNodeHandshake = async ( + api: InProcessApiHandler, + request: Request, + { + clientAddress, + executionCtx, + }: { clientAddress?: string; executionCtx?: ExecutionContext } = {}, +): Promise => { + const handshake: VitNodeHandshake = {}; + const handshakeRequest: VitNodeHandshakeRequest = new Request(request.url, { + headers: request.headers, + }); + handshakeRequest[HANDSHAKE_KEY] = handshake; + + const response = await api(handshakeRequest, { clientAddress }, executionCtx); + + if (!handshake.events || !response.ok) { + // Origin refused, rate limited, not ready, ... - the API's own answer. + return response.ok + ? new Response("WebSocket upgrade failed.", { status: 500 }) + : response; + } + await response.body?.cancel(); + + return { + cookieNames: handshake.cookieNames ?? [], + events: handshake.events, + setCookies: response.headers.getSetCookie(), + }; +}; + +const logError = (message: string, error: unknown): void => { + // oxlint-disable-next-line no-console + console.error(message, error); +}; + +const closeEvent = (code?: number, reason?: string): CloseEvent => { + const init = { code, reason, wasClean: code !== undefined && code !== 1006 }; + if (typeof CloseEvent === "function") return new CloseEvent("close", init); + + // Node 22 has no `CloseEvent` global. + return Object.assign(new Event("close"), init) as CloseEvent; +}; + +/** The runtime socket a {@link VitNodeConnection} drives. */ +export interface VitNodeRawSocket { + close: (code?: number, reason?: string) => void; + protocol?: string; + readonly readyState: number; + send: (data: ArrayBuffer | string | Uint8Array) => void; + url: string; +} + +/** + * One accepted connection's lifecycle over a runtime socket: one `WSContext` + * for its whole life (the registry, the rooms and every handler key their + * state by it), `onOpen` once, and `onClose` exactly once whether the socket + * closed, the runtime reported an error, or both. Handler errors are logged, + * never thrown into the runtime. Server-only. + */ +export interface VitNodeConnection { + /** Runs `onClose` unless it already ran. */ + close: (details: { code?: number; reason?: string }) => void; + /** Whether `onClose` already ran. */ + readonly closed: boolean; + /** + * Reports a runtime error: `onError`, then `onClose` with `1011`, then + * closes the socket. + */ + error: (error: unknown) => void; + /** Passes one frame to `onMessage`. */ + message: (data: ArrayBuffer | string) => void; + /** + * Creates the `WSContext` - or uses `existing`, when the host created it + * already - and runs `onOpen`, once. + */ + open: (socket: VitNodeRawSocket, raw?: unknown, existing?: WSContext) => void; + /** + * Binds the connection to an existing `WSContext` without running `onOpen`: + * a socket that opened before its host hibernated, whose handlers were + * rebuilt by replaying the handshake. Registry and rooms already know `ws`. + */ + resume: (ws: WSContext, socket: VitNodeRawSocket) => void; + /** The connection's `WSContext`, once opened. */ + readonly ws: undefined | WSContext; +} + +/** Creates the {@link VitNodeConnection} for an accepted handshake's events. */ +export const createVitNodeConnection = ( + events: WSEvents, +): VitNodeConnection => { + let ws: undefined | WSContext; + let socket: undefined | VitNodeRawSocket; + let closed = false; + + const close: VitNodeConnection["close"] = ({ code, reason }) => { + if (closed) return; + closed = true; + if (!ws) return; + + try { + events.onClose?.(closeEvent(code, reason), ws); + } catch (error) { + logError("WebSocket close handler error:", error); + } + }; + + return { + close, + get closed() { + return closed; + }, + error: error => { + logError("WebSocket error:", error); + if (ws && !closed) { + try { + events.onError?.(new Event("error"), ws); + } catch (handlerError) { + logError("WebSocket error handler error:", handlerError); + } + } + close({ code: 1011, reason: "Internal error" }); + try { + socket?.close(1011, "Internal error"); + } catch { + // Already gone. + } + }, + message: data => { + if (!ws || closed) return; + try { + events.onMessage?.(new MessageEvent("message", { data }), ws); + } catch (error) { + logError("WebSocket message handler error:", error); + } + }, + resume: (existing, target) => { + if (ws || closed) return; + socket = target; + ws = existing; + }, + open: (target, raw = target, existing) => { + if (ws || closed) return; + socket = target; + ws = + existing ?? + new WSContext({ + close: (code, reason) => { + target.close(code, reason); + }, + protocol: target.protocol, + raw, + get readyState() { + return target.readyState as WSReadyState; + }, + send: data => { + target.send(data); + }, + url: target.url, + }); + + try { + events.onOpen?.(new Event("open"), ws); + } catch (error) { + logError("WebSocket open handler error:", error); + } + }, + get ws() { + return ws; + }, + }; +}; diff --git a/packages/vitnode/src/ws/process-runtime.ts b/packages/vitnode/src/ws/process-runtime.ts new file mode 100644 index 0000000000..26fe4455ad --- /dev/null +++ b/packages/vitnode/src/ws/process-runtime.ts @@ -0,0 +1,352 @@ +import type { CacheClient } from "@/api/lib/cache"; + +import type { ConnectionCloseListener } from "./create-registry"; +import type { RealtimeDeliveryCommand } from "./realtime"; +import type { + InstanceMessageListener, + RealtimeRuntime, + RemoteRoomListener, +} from "./runtime"; + +import { createWebSocketRegistry } from "./create-registry"; +import { realtimeFromCommands } from "./realtime"; +import { + createListenerSet, + createServiceStore, + currentRealtimeScope, + deliverToRegistry, + runRealtimeHostCommand, + setProcessRealtimeRuntime, +} from "./runtime"; + +const SHARED_CLOSE_LISTENERS_KEY = Symbol.for("vitnode.ws.close-listeners"); + +/** + * Close listeners plugins register at module load with + * `wsRegistry.onConnectionClose`. Every registry runs them - the process one + * and each Cloudflare hub's - because a module-level registration cannot know + * which host will own the sockets. On `globalThis`, because the hub may be + * built from a different bundle's copy of this module than the plugin's. + */ +export const sharedCloseListeners: Set = (( + globalThis as typeof globalThis & { + [SHARED_CLOSE_LISTENERS_KEY]?: Set; + } +)[SHARED_CLOSE_LISTENERS_KEY] ??= new Set()); + +/** The sockets of this Node/Bun process. */ +export const processRegistry = createWebSocketRegistry({ + sharedCloseListeners, +}); + +const REALTIME_PUBSUB_CHANNEL = "vitnode:ws"; + +let instanceIdValue: string | undefined; + +/** + * Unique per process. Tagged onto every published message so the instance that + * sent it can ignore its own echo (it already delivered to its local clients). + * Created on first use: Workers forbid random values at module load. + */ +const instanceId = (): string => (instanceIdValue ??= crypto.randomUUID()); + +let publisher: CacheClient | null = null; + +/** + * Whether the Redis pub/sub bridge for cross-instance realtime is active. When + * `false`, pushes only reach clients on the current instance. + */ +export const isRealtimePubSubEnabled = (): boolean => publisher !== null; + +export const getRealtimeInstanceId = (): string => instanceId(); + +type RealtimePubSubMessage = + | (RealtimeDeliveryCommand & { origin: string }) + | { data: unknown; id: string; origin: string; type: "instance" }; + +const roomListeners = createListenerSet>( + "Realtime remote room", +); +const instanceListeners = createListenerSet< + Parameters +>("Realtime instance message"); + +/** + * The pub/sub wire format, unchanged from earlier releases so instances of the + * old and the new version deliver each other's messages during a deploy. + */ +const toWire = ( + message: + | RealtimeDeliveryCommand + | { data: unknown; id: string; type: "instance" }, +): Record => { + switch (message.type) { + case "closeUser": { + return { + data: message.deviceId, + id: "closeUser", + type: "closeUser", + userId: message.userId, + }; + } + case "sendToUsers": { + return { data: message.messages, id: message.id, type: "sendToUsers" }; + } + case "toRoom": { + return { + data: message.data, + id: message.id, + room: message.room, + type: "room", + }; + } + default: { + return message; + } + } +}; + +const publish = ( + message: + | RealtimeDeliveryCommand + | { data: unknown; id: string; type: "instance" }, +): void => { + if (!publisher) return; + + const task = publisher + .publish( + REALTIME_PUBSUB_CHANNEL, + JSON.stringify({ ...toWire(message), origin: instanceId() }), + ) + .catch(() => { + // Other instances miss this message, but the local clients were already + // served directly, so realtime keeps working on this instance. + }); + currentRealtimeScope()?.keepAlive?.(task); +}; + +const isNumber = (value: unknown): value is number => typeof value === "number"; + +/** Reads a pub/sub payload written by {@link toWire}. */ +const parsePubSubMessage = (raw: string): null | RealtimePubSubMessage => { + const value: unknown = JSON.parse(raw); + if (typeof value !== "object" || value === null) return null; + const message = value as Record; + const { data, id, origin, room, type, userId } = message; + if (typeof origin !== "string") return null; + + switch (type) { + case "broadcast": + case "instance": { + return typeof id === "string" ? { data, id, origin, type } : null; + } + case "closeUser": { + if (!isNumber(userId)) return null; + return { + deviceId: isNumber(data) ? data : null, + origin, + type, + userId, + }; + } + case "room": { + return typeof id === "string" && typeof room === "string" + ? { data, id, origin, room, type: "toRoom" } + : null; + } + case "sendToUser": { + return typeof id === "string" && isNumber(userId) + ? { data, id, origin, type, userId } + : null; + } + case "sendToUsers": { + const batch = data; + if (typeof id !== "string" || !Array.isArray(batch)) return null; + + return { + id, + messages: batch.flatMap((entry: unknown) => + typeof entry === "object" && + entry !== null && + "userId" in entry && + isNumber(entry.userId) && + "data" in entry + ? [{ data: entry.data, userId: entry.userId }] + : [], + ), + origin, + type, + }; + } + default: { + return null; + } + } +}; + +type PubSubState = "connecting" | "down" | "ready"; + +let pubSubState: PubSubState = "connecting"; +const readyWaiters = new Set<() => void>(); + +const setPubSubState = (state: PubSubState): void => { + pubSubState = state; + if (state !== "ready") return; + readyWaiters.forEach(resolve => resolve()); + readyWaiters.clear(); +}; + +/** + * Pub/Sub has no replay, so a socket that stayed open while this instance was + * unsubscribed may have missed messages. Closing it makes the client reconnect + * and run its normal restore: rejoin rooms and reload durable state. + */ +const resyncLocalConnections = (): void => { + // oxlint-disable-next-line no-console + console.warn( + "[VitNode] Realtime pub/sub resubscribed after a Redis outage; reconnecting local WebSocket clients so they reload missed state.", + ); + processRegistry.closeAll(1012, "Realtime resync"); +}; + +/** + * Resolves `true` once this instance can be reached by every other instance: + * subscribed to the Redis channel, or running without Redis at all (local + * delivery only). Resolves `false` when that does not happen within + * `timeoutMs` - the WebSocket handshake answers `503` then, and the client + * retries with backoff. Server-only. + */ +export const waitForRealtimePubSub = async ( + timeoutMs = 5000, +): Promise => { + if (!publisher || pubSubState === "ready") return true; + + return new Promise(resolve => { + const done = () => { + clearTimeout(timer); + resolve(true); + }; + const timer = setTimeout(() => { + readyWaiters.delete(done); + resolve(false); + }, timeoutMs); + readyWaiters.add(done); + }); +}; + +/** + * Bridges this process's realtime to every other instance through Redis + * pub/sub. A no-op without Redis, or when already initialized - a dev server + * that rebuilds the API keeps the first subscription. Node/Bun only: a + * Cloudflare deployment delivers through its Durable Object hub instead. + */ +export const initRealtimePubSub = (client: CacheClient | null): void => { + if (!client || publisher) return; + + publisher = client; + + // A connection in subscribe mode can't run other commands, so use a + // dedicated duplicate for receiving. `duplicate()` returns a disconnected + // client, hence the explicit `connect()`; node-redis restores the + // subscription itself after every reconnect, so it is only made once. + const subscriber = client.duplicate(); + let subscribed = false; + let subscribing = false; + + const onMessage = (raw: string): void => { + try { + const message = parsePubSubMessage(raw); + // Skip our own echo - the publisher already delivered locally. + if (!message || message.origin === instanceId()) return; + if (message.type === "instance") { + const { data, id: topic, origin } = message; + instanceListeners.emit({ data, origin, topic }); + + return; + } + if (message.type === "toRoom") { + roomListeners.emit(message.room, message.id, message.data); + } + deliverToRegistry(processRegistry, message); + } catch { + // Ignore malformed payloads. + } + }; + + // Until this succeeds the instance is not ready: WebSocket handshakes wait + // for it, so no socket is accepted that other instances cannot reach. A + // failed attempt is retried on the next `ready`. + const subscribe = (): void => { + if (subscribed || subscribing) return; + subscribing = true; + subscriber + .subscribe(REALTIME_PUBSUB_CHANNEL, onMessage) + .then(() => { + subscribed = true; + setPubSubState("ready"); + }) + .catch(() => { + // Realtime stays single-instance; local clients still get their messages. + }) + .finally(() => { + subscribing = false; + }); + }; + + subscriber.on("error", () => { + // Connection errors are non-fatal; local delivery still works. + }); + // node-redis resubscribes before it emits `ready` again, so a `ready` after a + // drop means messages flow again - but the ones sent meanwhile are gone. + subscriber.on("reconnecting", () => { + if (subscribed) setPubSubState("down"); + }); + subscriber.on("end", () => { + if (subscribed) setPubSubState("down"); + }); + subscriber.on("ready", () => { + if (!subscribed) { + subscribe(); + + return; + } + if (pubSubState !== "down") return; + setPubSubState("ready"); + resyncLocalConnections(); + }); + + void subscriber + .connect() + .then(subscribe) + .catch(() => { + // The reconnect strategy keeps retrying; `ready` subscribes then. + }); +}; + +const service = createServiceStore(); + +/** + * The Node/Bun runtime: this process's registry, delivered to directly and + * relayed to every other instance through Redis when it is configured. + */ +export const processRealtimeRuntime: RealtimeRuntime = { + bind: fn => fn, + crossInstance: isRealtimePubSubEnabled, + kind: "process", + onInstanceMessage: listener => instanceListeners.add(listener), + onRemoteRoomMessage: listener => roomListeners.add(listener), + publishToInstances: (topic, data) => { + publish({ data, id: topic, type: "instance" }); + }, + ready: waitForRealtimePubSub, + realtime: realtimeFromCommands(command => { + deliverToRegistry(processRegistry, command); + publish(command); + }), + registry: processRegistry, + runOnHost: async (name, payload) => + await runRealtimeHostCommand(processRealtimeRuntime, name, payload), + service: (key, create) => service(processRealtimeRuntime, key, create), +}; + +setProcessRealtimeRuntime(() => processRealtimeRuntime); diff --git a/packages/vitnode/src/ws/realtime.ts b/packages/vitnode/src/ws/realtime.ts new file mode 100644 index 0000000000..81e0ed55d6 --- /dev/null +++ b/packages/vitnode/src/ws/realtime.ts @@ -0,0 +1,108 @@ +import type { VitNodeWSChannel } from "./types"; + +/** The close code and reason a socket gets when its session is revoked. */ +export const SESSION_REVOKED_CLOSE_CODE = 1008; +export const SESSION_REVOKED_CLOSE_REASON = "Session revoked"; + +/** + * Server push to browsers over the shared WebSocket, available as + * `c.get("realtime")` in API handlers. + * + * Delivery is best effort and fire-and-forget: clients that are not connected + * miss the message, and nothing is replayed after a reconnect - keep durable + * state in the database and let clients reload it when they reconnect. + * + * Where it is delivered depends on the runtime: on Node/Bun without Redis + * (`REDIS_URL`) only clients connected to the current instance receive it; + * with Redis it is relayed to every instance. On Cloudflare Workers it is sent + * to the installation's Durable Object hub, which owns every socket. A message + * that has to leave the process is kept alive past the HTTP response with the + * request's `waitUntil`. + */ +export interface VitNodeRealtime { + /** Push a payload to every open connection. */ + broadcast: ( + channel: VitNodeWSChannel, + data: Receive, + ) => void; + /** + * Close a user's open connections everywhere - only the ones opened from + * `deviceId` when given. Core calls it whenever sessions are revoked + * (sign-out of a device, password change or reset, admin revoke), so a socket + * never keeps receiving a user's pushes after the session that opened it is + * gone. Clients reconnect with whatever session they still have. + */ + closeUserConnections: (userId: number, deviceId?: null | number) => void; + /** + * Push a payload only to the connections of a single user, across all of + * their browsers/devices. Use for per-user data such as notifications. + */ + sendToUser: ( + userId: number, + channel: VitNodeWSChannel, + data: Receive, + ) => void; + /** + * Push a different payload to each of many users on one channel. Same as + * calling `sendToUser` in a loop, but other instances (or the Cloudflare + * hub) get it as one batch instead of one message per user. `messages` is + * read once. + */ + sendToUsers: ( + channel: VitNodeWSChannel, + messages: Iterable<{ data: Receive; userId: number }>, + ) => void; + /** Push a payload to the connections that joined `room`. */ + toRoom: ( + room: string, + channel: VitNodeWSChannel, + data: Receive, + ) => void; +} + +/** + * The realtime operations in the serializable shape a transport carries - + * channel ids instead of channel objects, a materialized batch instead of an + * iterable. {@link realtimeFromCommands} turns it into a + * {@link VitNodeRealtime}. + */ +export type RealtimeDeliveryCommand = + | { data: unknown; id: string; room: string; type: "toRoom" } + | { data: unknown; id: string; type: "broadcast" } + | { data: unknown; id: string; type: "sendToUser"; userId: number } + | { + deviceId: null | number; + type: "closeUser"; + userId: number; + } + | { + id: string; + messages: { data: unknown; userId: number }[]; + type: "sendToUsers"; + }; + +/** Builds a {@link VitNodeRealtime} that hands every call to `run` as a command. */ +export const realtimeFromCommands = ( + run: (command: RealtimeDeliveryCommand) => void, +): VitNodeRealtime => ({ + broadcast: (channel, data) => { + run({ data, id: channel.id, type: "broadcast" }); + }, + closeUserConnections: (userId, deviceId = null) => { + run({ deviceId, type: "closeUser", userId }); + }, + sendToUser: (userId, channel, data) => { + run({ data, id: channel.id, type: "sendToUser", userId }); + }, + sendToUsers: (channel, messages) => { + const batch = Array.from(messages, ({ data, userId }) => ({ + data: data as unknown, + userId, + })); + if (batch.length === 0) return; + run({ id: channel.id, messages: batch, type: "sendToUsers" }); + }, + toRoom: (room, channel, data) => { + run({ data, id: channel.id, room, type: "toRoom" }); + }, +}); diff --git a/packages/vitnode/src/ws/registry.ts b/packages/vitnode/src/ws/registry.ts index 8da3a113fd..16be36bb20 100644 --- a/packages/vitnode/src/ws/registry.ts +++ b/packages/vitnode/src/ws/registry.ts @@ -1,588 +1,254 @@ import type { WSContext } from "hono/ws"; -import { AsyncLocalStorage } from "node:async_hooks"; - -import type { CacheClient } from "@/api/lib/cache"; - -import type { VitNodeWSChannel, VitNodeWSMessage } from "./types"; - -const connections = new Map(); -// Reverse indexes, so per-user sends and closing a socket touch only what -// belongs to that user/socket instead of scanning every connection or room. -const connectionsByUser = new Map>(); -const deviceBySocket = new Map(); -const roomsBySocket = new Map>(); - -const rooms = new Map>>(); - -type ConnectionCloseListener = ( - ws: WSContext, - left: { memberId: string; room: string }[], -) => void; - -const closeListeners = new Set(); - -let webSocketEnabled = false; +import type { + ConnectionCloseListener, + WebSocketRegistry, +} from "./create-registry"; +import type { VitNodeRealtime } from "./realtime"; +import type { + InstanceMessageListener, + RealtimeRuntime, + RealtimeScope, + RemoteRoomListener, +} from "./runtime"; + +import { sharedCloseListeners } from "./process-runtime"; +import { + currentRealtimeRuntime, + currentRealtimeScope, + RealtimeConfigurationError, + runInRealtimeScope, +} from "./runtime"; + +export type { + ConnectionCloseListener, + WebSocketRegistry, + WebSocketRoomLeft, +} from "./create-registry"; +export { createWebSocketRegistry } from "./create-registry"; +export { + getRealtimeInstanceId, + initRealtimePubSub, + waitForRealtimePubSub, +} from "./process-runtime"; +export type { VitNodeRealtime } from "./realtime"; +export { + currentRealtimeRuntime, + RealtimeConfigurationError, + runInRealtimeScope, +} from "./runtime"; +export type { RealtimeRuntime, RealtimeScope } from "./runtime"; + +const WEB_SOCKET_ENABLED_KEY = Symbol.for("vitnode.ws.enabled"); + +type EnabledHolder = typeof globalThis & { [WEB_SOCKET_ENABLED_KEY]?: true }; /** Called once when the app mounts the `/ws` handler. */ export const markWebSocketEnabled = (): void => { - webSocketEnabled = true; + (globalThis as EnabledHolder)[WEB_SOCKET_ENABLED_KEY] = true; }; /** Whether the app mounted the WebSocket `/ws` handler. */ -export const isWebSocketEnabled = (): boolean => webSocketEnabled; +export const isWebSocketEnabled = (): boolean => + (globalThis as EnabledHolder)[WEB_SOCKET_ENABLED_KEY] === true; -const encode = (id: string, data: unknown): string => - JSON.stringify({ data, id } satisfies VitNodeWSMessage); - -const sendRaw = (ws: WSContext, raw: string): void => { - try { - ws.send(raw); - } catch (_error) { - wsRegistry.remove(ws); - } +const logError = (message: string, error: unknown): void => { + // oxlint-disable-next-line no-console + console.error(message, error); }; -// A failed send removes that socket mid-loop, which Map/Set iteration allows. -const sendRawToAll = (sockets: Iterable, raw: string): void => { - for (const ws of sockets) sendRaw(ws, raw); -}; +/** + * The registry of the runtime that owns the current socket event. + * + * @throws {RealtimeConfigurationError} Where no runtime owns sockets - an + * HTTP request on Cloudflare, whose sockets live in the Durable Object hub. + */ +const currentRegistry = (): WebSocketRegistry => { + const runtime = currentRealtimeRuntime(); + if (runtime.registry) return runtime.registry; -const unindexUser = (ws: WSContext, userId: null | number | undefined) => { - if (userId === null || userId === undefined) return; - const sockets = connectionsByUser.get(userId); - if (!sockets) return; - sockets.delete(ws); - if (sockets.size === 0) connectionsByUser.delete(userId); + throw new RealtimeConfigurationError( + `[VitNode] wsRegistry is only available while handling a socket event; this ${runtime.kind} invocation owns no sockets. Push with c.get("realtime") instead.`, + ); }; /** - * This process's open WebSocket connections and rooms. Server-only. Every - * method acts on this instance only; for delivery across instances (via - * Redis) use `c.get("realtime")`. A socket whose send fails is removed. + * The open WebSocket connections and rooms of the runtime handling the + * current socket event: this Node/Bun process, or the installation's + * Cloudflare hub. Server-only. + * + * Every method acts on that runtime only; to push to every client, use + * `c.get("realtime")`. A socket whose send fails is removed. On Cloudflare it + * works inside WebSocket handlers - which run in the hub - and throws a + * {@link RealtimeConfigurationError} in an ordinary HTTP request, which owns + * no sockets. */ -export const wsRegistry = { +export const wsRegistry: Omit & { /** - * Tracks a new connection and the signed-in user who owns it (`null` for a - * guest), with the device of the session that authenticated it. + * Runs `listener` when a socket that was in a room closes, with the rooms + * and members it left - in every runtime, including Cloudflare hubs created + * later. Register it once, at module load. Returns an unsubscribe function. + * Listener errors are logged, not thrown. */ - add: ( - ws: WSContext, - userId: null | number, - deviceId: null | number = null, - ): void => { - unindexUser(ws, connections.get(ws)); - connections.set(ws, userId); - deviceBySocket.delete(ws); - if (userId === null) return; - if (deviceId !== null) deviceBySocket.set(ws, deviceId); - - const sockets = connectionsByUser.get(userId) ?? new Set(); - connectionsByUser.set(userId, sockets); - sockets.add(ws); + onConnectionClose: (listener: ConnectionCloseListener) => () => void; +} = { + add: (ws, userId, deviceId = null) => { + currentRegistry().add(ws, userId, deviceId); }, - /** Send `{ id, data }` to every open connection. */ - broadcast: (id: string, data: unknown): void => { - if (connections.size === 0) return; - sendRawToAll(connections.keys(), encode(id, data)); + broadcast: (id, data) => { + currentRegistry().broadcast(id, data); }, - /** - * Adds `memberId` on this socket to `room`; `true` if it was not there yet. - * Check who may join before calling it - a room is only as private as that. - */ - join: (ws: WSContext, room: string, memberId: string): boolean => { - const sockets = rooms.get(room) ?? new Map>(); - rooms.set(room, sockets); - const members = sockets.get(ws) ?? new Set(); - sockets.set(ws, members); - const joined = roomsBySocket.get(ws) ?? new Set(); - roomsBySocket.set(ws, joined); - joined.add(room); - - if (members.has(memberId)) return false; - members.add(memberId); - - return true; + closeAll: (code, reason) => { + currentRegistry().closeAll(code, reason); }, - /** Removes `memberId` on this socket from `room`; `true` if it was there. */ - leave: (ws: WSContext, room: string, memberId: string): boolean => { - const sockets = rooms.get(room); - const members = sockets?.get(ws); - if (!sockets || !members?.delete(memberId)) return false; - - if (members.size === 0) { - sockets.delete(ws); - const joined = roomsBySocket.get(ws); - joined?.delete(room); - if (joined?.size === 0) roomsBySocket.delete(ws); - } - if (sockets.size === 0) rooms.delete(room); - - return true; + closeUser: (userId, deviceId, code, reason) => { + currentRegistry().closeUser(userId, deviceId, code, reason); }, - /** Every member of `room` on this instance, with its socket. */ - membersOf: (room: string): { memberId: string; ws: WSContext }[] => - [...(rooms.get(room) ?? new Map>())].flatMap( - ([ws, members]) => [...members].map(memberId => ({ memberId, ws })), - ), - /** - * Runs `listener` when a socket that was in a room closes, with the rooms - * and members it left. Returns an unsubscribe function. Listener errors are - * logged, not thrown. - */ - onConnectionClose: (listener: ConnectionCloseListener): (() => void) => { - closeListeners.add(listener); + connectionCount: () => currentRegistry().connectionCount(), + join: (ws, room, memberId) => currentRegistry().join(ws, room, memberId), + leave: (ws, room, memberId) => currentRegistry().leave(ws, room, memberId), + membersOf: room => currentRegistry().membersOf(room), + onConnectionClose: listener => { + sharedCloseListeners.add(listener); return () => { - closeListeners.delete(listener); + sharedCloseListeners.delete(listener); }; }, - /** Forgets a closed connection and takes it out of every room. */ - remove: (ws: WSContext): void => { - unindexUser(ws, connections.get(ws)); - connections.delete(ws); - deviceBySocket.delete(ws); - - const joined = roomsBySocket.get(ws); - roomsBySocket.delete(ws); - if (!joined) return; - - const left: { memberId: string; room: string }[] = []; - joined.forEach(room => { - const sockets = rooms.get(room); - const members = sockets?.get(ws); - if (!sockets || !members) return; - - members.forEach(memberId => left.push({ memberId, room })); - sockets.delete(ws); - if (sockets.size === 0) rooms.delete(room); - }); - - if (left.length === 0) return; - - closeListeners.forEach(listener => { - try { - listener(ws, left); - } catch (error) { - // eslint-disable-next-line no-console - console.error("WebSocket close listener error:", error); - } - }); - }, - /** - * Closes every connection on this instance with `code`, so each client - * reconnects and restores its rooms and state. Cleanup runs through each - * socket's normal close handling. - */ - closeAll: (code: number, reason: string): void => { - for (const ws of [...connections.keys()]) { - try { - ws.close(code, reason); - } catch { - wsRegistry.remove(ws); - } - } + remove: (ws: WSContext) => { + currentRegistry().remove(ws); }, - /** - * Closes a user's connections on this instance - only those authenticated - * from `deviceId` when given, else all of them. Used when their sessions are - * revoked: the socket kept the identity of the session that opened it, so it - * must not outlive that session. The client reconnects as whoever it now is. - */ - closeUser: ( - userId: number, - deviceId: null | number, - code: number, - reason: string, - ): void => { - for (const ws of [...(connectionsByUser.get(userId) ?? [])]) { - const socketDevice = deviceBySocket.get(ws); - if ( - deviceId !== null && - socketDevice !== undefined && - socketDevice !== deviceId - ) { - continue; - } - try { - ws.close(code, reason); - } catch { - wsRegistry.remove(ws); - } - } + roomsOf: ws => currentRegistry().roomsOf(ws), + sendToUser: (userId, id, data) => { + currentRegistry().sendToUser(userId, id, data); }, - /** The rooms one socket is in. */ - roomsOf: (ws: WSContext): string[] => [...(roomsBySocket.get(ws) ?? [])], - /** Send `{ id, data }` to every socket in `room` on this instance. */ - toRoom: (room: string, id: string, data: unknown): void => { - const sockets = rooms.get(room); - if (!sockets) return; - sendRawToAll(sockets.keys(), encode(id, data)); - }, - /** Send `{ id, data }` only to the connections owned by `userId`. */ - sendToUser: (userId: number, id: string, data: unknown): void => { - const sockets = connectionsByUser.get(userId); - if (!sockets) return; - sendRawToAll(sockets, encode(id, data)); + toRoom: (room, id, data) => { + currentRegistry().toRoom(room, id, data); }, }; -const REALTIME_PUBSUB_CHANNEL = "vitnode:ws"; - -const SESSION_REVOKED_CLOSE_CODE = 1008; -const SESSION_REVOKED_CLOSE_REASON = "Session revoked"; - -/** - * Unique per process. Tagged onto every published message so the instance that - * sent it can ignore its own echo (it already delivered to its local clients). - */ -const instanceId = crypto.randomUUID(); - -let publisher: CacheClient | null = null; - /** - * Whether the Redis pub/sub bridge for cross-instance realtime is active. When - * `false`, `broadcast`/`sendToUser` only reach clients on the current instance. + * Runs `deliver` on the current runtime's delivery. Realtime is best effort, + * so a configuration error is logged with its fix instead of failing the + * request that pushed. */ -export const isRealtimePubSubEnabled = (): boolean => publisher !== null; - -interface RealtimePubSubMessage { - data: unknown; - id: string; - origin: string; - room?: string; - type: - | "broadcast" - | "closeUser" - | "instance" - | "room" - | "sendToUser" - | "sendToUsers"; - userId?: number; -} +const withRealtime = (deliver: (realtime: VitNodeRealtime) => void): void => { + let runtime: RealtimeRuntime; + try { + runtime = currentRealtimeRuntime(); + } catch (error) { + logError("[VitNode] Realtime message was not sent:", error); -const deliverLocally = (message: RealtimePubSubMessage): void => { - if (message.type === "broadcast") { - wsRegistry.broadcast(message.id, message.data); - } else if ( - message.type === "sendToUser" && - typeof message.userId === "number" - ) { - wsRegistry.sendToUser(message.userId, message.id, message.data); - } else if (message.type === "sendToUsers" && Array.isArray(message.data)) { - for (const entry of message.data as unknown[]) { - if ( - typeof entry === "object" && - entry !== null && - "userId" in entry && - typeof entry.userId === "number" && - "data" in entry - ) { - wsRegistry.sendToUser(entry.userId, message.id, entry.data); - } - } - } else if (message.type === "room" && typeof message.room === "string") { - wsRegistry.toRoom(message.room, message.id, message.data); - } else if ( - message.type === "closeUser" && - typeof message.userId === "number" - ) { - const deviceId = typeof message.data === "number" ? message.data : null; - wsRegistry.closeUser( - message.userId, - deviceId, - SESSION_REVOKED_CLOSE_CODE, - SESSION_REVOKED_CLOSE_REASON, - ); + return; } + deliver(runtime.realtime); }; -const roomListeners = new Set< - (room: string, id: string, data: unknown) => void ->(); - -export const onRemoteRoomMessage = ( - listener: (room: string, id: string, data: unknown) => void, -): (() => void) => { - roomListeners.add(listener); - - return () => { - roomListeners.delete(listener); - }; -}; - -type InstanceMessageListener = (message: { - data: unknown; - origin: string; - topic: string; -}) => void; - -const instanceListeners = new Set(); - -export const onInstanceMessage = ( - listener: InstanceMessageListener, -): (() => void) => { - instanceListeners.add(listener); - - return () => { - instanceListeners.delete(listener); - }; -}; - -export const getRealtimeInstanceId = (): string => instanceId; - -type KeepAlive = (task: Promise) => void; - -// The request a publish happens in, so a serverless runtime keeps the instance -// alive until Redis has the message instead of freezing it with the response. -const keepAliveScope = new AsyncLocalStorage(); - /** - * Runs `fn` with `keepAlive` receiving every realtime publish started inside - * it, including ones from code that has no request context of its own. The - * global middleware passes the request's `waitUntil`. Server-only. + * Pushes through the runtime the current code belongs to: the same object as + * `c.get("realtime")` in a request, and the right one in modules that have no + * Hono context. On Node/Bun outside a request it is the process runtime; on + * Cloudflare it must run inside a request, socket event or + * `runWithVitNodeCloudflare(...)` scope, and logs a configuration error + * otherwise. Server-only. See {@link VitNodeRealtime}. */ -export const runWithRealtimeKeepAlive = ( - keepAlive: KeepAlive, - fn: () => T, -): T => keepAliveScope.run(keepAlive, fn); - -const publish = (message: Omit): void => { - if (!publisher) return; - - const task = publisher - .publish( - REALTIME_PUBSUB_CHANNEL, - JSON.stringify({ ...message, origin: instanceId }), - ) - .catch(() => { - // Other instances miss this message, but the local clients were already - // served directly, so realtime keeps working on this instance. +export const realtime: VitNodeRealtime = { + broadcast: (channel, data) => { + withRealtime(target => { + target.broadcast(channel, data); }); - keepAliveScope.getStore()?.(task); -}; - -type PubSubState = "connecting" | "down" | "ready"; - -let pubSubState: PubSubState = "connecting"; -const readyWaiters = new Set<() => void>(); - -const setPubSubState = (state: PubSubState): void => { - pubSubState = state; - if (state !== "ready") return; - readyWaiters.forEach(resolve => resolve()); - readyWaiters.clear(); + }, + closeUserConnections: (userId, deviceId = null) => { + withRealtime(target => { + target.closeUserConnections(userId, deviceId); + }); + }, + sendToUser: (userId, channel, data) => { + withRealtime(target => { + target.sendToUser(userId, channel, data); + }); + }, + sendToUsers: (channel, messages) => { + withRealtime(target => { + target.sendToUsers(channel, messages); + }); + }, + toRoom: (room, channel, data) => { + withRealtime(target => { + target.toRoom(room, channel, data); + }); + }, }; /** - * Pub/Sub has no replay, so a socket that stayed open while this instance was - * unsubscribed may have missed messages. Closing it makes the client reconnect - * and run its normal restore: rejoin rooms and reload durable state. + * Whether pushes reach sockets beyond the current runtime: Redis pub/sub on + * Node/Bun, always on Cloudflare (one hub owns every socket). */ -const resyncLocalConnections = (): void => { - // oxlint-disable-next-line no-console - console.warn( - "[VitNode] Realtime pub/sub resubscribed after a Redis outage; reconnecting local WebSocket clients so they reload missed state.", - ); - wsRegistry.closeAll(1012, "Realtime resync"); +export const isRealtimePubSubEnabled = (): boolean => { + try { + return currentRealtimeRuntime().crossInstance(); + } catch { + return false; + } }; -/** - * Resolves `true` once this instance can be reached by every other instance: - * subscribed to the Redis channel, or running without Redis at all (local - * delivery only). Resolves `false` when that does not happen within - * `timeoutMs` - the WebSocket handshake answers `503` then, and the client - * retries with backoff. Server-only. - */ -export const waitForRealtimePubSub = async ( - timeoutMs = 5000, -): Promise => { - if (!publisher || pubSubState === "ready") return true; - - return new Promise(resolve => { - const done = () => { - clearTimeout(timer); - resolve(true); - }; - const timer = setTimeout(() => { - readyWaiters.delete(done); - resolve(false); - }, timeoutMs); - readyWaiters.add(done); +/** Sends `data` to every other instance of the current runtime. */ +export const publishToInstances = (topic: string, data: unknown): void => { + withRealtime(() => { + currentRealtimeRuntime().publishToInstances(topic, data); }); }; -export const initRealtimePubSub = (client: CacheClient | null): void => { - // No-op without Redis, or if already initialized. - if (!client || publisher) return; - - publisher = client; - - // A connection in subscribe mode can't run other commands, so use a - // dedicated duplicate for receiving. `duplicate()` returns a disconnected - // client, hence the explicit `connect()`; node-redis restores the - // subscription itself after every reconnect, so it is only made once. - const subscriber = client.duplicate(); - let subscribed = false; - let subscribing = false; - - const onMessage = (raw: string): void => { - try { - const message = JSON.parse(raw) as RealtimePubSubMessage; - // Skip our own echo - the publisher already delivered locally. - if (message.origin === instanceId) return; - if (message.type === "instance") { - const { data, id: topic, origin } = message; - instanceListeners.forEach(listener => { - listener({ data, origin, topic }); - }); - - return; - } - if (message.type === "room" && typeof message.room === "string") { - const { data, id, room } = message; - roomListeners.forEach(listener => listener(room, id, data)); - } - deliverLocally(message); - } catch { - // Ignore malformed payloads. - } - }; - - // Until this succeeds the instance is not ready: WebSocket handshakes wait - // for it, so no socket is accepted that other instances cannot reach. A - // failed attempt is retried on the next `ready`. - const subscribe = (): void => { - if (subscribed || subscribing) return; - subscribing = true; - subscriber - .subscribe(REALTIME_PUBSUB_CHANNEL, onMessage) - .then(() => { - subscribed = true; - setPubSubState("ready"); - }) - .catch(() => { - // Realtime stays single-instance; local clients still get their messages. - }) - .finally(() => { - subscribing = false; - }); - }; - - subscriber.on("error", () => { - // Connection errors are non-fatal; local delivery still works. - }); - // node-redis resubscribes before it emits `ready` again, so a `ready` after a - // drop means messages flow again - but the ones sent meanwhile are gone. - subscriber.on("reconnecting", () => { - if (subscribed) setPubSubState("down"); - }); - subscriber.on("end", () => { - if (subscribed) setPubSubState("down"); - }); - subscriber.on("ready", () => { - if (!subscribed) { - subscribe(); - - return; - } - if (pubSubState !== "down") return; - setPubSubState("ready"); - resyncLocalConnections(); - }); - - void subscriber - .connect() - .then(subscribe) - .catch(() => { - // The reconnect strategy keeps retrying; `ready` subscribes then. - }); -}; +/** + * Subscribes to room messages that reached the process runtime from another + * instance. Kept for compatibility; runtime-owned state subscribes with + * `runtime.onRemoteRoomMessage` instead. + */ +export const onRemoteRoomMessage = ( + listener: RemoteRoomListener, +): (() => void) => currentRealtimeRuntime().onRemoteRoomMessage(listener); -export const publishToInstances = (topic: string, data: unknown): void => { - publish({ data, id: topic, type: "instance" }); -}; +/** Subscribes to `publishToInstances` messages of the process runtime. */ +export const onInstanceMessage = ( + listener: InstanceMessageListener, +): (() => void) => currentRealtimeRuntime().onInstanceMessage(listener); /** - * Server push to browsers over the shared WebSocket, available as - * `c.get("realtime")` in API handlers. - * - * Delivery is best effort and fire-and-forget: clients that are not connected - * miss the message, and nothing is replayed after a reconnect - keep durable - * state in the database and let clients reload it when they reconnect. Without - * Redis (`REDIS_URL`) only clients connected to the current instance receive - * it; with Redis it is relayed to every instance, and the publish is kept - * alive past the HTTP response with the request's `waitUntil`. + * Runs `fn` with `keepAlive` receiving every realtime message started inside + * it that leaves the process, including ones from code that has no request + * context of its own. The global middleware passes the request's `waitUntil`. + * Keeps the runtime of the enclosing scope. Server-only. */ -export interface VitNodeRealtime { - /** Push a payload to every open connection. */ - broadcast: ( - channel: VitNodeWSChannel, - data: Receive, - ) => void; - /** - * Close a user's open connections on every instance - only the ones opened - * from `deviceId` when given. Core calls it whenever sessions are revoked - * (sign-out of a device, password change or reset, admin revoke), so a socket - * never keeps receiving a user's pushes after the session that opened it is - * gone. Clients reconnect with whatever session they still have. - */ - closeUserConnections: (userId: number, deviceId?: null | number) => void; - /** - * Push a payload only to the connections of a single user, across all of - * their browsers/devices. Use for per-user data such as notifications. - */ - sendToUser: ( - userId: number, - channel: VitNodeWSChannel, - data: Receive, - ) => void; - /** - * Push a different payload to each of many users on one channel. Same as - * calling `sendToUser` in a loop, but other instances get it as a single - * pub/sub message instead of one per user. - */ - sendToUsers: ( - channel: VitNodeWSChannel, - messages: Iterable<{ data: Receive; userId: number }>, - ) => void; - /** Push a payload to the connections that joined `room`. */ - toRoom: ( - room: string, - channel: VitNodeWSChannel, - data: Receive, - ) => void; -} +export const runWithRealtimeKeepAlive = ( + keepAlive: (task: Promise) => void, + fn: () => T, +): T => { + const scope: RealtimeScope | undefined = currentRealtimeScope(); + let runtime: RealtimeRuntime; + try { + runtime = scope?.runtime ?? currentRealtimeRuntime(); + } catch (error) { + // The request itself still works; each push logs the same error. + logError("[VitNode] Realtime is not available for this request:", error); -export const realtime: VitNodeRealtime = { - broadcast: (channel, data) => { - wsRegistry.broadcast(channel.id, data); - publish({ data, id: channel.id, type: "broadcast" }); - }, - closeUserConnections: (userId, deviceId = null) => { - wsRegistry.closeUser( - userId, - deviceId, - SESSION_REVOKED_CLOSE_CODE, - SESSION_REVOKED_CLOSE_REASON, - ); - publish({ data: deviceId, id: "closeUser", type: "closeUser", userId }); - }, - sendToUser: (userId, channel, data) => { - wsRegistry.sendToUser(userId, channel.id, data); - publish({ data, id: channel.id, type: "sendToUser", userId }); - }, - sendToUsers: (channel, messages) => { - const batch = [...messages]; - if (batch.length === 0) return; + return fn(); + } - for (const { data, userId } of batch) { - wsRegistry.sendToUser(userId, channel.id, data); - } - publish({ data: batch, id: channel.id, type: "sendToUsers" }); - }, - toRoom: (room, channel, data) => { - wsRegistry.toRoom(room, channel.id, data); - publish({ data, id: channel.id, room, type: "room" }); - }, + const outer = scope?.keepAlive; + + return runInRealtimeScope( + { + ...scope, + // Both: the request's own `waitUntil` may be missing where the outer + // scope (a Cloudflare invocation) has one. + keepAlive: outer + ? task => { + keepAlive(task); + outer(task); + } + : keepAlive, + runtime, + }, + fn, + ); }; diff --git a/packages/vitnode/src/ws/runtime.ts b/packages/vitnode/src/ws/runtime.ts new file mode 100644 index 0000000000..9d44d1983f --- /dev/null +++ b/packages/vitnode/src/ws/runtime.ts @@ -0,0 +1,361 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import type { WebSocketRegistry } from "./create-registry"; +import type { RealtimeDeliveryCommand, VitNodeRealtime } from "./realtime"; + +import { + SESSION_REVOKED_CLOSE_CODE, + SESSION_REVOKED_CLOSE_REASON, +} from "./realtime"; + +/** A room message that reached this runtime from outside it. */ +export type RemoteRoomListener = ( + room: string, + id: string, + data: unknown, +) => void; + +/** A message another instance published with `publishToInstances`. */ +export type InstanceMessageListener = (message: { + data: unknown; + origin: string; + topic: string; +}) => void; + +/** + * Where realtime state lives for the code that is running now: + * + * - `process` - a Node/Bun process: it owns its sockets, and Redis (when + * configured) relays pushes to every other process. + * - `cloudflare-hub` - a Cloudflare Durable Object hub: it owns every socket of + * one installation. + * - `cloudflare-edge` - an ordinary Cloudflare Worker invocation (HTTP, cron, + * queue): it owns no socket and sends every push to the hub. + * + * Core resolves one with {@link currentRealtimeRuntime}; plugins never touch + * it and keep using `c.get("realtime")` and `wsRegistry`. Server-only. + */ +export interface RealtimeRuntime { + /** + * Wraps `fn` so it runs inside this runtime's scope whenever it is called - + * for timers and listeners that fire outside of the event that created them. + */ + bind: (fn: (...args: A) => R) => (...args: A) => R; + /** Whether a push reaches sockets other than the ones in {@link registry}. */ + crossInstance: () => boolean; + /** A short name for logs and the admin integrations panel. */ + kind: "cloudflare-edge" | "cloudflare-hub" | "process"; + /** Subscribes to {@link publishToInstances} messages from other instances. */ + onInstanceMessage: (listener: InstanceMessageListener) => () => void; + /** + * Subscribes to room messages that were sent somewhere else and delivered + * here: from another Node instance over Redis, or from an HTTP request to + * the Cloudflare hub. Room owners use it to keep their own state in step. + */ + onRemoteRoomMessage: (listener: RemoteRoomListener) => () => void; + /** Sends `data` to every other instance. A no-op where there is none. */ + publishToInstances: (topic: string, data: unknown) => void; + /** + * Resolves `true` once a socket accepted here can be reached by every + * push, `false` when that did not happen within `timeoutMs`. + */ + ready: (timeoutMs?: number) => Promise; + /** Delivery for `c.get("realtime")` and the `realtime` import. */ + realtime: VitNodeRealtime; + /** The sockets this runtime owns; `null` where it owns none. */ + registry: null | WebSocketRegistry; + /** + * Runs a host command (see {@link registerRealtimeHostCommand}) on the + * runtime that owns the sockets - here, or in the Cloudflare hub - and + * resolves its JSON result. + */ + runOnHost: (name: string, payload: unknown) => Promise; + /** + * One value per runtime and `key`, created on first use - so state such as + * the Content Engine live server belongs to the runtime that owns the + * sockets it tracks, never to the isolate. + */ + service: (key: string, create: (runtime: RealtimeRuntime) => T) => T; +} + +/** What {@link runInRealtimeScope} makes current for the code it runs. */ +export interface RealtimeScope { + /** + * Keeps work started in this scope alive past the response: a request's + * `waitUntil`. Every realtime message that leaves the process is passed to + * it. + */ + keepAlive?: (task: Promise) => void; + /** + * Per-scope resources, such as a database client that is only valid for + * this request or this hub. See `scopedResource`. + */ + resources?: Map; + runtime: RealtimeRuntime; +} + +/** One per-scope resource and how to release it. */ +export interface ScopedResource { + /** Releases the resource; called when its owner goes idle. */ + dispose?: () => Promise | void; + instance: unknown; +} + +/** + * Releases and forgets every resource in `resources`, logging - never + * throwing - a failed release. A hub calls it when it goes idle, so a database + * connection does not keep it awake. + */ +export const disposeScopedResources = async ( + resources: Map, +): Promise => { + const entries = [...resources.values()]; + resources.clear(); + await Promise.all( + entries.map(async ({ dispose }) => { + try { + await dispose?.(); + } catch (error) { + // oxlint-disable-next-line no-console + console.error("[VitNode] Releasing a scoped resource failed:", error); + } + }), + ); +}; + +const SCOPE_KEY = Symbol.for("vitnode.realtime.scope"); +const PLATFORM_KEY = Symbol.for("vitnode.realtime.platform"); +const HOST_COMMANDS_KEY = Symbol.for("vitnode.realtime.host-commands"); + +interface PlatformRequirement { + /** Shown in the error: how to establish a scope there. */ + hint: string; + name: string; +} + +type Shared = typeof globalThis & { + [HOST_COMMANDS_KEY]?: Map; + [PLATFORM_KEY]?: PlatformRequirement; + [SCOPE_KEY]?: AsyncLocalStorage; +}; + +// On `globalThis`: the app's server bundle and Nitro's bundle each load their +// own copy of this module, and a scope one of them opens must be visible to +// the other. +const shared = globalThis as Shared; +const scopes = (shared[SCOPE_KEY] ??= new AsyncLocalStorage()); + +/** + * Runs `fn` with `scope` current, so every realtime call inside it - also + * ones from modules that have no Hono context - resolves `scope.runtime`. + * Server-only. + */ +export const runInRealtimeScope = (scope: RealtimeScope, fn: () => T): T => + scopes.run(scope, fn); + +/** The scope opened by the closest {@link runInRealtimeScope}, if any. */ +export const currentRealtimeScope = (): RealtimeScope | undefined => + scopes.getStore(); + +/** + * Thrown when realtime is used where its runtime cannot be resolved: on a + * platform that requires an explicit scope (Cloudflare Workers) outside of + * one, or with its configuration missing. The message says what to fix. + */ +export class RealtimeConfigurationError extends Error { + override name = "RealtimeConfigurationError"; +} + +/** + * Declares that this isolate runs on a platform where process-wide realtime + * state is wrong - every realtime call must happen inside a scope. Called by + * the Cloudflare integration when it loads. + */ +export const requireRealtimeScope = ( + requirement: PlatformRequirement, +): void => { + shared[PLATFORM_KEY] = requirement; +}; + +/** The platform requirement set with {@link requireRealtimeScope}, if any. */ +export const realtimeScopeRequirement = (): PlatformRequirement | undefined => + shared[PLATFORM_KEY]; + +/** Whether this code runs in Cloudflare's workerd runtime. */ +export const isWorkerdRuntime = (): boolean => + typeof navigator !== "undefined" && + (navigator as { userAgent?: string }).userAgent === "Cloudflare-Workers"; + +let processRuntime: (() => RealtimeRuntime) | undefined; + +/** Registers the Node/Bun runtime. Called once by `ws/process-runtime`. */ +export const setProcessRealtimeRuntime = (get: () => RealtimeRuntime): void => { + processRuntime = get; +}; + +/** + * The runtime the current code belongs to: the open scope's, else - on Node + * and Bun - the process runtime. + * + * @throws {RealtimeConfigurationError} On Cloudflare Workers outside a scope, + * where a process-wide fallback would deliver to nobody. + */ +export const currentRealtimeRuntime = (): RealtimeRuntime => { + const scope = scopes.getStore(); + if (scope) return scope.runtime; + + const requirement = shared[PLATFORM_KEY]; + if (requirement) { + throw new RealtimeConfigurationError( + `[VitNode] Realtime was used outside a VitNode execution scope on ${requirement.name}. ${requirement.hint}`, + ); + } + if (isWorkerdRuntime()) { + throw new RealtimeConfigurationError( + '[VitNode] Realtime is running on Cloudflare Workers without the Cloudflare transport. Set `vitnode({ webSocket: { transport: "cloudflare" } })` in vite.config.ts and declare the hub binding - see "Deploy WebSockets on Cloudflare" in the docs.', + ); + } + if (!processRuntime) { + throw new RealtimeConfigurationError( + "[VitNode] No realtime runtime is registered in this process.", + ); + } + + return processRuntime(); +}; + +/** A command a socket host runs for code that does not own the sockets. */ +export type RealtimeHostCommand = ( + runtime: RealtimeRuntime, + payload: unknown, +) => unknown; + +const hostCommands = (shared[HOST_COMMANDS_KEY] ??= new Map()); + +/** + * Registers a named command that mutates state owned by the socket host - + * the Content Engine's loaded documents, for example. Code that does not own + * the sockets runs it with `runtime.runOnHost(name, payload)`: in-process on + * Node/Bun, in the Durable Object hub on Cloudflare. The handler gets the + * owning runtime and must validate `payload`, which crossed a boundary. Its + * result must be JSON. Registering a name again replaces the handler. + */ +export const registerRealtimeHostCommand = ( + name: string, + handler: RealtimeHostCommand, +): void => { + hostCommands.set(name, handler); +}; + +/** Runs a registered host command on `runtime`, which owns the sockets. */ +export const runRealtimeHostCommand = async ( + runtime: RealtimeRuntime, + name: string, + payload: unknown, +): Promise => { + const handler = hostCommands.get(name); + if (!handler) { + throw new RealtimeConfigurationError( + `[VitNode] Unknown realtime host command "${name}".`, + ); + } + + return await handler(runtime, payload); +}; + +/** Whether a host command named `name` is registered in this isolate. */ +export const hasRealtimeHostCommand = (name: string): boolean => + hostCommands.has(name); + +/** + * Applies one delivery command to the sockets in `registry`. Shared by every + * runtime that owns sockets, so local, Redis and hub delivery behave the same. + */ +export const deliverToRegistry = ( + registry: WebSocketRegistry, + command: RealtimeDeliveryCommand, +): void => { + switch (command.type) { + case "broadcast": { + registry.broadcast(command.id, command.data); + + return; + } + case "closeUser": { + registry.closeUser( + command.userId, + command.deviceId, + SESSION_REVOKED_CLOSE_CODE, + SESSION_REVOKED_CLOSE_REASON, + ); + + return; + } + case "sendToUser": { + registry.sendToUser(command.userId, command.id, command.data); + + return; + } + case "sendToUsers": { + for (const { data, userId } of command.messages) { + registry.sendToUser(userId, command.id, data); + } + + return; + } + case "toRoom": { + registry.toRoom(command.room, command.id, command.data); + } + } +}; + +/** + * A set of listeners with explicit unsubscribe, which reports listener errors + * instead of letting one listener stop the others. + */ +export const createListenerSet = (label: string) => { + const listeners = new Set<(...args: A) => void>(); + + return { + add: (listener: (...args: A) => void): (() => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + clear: () => { + listeners.clear(); + }, + emit: (...args: A): void => { + for (const listener of [...listeners]) { + try { + listener(...args); + } catch (error) { + // oxlint-disable-next-line no-console + console.error(`${label} listener error:`, error); + } + } + }, + get size() { + return listeners.size; + }, + }; +}; + +/** Per-runtime `service` storage, shared by every runtime implementation. */ +export const createServiceStore = () => { + const services = new Map(); + + return ( + runtime: RealtimeRuntime, + key: string, + create: (runtime: RealtimeRuntime) => T, + ): T => { + if (services.has(key)) return services.get(key) as T; + const value = create(runtime); + services.set(key, value); + + return value; + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcced41439..69f2cead8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,7 +186,7 @@ importers: version: 0.77.0 react-doctor: specifier: ^0.9.22 - version: 0.9.22(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(eslint@10.11.0(jiti@2.7.0))(oxlint-tsgolint@7.0.2006)(react@19.3.1) + version: 0.9.22(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2))(oxlint-tsgolint@7.0.2006)(react@19.3.1)(supports-color@10.2.2) tsx: specifier: ^4.23.15 version: 4.23.15 @@ -259,7 +259,7 @@ importers: version: 2.1.6(hono@4.13.17) '@react-email/ui': specifier: ^6.11.8 - version: 6.11.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 6.11.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@types/node': specifier: ^26.6.6 version: 26.6.6 @@ -298,7 +298,7 @@ importers: version: 7.0.2006 react-email: specifier: ^6.11.8 - version: 6.11.8(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 6.11.8(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(supports-color@10.2.2) tsc-alias: specifier: ^1.9.10 version: 1.9.10 @@ -322,7 +322,7 @@ importers: version: 1.4.0(preact@10.29.8)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(solid-js@1.9.18) '@tanstack/react-form': specifier: ^1.33.5 - version: 1.33.5(@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 1.33.5(@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@tanstack/react-query': specifier: ^5.107.1 version: 5.107.1(@types/react@19.3.0)(react@19.3.1) @@ -334,7 +334,7 @@ importers: version: 1.167.3(@tanstack/query-core@5.107.1)(@tanstack/react-query@5.107.1(@types/react@19.3.0)(react@19.3.1))(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@tanstack/router-core@1.171.39)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@tanstack/react-start': specifier: ^1.169.4 - version: 1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@vitnode/blog': specifier: workspace:* version: link:../../plugins/blog @@ -364,13 +364,13 @@ importers: version: 1.0.0-rc.5(@opentelemetry/api@1.9.1)(postgres@4.1.0)(zod@4.6.7) fumadocs-core: specifier: 16.18.1 - version: 16.18.1(@mdx-js/mdx@3.1.1)(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(zod@4.6.7) + version: 16.18.1(@mdx-js/mdx@3.1.1(supports-color@10.2.2))(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(supports-color@10.2.2)(zod@4.6.7) fumadocs-mdx: specifier: ^15.5.0 - version: 15.5.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.18.1(@mdx-js/mdx@3.1.1)(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(zod@4.6.7))(next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 15.5.0(70d8c7dc3e1aee32c8e726401d4e32e2) fumadocs-ui: specifier: npm:@fumadocs/base-ui@16.18.1 - version: '@fumadocs/base-ui@16.18.1(@date-fns/tz@1.5.0)(@types/mdx@2.0.14)(@types/react@19.3.0)(date-fns@4.4.0)(fumadocs-core@16.18.1(@mdx-js/mdx@3.1.1)(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(zod@4.6.7))(next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.5)' + version: '@fumadocs/base-ui@16.18.1(@date-fns/tz@1.5.0)(@types/mdx@2.0.14)(@types/react@19.3.0)(date-fns@4.4.0)(fumadocs-core@16.18.1(@mdx-js/mdx@3.1.1(supports-color@10.2.2))(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(supports-color@10.2.2)(zod@4.6.7))(next@16.3.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.5)' hono: specifier: ^4.13.17 version: 4.13.17 @@ -397,7 +397,7 @@ importers: version: 19.3.1(react@19.3.1) shadcn: specifier: ^4.23.0 - version: 4.23.0 + version: 4.23.0(supports-color@10.2.2) sonner: specifier: ^2.0.8 version: 2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -419,7 +419,7 @@ importers: version: 0.8.6(@emnapi/core@2.0.0-beta.2)(@emnapi/runtime@2.0.0-beta.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@tanstack/router-cli': specifier: ^1.167.46 - version: 1.167.46 + version: 1.167.46(supports-color@10.2.2) '@types/d3-shape': specifier: ^3.2.0 version: 3.2.0 @@ -468,15 +468,18 @@ importers: vite: specifier: ^8.3.6 version: 8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + wrangler: + specifier: ^4.153.0 + version: 4.160.1(@types/node@26.6.6) packages/config: dependencies: eslint-plugin-perfectionist: specifier: ^5.13.0 - version: 5.13.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.1.0-beta) + version: 5.13.0(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@7.1.0-beta) eslint-plugin-react-you-might-not-need-an-effect: specifier: ^1.1.0 - version: 1.1.0(eslint@10.11.0(jiti@2.7.0)) + version: 1.1.0(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2)) devDependencies: oxfmt: specifier: ^0.77.0 @@ -538,7 +541,7 @@ importers: dependencies: '@elastic/elasticsearch': specifier: ^9.5.1 - version: 9.5.1(apache-arrow@21.2.0) + version: 9.5.1(apache-arrow@21.2.0)(supports-color@10.2.2) devDependencies: '@vitnode/config': specifier: workspace:* @@ -731,7 +734,7 @@ importers: version: 3.2.2(react@19.3.1) '@ferrucc-io/emoji-picker': specifier: ^0.1.2 - version: 0.1.2(@babel/core@7.29.13)(@babel/template@7.29.7)(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.5) + version: 0.1.2(@babel/core@7.29.13(supports-color@10.2.2))(@babel/template@7.29.7)(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.5) '@hono/swagger-ui': specifier: ^0.6.2 version: 0.6.2(hono@4.13.17) @@ -867,6 +870,9 @@ importers: redis: specifier: ^6.3.1 version: 6.3.1(@opentelemetry/api@1.9.1) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 sanitize-html: specifier: ^2.18.1 version: 2.18.1 @@ -875,7 +881,7 @@ importers: version: 7.8.6 shadcn: specifier: ^4.23.0 - version: 4.23.0 + version: 4.23.0(supports-color@10.2.2) sharp: specifier: ^0.35.5 version: 0.35.5(@types/node@26.6.6) @@ -900,10 +906,10 @@ importers: version: 0.9.1(hono@4.13.17)(zod@4.6.7) '@react-email/ui': specifier: ^6.11.8 - version: 6.11.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 6.11.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@tanstack/react-form': specifier: ^1.33.5 - version: 1.33.5(@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 1.33.5(@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@tanstack/react-query': specifier: ^5.107.1 version: 5.107.1(@types/react@19.3.0)(react@19.3.1) @@ -912,7 +918,7 @@ importers: version: 1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@tanstack/react-start': specifier: ^1.169.4 - version: 1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@testing-library/dom': specifier: ^10.4.2 version: 10.4.2 @@ -981,7 +987,7 @@ importers: version: 19.3.1(react@19.3.1) react-email: specifier: ^6.11.8 - version: 6.11.8(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 6.11.8(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(supports-color@10.2.2) sonner: specifier: ^2.0.8 version: 2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -1017,7 +1023,7 @@ importers: version: 1.6.3(hono@4.13.17)(zod@4.6.7) '@tanstack/react-form': specifier: ^1.33.5 - version: 1.33.5(@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 1.33.5(@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@tanstack/react-query': specifier: ^5.107.1 version: 5.107.1(@types/react@19.3.0)(react@19.3.1) @@ -1050,7 +1056,7 @@ importers: version: 19.3.1(react@19.3.1) react-email: specifier: ^6.11.8 - version: 6.11.8(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 6.11.8(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(supports-color@10.2.2) sonner: specifier: ^2.0.8 version: 2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -1063,7 +1069,7 @@ importers: devDependencies: '@react-email/ui': specifier: ^6.11.8 - version: 6.11.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 6.11.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@testing-library/react': specifier: ^16.3.3 version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -1111,7 +1117,7 @@ importers: version: 1.6.3(hono@4.13.17)(zod@4.6.7) '@tanstack/react-form': specifier: ^1.33.5 - version: 1.33.5(@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 1.33.5(@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@tanstack/react-query': specifier: ^5.107.1 version: 5.107.1(@types/react@19.3.0)(react@19.3.1) @@ -1187,7 +1193,7 @@ importers: version: 1.6.3(hono@4.13.17)(zod@4.6.7) '@tanstack/react-form': specifier: ^1.33.5 - version: 1.33.5(@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 1.33.5(@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@tanstack/react-query': specifier: ^5.107.1 version: 5.107.1(@types/react@19.3.0)(react@19.3.1) @@ -1621,6 +1627,53 @@ packages: '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20261110.1': + resolution: {integrity: sha512-RazOdADqTgwYhE60+i6c/R5BA1J5aC//39oousnWBxmoVAiqUh1qsXNCGMbaEV6UC5RnZyWsIBRIcAYNDe1xTg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20261110.1': + resolution: {integrity: sha512-TFsn/t+wczWnWdm+ytlWe+xHEO9sDgfIBmtMo8OxujuS2DXCmGimiyvJHmuv+6+N9OhUb0dM+fveXJI9yjuzew==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20261110.1': + resolution: {integrity: sha512-i4080Pp6Q0nwS8OMqt3cbpJoWvbCrIlFKa+4Inbn+y2pWKTVsq6F9bgdi0l8CBZPMszd03gDlQR++dqMbErh2Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20261110.1': + resolution: {integrity: sha512-0l+JyYWOhEzTs7yOkIdf1ZzcXd9WalPZ1kD0bBAobFvDhO/1jBnQkS4rzzyUkGV7g8pb3dkfrBP9c2ZiRLOfdw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20261110.1': + resolution: {integrity: sha512-zP8IGyWvohyJhbNjHOnTA9anRQtGN7X01T/dG+R9IA4vu2egRODsD02aoYzv0a0qImjt7tTpOOA+uVotDJPcSA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} @@ -2332,6 +2385,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@keyv/bigmap@1.3.1': resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} engines: {node: '>= 18'} @@ -3391,6 +3447,15 @@ packages: engines: {node: '>=18'} hasBin: true + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@quansync/fs@1.1.0': resolution: {integrity: sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==} @@ -4046,6 +4111,10 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -4077,6 +4146,9 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -5318,6 +5390,9 @@ packages: peerDependencies: react: '>=17.0.1' + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bowser@2.15.0: resolution: {integrity: sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ==} @@ -5499,6 +5574,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -6049,6 +6128,9 @@ packages: resolution: {integrity: sha512-jKG29hxqGrL9ktunIwmt9aHbP3n995DGUY1KyXS7iGYFoD/yDdpc2CjJt6remFI3x2H1JOmBBMo0KXxjEQ2G6g==} hasBin: true + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -7174,6 +7256,15 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + miniflare@5.20261110.1-beta: + resolution: {integrity: sha512-CO8T1DO3/JFxEWvJ7yaHiPx5Ph/5FhRzYKve6cM8O2frHmY7yWudV5p8FU11bfGRAerChGpuK+l0HoxOCw2nFw==} + engines: {node: '>=22.12.0'} + peerDependencies: + kerberos: ^2.0.0 + peerDependenciesMeta: + kerberos: + optional: true + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -7507,6 +7598,9 @@ packages: resolution: {integrity: sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -8156,6 +8250,10 @@ packages: babel-plugin-macros: optional: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + systeminformation@5.33.19: resolution: {integrity: sha512-Zm7wGeCG7PqfvzegMJIzrxICopWqYCeLATVmvxOOci1isLcNVKgY4Fr0hhYQzvc7cgtK3377jQVChTFAnznOiA==} engines: {node: '>=10.0.0'} @@ -8342,6 +8440,10 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + undici@7.30.1: + resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==} + engines: {node: '>=20.18.1'} + undici@7.30.2: resolution: {integrity: sha512-mLyPQnHwUxaM/rideoK/+KL8DaAJLRl4aBCfmX444aCSLnIaomqPLtNRe2jdCYcsk5wZzXrDIQC8ZeYNRAYyIA==} engines: {node: '>=20.18.1'} @@ -8350,6 +8452,9 @@ packages: resolution: {integrity: sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==} engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-emoji-json@0.8.0: resolution: {integrity: sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g==} @@ -8642,6 +8747,33 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + workerd@1.20261110.1: + resolution: {integrity: sha512-GKdtki7t19f16vegol8Iw5Dh0mnjHLuwLx8rlhTPvyRBEXM0Osl9eYexWAW8uZD7OMw4WKNq9yvENg5kPDUJ9w==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.160.1: + resolution: {integrity: sha512-gM8daj5geYTEw+038dV5OQAmrSIo/9e97FoJuNf+r9PllWR0ZnqMI2LXZZDSHpGsMez3mKKFYH5o/hBsf1ZTHg==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20261110.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -8714,6 +8846,12 @@ packages: yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + yuku-analyzer@0.19.0: resolution: {integrity: sha512-Iu7DV5cWOQ3ERSA7oBquz/RT5IEGsUyUeCivYs9lQaZ4nSjM31yD17mH616JKcTyVoVWYivsFZ+twT3UyBosTA==} engines: {node: '>=14.18.0'} @@ -8965,20 +9103,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.13': + '@babel/core@7.29.13(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.13 '@babel/helper-compilation-targets': 7.29.13 - '@babel/helper-module-transforms': 7.29.13(@babel/core@7.29.13) + '@babel/helper-module-transforms': 7.29.13(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.13 '@babel/parser': 7.29.13 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.13 + '@babel/traverse': 7.29.13(supports-color@10.2.2) '@babel/types': 7.29.11 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.2 @@ -9005,41 +9143,41 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.2 - '@babel/helper-create-class-features-plugin@7.29.13(@babel/core@7.29.13)': + '@babel/helper-create-class-features-plugin@7.29.13(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.13) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.13 - '@babel/traverse': 7.29.13 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.13(supports-color@10.2.2) + '@babel/traverse': 7.29.13(supports-color@10.2.2) semver: 6.3.2 transitivePeerDependencies: - supports-color '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.29.7': + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.13 + '@babel/traverse': 7.29.13(supports-color@10.2.2) '@babel/types': 7.29.11 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.13 + '@babel/traverse': 7.29.13(supports-color@10.2.2) '@babel/types': 7.29.11 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.13(@babel/core@7.29.13)': + '@babel/helper-module-transforms@7.29.13(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.13 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.13(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.13 + '@babel/traverse': 7.29.13(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -9049,18 +9187,18 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.13)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.13 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/core': 7.29.13(supports-color@10.2.2) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.13 + '@babel/traverse': 7.29.13(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.13': + '@babel/helper-skip-transparent-expression-wrappers@7.29.13(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.13 + '@babel/traverse': 7.29.13(supports-color@10.2.2) '@babel/types': 7.29.11 transitivePeerDependencies: - supports-color @@ -9084,43 +9222,43 @@ snapshots: dependencies: '@babel/types': 7.29.11 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.13)': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.13(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.13)': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.13(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.13)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.13 - '@babel/helper-module-transforms': 7.29.13(@babel/core@7.29.13) + '@babel/core': 7.29.13(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.13(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.13)': + '@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.13(@babel/core@7.29.13) + '@babel/helper-create-class-features-plugin': 7.29.13(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.13 - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.13) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.13(supports-color@10.2.2) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.13(supports-color@10.2.2)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.29.7(@babel/core@7.29.13)': + '@babel/preset-typescript@7.29.7(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.13) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.13) - '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.13) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.13(supports-color@10.2.2)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -9134,7 +9272,7 @@ snapshots: '@babel/parser': 7.29.13 '@babel/types': 7.29.11 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.0(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.13 @@ -9142,11 +9280,11 @@ snapshots: '@babel/parser': 7.29.2 '@babel/template': 7.29.7 '@babel/types': 7.29.11 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/traverse@7.29.13': + '@babel/traverse@7.29.13(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.13 @@ -9154,7 +9292,7 @@ snapshots: '@babel/parser': 7.29.13 '@babel/template': 7.29.7 '@babel/types': 7.29.11 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -9202,6 +9340,33 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261110.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20261110.1 + + '@cloudflare/workerd-darwin-64@1.20261110.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20261110.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20261110.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20261110.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20261110.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@date-fns/tz@1.5.0': {} '@dnd-kit/accessibility@3.1.1(react@19.3.1)': @@ -9257,20 +9422,20 @@ snapshots: '@dotenvx/primitives@0.8.0': {} - '@elastic/elasticsearch@9.5.1(apache-arrow@21.2.0)': + '@elastic/elasticsearch@9.5.1(apache-arrow@21.2.0)(supports-color@10.2.2)': dependencies: - '@elastic/transport': 9.4.0 + '@elastic/transport': 9.4.0(supports-color@10.2.2) tslib: 2.9.1 optionalDependencies: apache-arrow: 21.2.0 transitivePeerDependencies: - supports-color - '@elastic/transport@9.4.0': + '@elastic/transport@9.4.0(supports-color@10.2.2)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) hpagent: 1.2.0 ms: 2.1.3 secure-json-parse: 4.1.0 @@ -9394,17 +9559,17 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.11.0(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2))': dependencies: - eslint: 10.11.0(jiti@2.7.0) + eslint: 10.11.0(jiti@2.7.0)(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@10.2.2)': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 transitivePeerDependencies: - supports-color @@ -9426,11 +9591,11 @@ snapshots: '@exodus/bytes@1.16.0': {} - '@ferrucc-io/emoji-picker@0.1.2(@babel/core@7.29.13)(@babel/template@7.29.7)(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.5)': + '@ferrucc-io/emoji-picker@0.1.2(@babel/core@7.29.13(supports-color@10.2.2))(@babel/template@7.29.7)(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.5)': dependencies: '@tanstack/react-virtual': 3.14.13(react-dom@19.3.1(react@19.3.1))(react@19.3.1) clsx: 2.1.1 - jotai: 2.20.3(@babel/core@7.29.13)(@babel/template@7.29.7)(@types/react@19.3.0)(react@19.3.1) + jotai: 2.20.3(@babel/core@7.29.13(supports-color@10.2.2))(@babel/template@7.29.7)(@types/react@19.3.0)(react@19.3.1) node-emoji: 2.2.0 react: 19.3.1 react-dom: 19.3.1(react@19.3.1) @@ -9480,14 +9645,14 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - '@fumadocs/base-ui@16.18.1(@date-fns/tz@1.5.0)(@types/mdx@2.0.14)(@types/react@19.3.0)(date-fns@4.4.0)(fumadocs-core@16.18.1(@mdx-js/mdx@3.1.1)(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(zod@4.6.7))(next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.5)': + '@fumadocs/base-ui@16.18.1(@date-fns/tz@1.5.0)(@types/mdx@2.0.14)(@types/react@19.3.0)(date-fns@4.4.0)(fumadocs-core@16.18.1(@mdx-js/mdx@3.1.1(supports-color@10.2.2))(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(supports-color@10.2.2)(zod@4.6.7))(next@16.3.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.5)': dependencies: '@base-ui/react': 1.9.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@fuma-translate/react': 1.0.2(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@fumadocs/tailwind': 0.1.5(tailwindcss@4.3.5) class-variance-authority: 0.7.1 cn: 0.5.2 - fumadocs-core: 16.18.1(@mdx-js/mdx@3.1.1)(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(zod@4.6.7) + fumadocs-core: 16.18.1(@mdx-js/mdx@3.1.1(supports-color@10.2.2))(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(supports-color@10.2.2)(zod@4.6.7) lucide-react: 1.70.0(@types/react@19.3.0)(react@19.3.1) motion: 14.10.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) next-themes: 0.4.6(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -9499,7 +9664,7 @@ snapshots: optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.3.0 - next: 16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + next: 16.3.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) transitivePeerDependencies: - '@date-fns/tz' - date-fns @@ -9799,6 +9964,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: hashery: 1.5.1 @@ -9809,7 +9979,7 @@ snapshots: '@levischuck/tiny-cbor@0.2.11': {} - '@mdx-js/mdx@3.1.1': + '@mdx-js/mdx@3.1.1(supports-color@10.2.2)': dependencies: '@types/estree': 1.0.10 '@types/estree-jsx': 1.0.5 @@ -9821,14 +9991,14 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-scope: 1.0.1 estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.7 + hast-util-to-jsx-runtime: 2.3.7(supports-color@10.2.2) markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 recma-jsx: 1.0.1(acorn@8.19.0) recma-stringify: 1.0.0 - rehype-recma: 1.0.0 - remark-mdx: 3.1.1 - remark-parse: 11.0.0 + rehype-recma: 1.0.0(supports-color@10.2.2) + remark-mdx: 3.1.1(supports-color@10.2.2) + remark-parse: 11.0.0(supports-color@10.2.2) remark-rehype: 11.1.2 source-map: 0.7.6 unified: 11.0.5 @@ -9970,12 +10140,12 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.45.0 - '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.220.0 import-in-the-middle: 3.6.0 - require-in-the-middle: 8.0.1 + require-in-the-middle: 8.0.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -10505,6 +10675,18 @@ snapshots: playwright: 1.61.1 optional: true + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@quansync/fs@1.1.0': dependencies: quansync: 1.0.0 @@ -10655,11 +10837,11 @@ snapshots: react: 19.3.1 react-dom: 19.3.1(react@19.3.1) - '@react-email/ui@6.11.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + '@react-email/ui@6.11.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': dependencies: esbuild: 0.28.2 jiti: 2.6.1 - next: 16.4.3(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + next: 16.4.3(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) transitivePeerDependencies: - '@babel/core' - '@opentelemetry/api' @@ -10881,7 +11063,7 @@ snapshots: dependencies: '@sentry/conventions': 0.16.0 - '@sentry/node-core@10.78.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1))': + '@sentry/node-core@10.78.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1))': dependencies: '@sentry/conventions': 0.16.0 '@sentry/core': 10.78.0 @@ -10890,17 +11072,17 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) '@opentelemetry/sdk-trace-base': 2.12.0(@opentelemetry/api@1.9.1) - '@sentry/node@10.78.0(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))': + '@sentry/node@10.78.0(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(supports-color@10.2.2)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) '@opentelemetry/sdk-trace-base': 2.12.0(@opentelemetry/api@1.9.1) '@sentry/conventions': 0.16.0 '@sentry/core': 10.78.0 - '@sentry/node-core': 10.78.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)) + '@sentry/node-core': 10.78.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)) '@sentry/opentelemetry': 10.78.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)) '@sentry/server-utils': 10.78.0 import-in-the-middle: 3.6.0 @@ -10922,12 +11104,12 @@ snapshots: '@sentry/conventions': 0.16.0 '@sentry/core': 10.78.0 - '@shadcn/registry@0.2.1': + '@shadcn/registry@0.2.1(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) '@babel/parser': 7.29.13 - '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.13) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.13) + '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2) '@dotenvx/dotenvx': 1.75.1 cn: 0.2.6 code-block-writer: 13.0.3 @@ -11009,6 +11191,8 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@sindresorhus/is@7.2.0': {} + '@sindresorhus/merge-streams@4.0.0': {} '@smithy/core@3.39.0': @@ -11046,6 +11230,8 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@speed-highlight/core@1.2.24': {} + '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -11209,13 +11395,13 @@ snapshots: '@tanstack/query-core@5.107.1': {} - '@tanstack/react-form@1.33.5(@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + '@tanstack/react-form@1.33.5(@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': dependencies: '@tanstack/form-core': 1.33.5 '@tanstack/react-store': 0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1) react: 19.3.1 optionalDependencies: - '@tanstack/react-start': 1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + '@tanstack/react-start': 1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) transitivePeerDependencies: - react-dom @@ -11254,14 +11440,14 @@ snapshots: react: 19.3.1 react-dom: 19.3.1(react@19.3.1) - '@tanstack/react-start-rsc@0.1.65(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': + '@tanstack/react-start-rsc@0.1.65(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@tanstack/react-router': 1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@tanstack/router-core': 1.171.39 - '@tanstack/router-utils': 1.162.4 + '@tanstack/router-utils': 1.162.4(supports-color@10.2.2) '@tanstack/start-client-core': 1.170.39 '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.55(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(esbuild@0.28.2)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + '@tanstack/start-plugin-core': 1.171.55(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(esbuild@0.28.2)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@tanstack/start-storage-context': 1.167.41 pathe: 2.0.3 react: 19.3.1 @@ -11287,15 +11473,15 @@ snapshots: react: 19.3.1 react-dom: 19.3.1(react@19.3.1) - '@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': + '@tanstack/react-start@1.169.4(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@tanstack/react-router': 1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@tanstack/react-start-client': 1.168.44(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - '@tanstack/react-start-rsc': 0.1.65(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + '@tanstack/react-start-rsc': 0.1.65(esbuild@0.28.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@tanstack/react-start-server': 1.167.52(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - '@tanstack/router-utils': 1.162.4 + '@tanstack/router-utils': 1.162.4(supports-color@10.2.2) '@tanstack/start-client-core': 1.170.39 - '@tanstack/start-plugin-core': 1.171.55(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(esbuild@0.28.2)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + '@tanstack/start-plugin-core': 1.171.55(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(esbuild@0.28.2)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@tanstack/start-server-core': 1.169.45 pathe: 2.0.3 react: 19.3.1 @@ -11328,9 +11514,9 @@ snapshots: react: 19.3.1 react-dom: 19.3.1(react@19.3.1) - '@tanstack/router-cli@1.167.46': + '@tanstack/router-cli@1.167.46(supports-color@10.2.2)': dependencies: - '@tanstack/router-generator': 1.167.46 + '@tanstack/router-generator': 1.167.46(supports-color@10.2.2) cac: 7.1.0 chokidar: 5.0.0 transitivePeerDependencies: @@ -11343,11 +11529,11 @@ snapshots: seroval: 1.7.10 seroval-plugins: 1.7.8(seroval@1.7.10) - '@tanstack/router-generator@1.167.46': + '@tanstack/router-generator@1.167.46(supports-color@10.2.2)': dependencies: '@babel/types': 7.29.11 '@tanstack/router-core': 1.171.39 - '@tanstack/router-utils': 1.162.4 + '@tanstack/router-utils': 1.162.4(supports-color@10.2.2) '@tanstack/virtual-file-routes': 1.162.0 jiti: 2.7.0 magic-string: 0.30.21 @@ -11356,14 +11542,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.48(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(esbuild@0.28.2)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': + '@tanstack/router-plugin@1.168.48(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(esbuild@0.28.2)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) '@babel/template': 7.29.7 '@babel/types': 7.29.11 '@tanstack/router-core': 1.171.39 - '@tanstack/router-generator': 1.167.46 - '@tanstack/router-utils': 1.162.4 + '@tanstack/router-generator': 1.167.46(supports-color@10.2.2) + '@tanstack/router-utils': 1.162.4(supports-color@10.2.2) chokidar: 5.0.0 unplugin: 3.7.0(esbuild@0.28.2)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) zod: 4.6.7 @@ -11385,13 +11571,13 @@ snapshots: '@tanstack/query-core': 5.107.1 '@tanstack/router-core': 1.171.39 - '@tanstack/router-utils@1.162.4': + '@tanstack/router-utils@1.162.4(supports-color@10.2.2)': dependencies: '@babel/generator': 7.29.13 '@babel/parser': 7.29.13 '@babel/types': 7.29.11 ansis: 4.5.0 - babel-dead-code-elimination: 1.0.12 + babel-dead-code-elimination: 1.0.12(supports-color@10.2.2) diff: 9.0.0 pathe: 2.0.3 tinyglobby: 0.2.17 @@ -11407,16 +11593,16 @@ snapshots: '@tanstack/start-fn-stubs@1.162.0': {} - '@tanstack/start-plugin-core@1.171.55(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(esbuild@0.28.2)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': + '@tanstack/start-plugin-core@1.171.55(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(esbuild@0.28.2)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) '@babel/types': 7.29.11 '@jridgewell/remapping': 2.3.5 '@tanstack/router-core': 1.171.39 - '@tanstack/router-generator': 1.167.46 - '@tanstack/router-plugin': 1.168.48(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(esbuild@0.28.2)(rolldown@1.3.0)(rollup@4.63.5)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - '@tanstack/router-utils': 1.162.4 + '@tanstack/router-generator': 1.167.46(supports-color@10.2.2) + '@tanstack/router-plugin': 1.168.48(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(esbuild@0.28.2)(rolldown@1.3.0)(rollup@4.63.5)(supports-color@10.2.2)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + '@tanstack/router-utils': 1.162.4(supports-color@10.2.2) '@tanstack/start-server-core': 1.169.45 exsolve: 1.2.0 lightningcss: 1.33.0 @@ -11885,11 +12071,11 @@ snapshots: dependencies: '@types/node': 26.6.6 - '@typescript-eslint/project-service@8.72.1(typescript@7.1.0-beta)': + '@typescript-eslint/project-service@8.72.1(supports-color@10.2.2)(typescript@7.1.0-beta)': dependencies: '@typescript-eslint/tsconfig-utils': 8.72.1(typescript@7.1.0-beta) '@typescript-eslint/types': 8.72.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 7.1.0-beta transitivePeerDependencies: - supports-color @@ -11905,13 +12091,13 @@ snapshots: '@typescript-eslint/types@8.72.1': {} - '@typescript-eslint/typescript-estree@8.72.1(typescript@7.1.0-beta)': + '@typescript-eslint/typescript-estree@8.72.1(supports-color@10.2.2)(typescript@7.1.0-beta)': dependencies: - '@typescript-eslint/project-service': 8.72.1(typescript@7.1.0-beta) + '@typescript-eslint/project-service': 8.72.1(supports-color@10.2.2)(typescript@7.1.0-beta) '@typescript-eslint/tsconfig-utils': 8.72.1(typescript@7.1.0-beta) '@typescript-eslint/types': 8.72.1 '@typescript-eslint/visitor-keys': 8.72.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 semver: 7.8.6 tinyglobby: 0.2.17 @@ -11920,13 +12106,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.72.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.1.0-beta)': + '@typescript-eslint/utils@8.72.1(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@7.1.0-beta)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2)) '@typescript-eslint/scope-manager': 8.72.1 '@typescript-eslint/types': 8.72.1 - '@typescript-eslint/typescript-estree': 8.72.1(typescript@7.1.0-beta) - eslint: 10.11.0(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.72.1(supports-color@10.2.2)(typescript@7.1.0-beta) + eslint: 10.11.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 7.1.0-beta transitivePeerDependencies: - supports-color @@ -12124,12 +12310,12 @@ snapshots: '@ai-sdk/provider-utils': 5.0.75(zod@4.6.7) zod: 4.6.7 - ajv-formats@2.1.1(ajv@8.20.0): - optionalDependencies: + ajv-formats@2.1.1: + dependencies: ajv: 8.20.0 - ajv-formats@3.0.1(ajv@8.20.0): - optionalDependencies: + ajv-formats@3.0.1: + dependencies: ajv: 8.20.0 ajv@6.15.0: @@ -12208,11 +12394,11 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 - babel-dead-code-elimination@1.0.12: + babel-dead-code-elimination@1.0.12(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) '@babel/parser': 7.29.13 - '@babel/traverse': 7.29.13 + '@babel/traverse': 7.29.13(supports-color@10.2.2) '@babel/types': 7.29.11 transitivePeerDependencies: - supports-color @@ -12242,6 +12428,8 @@ snapshots: dependencies: react: 19.3.1 + blake3-wasm@2.1.5: {} + bowser@2.15.0: {} brace-expansion@5.0.12: @@ -12379,7 +12567,7 @@ snapshots: conf@10.2.0: dependencies: ajv: 8.20.0 - ajv-formats: 2.1.1(ajv@8.20.0) + ajv-formats: 2.1.1 atomically: 1.7.0 debounce-fn: 4.0.0 dot-prop: 6.0.1 @@ -12392,7 +12580,7 @@ snapshots: conf@15.1.0: dependencies: ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) + ajv-formats: 3.0.1 atomically: 2.1.1 debounce-fn: 6.0.0 dot-prop: 10.2.0 @@ -12409,6 +12597,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.1.1: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -12573,9 +12763,11 @@ snapshots: debounce@2.2.0: {} - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js@10.6.0: {} @@ -12718,7 +12910,7 @@ snapshots: engine.io-parser@5.2.3: {} - engine.io@6.6.11: + engine.io@6.6.11(supports-color@10.2.2): dependencies: '@types/cors': 2.8.19 '@types/node': 26.6.6 @@ -12726,7 +12918,7 @@ snapshots: accepts: 1.3.8 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) engine.io-parser: 5.2.3 ws: 8.21.3 transitivePeerDependencies: @@ -12761,6 +12953,8 @@ snapshots: crossws: 0.4.17(srvx@1.0.8) srvx: 1.0.8 + error-stack-parser-es@1.0.5: {} + es-module-lexer@2.3.2: {} es-module-lexer@3.0.4: {} @@ -12814,31 +13008,31 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-plugin-perfectionist@5.13.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.1.0-beta): + eslint-plugin-perfectionist@5.13.0(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@7.1.0-beta): dependencies: '@typescript-eslint/types': 8.72.1 - '@typescript-eslint/utils': 8.72.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.1.0-beta) + '@typescript-eslint/utils': 8.72.1(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@7.1.0-beta) natural-orderby: 5.0.0 optionalDependencies: - eslint: 10.11.0(jiti@2.7.0) + eslint: 10.11.0(jiti@2.7.0)(supports-color@10.2.2) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-react-hooks@7.1.1(eslint@10.11.0(jiti@2.7.0)): + eslint-plugin-react-hooks@7.1.1(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) '@babel/parser': 7.29.13 - eslint: 10.11.0(jiti@2.7.0) + eslint: 10.11.0(jiti@2.7.0)(supports-color@10.2.2) hermes-parser: 0.25.1 zod: 4.6.7 zod-validation-error: 4.0.2(zod@4.6.7) transitivePeerDependencies: - supports-color - eslint-plugin-react-you-might-not-need-an-effect@1.1.0(eslint@10.11.0(jiti@2.7.0)): + eslint-plugin-react-you-might-not-need-an-effect@1.1.0(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: - eslint: 10.11.0(jiti@2.7.0) + eslint: 10.11.0(jiti@2.7.0)(supports-color@10.2.2) eslint-scope@9.1.2: dependencies: @@ -12851,11 +13045,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.11.0(jiti@2.7.0): + eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@10.2.2) '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.3 @@ -12865,7 +13059,7 @@ snapshots: '@types/estree': 1.0.10 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -13073,19 +13267,19 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.18.1(@mdx-js/mdx@3.1.1)(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(zod@4.6.7): + fumadocs-core@16.18.1(@mdx-js/mdx@3.1.1(supports-color@10.2.2))(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(supports-color@10.2.2)(zod@4.6.7): dependencies: '@fumari/image-size': 0.1.1 estree-util-value-to-estree: 3.5.0 github-slugger: 2.0.0 hast-util-from-dom: 5.0.1 - hast-util-to-estree: 3.1.3 - mdast-util-mdx: 3.0.0 - micromark: 4.0.3 + hast-util-to-estree: 3.1.3(supports-color@10.2.2) + mdast-util-mdx: 3.0.0(supports-color@10.2.2) + micromark: 4.0.3(supports-color@10.2.2) micromark-extension-gfm-table: 2.1.2 npm-to-yarn: 3.2.0 - remark: 15.0.1 - remark-gfm: 4.0.1 + remark: 15.0.1(supports-color@10.2.2) + remark-gfm: 4.0.1(supports-color@10.2.2) remark-rehype: 11.1.2 scroll-into-view-if-needed: 3.1.0 shiki: 4.6.0 @@ -13095,7 +13289,7 @@ snapshots: yaml: 2.9.1 zbsearch: 4.0.2 optionalDependencies: - '@mdx-js/mdx': 3.1.1 + '@mdx-js/mdx': 3.1.1(supports-color@10.2.2) '@modelcontextprotocol/server': 2.4.0 '@tanstack/react-router': 1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@types/estree-jsx': 1.0.5 @@ -13103,24 +13297,24 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.3.0 lucide-react: 1.70.0(@types/react@19.3.0)(react@19.3.1) - next: 16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + next: 16.3.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) react: 19.3.1 react-dom: 19.3.1(react@19.3.1) zod: 4.6.7 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.5.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.18.1(@mdx-js/mdx@3.1.1)(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(zod@4.6.7))(next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react@19.3.1)(rolldown@1.3.0)(vite@8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + fumadocs-mdx@15.5.0(70d8c7dc3e1aee32c8e726401d4e32e2): dependencies: - '@mdx-js/mdx': 3.1.1 + '@mdx-js/mdx': 3.1.1(supports-color@10.2.2) '@standard-schema/spec': 1.1.0 chokidar: 6.0.0 esbuild: 0.28.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.18.1(@mdx-js/mdx@3.1.1)(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(zod@4.6.7) + fumadocs-core: 16.18.1(@mdx-js/mdx@3.1.1(supports-color@10.2.2))(@modelcontextprotocol/server@2.4.0)(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(next@16.3.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(supports-color@10.2.2)(zod@4.6.7) github-slugger: 2.0.0 magic-string: 1.4.5 - mdast-util-mdx: 3.0.0 + mdast-util-mdx: 3.0.0(supports-color@10.2.2) picocolors: 1.1.1 picomatch: 4.0.7 tinyexec: 1.3.1 @@ -13135,7 +13329,7 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 '@types/react': 19.3.0 - next: 16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + next: 16.3.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) react: 19.3.1 rolldown: 1.3.0 vite: 8.3.6(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) @@ -13229,7 +13423,7 @@ snapshots: dependencies: '@types/hast': 3.0.5 - hast-util-to-estree@3.1.3: + hast-util-to-estree@3.1.3(supports-color@10.2.2): dependencies: '@types/estree': 1.0.10 '@types/estree-jsx': 1.0.5 @@ -13239,9 +13433,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.1 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-jsx: 3.2.1(supports-color@10.2.2) + mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -13264,7 +13458,7 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.7: + hast-util-to-jsx-runtime@2.3.7(supports-color@10.2.2): dependencies: '@types/estree': 1.0.10 '@types/hast': 3.0.5 @@ -13273,9 +13467,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.1 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-jsx: 3.2.1(supports-color@10.2.2) + mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-object: 2.0.2 @@ -13469,9 +13663,9 @@ snapshots: jose@6.2.15: {} - jotai@2.20.3(@babel/core@7.29.13)(@babel/template@7.29.7)(@types/react@19.3.0)(react@19.3.1): + jotai@2.20.3(@babel/core@7.29.13(supports-color@10.2.2))(@babel/template@7.29.7)(@types/react@19.3.0)(react@19.3.1): optionalDependencies: - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) '@babel/template': 7.29.7 '@types/react': 19.3.0 react: 19.3.1 @@ -13677,14 +13871,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.1.0 - mdast-util-from-markdown@2.1.0: + mdast-util-from-markdown@2.1.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.3 + micromark: 4.0.3(supports-color@10.2.2) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -13702,69 +13896,69 @@ snapshots: mdast-util-find-and-replace: 3.0.4 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0: + mdast-util-gfm-footnote@2.1.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) mdast-util-to-markdown: 2.2.3 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.1: + mdast-util-gfm-strikethrough@2.0.1(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) mdast-util-to-markdown: 2.2.3 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.1: + mdast-util-gfm-table@2.0.1(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.1.0 - mdast-util-from-markdown: 2.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) mdast-util-to-markdown: 2.2.3 mdast-util-to-string: 4.0.0 unist-util-visit: 5.2.0 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.1: + mdast-util-gfm-task-list-item@2.0.1(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) mdast-util-to-markdown: 2.2.3 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0: + mdast-util-gfm@3.1.0(supports-color@10.2.2): dependencies: - mdast-util-from-markdown: 2.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) mdast-util-gfm-autolink-literal: 2.1.0 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.1 - mdast-util-gfm-table: 2.0.1 - mdast-util-gfm-task-list-item: 2.0.1 + mdast-util-gfm-footnote: 2.1.0(supports-color@10.2.2) + mdast-util-gfm-strikethrough: 2.0.1(supports-color@10.2.2) + mdast-util-gfm-table: 2.0.1(supports-color@10.2.2) + mdast-util-gfm-task-list-item: 2.0.1(supports-color@10.2.2) mdast-util-to-markdown: 2.2.3 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1: + mdast-util-mdx-expression@2.0.1(supports-color@10.2.2): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) mdast-util-to-markdown: 2.2.3 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.1: + mdast-util-mdx-jsx@3.2.1(supports-color@10.2.2): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 @@ -13772,7 +13966,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) mdast-util-to-markdown: 2.2.3 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -13781,23 +13975,23 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0: + mdast-util-mdx@3.0.0(supports-color@10.2.2): dependencies: - mdast-util-from-markdown: 2.1.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.1 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-jsx: 3.2.1(supports-color@10.2.2) + mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) mdast-util-to-markdown: 2.2.3 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1: + mdast-util-mdxjs-esm@2.0.1(supports-color@10.2.2): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) mdast-util-to-markdown: 2.2.3 transitivePeerDependencies: - supports-color @@ -14090,10 +14284,10 @@ snapshots: micromark-util-types@2.0.3: {} - micromark@4.0.3: + micromark@4.0.3(supports-color@10.2.2): dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.4 @@ -14136,6 +14330,19 @@ snapshots: mimic-function@5.0.1: {} + miniflare@5.20261110.1-beta(@types/node@26.6.6): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.5(@types/node@26.6.6) + undici: 7.30.1 + workerd: 1.20261110.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + minimatch@10.2.6: dependencies: brace-expansion: 5.0.12 @@ -14184,7 +14391,7 @@ snapshots: react: 19.3.1 react-dom: 19.3.1(react@19.3.1) - next@16.3.8(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + next@16.3.8(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): dependencies: '@next/env': 16.3.8 '@swc/helpers': 0.5.23 @@ -14193,7 +14400,7 @@ snapshots: postcss: 8.5.23 react: 19.3.1 react-dom: 19.3.1(react@19.3.1) - styled-jsx: 5.1.6(@babel/core@7.29.13)(react@19.3.1) + styled-jsx: 5.1.6(@babel/core@7.29.13(supports-color@10.2.2))(react@19.3.1) optionalDependencies: '@next/swc-darwin-arm64': 16.3.8 '@next/swc-darwin-x64': 16.3.8 @@ -14213,7 +14420,7 @@ snapshots: - babel-plugin-macros optional: true - next@16.4.3(@babel/core@7.29.13)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + next@16.4.3(@babel/core@7.29.13(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): dependencies: '@next/env': 16.4.3 '@swc/helpers': 0.5.23 @@ -14222,7 +14429,7 @@ snapshots: postcss: 8.5.23 react: 19.3.1 react-dom: 19.3.1(react@19.3.1) - styled-jsx: 5.1.6(@babel/core@7.29.13)(react@19.3.1) + styled-jsx: 5.1.6(@babel/core@7.29.13(supports-color@10.2.2))(react@19.3.1) optionalDependencies: '@next/swc-darwin-arm64': 16.4.3 '@next/swc-darwin-x64': 16.4.3 @@ -14581,6 +14788,8 @@ snapshots: lru-cache: 11.5.3 minipass: 7.1.3 + path-to-regexp@6.3.0: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -14777,15 +14986,15 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - react-doctor@0.9.22(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(eslint@10.11.0(jiti@2.7.0))(oxlint-tsgolint@7.0.2006)(react@19.3.1): + react-doctor@0.9.22(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2))(oxlint-tsgolint@7.0.2006)(react@19.3.1)(supports-color@10.2.2): dependencies: '@astrojs/compiler': 4.0.0 - '@sentry/node': 10.78.0(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1)) + '@sentry/node': 10.78.0(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(supports-color@10.2.2) agent-install: 0.0.5 bippy: 0.6.1(react@19.3.1) conf: 15.1.0 confbox: 0.2.4 - eslint-plugin-react-hooks: 7.1.1(eslint@10.11.0(jiti@2.7.0)) + eslint-plugin-react-hooks: 7.1.1(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) is-unicode-supported: 2.1.0 jiti: 2.7.0 magicast: 0.5.5 @@ -14812,10 +15021,10 @@ snapshots: react: 19.3.1 scheduler: 0.28.0 - react-email@6.11.8(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + react-email@6.11.8(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(supports-color@10.2.2): dependencies: '@babel/parser': 7.29.2 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@10.2.2) '@react-email/render': 2.2.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1) chokidar: 5.0.0 commander: 13.1.0 @@ -14834,7 +15043,7 @@ snapshots: prompts: 2.4.2 react: 19.3.1 react-dom: 19.3.1(react@19.3.1) - socket.io: 4.8.4 + socket.io: 4.8.4(supports-color@10.2.2) tailwindcss: 4.3.5 tsconfig-paths: 4.2.0 transitivePeerDependencies: @@ -14946,36 +15155,36 @@ snapshots: dependencies: regex-utilities: 2.3.0 - rehype-recma@1.0.0: + rehype-recma@1.0.0(supports-color@10.2.2): dependencies: '@types/estree': 1.0.10 '@types/hast': 3.0.5 - hast-util-to-estree: 3.1.3 + hast-util-to-estree: 3.1.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - remark-gfm@4.0.1: + remark-gfm@4.0.1(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 + mdast-util-gfm: 3.1.0(supports-color@10.2.2) micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@10.2.2) remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@3.1.1: + remark-mdx@3.1.1(supports-color@10.2.2): dependencies: - mdast-util-mdx: 3.0.0 + mdast-util-mdx: 3.0.0(supports-color@10.2.2) micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - remark-parse@11.0.0: + remark-parse@11.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) micromark-util-types: 2.0.3 unified: 11.0.5 transitivePeerDependencies: @@ -14995,10 +15204,10 @@ snapshots: mdast-util-to-markdown: 2.2.3 unified: 11.0.5 - remark@15.0.1: + remark@15.0.1(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@10.2.2) remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: @@ -15006,9 +15215,9 @@ snapshots: require-from-string@2.0.2: {} - require-in-the-middle@8.0.1: + require-in-the-middle@8.0.1(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) module-details-from-path: 1.0.4 transitivePeerDependencies: - supports-color @@ -15179,15 +15388,15 @@ snapshots: seroval@1.7.9: {} - shadcn@4.23.0: + shadcn@4.23.0(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) '@babel/parser': 7.29.13 - '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.13) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.13) + '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.13(supports-color@10.2.2))(supports-color@10.2.2) '@dotenvx/dotenvx': 1.75.1 '@modelcontextprotocol/server': 2.4.0 - '@shadcn/registry': 0.2.1 + '@shadcn/registry': 0.2.1(supports-color@10.2.2) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.29.5 cn: 0.2.6 @@ -15283,30 +15492,30 @@ snapshots: smart-buffer@4.2.0: {} - socket.io-adapter@2.5.8: + socket.io-adapter@2.5.8(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) ws: 8.21.3 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - socket.io-parser@4.2.7: + socket.io-parser@4.2.7(supports-color@10.2.2): dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - socket.io@4.8.4: + socket.io@4.8.4(supports-color@10.2.2): dependencies: accepts: 1.3.8 cors: 2.8.6 - debug: 4.4.3 - engine.io: 6.6.11 - socket.io-adapter: 2.5.8 - socket.io-parser: 4.2.7 + debug: 4.4.3(supports-color@10.2.2) + engine.io: 6.6.11(supports-color@10.2.2) + socket.io-adapter: 2.5.8(supports-color@10.2.2) + socket.io-parser: 4.2.7(supports-color@10.2.2) transitivePeerDependencies: - bufferutil - supports-color @@ -15410,12 +15619,14 @@ snapshots: dependencies: inline-style-parser: 0.2.9 - styled-jsx@5.1.6(@babel/core@7.29.13)(react@19.3.1): + styled-jsx@5.1.6(@babel/core@7.29.13(supports-color@10.2.2))(react@19.3.1): dependencies: client-only: 0.0.1 react: 19.3.1 optionalDependencies: - '@babel/core': 7.29.13 + '@babel/core': 7.29.13(supports-color@10.2.2) + + supports-color@10.2.2: {} systeminformation@5.33.19: {} @@ -15595,10 +15806,16 @@ snapshots: undici-types@8.9.0: {} + undici@7.30.1: {} + undici@7.30.2: {} undici@8.12.4: {} + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + unicode-emoji-json@0.8.0: {} unicode-emoji-modifier-base@1.0.0: {} @@ -15806,6 +16023,35 @@ snapshots: word-wrap@1.2.5: {} + workerd@1.20261110.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20261110.1 + '@cloudflare/workerd-darwin-arm64': 1.20261110.1 + '@cloudflare/workerd-linux-64': 1.20261110.1 + '@cloudflare/workerd-linux-arm64': 1.20261110.1 + '@cloudflare/workerd-windows-64': 1.20261110.1 + + wrangler@4.160.1(@types/node@26.6.6): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261110.1) + '@cspotcode/source-map-support': 0.8.1 + blake3-wasm: 2.1.5 + esbuild: 0.28.2 + miniflare: 5.20261110.1-beta(@types/node@26.6.6) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20261110.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - kerberos + - utf-8-validate + + ws@8.21.0: {} + ws@8.21.3: {} ws@8.22.1: {} @@ -15849,6 +16095,19 @@ snapshots: yoga-layout@3.2.1: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + yuku-analyzer@0.19.0: dependencies: '@yuku-toolchain/types': 0.19.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 119d74ca6b..c88bbc3164 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ allowBuilds: esbuild: true sharp: true unrs-resolver: true + workerd: false packageExtensions: '@tiptap/y-tiptap': dependencies: diff --git a/scripts/cloudflare-smoke/README.md b/scripts/cloudflare-smoke/README.md new file mode 100644 index 0000000000..eb1da075f4 --- /dev/null +++ b/scripts/cloudflare-smoke/README.md @@ -0,0 +1,57 @@ +# Cloudflare realtime smoke test + +`smoke.mjs` drives VitNode realtime end to end against a running Cloudflare +Worker (local `wrangler dev` or a deployment): every push starts as an ordinary +HTTP request, and the sockets live in the Durable Object hub. It checks all five +realtime methods, rooms, the handshake policy (Origin, rate limit, client IP), +session revocation, reconnects, two-editor Content Engine live editing, and - +with `SMOKE_HIBERNATION_WAIT=80` - that a hibernated hub wakes with its sockets, +rooms and sessions intact. + +`realtime-demo.ts` is the app-side half: an admin-only push endpoint and a demo +room the script talks to. Neither file is part of any package. + +## Run it + +1. Generate a Single App with the Cloudflare target inside this repository, so + it links the local packages: + + ```bash + node packages/create-vitnode-app/dist/src/index.js apps/cf-smoke \ + --mode singleApp --deploy cloudflare --package-manager pnpm --skip-git + ``` + + In `apps/cf-smoke/package.json` set `@vitnode/core` and `@vitnode/config` + to `workspace:*`, and add `@vitnode/blog: workspace:*`, `postgres`, and the + dev dependencies `ws` and `yjs`. + +2. Register the blog plugin (`blogPlugin()` in `src/vitnode.config.ts`, + `blogApiPlugin()` in `src/vitnode.api.config.ts`), copy + `realtime-demo.ts` to `src/server/`, and in `src/server/vitnode-api.server.ts` + pass `realtimeDemoEvents` to `handleVitNodeWebSocket(...)` and call + `mountRealtimeDemo(app)` after the `/ws` route. + +3. Copy `.env.example` to `.env` and `.dev.vars.example` to `.dev.vars`, point + both at an empty PostgreSQL database, then: + + ```bash + pnpm install + cd apps/cf-smoke + pnpm db:prepare && pnpm build && pnpm preview --ip 0.0.0.0 + ``` + +4. In another terminal, from `apps/cf-smoke` (so `postgres`, `ws` and `yjs` + resolve): + + ```bash + SMOKE_URL=http://:8787 node ../../scripts/cloudflare-smoke/smoke.mjs + ``` + + A non-loopback address makes the client-IP check meaningful. Add + `SMOKE_HIBERNATION_WAIT=80` for the hibernation round, and run + `pnpm preview --var VITNODE_REALTIME_SHARDS:3` to test three shards. The + first account the script creates becomes the root admin, so start from an + empty database. The burst at the end spends the IP's rate-limit budget for a + minute. + +Delete `apps/cf-smoke` afterwards; it is not meant to be committed. diff --git a/scripts/cloudflare-smoke/realtime-demo.ts b/scripts/cloudflare-smoke/realtime-demo.ts new file mode 100644 index 0000000000..455c9360db --- /dev/null +++ b/scripts/cloudflare-smoke/realtime-demo.ts @@ -0,0 +1,153 @@ +import type { OpenAPIHono } from "@hono/zod-openapi"; +import type { Context } from "hono"; +import type { WSEvents } from "hono/ws"; + +import { + assertStaffPermission, + isStaff, +} from "@vitnode/core/api/lib/check-staff-permission"; +import { realtime, wsRegistry } from "@vitnode/core/ws/registry"; +import { createWebSocketChannel } from "@vitnode/core/ws/types"; +import { z } from "zod"; + +/** + * A small, admin-only surface that pushes through every realtime operation + * from an ordinary HTTP request, plus a room join on the socket. It exists so + * `smoke.mjs` can prove, against the real Worker, that a request + * reaches the sockets the hub owns. Nothing here is needed by an app. + */ +export const demoChannel = createWebSocketChannel<{ + room: string; + type: "join" | "leave" | "whoami"; +}>({ id: "demo", module: "realtime", pluginId: "cloudflare" }); + +const STAFF_ROOM = "demo:staff"; + +const pushSchema = z.discriminatedUnion("op", [ + z.object({ data: z.unknown(), op: z.literal("broadcast") }), + z.object({ data: z.unknown(), op: z.literal("sendToUser"), userId: z.int() }), + z.object({ + messages: z.array(z.object({ data: z.unknown(), userId: z.int() })), + op: z.literal("sendToUsers"), + }), + z.object({ data: z.unknown(), op: z.literal("toRoom"), room: z.string() }), + z.object({ + deviceId: z.int().nullable().optional(), + op: z.literal("closeUserConnections"), + userId: z.int(), + }), +]); + +/** + * `POST /api/realtime-demo/admin/push` - `/admin/` in the path makes the API + * require an admin session, and the handler a staff permission. `via=import` + * uses the `realtime` import instead of `c.get("realtime")`, the way core + * modules without a Hono context push. + */ +export const mountRealtimeDemo = (app: OpenAPIHono): void => { + app.post("/realtime-demo/admin/push", async c => { + await assertStaffPermission(c, { + module: "notifications", + permission: "can_manage", + plugin: "@vitnode/core", + type: "admin", + }); + const body = pushSchema.parse(await c.req.json()); + const target = + c.req.query("via") === "import" ? realtime : c.get("realtime"); + + switch (body.op) { + case "broadcast": { + target.broadcast(demoChannel, body.data); + break; + } + case "closeUserConnections": { + target.closeUserConnections(body.userId, body.deviceId ?? null); + break; + } + case "sendToUser": { + target.sendToUser(body.userId, demoChannel, body.data); + break; + } + case "sendToUsers": { + target.sendToUsers(demoChannel, body.messages); + break; + } + case "toRoom": { + target.toRoom(body.room, demoChannel, body.data); + break; + } + } + + return c.json({ ip: c.get("ipAddress"), ok: true }); + }); +}; + +const joinSchema = z.object({ + data: z.object({ + room: z.string().min(1).max(64), + type: z.enum(["join", "leave", "whoami"]), + }), + id: z.literal(demoChannel.id), +}); + +/** + * Connection-level events for `handleVitNodeWebSocket`: a tab joins or leaves + * a room by name. `demo:staff` admits admins only - the check a real room + * makes before `wsRegistry.join`. + */ +export const realtimeDemoEvents = async (c: Context): Promise => { + const user = c.get("user"); + const isAdmin = user + ? await isStaff(c, { type: "admin", userId: user.id }) + : false; + + return demoEvents({ + handshakeAt: Date.now(), + ip: c.get("ipAddress"), + isAdmin, + }); +}; + +const demoEvents = ({ + handshakeAt, + ip, + isAdmin, +}: { + handshakeAt: number; + ip: string; + isAdmin: boolean; +}): WSEvents => ({ + onMessage: (event, ws) => { + if (typeof event.data !== "string") return; + let parsed: z.infer; + try { + parsed = joinSchema.parse(JSON.parse(event.data)); + } catch { + return; + } + const { room, type } = parsed.data; + const reply = (data: unknown) => { + ws.send(JSON.stringify({ data, id: demoChannel.id })); + }; + + if (type === "whoami") { + // The IP the handshake was rate limited and audited with, and when it + // ran: a hub that woke from hibernation replays it, so this changes. + reply({ handshakeAt, ip }); + + return; + } + if (type === "leave") { + reply({ left: wsRegistry.leave(ws, room, "tab"), room }); + + return; + } + if (room === STAFF_ROOM && !isAdmin) { + reply({ error: "FORBIDDEN", room }); + + return; + } + reply({ joined: wsRegistry.join(ws, room, "tab"), room }); + }, +}); diff --git a/scripts/cloudflare-smoke/smoke.mjs b/scripts/cloudflare-smoke/smoke.mjs new file mode 100644 index 0000000000..77be58aaf8 --- /dev/null +++ b/scripts/cloudflare-smoke/smoke.mjs @@ -0,0 +1,656 @@ +// End-to-end check of VitNode realtime on a running Worker. Every push starts +// as an ordinary HTTP request to the Worker; the sockets live in the Durable +// Object hub. See README.md next to this file for the setup; run it against a +// fresh database: the first account it creates becomes the root admin. +// +// SMOKE_URL (default http://localhost:8787) and POSTGRES_URL (for seeding a +// blog post) can be overridden. SMOKE_HIBERNATION_WAIT=80 adds a hibernation +// round. Against `dev` (Node transport, rate limiter off) run it with +// SMOKE_URL=http://localhost:3000 SMOKE_RATE_LIMIT=off. +import postgres from "postgres"; +import WebSocket from "ws"; +import * as Y from "yjs"; + +const BASE = process.env.SMOKE_URL ?? "http://localhost:8787"; +const ORIGIN = new URL(BASE).origin; +const WS_URL = `${BASE.replace(/^http/, "ws")}/api/ws`; +const CORE = `${BASE}/api/@vitnode/core`; +const PASSWORD = "Smoke-test-1234"; +const DEMO_ID = "cloudflare_realtime_demo"; +const LIVE_ID = "@vitnode/core_content_live"; +const NOTIFICATIONS_STATE_ID = "@vitnode/core_notifications_state"; + +const results = []; +const check = (name, ok, detail = "") => { + results.push({ detail, name, ok }); + console.log( + `${ok ? "PASS" : "FAIL"} ${name}${detail ? ` - ${detail}` : ""}`, + ); +}; +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +/** A cookie jar per "browser". */ +const browser = () => { + const cookies = new Map(); + + return { + cookie: () => [...cookies].map(([k, v]) => `${k}=${v}`).join("; "), + fetch: async (path, init = {}) => { + const response = await fetch( + path.startsWith("http") ? path : `${CORE}${path}`, + { + ...init, + headers: { + ...(init.body ? { "content-type": "application/json" } : {}), + cookie: [...cookies].map(([k, v]) => `${k}=${v}`).join("; "), + origin: ORIGIN, + ...init.headers, + }, + }, + ); + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(";"); + const [name, ...value] = pair.split("="); + if (/max-age=0|expires=thu, 01 jan 1970/i.test(raw)) + cookies.delete(name); + else cookies.set(name, value.join("=")); + } + + return response; + }, + }; +}; + +/** One socket with its received frames. */ +const connect = (jar, { headers = {}, origin = ORIGIN } = {}) => + new Promise(resolve => { + const ws = new WebSocket(WS_URL, { + headers: { cookie: jar?.cookie() ?? "", origin, ...headers }, + }); + const frames = []; + const socket = { + closed: new Promise(done => + ws.on("close", (code, reason) => + done({ code, reason: String(reason) }), + ), + ), + frames, + of: id => + frames.filter(frame => frame.id === id).map(frame => frame.data), + send: (id, data) => ws.send(JSON.stringify({ data, id })), + setCookie: [], + status: 101, + waitFor: async (predicate, ms = 3000) => { + const until = Date.now() + ms; + while (Date.now() < until) { + const found = frames.find(predicate); + if (found) return found; + await sleep(25); + } + + return undefined; + }, + ws, + }; + ws.on("message", raw => frames.push(JSON.parse(String(raw)))); + ws.on("upgrade", response => { + socket.setCookie = response.headers["set-cookie"] ?? []; + }); + ws.on("open", () => resolve(socket)); + ws.on("unexpected-response", (_request, response) => { + socket.status = response.statusCode; + resolve(socket); + }); + ws.on("error", () => resolve(socket)); + }); + +/** + * Resolves the close code and reason once the server's close frame arrives. + * A hibernating hub sends it at once but may tear the TCP connection down + * later; browsers finish the close shortly after the frame, Node's `ws` only + * after 30 s, so this reads the frame instead of waiting for `close`. + */ +const closeFrameOf = async (socket, ms = 5000) => { + const until = Date.now() + ms; + while (Date.now() < until && socket.ws.readyState < 2) await sleep(50); + if (socket.ws.readyState < 2) return undefined; + + return { + code: socket.ws._closeCode, + reason: String(socket.ws._closeMessage ?? ""), + }; +}; + +const signUp = async email => { + const jar = browser(); + await jar.fetch("/users/sign_up", { + body: JSON.stringify({ + email, + name: email.split("@")[0], + password: PASSWORD, + }), + method: "POST", + }); +}; + +const signIn = async (email, { admin = false } = {}) => { + const jar = browser(); + const response = await jar.fetch("/users/sign_in", { + body: JSON.stringify({ email, isAdmin: admin, password: PASSWORD }), + method: "POST", + }); + if (!response.ok) throw new Error(`sign in ${email}: ${response.status}`); + const me = await (await jar.fetch("/users/session")).json(); + + return { id: me.user?.id, jar }; +}; + +const push = async (admin, body, via = "context") => + await admin.fetch(`${BASE}/api/realtime-demo/admin/push?via=${via}`, { + body: JSON.stringify(body), + method: "POST", + }); + +// 1. Cold hub: the very first thing is an upgrade, with no earlier HTTP visit. +const guest = await connect(null); +check( + "cold first upgrade opens a guest socket", + guest.status === 101 && guest.ws.readyState === 1, + `status ${guest.status}`, +); + +// 2. Handshake policy. +const evil = await connect(null, { origin: "https://evil.example" }); +check( + "foreign Origin is refused before any socket exists", + evil.status === 403, + `status ${evil.status}`, +); + +await signUp("alice@example.com"); +await signUp("bob@example.com"); +const aliceA = await signIn("alice@example.com"); +const aliceB = await signIn("alice@example.com"); +const bob = await signIn("bob@example.com"); +const admin = await signIn("alice@example.com", { admin: true }); +check( + "accounts signed in", + Boolean(aliceA.id && bob.id && aliceA.id !== bob.id), + `alice ${aliceA.id}, bob ${bob.id}`, +); + +const a1 = await connect(aliceA.jar); +const a2 = await connect(aliceB.jar); +const b1 = await connect(bob.jar); +const opened = [a1, a2, b1].every(socket => socket.status === 101); +check("authenticated sockets open through the hub", opened); +const state = await a1.waitFor(frame => frame.id === NOTIFICATIONS_STATE_ID); +check( + "signed-in socket gets its notification state from the database", + Boolean(state), + JSON.stringify(state?.data ?? null), +); + +const clear = () => + [guest, a1, a2, b1].forEach(socket => socket.frames.splice(0)); +const demoFrames = socket => socket.of(DEMO_ID); + +// 3. broadcast from an HTTP request reaches every socket in the hub. +clear(); +let response = await push(admin.jar, { data: { n: 1 }, op: "broadcast" }); +await sleep(400); +check( + "HTTP broadcast reaches every socket", + response.ok && + [guest, a1, a2, b1].every(socket => demoFrames(socket).length === 1), + `status ${response.status}`, +); + +// 4. sendToUser: only the target's devices. +clear(); +response = await push(admin.jar, { + data: "for bob", + op: "sendToUser", + userId: bob.id, +}); +await sleep(400); +check( + "sendToUser reaches only the target user", + response.ok && + demoFrames(b1).length === 1 && + [guest, a1, a2].every(socket => demoFrames(socket).length === 0), +); + +clear(); +response = await push( + admin.jar, + { data: "for alice", op: "sendToUser", userId: aliceA.id }, + "import", +); +await sleep(400); +check( + "direct `realtime` import reaches all of a user's devices", + response.ok && + demoFrames(a1).length === 1 && + demoFrames(a2).length === 1 && + demoFrames(b1).length === 0, +); + +// 5. sendToUsers: each user their own payload, once. +clear(); +response = await push(admin.jar, { + messages: [ + { data: "A", userId: aliceA.id }, + { data: "B", userId: bob.id }, + ], + op: "sendToUsers", +}); +await sleep(400); +check( + "sendToUsers delivers each user's own payload once", + response.ok && + JSON.stringify(demoFrames(a1)) === '["A"]' && + JSON.stringify(demoFrames(a2)) === '["A"]' && + JSON.stringify(demoFrames(b1)) === '["B"]' && + demoFrames(guest).length === 0, +); + +// 6. Rooms. +clear(); +b1.send(DEMO_ID, { room: "demo:lobby", type: "join" }); +b1.send(DEMO_ID, { room: "demo:lobby", type: "join" }); +b1.send(DEMO_ID, { room: "demo:staff", type: "join" }); +a1.send(DEMO_ID, { room: "demo:staff", type: "join" }); +await sleep(400); +check( + "room join is idempotent per member and private rooms refuse non-staff", + JSON.stringify(demoFrames(b1)) === + JSON.stringify([ + { joined: true, room: "demo:lobby" }, + { joined: false, room: "demo:lobby" }, + { error: "FORBIDDEN", room: "demo:staff" }, + ]) && + JSON.stringify(demoFrames(a1)) === + JSON.stringify([{ joined: true, room: "demo:staff" }]), +); +clear(); +await push(admin.jar, { data: "lobby", op: "toRoom", room: "demo:lobby" }); +await push(admin.jar, { data: "staff", op: "toRoom", room: "demo:staff" }); +await sleep(400); +check( + "toRoom reaches only room members", + JSON.stringify(demoFrames(b1)) === '["lobby"]' && + JSON.stringify(demoFrames(a1)) === '["staff"]' && + demoFrames(a2).length === 0, +); +b1.send(DEMO_ID, { room: "demo:lobby", type: "leave" }); +await sleep(200); +clear(); +await push(admin.jar, { + data: "lobby again", + op: "toRoom", + room: "demo:lobby", +}); +await sleep(400); +check("a member that left the room gets nothing", demoFrames(b1).length === 0); + +// 7. Content Engine live editing between two of Alice's sessions. +let postId; +const sql = postgres( + process.env.POSTGRES_URL ?? "postgresql://root:root@127.0.0.1:5432/vitnode", + { max: 1, onnotice: () => {} }, +); +try { + [{ id: postId }] = + await sql`insert into blog_posts default values returning id`; +} finally { + await sql.end(); +} +const room = { contentTypeId: "blog.post", itemId: postId }; +const doc = { ...room, field: "content", locale: "en" }; +// Live editing is an AdminCP feature: each editor's socket carries an admin +// session, as it does in the browser. +const editor = await signIn("alice@example.com", { admin: true }); +const liveA = await connect(admin.jar); +const liveB = await connect(editor.jar); +const live = socket => socket.of(LIVE_ID); +liveA.send(LIVE_ID, { clientId: "tab-a", locale: "en", room, type: "join" }); +await liveA.waitFor( + frame => frame.id === LIVE_ID && frame.data.type === "joined", +); +liveB.send(LIVE_ID, { clientId: "tab-b", locale: "en", room, type: "join" }); +const joinedB = await liveB.waitFor( + frame => frame.id === LIVE_ID && frame.data.type === "joined", +); +const presenceA = await liveA.waitFor( + frame => + frame.id === LIVE_ID && + frame.data.type === "presence" && + frame.data.members.length === 2, +); +check( + "live: two editors join after async authorization and see each other's presence", + Boolean(joinedB && presenceA), + JSON.stringify(live(liveA).map(m => m.type)), +); + +const docA = new Y.Doc(); +const docB = new Y.Doc(); +const b64 = bytes => Buffer.from(bytes).toString("base64"); +const applyFrames = (socket, target, clientId) => { + for (const message of live(socket)) { + if (message.type === "doc:update" && message.from !== clientId) + Y.applyUpdate(target, Buffer.from(message.update, "base64")); + if (message.type === "doc:seed") { + target.getXmlFragment("prosemirror").insert(0, [new Y.XmlText("seed")]); + socket.send(LIVE_ID, { + clientId, + doc, + type: "doc:update", + update: b64(Y.encodeStateAsUpdate(target)), + }); + } + } + socket.frames.splice(0); +}; +liveA.send(LIVE_ID, { + clientId: "tab-a", + doc, + stateVector: b64(Y.encodeStateVector(docA)), + type: "doc:open", +}); +liveB.send(LIVE_ID, { + clientId: "tab-b", + doc, + stateVector: b64(Y.encodeStateVector(docB)), + type: "doc:open", +}); +await sleep(800); +applyFrames(liveA, docA, "tab-a"); +applyFrames(liveB, docB, "tab-b"); +await sleep(500); +applyFrames(liveA, docA, "tab-a"); +applyFrames(liveB, docB, "tab-b"); + +const edit = (target, clientId, socket, text) => { + const before = Y.encodeStateVector(target); + target.getText("smoke").insert(target.getText("smoke").length, text); + socket.send(LIVE_ID, { + clientId, + doc, + type: "doc:update", + update: b64(Y.encodeStateAsUpdate(target, before)), + }); +}; +edit(docA, "tab-a", liveA, "Hello "); +edit(docB, "tab-b", liveB, "world"); +await sleep(800); +applyFrames(liveA, docA, "tab-a"); +applyFrames(liveB, docB, "tab-b"); +const textA = docA.getText("smoke").toString(); +const textB = docB.getText("smoke").toString(); +check( + "live: concurrent edits converge on both clients", + textA === textB && textA.includes("Hello") && textA.includes("world"), + JSON.stringify([textA, textB]), +); + +// The hub persists dirty documents from a timer, outside any socket event. +await sleep(2_600); +const persisted = postgres( + process.env.POSTGRES_URL ?? "postgresql://root:root@127.0.0.1:5432/vitnode", + { max: 1, onnotice: () => {} }, +); +let storedBytes = 0; +try { + const [row] = + await persisted`select octet_length(state) as bytes from core_content_documents where "contentTypeId" = 'blog.post' and "itemId" = ${String(postId)} and field = 'content'`; + storedBytes = Number(row?.bytes ?? 0); +} finally { + await persisted.end(); +} +check( + "live: the hub persists the edited document to the database", + storedBytes > 0, + `${storedBytes} bytes`, +); + +// 8. HTTP reset reaches the hub's loaded document and its editors. +response = await admin.jar.fetch( + `${BASE}/api/@vitnode/blog/admin/content/posts/${postId}/draft/discard`, + { method: "POST" }, +); +const reset = await liveA.waitFor( + frame => frame.id === LIVE_ID && frame.data.type === "reset", +); +const resetB = await liveB.waitFor( + frame => frame.id === LIVE_ID && frame.data.type === "reset", +); +liveA.send(LIVE_ID, { + clientId: "tab-a", + doc, + type: "doc:update", + update: b64(Y.encodeStateAsUpdate(new Y.Doc())), +}); +const notJoined = await liveA.waitFor( + frame => + frame.id === LIVE_ID && + frame.data.type === "error" && + frame.data.code === "NOT_JOINED", +); +check( + "HTTP discard resets the record and unloads the hub's document", + Boolean(reset && resetB && notJoined), + `status ${response.status}`, +); + +// 9. Session revocation: one device, then all of them. +const staleCookie = aliceB.jar.cookie(); +response = await aliceB.jar.fetch("/users/sign_out", { method: "DELETE" }); +// The hub drops the socket at once and sends its close frame. +a1.frames.splice(0); +a2.frames.splice(0); +await push(admin.jar, { + data: "after sign-out", + op: "sendToUser", + userId: aliceA.id, +}); +await sleep(400); +check( + "a signed-out device gets no push, the user's other device still does", + demoFrames(a2).length === 0 && demoFrames(a1).length === 1, + JSON.stringify([demoFrames(a1), demoFrames(a2)]), +); +const closeA2 = await closeFrameOf(a2); +check( + "signing out one device closes only that device's socket", + closeA2?.code === 1008 && a1.ws.readyState === 1, + `sign_out ${response.status}, close ${closeA2?.code} ${closeA2?.reason ?? ""}`, +); +// A handshake with the signed-out session's cookie opens as a guest, and its +// 101 answer carries the Set-Cookie that clears the dead session. +const stale = await connect({ cookie: () => staleCookie }); +check( + "the handshake's Set-Cookie reaches the browser on the 101 response", + stale.status === 101 && + stale.setCookie.some( + cookie => + /^vitnode_auth=;/.test(cookie) || + /^vitnode_auth=.*(max-age=0|expires=thu, 01 jan 1970)/i.test(cookie), + ), + JSON.stringify(stale.setCookie.map(cookie => cookie.split(";")[0])), +); +stale.ws.close(); +await push(admin.jar, { op: "closeUserConnections", userId: aliceA.id }); +const closeA1 = await closeFrameOf(a1); +check( + "closeUserConnections without a device closes every socket of the user", + closeA1?.code === 1008 && b1.ws.readyState === 1, +); + +// 10. Reconnect: the client opens a new socket, rejoins, and reloads state. +// liveA was Alice's too, so the revocation above closed it; it reconnects like +// the browser does: new socket, join again, reload the document. +const again = await connect(admin.jar); +again.send(LIVE_ID, { clientId: "tab-a", locale: "en", room, type: "join" }); +const rejoined = await again.waitFor( + frame => frame.id === LIVE_ID && frame.data.type === "joined", +); +// A member session reloads its notification state on every new socket. +const member = await connect(aliceA.jar); +const reloadedState = + (await member.waitFor(frame => frame.id === NOTIFICATIONS_STATE_ID)) !== + undefined; +member.ws.close(); +check( + "reconnect: a new socket rejoins its room and reloads durable state", + Boolean(rejoined) && reloadedState, + `status ${again.status}, frames ${JSON.stringify(again.frames.map(frame => frame.data?.type ?? frame.id))}`, +); + +// 11. An abruptly dropped socket leaves the room once; others stay. +liveB.ws.terminate(); +const presenceAfterDrop = await again.waitFor( + frame => + frame.id === LIVE_ID && + frame.data.type === "presence" && + frame.data.members.every(member => member.clientId !== "tab-b"), + 5000, +); +check( + "a dropped socket is cleaned from the registry and live presence", + Boolean(presenceAfterDrop), +); + +// 12. The visitor's IP reaches the API on both paths, and a client cannot +// choose it: the header the Worker uses to tell the hub is replaced. +const ipOf = async socket => { + socket.send(DEMO_ID, { room: "-", type: "whoami" }); + const frame = await socket.waitFor(f => f.id === DEMO_ID && f.data?.ip); + + return frame?.data.ip; +}; +const httpIp = ( + await (await push(admin.jar, { data: null, op: "broadcast" })).json() +).ip; +const spoofed = await connect(null, { + headers: { + "x-forwarded-for": "203.0.113.8", + "x-vitnode-hub-client-ip": "203.0.113.7", + }, +}); +const wsIp = await ipOf(spoofed); +const loopback = ["localhost", "127.0.0.1"].includes(new URL(BASE).hostname); +check( + "client IP is the same on HTTP and in the hub, and not spoofable", + Boolean(httpIp) && + httpIp === wsIp && + !String(wsIp).startsWith("203.0.113") && + (loopback || httpIp !== "127.0.0.1"), + `http ${httpIp}, socket ${wsIp}`, +); +spoofed.ws.close(); + +// 13. Hibernation (SMOKE_HIBERNATION_WAIT=): every socket stays +// quiet until the hub is evicted. A hub stays awake while timers run - the +// rate limiter keeps one for a minute after the last handshake - so wait +// about 80 seconds against `pnpm preview`. +const hibernationWait = Number(process.env.SMOKE_HIBERNATION_WAIT ?? 0); +if (hibernationWait > 0) { + const sleeper = await connect(bob.jar); + sleeper.send(DEMO_ID, { room: "demo:night", type: "join" }); + await sleeper.waitFor(f => f.id === DEMO_ID && f.data?.room === "demo:night"); + sleeper.send(DEMO_ID, { room: "-", type: "whoami" }); + const before = ( + await sleeper.waitFor(f => f.id === DEMO_ID && f.data?.handshakeAt) + )?.data; + await signUp("carol@example.com"); + const carol = await signIn("carol@example.com"); + const carolSocket = await connect(carol.jar); + for (const socket of [guest, b1, again]) socket.ws.close(); + console.log(` (idling ${hibernationWait}s so the hub can hibernate)`); + await sleep(hibernationWait * 1000); + + // The session behind Carol's socket disappears while the hub sleeps, + // without the revocation hook - only a replayed handshake can notice. + const db = postgres( + process.env.POSTGRES_URL ?? "postgresql://root:root@127.0.0.1:5432/vitnode", + { max: 1, onnotice: () => {} }, + ); + try { + await db`delete from core_sessions where "userId" = ${carol.id}`; + } finally { + await db.end(); + } + + sleeper.frames.splice(0); + await push(admin.jar, { + data: "while asleep", + op: "sendToUser", + userId: bob.id, + }); + await push(admin.jar, { + data: "room after wake", + op: "toRoom", + room: "demo:night", + }); + await sleep(500); + check( + "hibernation: a woken hub still knows its sockets' users and rooms", + JSON.stringify(sleeper.of(DEMO_ID)) === + '["while asleep","room after wake"]', + JSON.stringify(sleeper.of(DEMO_ID)), + ); + sleeper.frames.splice(0); + sleeper.send(DEMO_ID, { room: "-", type: "whoami" }); + const after = ( + await sleeper.waitFor(f => f.id === DEMO_ID && f.data?.handshakeAt) + )?.data; + check( + "hibernation: the first message after a wake replays the handshake", + Boolean(before && after) && + after.handshakeAt > before.handshakeAt && + after.ip === before.ip, + `handshake ${before?.handshakeAt} -> ${after?.handshakeAt}`, + ); + const carolClosed = carolSocket.closed; + carolSocket.send(DEMO_ID, { room: "-", type: "whoami" }); + const carolClose = await Promise.race([ + carolClosed, + sleep(5000).then(() => undefined), + ]); + check( + "hibernation: a socket whose session ended while asleep is closed on its next message", + carolClose?.code === 1008, + `close ${carolClose?.code} ${carolClose?.reason ?? ""}`, + ); + sleeper.ws.close(); +} + +// 14. Handshake rate limit: a burst of guest handshakes from one IP ends in +// 429 - also with a forged hub IP header. Last, because it spends this IP's +// budget for the next minute. +// `vitnode dev` turns the rate limiter off: SMOKE_RATE_LIMIT=off skips this. +let limited; +const expectLimit = process.env.SMOKE_RATE_LIMIT !== "off"; +for (let attempt = 0; expectLimit && attempt < 400 && !limited; attempt++) { + const socket = await connect(null, { + headers: { "x-vitnode-hub-client-ip": `198.51.100.${attempt % 250}` }, + }); + if (socket.status === 429) limited = attempt; + socket.ws.terminate(); +} +if (expectLimit) { + check( + "a burst of handshakes is rate limited with 429", + limited !== undefined, + `after ${limited} handshakes`, + ); +} + +for (const socket of [guest, b1, again]) socket.ws.close(); +const failed = results.filter(result => !result.ok); +console.log( + `\n${results.length - failed.length}/${results.length} checks passed`, +); +process.exit(failed.length === 0 ? 0 : 1);