diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index f661052b3..a0ff59272 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -285,3 +285,110 @@ That's it. The Cloudflare Vite plugin produces a complete build output with a me ### Local development `pnpm dev` continues to work as before -- Miniflare creates local instances of all bindings regardless of the IDs in your `wrangler.jsonc`. Your real resource IDs are only used when you run `wrangler deploy`. + +### AI (self-host) + +`void/ai` works on your own Cloudflare account, along two paths: + +- **Workers AI** (`ai.run`, `ai.stream`, `ai.image`) works out of the box. When your app imports `void/ai`, `vite build` infers that you need AI and adds a Workers AI binding (`env.AI`) to the generated `wrangler.json` automatically -- you do **not** add it to `wrangler.jsonc`. +- **Provider models** (`ai.provider("openai").fetch(...)`) route through _your own_ Cloudflare AI Gateway. Set its id in `void.json` and add the provider's API key as a Worker secret. + +```jsonc +// void.json +{ + "ai": { + "gateway": "my-gateway", // an AI Gateway in YOUR Cloudflare account + }, +} +``` + +```bash +# provider API key, stored as a Worker secret (never committed) +wrangler secret put OPENAI_API_KEY +``` + +```ts +// routes/chat.ts +import { defineHandler } from 'void'; +import { ai } from 'void/ai'; + +export const POST = defineHandler(async (c) => { + // Workers AI -- uses env.AI directly + const summary = await ai.run('@cf/meta/llama-3.1-8b-instruct', { + prompt: 'Summarize the changelog.', + }); + + // Provider model -- routes through your "my-gateway" AI Gateway, + // authed with the OPENAI_API_KEY secret above + const res = await ai.provider('openai').fetch('chat/completions', { + method: 'POST', + body: JSON.stringify({ model: 'gpt-4o-mini', messages: [] }), + }); + + return c.json({ summary, provider: await res.json() }); +}); +``` + +`ai.gateway` is required for `ai.provider().fetch()` -- without it, that call returns a `501`. No AI traffic or provider secret passes through Void's shared proxy: requests go directly to _your own_ Cloudflare AI Gateway, authenticated with provider secrets from _your_ Worker's environment. (The request and that secret are of course still sent onward to the AI Gateway and the upstream provider you call.) + +Notes: + +- **No cross-tenant usage metering.** On the Void platform, AI calls are metered and billed through a shared proxy. Self-hosted, there is no metering -- you get your own [Cloudflare AI Gateway analytics](https://developers.cloudflare.com/ai-gateway/) instead. +- **The runtime reads the `env.AI` binding by name** -- a custom-named root AI binding is not supported. +- **`void dev` has no local Workers AI emulation.** In development, AI still routes through Void, so `void dev` AI requires `void auth login` even when you deploy self-hosted. + +### ISR (self-host) + +[Revalidation (ISR)](../guide/edge/revalidation.md) works self-hosted, but the cache KV is **not** auto-injected (a KV binding needs a real namespace id). Create a namespace and bind it as `ISR_CACHE`: + +```bash +wrangler kv namespace create ISR_CACHE +``` + +```jsonc +// wrangler.jsonc +{ + "kv_namespaces": [ + { + "binding": "ISR_CACHE", + "id": "", + }, + ], +} +``` + +Configure revalidation exactly as on the platform -- globally or per-path in `void.json`: + +```jsonc +// void.json +{ + "routing": { + "revalidate": { "/blog/*": 3600, "*": 60 }, + }, +} +``` + +...or per page with an exported `revalidate` literal in a `.server.ts` companion (Pages mode): + +```ts +// pages/blog/[slug].server.ts +export const revalidate = 3600; // seconds +``` + +On-demand purges work through `revalidate()`: + +```ts +import { revalidate } from 'void/isr'; + +await revalidate({ paths: ['/blog/hello'] }); +// or purge every ISR page: +await revalidate({ all: true }); +``` + +Self-hosted, `revalidate()` purges this worker's own KV entries (global, authoritative) plus the current colo's edge cache. + +Limits (the single-worker cache ladder can't do everything the platform's dispatch layer does): + +- **No fleet-wide / cross-colo edge purge.** `revalidate()` clears KV globally and the _local_ colo's edge cache; other colos keep serving their edge copy until it expires (bounded by the response's `s-maxage`), then re-render on the next edge miss. +- **The pages-protocol JSON variant is not served from a cold colo's KV.** A colo that hasn't rendered the HTML yet re-renders the JSON live; the JSON edge cache is warmed only as a side effect of the HTML render path. +- **The warm cache is dropped on every redeploy.** Each `vite build` bakes a fresh deployment id into the cache keys, so cached HTML never outlives the hashed assets it references -- the first request after a deploy is a cold render. diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md deleted file mode 100644 index cadae8d03..000000000 --- a/packages/void/AGENTS.md +++ /dev/null @@ -1,205 +0,0 @@ -# void - -Core Vite plugin — routing, inference, migrations, CLI. - -## Architecture - -The main export `voidPlugin()` returns an array of Vite plugins. For the default Cloudflare target, this includes the `cloudflare()` plugin. For non-CF targets (`node`, `bun`, `deno`), it replaces `cloudflare()` with `nodeTargetPlugin()`: - -``` -# Cloudflare (default) -voidPlugin() → [routingPlugin, migrationPlugin, cloudflare({...inferredBindings})] - -# Node.js / Bun / Deno -voidPlugin() → [routingPlugin, prerenderPlugin, nodeTargetPlugin()] -``` - -Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/env`, `void/ws`) at compile time. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. - -### Runtime Framework - -**Hono** is used as the runtime framework. The generated virtual module creates a Hono app with file-based routes and middleware. Route handlers receive a Hono `Context` object (`c`) and can use the full Hono API (`c.json()`, `c.text()`, `c.req.param()`, etc.) plus the Hono middleware ecosystem. - -- `defineHandler(fn)` — wraps a route handler `(c: CloudContext) => R`, returns `TypedHandler<{}, R>` -- `defineHandler.withValidator(validators)(fn)` — validates body/query/params via Standard Schema before calling handler, returns `TypedHandler` with phantom types for codegen -- `defineMiddleware(fn)` — wraps Hono middleware `(c, next) => void` -- `basicAuth(options)` — built-in Basic Auth middleware for temporary site gates; excludes Void internals under `/__void` -- Handlers can return plain objects/strings (auto-converted via `convertReturnValue`) or use Hono's `c.json()` / `c.text()` directly. -- Route files use **named HTTP method exports** (`export const GET`, `export const POST`, etc.) — one file per path, multiple methods per file. - -### Source Layout - -``` -src/ - index.ts # Main export: voidPlugin() → Plugin[] - config.ts # readConfig(), VoidConfig, convertHeadersConfig(), redirect/header helpers - plugin-routing.ts # Virtual module resolution, file watching, HMR, type codegen - plugin-node-target.ts # Non-CF deploy targets (Node.js, Bun, Deno) — binding guard, dev server, build entry - plugin-inference.ts # Binding inference orchestration (calls walk.ts) - plugin-migration.ts # Local migration runner on dev startup - standard-schema.ts # Inlined Standard Schema V1 types (no @standard-schema/spec dep) - routes-stub.ts # Empty RouteMap/WebSocketRouteMap interfaces (augmented by generated routes.d.ts) - router/ - parse-filename.ts # Route filename → RouteDefinition (pure function) - detect-exports.ts # Detects named HTTP method exports (GET, POST, etc.) via parseSync - websocket.ts # .ws.ts scanner + DO class metadata extraction - scan.ts # Filesystem scanner using tinyglobby + export detection - compile.ts # Generates Hono app code + worker entry + websocket wiring - codegen/ - route-types.ts # Generates .void/routes.d.ts (RouteMap + WebSocketRouteMap) - db-types.ts # (removed — Drizzle schema is now the type source) - queue-types.ts # Generates .void/queues.d.ts (QueueMap with payload types) - queues/ - scan.ts # Queue file scanner (queues/ dir, name from filename + consumer options) - runtime/ - handler.ts # defineHandler(), defineHandler.withValidator(), defineMiddleware(), defineHead(), defineQueue() - queues.ts # QueueMap stub interface + typed `queues` proxy using env.QUEUE_* - validator.ts # runValidation() — Standard Schema body/query/params validation - response.ts # convertReturnValue() — return → Response - fetch.ts # Typed fetch client for void/client - fetch-stream.ts # SSE stream consumer (fetchStream) for void/client - ws.ts # defineRoom(), defineWebSocket(), typed connect() client - ws-server.ts # DO runtime helpers for generated websocket routes - ai.ts # AI proxy — auto-detects backend (service binding / HTTPS / direct binding) - db.ts # Drizzle D1 instance (auto-wired with user schema via virtual module) - inference/ - walk.ts # AST walking with vite's parseSync + estree-walker - migrations/ - runner.ts # Applies pending migrations via drizzle-orm's programmatic migrate() - collect.ts # Reads .sql files from db/migrations/ dir, sorted by filename - validate.ts # Destructive-op detection + pragma enforcement - cli/ - cli.ts # CLI entry: `void deploy|link|login` - deploy.ts # Orchestrator: build → collect/validate migrations → package → upload - project.ts # Reads/creates .void/project.json (projectId + slug only) - auth.ts # Token read/save + runLogin() (local server + browser OAuth) - login.ts # Login command UI (@clack/prompts) - package.ts # Packages dist/ into FormData (worker + assets + migrations + manifest) - agents.ts # Shared agent configs, detection, MCP config targets - client.ts # HTTP client for platform API - types.ts # DeployManifest, MigrationMeta types -``` - -### Key Design Decisions - -- **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. -- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. -- **CF plugin API**: Worker config (`main`, `d1_databases`, `kv_namespaces`, `r2_buckets`) goes inside the `config` option of `cloudflare()`, NOT as top-level properties. `persistState` takes `{ path: string }`, not a raw string. The `config` option uses a **function form** `(resolved: WorkerConfig) => Partial` so that bindings from the user's `wrangler.json` (if present) take precedence over inferred placeholder bindings. -- **Migration DB path**: Miniflare stores D1 data at a deterministic path computed from the `database_id` using HMAC-SHA256. The `getMiniflareDatabasePath()` function in `migrations/runner.ts` replicates this algorithm so migrations target `/v3/d1/miniflare-D1DatabaseObject/.sqlite` (for example `.void/v3/...` in default/class-A mode and `.wrangler/state/v3/...` in class-B/C framework mode). -- **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a Drizzle D1 instance. `@schema` is an auto-configured alias for `db/schema.ts`. Migrations live in `db/migrations/`. CLI commands: `void db push` (prototype), `void db generate` (create migration files), `void db migrate` (apply locally). `drizzle-orm` and `drizzle-kit` ship with void. -- **Journal coherence invariant**: `db/migrations/*.sql` and `db/migrations/meta/_journal.json` must stay in sync — every `.sql` file has a matching journal entry (by `tag`) and vice versa. `assertJournalCoherence` in `src/migrations/validate.ts` is the gate, called from `deploy`, `db status`, `db migrate`, and `db reset`. The two CLI commands that mutate migration state — `void gen migration` and `void db rename-migrations` — are responsible for keeping the journal coherent themselves: the first appends a new entry when writing a file (failing loud if the journal is missing — run `drizzle-kit generate` first), the second rewrites `tag` values alongside the file renames and also renames the matching `meta/_snapshot.json` (with byte-level rollback on failure). `gen migration` additionally copies the previous `meta/_snapshot.json` forward to the new idx so drizzle-kit's drop/rollback tooling keeps working on mixed hand-written + drizzle projects; projects without prior snapshots are skipped silently. Drift will fail loudly before touching any remote DB. Motivated by [void-sdk/void#6](https://github.com/void-sdk/void/issues/6), where an orphan scaffolded `.sql` file half-applied against D1 and wedged the deploy. - -### Package Exports - -| Subpath | What | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `void` | `voidPlugin()` named export + `defineHandler` + `defineMiddleware` + `basicAuth` + `defineHead` + `defineQueue` + `CloudContext` type | -| `void/handler` | `defineHandler()`, `defineHandler.withValidator()`, `defineMiddleware()`, `basicAuth()`, `defineHead()`, types | -| `void/response` | `convertReturnValue()` | -| `void/validator` | `runValidation()`, `ValidatorSlots`, `HandlerInput` types | -| `void/routes` | Empty `RouteMap` + `WebSocketRouteMap` stubs (augmented by generated `routes.d.ts`) | -| `void/client` | Typed `fetch()` client + `fetchStream()` SSE consumer + `FetchError` | -| `void/ws` | `defineRoom()`, `defineWebSocket()`, typed `connect()`, and WebSocket context types | -| `void/db` | `db` Drizzle D1 instance (auto-wired with user schema) + `createDb()` for custom D1 bindings | -| `void/queues` | `queues` typed proxy + `QueueMap` stub interface (augmented by generated `queues.d.ts`) | -| `void/ai` | `ai` proxy — `ai.run()`, `ai.stream()`, `ai.models()` with auto-detected backend (service binding / HTTPS / direct) | -| `void/log` | `logger.error/warn/info(msg, fields?)` — emits stringified JSON to `console.*` so Cloudflare Tail captures level + msg | -| `void/env` | `defineEnv()`, typed `env` proxy, built-in schema helpers (`string`, `number`, `oneOf`, …) + global Cloudflare types | - -The CLI binary is at `dist/cli/cli.mjs` (declared in `bin` field of package.json). - -### Internal Staging Projects - -Internal-only staging is selected per linked project with `.void/project.json`: - -```json -{ - "projectId": "proj_...", - "slug": "my-app", - "voidInternalStaging": true -} -``` - -A pre-link file containing only `{ "voidInternalStaging": true }` is valid for Void platform developers who need `void auth login` or `void project link` to target staging before a project is linked. - -When `voidInternalStaging` is true: - -- **Token**: reads/writes `stagingToken` in `~/.void/config.json` instead of `token` -- **API URL**: defaults to `https://api.void.cloud` (prod); when staging is active, uses `https://api.staging.void.cloud` (unless overridden by `VOID_API_URL` or `api` config field) -- **Site domain**: uses `staging.void.app` for generated project URLs -- **Proxy**: injects staging proxy bindings for local dev AI and prerendering - -Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). - -### Route Filename Parsing Rules - -Applied in order to a path relative to `routes/`: - -1. Strip extension (`.ts`, `.js`, `.mts`, `.mjs`) -2. Strip env suffix (`.dev`, `.prod`) -3. Strip trailing `index` -4. Remove route group dirs `(groupname)` -5. Convert `[param]` → `:param`, `[...param]` → `:param{.+}`, `[...]` → `*` -6. Prefix with `/` - -HTTP methods are determined by **named exports** in each file (detected by `detect-exports.ts` via `parseSync`), not by filename suffixes. Files/dirs starting with `_` are ignored by the scanner. - -## Code Generation (`void gen`) - -Rails/Laravel-style scaffolding commands for common boilerplate: - -| Command | Creates | -| -------------------------------------------------- | ----------------------------------------------------------- | -| `void gen model [cols...]` | Drizzle table in `db/schema/` + barrel export + CRUD routes | -| `void gen migration ` | Empty migration file with auto-numbered prefix | -| `void gen route [--methods get,post,...]` | Route file with `defineHandler` exports | -| `void gen middleware ` | Numbered middleware file with `defineMiddleware` | -| `void gen ssr [--react\|--vue\|--svelte\|--solid]` | SSR entry + client entry + App component | -| `void gen cron ` | Cron job file with `defineScheduled` in `crons/` | -| `void gen queue ` | Queue consumer file with `defineQueue` | - -Column types for `gen model`: `string`, `text`, `datetime` → `text()`, `integer` → `integer()`, `boolean` → `integer({ mode: "boolean" })`, `real` → `real()`. Append `?` for nullable (omits `.notNull()`). - -`gen model` creates Drizzle table definitions in `db/schema/` and updates the `db/schema.ts` barrel export. Auto-detects your validation library (valibot, zod, or arktype) from `package.json`. If none is found, prompts to choose one or skip validation. - -Source: `src/cli/gen-args.ts` (arg parsing), `src/cli/gen.ts` (command runner). Tests: `test/unit/gen-args.test.ts`, `test/unit/gen.test.ts`. - -## Coding Agent Integration - -Void ships with first-class support for coding agents (Claude Code, Cursor, Codex, etc.): - -- **`AGENT_PROMPT.md`** — compressed reference prompt shipped in the `void` npm package. Covers project structure, routing, DB, auth, typed fetch, KV/storage/queues/cron, CLI, and deployment. -- **`skills/`** — Claude Code skills shipped in the npm package: - - `void` — unified app + CLI skill covering project architecture, runtime APIs, deployment flows, and CLI operations (deploy, db, gen, secrets, domains, projects) with lazy-loaded reference files. - - `migrate-cloudflare-to-void` — guides migration from `@cloudflare/vite-plugin` to `void`. -- **`docs/`** — all 23 docs markdown files are included in the npm package at `node_modules/void/docs/`. -- **`void init --agents`** — runs all agent setup steps from a single detection/selection pass: injects Void instructions with versioned markers, symlinks skills from the `void` package, and writes MCP config (or prints manual commands/JSON for unsupported/generic agents). -- **`void init` in an empty directory** — scaffolds a Pages-mode starter (React, Vue, Svelte, or Solid), then lets you pick D1, PostgreSQL, or Static Pages before running the same setup flow. - -Agent instructions use versioned markers (`` / ``) for safe update-in-place across version upgrades. - -## Tests - -| Test file | Covers | -| ------------------------------------------- | -------------------------------------------------------------- | -| `test/unit/parse-filename.test.ts` | Route filename parsing (21 tests) | -| `test/unit/scan.test.ts` | Filesystem scanning + queue consumers (13 tests) | -| `test/unit/detect-exports.test.ts` | Named HTTP method export detection (8 tests) | -| `test/unit/compile.test.ts` | Code generation + queue routes (10 tests) | -| `test/unit/response.test.ts` | Return value → Response (8 tests) | -| `test/unit/inference.test.ts` | AST-based binding inference + queues + AI (19 tests) | -| `test/unit/fetch-stream.test.ts` | SSE stream consumer (fetchStream) (5 tests) | -| `test/unit/validate-migrations.test.ts` | Destructive-op detection + pragma (12 tests) | -| `test/unit/collect-migrations.test.ts` | Migration file collection (4 tests) | -| `test/unit/validator.test.ts` | Standard Schema validation runtime (8 tests) | -| `test/unit/validator-real-schemas.test.ts` | Real schema validation (zod/valibot/arktype) (16 tests) | -| `test/unit/define-handler.test.ts` | defineHandler + middleware composition (18 tests) | -| `test/unit/fetch.test.ts` | Typed fetch client (9 tests) | -| `test/unit/ws.test.ts` | Typed websocket client (3 tests) | -| `test/unit/route-types.test.ts` | Route type codegen output (6 tests) | -| `test/unit/route-type-extraction.test.ts` | Type extraction pipeline (Serialize + ExtractInput) (14 tests) | -| `test/unit/db.test.ts` | Drizzle D1 instance + schema wiring (25 tests) | -| `test/unit/queue-types.test.ts` | Queue type codegen output (3 tests) | -| `test/unit/plugin-node-target.test.ts` | Node target plugin — binding guard, plugin creation (5 tests) | -| `test/integration/node-target.test.ts` | Node app playground — dev server + production build (5 tests) | -| `test/integration/queue-playground.test.ts` | Queue send → consumer → KV round-trip (1 test) | diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/packages/void/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/packages/void/CLAUDE.md b/packages/void/CLAUDE.md new file mode 100644 index 000000000..23d5f5b37 --- /dev/null +++ b/packages/void/CLAUDE.md @@ -0,0 +1,219 @@ +# void + +Core Vite plugin — routing, inference, migrations, CLI. + +## Architecture + +The main export `voidPlugin()` returns an array of Vite plugins. For the default Cloudflare target, this includes the `cloudflare()` plugin. For non-CF targets (`node`, `bun`, `deno`), it replaces `cloudflare()` with `nodeTargetPlugin()`: + +``` +# Cloudflare (default) +voidPlugin() → [routingPlugin, migrationPlugin, cloudflare({...inferredBindings})] + +# Node.js / Bun / Deno +voidPlugin() → [routingPlugin, prerenderPlugin, nodeTargetPlugin()] +``` + +Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/env`, `void/ws`) at compile time. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. + +### Runtime Framework + +**Hono** is used as the runtime framework. The generated virtual module creates a Hono app with file-based routes and middleware. Route handlers receive a Hono `Context` object (`c`) and can use the full Hono API (`c.json()`, `c.text()`, `c.req.param()`, etc.) plus the Hono middleware ecosystem. + +- `defineHandler(fn)` — wraps a route handler `(c: CloudContext) => R`, returns `TypedHandler<{}, R>` +- `defineHandler.withValidator(validators)(fn)` — validates body/query/params via Standard Schema before calling handler, returns `TypedHandler` with phantom types for codegen +- `defineMiddleware(fn)` — wraps Hono middleware `(c, next) => void` +- `basicAuth(options)` — built-in Basic Auth middleware for temporary site gates; excludes Void internals under `/__void` +- Handlers can return plain objects/strings (auto-converted via `convertReturnValue`) or use Hono's `c.json()` / `c.text()` directly. +- Route files use **named HTTP method exports** (`export const GET`, `export const POST`, etc.) — one file per path, multiple methods per file. + +### Source Layout + +``` +src/ + index.ts # Main export: voidPlugin() → Plugin[] + config.ts # readConfig(), VoidConfig, convertHeadersConfig(), redirect/header helpers + plugin-routing.ts # Virtual module resolution, file watching, HMR, type codegen + plugin-node-target.ts # Non-CF deploy targets (Node.js, Bun, Deno) — binding guard, dev server, build entry + plugin-inference.ts # Binding inference orchestration (calls walk.ts) + plugin-migration.ts # Local migration runner on dev startup + standard-schema.ts # Inlined Standard Schema V1 types (no @standard-schema/spec dep) + routes-stub.ts # Empty RouteMap/WebSocketRouteMap interfaces (augmented by generated routes.d.ts) + router/ + parse-filename.ts # Route filename → RouteDefinition (pure function) + detect-exports.ts # Detects named HTTP method exports (GET, POST, etc.) via parseSync + websocket.ts # .ws.ts scanner + DO class metadata extraction + scan.ts # Filesystem scanner using tinyglobby + export detection + compile.ts # Generates Hono app code + worker entry + websocket wiring + codegen/ + route-types.ts # Generates .void/routes.d.ts (RouteMap + WebSocketRouteMap) + db-types.ts # (removed — Drizzle schema is now the type source) + queue-types.ts # Generates .void/queues.d.ts (QueueMap with payload types) + queues/ + scan.ts # Queue file scanner (queues/ dir, name from filename + consumer options) + runtime/ + handler.ts # defineHandler(), defineHandler.withValidator(), defineMiddleware(), defineHead(), defineQueue() + queues.ts # QueueMap stub interface + typed `queues` proxy using env.QUEUE_* + validator.ts # runValidation() — Standard Schema body/query/params validation + response.ts # convertReturnValue() — return → Response + fetch.ts # Typed fetch client for void/client + fetch-stream.ts # SSE stream consumer (fetchStream) for void/client + ws.ts # defineRoom(), defineWebSocket(), typed connect() client + ws-server.ts # DO runtime helpers for generated websocket routes + ai.ts # AI proxy — auto-detects backend (service binding / HTTPS / direct binding) + db.ts # Drizzle D1 instance (auto-wired with user schema via virtual module) + inference/ + walk.ts # AST walking with vite's parseSync + estree-walker + migrations/ + runner.ts # Applies pending migrations via drizzle-orm's programmatic migrate() + collect.ts # Reads .sql files from db/migrations/ dir, sorted by filename + validate.ts # Destructive-op detection + pragma enforcement + cli/ + cli.ts # CLI entry: `void deploy|link|login` + deploy.ts # Orchestrator: build → collect/validate migrations → package → upload + project.ts # Reads/creates .void/project.json (projectId + slug only) + auth.ts # Token read/save + runLogin() (local server + browser OAuth) + login.ts # Login command UI (@clack/prompts) + package.ts # Packages dist/ into FormData (worker + assets + migrations + manifest) + agents.ts # Shared agent configs, detection, MCP config targets + client.ts # HTTP client for platform API + types.ts # DeployManifest, MigrationMeta types +``` + +### Key Design Decisions + +- **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. +- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. +- **CF plugin API**: Worker config (`main`, `d1_databases`, `kv_namespaces`, `r2_buckets`) goes inside the `config` option of `cloudflare()`, NOT as top-level properties. `persistState` takes `{ path: string }`, not a raw string. The `config` option uses a **function form** `(resolved: WorkerConfig) => Partial` so that bindings from the user's `wrangler.json` (if present) take precedence over inferred placeholder bindings. +- **Migration DB path**: Miniflare stores D1 data at a deterministic path computed from the `database_id` using HMAC-SHA256. The `getMiniflareDatabasePath()` function in `migrations/runner.ts` replicates this algorithm so migrations target `/v3/d1/miniflare-D1DatabaseObject/.sqlite` (for example `.void/v3/...` in default/class-A mode and `.wrangler/state/v3/...` in class-B/C framework mode). +- **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a Drizzle D1 instance. `@schema` is an auto-configured alias for `db/schema.ts`. Migrations live in `db/migrations/`. CLI commands: `void db push` (prototype), `void db generate` (create migration files), `void db migrate` (apply locally). `drizzle-orm` and `drizzle-kit` ship with void. +- **Journal coherence invariant**: `db/migrations/*.sql` and `db/migrations/meta/_journal.json` must stay in sync — every `.sql` file has a matching journal entry (by `tag`) and vice versa. `assertJournalCoherence` in `src/migrations/validate.ts` is the gate, called from `deploy`, `db status`, `db migrate`, and `db reset`. The two CLI commands that mutate migration state — `void gen migration` and `void db rename-migrations` — are responsible for keeping the journal coherent themselves: the first appends a new entry when writing a file (failing loud if the journal is missing — run `drizzle-kit generate` first), the second rewrites `tag` values alongside the file renames and also renames the matching `meta/_snapshot.json` (with byte-level rollback on failure). `gen migration` additionally copies the previous `meta/_snapshot.json` forward to the new idx so drizzle-kit's drop/rollback tooling keeps working on mixed hand-written + drizzle projects; projects without prior snapshots are skipped silently. Drift will fail loudly before touching any remote DB. Motivated by [void-sdk/void#6](https://github.com/void-sdk/void/issues/6), where an orphan scaffolded `.sql` file half-applied against D1 and wedged the deploy. + +### Self-Host AI + ISR + +Two features need extra wiring on a self-hosted `vite build && wrangler deploy` (no Void platform proxy/dispatch in front of the worker). Both are inert on managed `void deploy`. + +- **`ai.gateway` config + `__VOID_AI_GATEWAY` var** — `void.json`'s `ai.gateway` (validated in `config.ts` as `ai?: { gateway?: string }`; non-empty string, `additionalProperties: false`) names a Cloudflare AI Gateway in the user's own account. `mergeBindings` (`index.ts`): (1) injects `{ ai: { binding: 'AI' } }`, but only when `shouldBindWorkersAi()` is true — the app infers `needsAI`, `command === 'build'`, and neither the resolved root wrangler config nor the result already declares an `ai` binding (so it never clobbers a user's own `ai` binding); (2) emits `vars.__VOID_AI_GATEWAY = config.ai.gateway` whenever `config.ai?.gateway` is set. At runtime, `runtime/ai.ts` `resolveBackend()` selects the direct backend (Priority 3) when `env.AI` is present and no `__VOID_PROXY`/`__VOID_TOKEN` exists. `ai.run/stream/image` hit Workers AI directly; `ai.provider().fetch()` routes through the user's own gateway via `directAi.gateway(__VOID_AI_GATEWAY).getUrl(provider)` with the provider key from the user's own secret (`resolveProviderKey` → `env[PROVIDER_KEY_MAP[provider]]`), returning HTTP 501 if `__VOID_AI_GATEWAY` is unset. No cross-tenant metering on this path. The runtime reads `env.AI` **by name** — a custom-named root AI binding is not honored. +- **Internal-binding leak check permits exactly `__VOID_AI_GATEWAY`** — the integration guard (`test/integration/kitchen-sink-build.test.ts`) that asserts no `__VOID_*` var reaches `dist/ssr/wrangler.json` explicitly excludes `__VOID_AI_GATEWAY`, because it is a legitimate, non-credential runtime var (unlike the credential keys in `INTERNAL_ENV_KEYS`, which are stripped). +- **`ISR_CACHE` self-host requirement** — self-host ISR (`runtime/isr-cache.ts`, `runtime/isr.ts`) reads `env.ISR_CACHE` (a `KVNamespace`) + `env.__VOID_ISR_DEPLOYMENT_ID`. Unlike D1/KV/R2, `ISR_CACHE` is **not** auto-injected (a KV binding needs a real namespace id), so the user must create the namespace and declare it as `ISR_CACHE` in their root wrangler config. `routing.revalidate` (void.json) and per-page `export const revalidate` are merged by `collectRevalidate` (`isr-config.ts`) and baked as `__VOID_ISR_REVALIDATE`. On-demand `revalidate()` on self-host purges the worker's own KV (global) plus the local-colo `caches.default` edge variants only. + +### Package Exports + +| Subpath | What | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `void` | `voidPlugin()` named export + `defineHandler` + `defineMiddleware` + `basicAuth` + `defineHead` + `defineQueue` + `CloudContext` type | +| `void/handler` | `defineHandler()`, `defineHandler.withValidator()`, `defineMiddleware()`, `basicAuth()`, `defineHead()`, types | +| `void/response` | `convertReturnValue()` | +| `void/validator` | `runValidation()`, `ValidatorSlots`, `HandlerInput` types | +| `void/routes` | Empty `RouteMap` + `WebSocketRouteMap` stubs (augmented by generated `routes.d.ts`) | +| `void/client` | Typed `fetch()` client + `fetchStream()` SSE consumer + `FetchError` | +| `void/ws` | `defineRoom()`, `defineWebSocket()`, typed `connect()`, and WebSocket context types | +| `void/db` | `db` Drizzle D1 instance (auto-wired with user schema) + `createDb()` for custom D1 bindings | +| `void/queues` | `queues` typed proxy + `QueueMap` stub interface (augmented by generated `queues.d.ts`) | +| `void/ai` | `ai` proxy — `ai.run()`, `ai.stream()`, `ai.models()` with auto-detected backend (service binding / HTTPS / direct) | +| `void/log` | `logger.error/warn/info(msg, fields?)` — emits stringified JSON to `console.*` so Cloudflare Tail captures level + msg | +| `void/env` | `defineEnv()`, typed `env` proxy, built-in schema helpers (`string`, `number`, `oneOf`, …) + global Cloudflare types | + +The CLI binary is at `dist/cli/cli.mjs` (declared in `bin` field of package.json). + +### Internal Staging Projects + +Internal-only staging is selected per linked project with `.void/project.json`: + +```json +{ + "projectId": "proj_...", + "slug": "my-app", + "voidInternalStaging": true +} +``` + +A pre-link file containing only `{ "voidInternalStaging": true }` is valid for Void platform developers who need `void auth login` or `void project link` to target staging before a project is linked. + +When `voidInternalStaging` is true: + +- **Token**: reads/writes `stagingToken` in `~/.void/config.json` instead of `token` +- **API URL**: defaults to `https://api.void.cloud` (prod); when staging is active, uses `https://api.staging.void.cloud` (unless overridden by `VOID_API_URL` or `api` config field) +- **Site domain**: uses `staging.void.app` for generated project URLs +- **Proxy**: injects staging proxy bindings for local dev AI and prerendering + +Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). + +### Route Filename Parsing Rules + +Applied in order to a path relative to `routes/`: + +1. Strip extension (`.ts`, `.js`, `.mts`, `.mjs`) +2. Strip env suffix (`.dev`, `.prod`) +3. Strip trailing `index` +4. Remove route group dirs `(groupname)` +5. Convert `[param]` → `:param`, `[...param]` → `:param{.+}`, `[...]` → `*` +6. Prefix with `/` + +HTTP methods are determined by **named exports** in each file (detected by `detect-exports.ts` via `parseSync`), not by filename suffixes. Files/dirs starting with `_` are ignored by the scanner. + +## Code Generation (`void gen`) + +Rails/Laravel-style scaffolding commands for common boilerplate: + +| Command | Creates | +| -------------------------------------------------- | ----------------------------------------------------------- | +| `void gen model [cols...]` | Drizzle table in `db/schema/` + barrel export + CRUD routes | +| `void gen migration ` | Empty migration file with auto-numbered prefix | +| `void gen route [--methods get,post,...]` | Route file with `defineHandler` exports | +| `void gen middleware ` | Numbered middleware file with `defineMiddleware` | +| `void gen ssr [--react\|--vue\|--svelte\|--solid]` | SSR entry + client entry + App component | +| `void gen cron ` | Cron job file with `defineScheduled` in `crons/` | +| `void gen queue ` | Queue consumer file with `defineQueue` | + +Column types for `gen model`: `string`, `text`, `datetime` → `text()`, `integer` → `integer()`, `boolean` → `integer({ mode: "boolean" })`, `real` → `real()`. Append `?` for nullable (omits `.notNull()`). + +`gen model` creates Drizzle table definitions in `db/schema/` and updates the `db/schema.ts` barrel export. Auto-detects your validation library (valibot, zod, or arktype) from `package.json`. If none is found, prompts to choose one or skip validation. + +Source: `src/cli/gen-args.ts` (arg parsing), `src/cli/gen.ts` (command runner). Tests: `test/unit/gen-args.test.ts`, `test/unit/gen.test.ts`. + +## Coding Agent Integration + +Void ships with first-class support for coding agents (Claude Code, Cursor, Codex, etc.): + +- **`AGENT_PROMPT.md`** — compressed reference prompt shipped in the `void` npm package. Covers project structure, routing, DB, auth, typed fetch, KV/storage/queues/cron, CLI, and deployment. +- **`skills/`** — Claude Code skills shipped in the npm package: + - `void` — unified app + CLI skill covering project architecture, runtime APIs, deployment flows, and CLI operations (deploy, db, gen, secrets, domains, projects) with lazy-loaded reference files. + - `migrate-cloudflare-to-void` — guides migration from `@cloudflare/vite-plugin` to `void`. +- **`docs/`** — all 23 docs markdown files are included in the npm package at `node_modules/void/docs/`. +- **`void init --agents`** — runs all agent setup steps from a single detection/selection pass: injects Void instructions with versioned markers, symlinks skills from the `void` package, and writes MCP config (or prints manual commands/JSON for unsupported/generic agents). +- **`void init` in an empty directory** — scaffolds a Pages-mode starter (React, Vue, Svelte, or Solid), then lets you pick D1, PostgreSQL, or Static Pages before running the same setup flow. + +Agent instructions use versioned markers (`` / ``) for safe update-in-place across version upgrades. + +## Tests + +| Test file | Covers | +| ------------------------------------------- | -------------------------------------------------------------- | +| `test/unit/parse-filename.test.ts` | Route filename parsing (21 tests) | +| `test/unit/scan.test.ts` | Filesystem scanning + queue consumers (13 tests) | +| `test/unit/detect-exports.test.ts` | Named HTTP method export detection (8 tests) | +| `test/unit/compile.test.ts` | Code generation + queue routes (10 tests) | +| `test/unit/response.test.ts` | Return value → Response (8 tests) | +| `test/unit/inference.test.ts` | AST-based binding inference + queues + AI (19 tests) | +| `test/unit/fetch-stream.test.ts` | SSE stream consumer (fetchStream) (5 tests) | +| `test/unit/validate-migrations.test.ts` | Destructive-op detection + pragma (12 tests) | +| `test/unit/collect-migrations.test.ts` | Migration file collection (4 tests) | +| `test/unit/validator.test.ts` | Standard Schema validation runtime (8 tests) | +| `test/unit/validator-real-schemas.test.ts` | Real schema validation (zod/valibot/arktype) (16 tests) | +| `test/unit/define-handler.test.ts` | defineHandler + middleware composition (18 tests) | +| `test/unit/fetch.test.ts` | Typed fetch client (9 tests) | +| `test/unit/ws.test.ts` | Typed websocket client (3 tests) | +| `test/unit/route-types.test.ts` | Route type codegen output (6 tests) | +| `test/unit/route-type-extraction.test.ts` | Type extraction pipeline (Serialize + ExtractInput) (14 tests) | +| `test/unit/db.test.ts` | Drizzle D1 instance + schema wiring (25 tests) | +| `test/unit/queue-types.test.ts` | Queue type codegen output (3 tests) | +| `test/unit/plugin-node-target.test.ts` | Node target plugin — binding guard, plugin creation (5 tests) | +| `test/integration/node-target.test.ts` | Node app playground — dev server + production build (5 tests) | +| `test/integration/queue-playground.test.ts` | Queue send → consumer → KV round-trip (1 test) | + +## Pitfalls / Things That Broke + +- **`__VOID_ISR_DEPLOYMENT_ID` must be a compile-time literal.** `router/compile.ts` stringifies `crypto.randomUUID()` once at build time (`export const __VOID_ISR_DEPLOYMENT_ID = "..."`, exactly like `__VOID_BUILD_KEY`) so every isolate of a deployment shares one id. A bare runtime `crypto.randomUUID()` per-isolate in the emitted source would mint a fresh id per isolate, so the edge-cache key (`handleIsr`'s `deploymentId` option) and the KV key (`env.__VOID_ISR_DEPLOYMENT_ID`, also used by the `revalidate()` purge path) would disagree — breaking cache reads and on-demand purge. +- **Self-host ISR requires a user-declared `ISR_CACHE` KV.** There is no `id: 'local'` injection for it: a placeholder id is fine for miniflare-backed dev but breaks a real `wrangler deploy`, which rejects an unknown namespace id. The user creates the namespace and binds it as `ISR_CACHE`; `readIsrCache`/`writeIsrCache` fail open to a live render when it is absent. +- **Workers AI binding injection is `command === 'build'`-gated.** `shouldBindWorkersAi()` only injects `{ ai: { binding: 'AI' } }` on build, because dev/miniflare has no local Workers AI emulation — `void dev` AI still routes through the Void proxy (P2 `__VOID_TOKEN` path) and needs `void auth login`. diff --git a/packages/void/package.json b/packages/void/package.json index 3ded854e1..6436c42c3 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -38,6 +38,7 @@ "#self/runtime/live-server": "./dist/runtime/live-server.mjs", "#self/runtime/fetch": "./dist/runtime/fetch.mjs", "#self/runtime/fetch-stream": "./dist/runtime/fetch-stream.mjs", + "#self/runtime/isr-cache": "./dist/runtime/isr-cache.mjs", "#self/runtime/migration-handler": "./dist/runtime/migration-handler.mjs", "#self/runtime/migration-handler-pg": "./dist/runtime/migration-handler-pg.mjs", "#self/runtime/ws-server": "./dist/runtime/ws-server.mjs" diff --git a/packages/void/schema.json b/packages/void/schema.json index 03e162fb6..706f62098 100644 --- a/packages/void/schema.json +++ b/packages/void/schema.json @@ -363,6 +363,17 @@ "type": "string", "enum": ["pg"], "description": "Database backend. Omit for D1 (SQLite, default). Set to \"pg\" for PostgreSQL via Hyperdrive." + }, + "ai": { + "type": "object", + "description": "AI configuration for self-hosted deploys. Configures the Cloudflare AI Gateway used by ai.provider().fetch() (Workers AI ai.run/ai.image use the env.AI binding directly and are not affected).", + "properties": { + "gateway": { + "type": "string", + "description": "Cloudflare AI Gateway name/slug to route ai.provider().fetch() through your own account." + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 4f1e5146f..3e6a72793 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -33,6 +33,7 @@ import { import { scanJobsSync } from '../crons/scan.ts'; import { writeDrizzleConfig } from '../drizzle/config.ts'; import { discoverSchema } from '../drizzle/discover.ts'; +import { collectRevalidate } from '../isr-config.ts'; import { voidWarn } from '../log.ts'; import { collectMigrations } from '../migrations/collect.ts'; import { assertJournalCoherence, validateMigrations } from '../migrations/validate.ts'; @@ -1717,40 +1718,14 @@ async function runFullDeploy( ? true : Boolean(validateSsrEntry(root, paths)) || (hasPages && deployConfig.output !== 'static'); - // Resolve revalidate config from void.json + per-page .server.ts exports - const vcRevalidate = readConfig(root).routing?.revalidate; - let revalidate: number | Record | undefined; - if (vcRevalidate != null) { - revalidate = vcRevalidate; - } - // Merge per-page revalidate exports (pages mode) + // Resolve revalidate config from void.json + per-page .server.ts exports. + // The page scan is also reused by the fully-static optimization below. const pageScan = existsSync(paths.pagesDir) ? routeScan.pages.pages.length > 0 ? routeScan.pages : await scanPages(root, { output: deployConfig.output, paths }) : null; - if (pageScan) { - const perPageRules: Record = {}; - for (const page of pageScan.pages) { - if (page.revalidate != null) { - perPageRules[page.pattern] = page.revalidate; - } - } - if (Object.keys(perPageRules).length > 0) { - if (typeof revalidate === 'number') { - // Global number → pathmap with "*" fallback, per-page entries first - revalidate = Object.fromEntries([...Object.entries(perPageRules), ['*', revalidate]]); - } else if (typeof revalidate === 'object') { - // Per-page entries first (higher precedence in first-match) - revalidate = Object.fromEntries([ - ...Object.entries(perPageRules), - ...Object.entries(revalidate), - ]); - } else { - revalidate = perPageRules; - } - } - } + const revalidate = collectRevalidate(deployConfig, pageScan); // Fully-static optimization: if output: "static" and all pages are prerendered // with no backend features, deploy as a static site (no worker needed) diff --git a/packages/void/src/config.ts b/packages/void/src/config.ts index 5bad1fc52..9d3ef1121 100644 --- a/packages/void/src/config.ts +++ b/packages/void/src/config.ts @@ -55,6 +55,11 @@ export type AuthConfig = { providers?: Array; }; +/** AI configuration — the Cloudflare AI Gateway used by `ai.provider().fetch()` on self-hosted deploys (Workers AI `ai.run`/`ai.image` use the `env.AI` binding directly). */ +export type AiConfig = { + gateway?: string; +}; + /** Shape of the `void.json` config file. All fields optional. */ export type VoidConfig = { $schema?: string; @@ -69,6 +74,7 @@ export type VoidConfig = { remote?: boolean; worker?: WranglerConfig; database?: 'pg'; + ai?: AiConfig; }; const CONFIG_FILE = 'void.json'; @@ -881,6 +887,27 @@ export function readConfig(root: string): VoidConfig { } } + // Validate ai + if (obj.ai !== undefined) { + if (typeof obj.ai !== 'object' || obj.ai === null || Array.isArray(obj.ai)) { + throw new Error(`config: "ai" in ${CONFIG_FILE} must be an object.`); + } + + const aiCfg = obj.ai as Record; + for (const key of Object.keys(aiCfg)) { + if (key !== 'gateway') { + throw new Error(`config: Unknown key 'ai.${key}' in ${CONFIG_FILE}. Valid: gateway.`); + } + } + + if ( + aiCfg.gateway !== undefined && + (typeof aiCfg.gateway !== 'string' || aiCfg.gateway.length === 0) + ) { + throw new Error(`config: "ai.gateway" in ${CONFIG_FILE} must be a non-empty string.`); + } + } + return obj as VoidConfig; } diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 1dbec4f99..44949440f 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -343,6 +343,26 @@ function hasBinding(list: Array<{ binding: string }> | undefined, name: string): return Array.isArray(list) && list.some((b) => b.binding === name); } +/** + * Decide whether to inject a Workers AI binding into the built wrangler config. + * + * Build-only: dev/miniflare has no local Workers AI, so serve must never bind + * `env.AI` (dev uses the P2 `__VOID_TOKEN` proxy path instead). + * + * `!resolvedAi` is load-bearing: mergeBindings' return is defu-merged over the + * resolved root-wrangler config and wins, so skipping when the user already + * declared an `ai` binding avoids clobbering theirs. `!resultAi` is harmless + * defense-in-depth (nothing else in mergeBindings sets `result.ai`). + */ +export function shouldBindWorkersAi( + command: string, + needsAI: boolean, + resolvedAi: unknown, + resultAi: unknown, +): boolean { + return needsAI && command === 'build' && !resultAi && !resolvedAi; +} + const CLOUDFLARE_BUILTIN_IMPORT = /^cloudflare:/; const SANDBOX_SDK_IMPORT = '@cloudflare/sandbox'; const SANDBOX_SDK_IMPORT_FILTER = /^@cloudflare\/sandbox$/; @@ -981,6 +1001,34 @@ export function voidPlugin(options?: { result.r2_buckets = [{ binding: r2Name, bucket_name: 'default' }]; } + // Workers AI — self-host only. The managed deploy ignores dist/wrangler.json, + // so this binding only takes effect on a real `wrangler deploy`. Build-gated + // because dev/miniflare has no local Workers AI (dev uses the P2 + // __VOID_TOKEN proxy path). The `ai` config is a single object (not an array) + // and needs no external id, so it is safe to inject unconditionally here. + // Guard rationale: mergeBindings' return is defu-merged over `resolved` and + // wins, so `!resolvedAi` is load-bearing (prevents clobbering a user's own + // root-wrangler `ai` binding); `!resultAi` is harmless defense-in-depth + // (nothing else in mergeBindings ever sets `result.ai`). + if ( + shouldBindWorkersAi( + viteCommand, + bindings.needsAI, + (resolved as { ai?: unknown }).ai, + (result as { ai?: unknown }).ai, + ) + ) { + (result as Record).ai = { binding: 'AI' }; + } + + // AI Gateway id → runtime var (harmless in serve; only read by the P3 direct + // backend that routes Workers AI inference through the user's own Gateway). + if (config.ai?.gateway) { + const vars = (result.vars as Record) ?? {}; + vars.__VOID_AI_GATEWAY = config.ai.gateway; + result.vars = vars; + } + // Queues — add producer + consumer bindings for local dev if (queues.length > 0) { (result as Record).queues = { diff --git a/packages/void/src/isr-config.ts b/packages/void/src/isr-config.ts new file mode 100644 index 000000000..70e0b24bc --- /dev/null +++ b/packages/void/src/isr-config.ts @@ -0,0 +1,85 @@ +import type { VoidConfig } from './config.ts'; +import type { PageScanResult } from './pages/types.ts'; + +/** + * Whether a resolved `revalidate` value represents an active ISR configuration. + * + * Mirrors the presence check used when writing the deploy manifest + * (`packages/void/src/cli/package.ts`): `null`/`undefined`/`0`/`{}` are treated + * as "no ISR", a positive number or a non-empty path map is treated as "ISR on". + */ +export function hasRevalidate(r: unknown): boolean { + if (r == null) { + return false; + } + if (typeof r === 'number') { + return r > 0; + } + if (typeof r === 'object') { + return Object.keys(r as object).length > 0; + } + return false; +} + +/** + * Merge the app's `void.json` `routing.revalidate` config with per-page + * `export const revalidate` rules discovered by the page scan, producing the + * final revalidate map that gets baked into the deploy manifest (managed + * `void deploy`) or the self-hosted worker build. + * + * This is the single source of truth for the merge so the managed and + * self-host build paths cannot diverge. Behaviour is preserved verbatim from + * the original inline logic in `void deploy`: + * + * - Config value seeds the result (`number | Record | undefined`). + * - Per-page rules (page pattern → revalidate seconds) are collected from the + * already-computed page scan (`null` when the app has no pages dir). + * - When there are per-page rules: + * - config is a plain number → path map with per-page entries first and the + * config number applied to `"*"` as a fallback; + * - config is an object map → per-page entries first (higher precedence in + * first-match ordering), then the config entries; + * - config is absent → the per-page rules alone. + * - Empty results (no config, no per-page rules) return `undefined`. + */ +export function collectRevalidate( + config: VoidConfig, + pageScan: PageScanResult | null, +): number | Record | undefined { + const vcRevalidate = config.routing?.revalidate; + let revalidate: number | Record | undefined; + if (vcRevalidate != null) { + revalidate = vcRevalidate; + } + // Merge per-page revalidate exports (pages mode) + if (pageScan) { + const perPageRules: Record = {}; + for (const page of pageScan.pages) { + if (page.revalidate != null) { + // Single-segment params (:slug) stay as :slug so the matcher can anchor + // them to ONE path segment; named catch-alls (:slug{.+}) and anonymous + // catch-alls (*) stay/become '*' (a splat that crosses '/'). See + // matchLiteralPattern. This applies ONLY to scanned page patterns — the + // user's own `routing.revalidate` keys below are authored literals and + // pass through untouched. + const key = page.pattern.replace(/:[A-Za-z0-9_]+\{[^}]*\}/g, '*'); + perPageRules[key] = page.revalidate; + } + } + if (Object.keys(perPageRules).length > 0) { + if (typeof revalidate === 'number') { + // Global number → pathmap with "*" fallback, per-page entries first + revalidate = Object.fromEntries([...Object.entries(perPageRules), ['*', revalidate]]); + } else if (typeof revalidate === 'object') { + // Per-page entries first (higher precedence in first-match) + revalidate = Object.fromEntries([ + ...Object.entries(perPageRules), + ...Object.entries(revalidate), + ]); + } else { + revalidate = perPageRules; + } + } + } + return revalidate; +} diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index f9b845f87..51b8958ba 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -17,6 +17,7 @@ import { generateQueueTypes } from './codegen/queue-types.ts'; import { generateRouteTypes } from './codegen/route-types.ts'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; import { readConfig, getDatabaseDialect } from './config.ts'; +import { collectRevalidate, hasRevalidate } from './isr-config.ts'; import { readProjectPaths } from './project-paths.ts'; import { compileRouteTable, @@ -292,6 +293,14 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug let resolvedConfig: import('vite').ResolvedConfig | null = null; + // Merged ISR revalidate map for the CURRENT build, gated by `emitIsr` (see + // buildStart). The `load` hook threads this into `compileRouteTable` so the + // baked `__VOID_ISR_REVALIDATE` const and the worker entry's `handleIsr` + // wiring derive from the SAME decision within one build. `undefined` whenever + // ISR is not emitted (dev, managed `void deploy`, non-CF target, or no + // revalidate config), keeping today's behavior byte-identical. + let isrRevalidate: number | Record | undefined; + function resolveAuthClientPath(config: import('vite').ResolvedConfig): string { if (config.plugins.some((plugin) => plugin.name === 'void-react:pages')) { return explicitAuthClientPaths.react; @@ -520,6 +529,26 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug ); writeFileIfChanged(join(outDir, 'index.ts'), compileNodeServer(options.target)); } else { + // ISR gate. Only a real (non-managed) production CF `vite build` bakes + // ISR into the worker: managed `void deploy` sets VOID_DEPLOY_PROJECT_ID + // (the platform owns ISR then), dev (`command === 'serve'`) never emits + // it, and non-CF targets took the branch above. Read the command from + // `resolvedConfig` (set in configResolved, available here) — not a stale + // captured value. + const merged = collectRevalidate(readConfig(root), scan!.pages); + const emitIsr = + !options.target && + resolvedConfig?.command === 'build' && + !process.env.VOID_DEPLOY_PROJECT_ID && + hasRevalidate(merged); + // Route table + worker entry must agree within one build: the load hook + // bakes `__VOID_ISR_REVALIDATE`/`__VOID_ISR_DEPLOYMENT_ID` from + // `isrRevalidate`, and the entry's `handleIsr` wiring imports those same + // consts. Both derive from `emitIsr` so they are present or absent together. + isrRevalidate = emitIsr ? merged : undefined; + // Resolve the ISR runtime path LAZILY — only when ISR is actually emitted + // — so non-ISR/managed/dev builds never depend on it. + const isr = emitIsr ? { runtimePath: resolveFile('#self/runtime/isr-cache') } : undefined; writeFileIfChanged( join(outDir, 'entry.ts'), compileWorkerEntry( @@ -538,6 +567,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug : false, shouldServeSpaFallback(), options.live, + isr, ), ); } @@ -598,6 +628,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug ssr, compileOptions, getPagesCompileOptions(), + isrRevalidate, ); }, }, diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 966e01dca..274ba72b2 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -2,7 +2,7 @@ import type { VoidAuthProvider } from '../auth/providers.ts'; import type { DevRedirectRule, HeaderRule } from '../cli/types.ts'; import type { MigrationPlanBundle } from '../migrations/plan.ts'; import { resolveLayoutIds } from '../pages/layout-resolution.ts'; -import { INTERNAL_BINDING_PREFIXES } from '../runtime/env-raw.ts'; +import { INTERNAL_BINDING_EXACT_NAMES, INTERNAL_BINDING_PREFIXES } from '../runtime/env-raw.ts'; import type { ScanResult } from './scan.ts'; /** @@ -740,6 +740,13 @@ export function compileRouteTable( ssr: SsrCompileOptions | null, options: CompileOptions, pages: PagesCompileOptions | null = null, + /** + * Merged ISR revalidate config (number | Record) baked into + * the route table as `__VOID_ISR_REVALIDATE`. Task 8 supplies the real value; + * `undefined` (the default) emits `= undefined;` so non-ISR builds are + * unaffected — the const is only imported/used when the entry wires ISR. + */ + mergedRevalidate?: number | Record, ): string { const lines: Array = []; const auth = options.auth; @@ -1706,6 +1713,14 @@ window.__vite_plugin_react_preamble_installed__ = true; lines.push( `export const __VOID_DEV_TRIGGER_TOKEN = ${JSON.stringify(options.devTriggerToken ?? crypto.randomUUID())};`, ); + // Baked ISR config. `__VOID_ISR_REVALIDATE` is the merged revalidate map/number + // (or `undefined`). `__VOID_ISR_DEPLOYMENT_ID` MUST be a COMPILE-TIME LITERAL — + // stringified here at build time exactly like `__VOID_BUILD_KEY` above — so every + // isolate of a deployment shares one id (edge-cache keys, KV keys, and the + // `revalidate()` purge path must all agree). A runtime `crypto.randomUUID()` in the + // emitted source would mint a fresh id per isolate and break cache reads + purge. + lines.push(`export const __VOID_ISR_REVALIDATE = ${JSON.stringify(mergedRevalidate)};`); + lines.push(`export const __VOID_ISR_DEPLOYMENT_ID = ${JSON.stringify(crypto.randomUUID())};`); lines.push(''); lines.push(`export default app;`); @@ -2415,6 +2430,15 @@ export function compileWorkerEntry( sandbox?: boolean | { bindingName?: string; className?: string; runtimePath?: string }, serveSpaFallback = false, live?: boolean | { bindingName?: string; className?: string }, + /** + * When present, wraps the render path with the self-host ISR cache ladder. + * `runtimePath` is the import specifier for `handleIsr` (Task 10 export). The + * deployment id is NOT passed here — the single source of truth is the + * compile-time `__VOID_ISR_DEPLOYMENT_ID` const imported from the route table. + * When absent, the render path is emitted byte-identical to today (zero + * behavioral change for managed/dev/no-ISR builds). + */ + isr?: { runtimePath: string }, ): string { const remotePath = remote?.remotePath; const remoteBindingNames = JSON.stringify(remote?.bindingNames); @@ -2465,12 +2489,20 @@ export function compileWorkerEntry( // Filter internal platform bindings so user code cannot access them via c.env. // The original env is preserved for framework internals (withRuntimeEnv, etc.). // Prefixes derived from INTERNAL_BINDING_PREFIXES (shared with env.ts). - const prefixCheck = INTERNAL_BINDING_PREFIXES.map( - (p) => `prop.startsWith(${JSON.stringify(p)})`, - ).join(' || '); - const prefixCheckNeg = INTERNAL_BINDING_PREFIXES.map( - (p) => `!key.startsWith(${JSON.stringify(p)})`, - ).join(' && '); + // ISR_CACHE (the self-host ISR KV binding) is hidden by exact name ONLY when + // ISR is emitted — it exists only then (the emitIsr gate). When ISR is absent + // the filter stays prefix-only, byte-identical to the pre-ISR base entry, and + // a user's own binding named ISR_CACHE (if any) is preserved. `handleIsr` + // reads the raw env, so hiding ISR_CACHE from userEnv is safe. + const exactNames = isr ? INTERNAL_BINDING_EXACT_NAMES : []; + const prefixCheck = [ + ...INTERNAL_BINDING_PREFIXES.map((p) => `prop.startsWith(${JSON.stringify(p)})`), + ...exactNames.map((n) => `prop === ${JSON.stringify(n)}`), + ].join(' || '); + const prefixCheckNeg = [ + ...INTERNAL_BINDING_PREFIXES.map((p) => `!key.startsWith(${JSON.stringify(p)})`), + ...exactNames.map((n) => `key !== ${JSON.stringify(n)}`), + ].join(' && '); const filterBindingsHelper = ` function __filterInternalBindings(env) { return new Proxy(env, { @@ -2497,29 +2529,63 @@ function __filterInternalBindings(env) { const assetHelper = compileAssetFallbackHelper(assetsDir, serveSpaFallback); + // ISR wiring (self-host only; injected by Task 8 for non-managed builds). When + // `isr` is absent every fragment below is an empty string, so the generated + // fetch handler is byte-identical to the pre-ISR output. + const isrImport = isr + ? `import { handleIsr } from ${JSON.stringify(isr.runtimePath)};\nimport { __VOID_ISR_REVALIDATE, __VOID_ISR_DEPLOYMENT_ID } from 'virtual:void-routes';\n` + : ''; + // Capture deployment id + host on the raw env at the top of fetch(), before + // dispatch — always, so `revalidate()` (which reads the raw env) can see them + // even on requests that never hit the ISR render ladder. The deployment id is + // invariant (`??=`), but the host MUST track the CURRENT request: a warm env + // object can be reused across hostnames, so `??=` would freeze it to the first + // host ever seen and make `revalidate()` from a second custom domain read the + // wrong host. Assign it every fetch. + const isrEnvCapture = isr + ? ` env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;\n env.__VOID_ISR_HOST = new URL(request.url).hostname;\n` + : ''; + // Scheduled/queue handlers have no request, so there is no host to stamp — but + // they still need the deployment id on the raw env so an on-demand + // `revalidate()` called from a cron or queue consumer can find it and purge KV + // (edge-cache deletes are skipped when host is undefined, per runtime/isr.ts). + const isrDeploymentStamp = isr + ? ` env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;\n` + : ''; + // Render site: unchanged single line when ISR is off; otherwise the render + // closure is threaded through the ISR cache ladder (only when ISR_CACHE binds). + const renderBlock = isr + ? ` const __render = async (req) => serveWithAssets(req, env, await app.fetch(req, userEnv, ctx)); + if (env.ISR_CACHE) { + const __isr = await handleIsr(request, env, ctx, { revalidate: __VOID_ISR_REVALIDATE, deploymentId: __VOID_ISR_DEPLOYMENT_ID }, __render); + if (__isr) return __isr; + } + return __render(request);` + : ` return serveWithAssets(request, env, await app.fetch(request, userEnv, ctx));`; + const fetchHandler = remotePath ? `async (request, env, ctx) => { ${envValidateCall} const ws = await __voidHandleWebSocket(request, env); if (ws) return ws; if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall}; - return __withRawEnv(env, () => { +${isrEnvCapture} return __withRawEnv(env, () => { const userEnv = __filterInternalBindings(env); return withRuntimeEnv(env, async () => { const internal = await __handleInternal(request, env, ctx); if (internal) return internal; - return serveWithAssets(request, env, await app.fetch(request, userEnv, ctx)); +${renderBlock} }); }); }` : `async (request, env, ctx) => { ${envValidateCall} const ws = await __voidHandleWebSocket(request, env); if (ws) return ws; - return __withRawEnv(env, () => { +${isrEnvCapture} return __withRawEnv(env, () => { const userEnv = __filterInternalBindings(env); return withRuntimeEnv(env, async () => { const internal = await __handleInternal(request, env, ctx); if (internal) return internal; - return serveWithAssets(request, env, await app.fetch(request, userEnv, ctx)); +${renderBlock} }); }); }`; @@ -2548,21 +2614,21 @@ ${envValidateCall} const ws = await __voidHandleWebSocket(request, env); const scheduledHandler = remotePath ? `async function handleScheduled(controller, env, ctx) { ${envValidateCall} if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall}; - const userEnv = __filterInternalBindings(env); +${isrDeploymentStamp} const userEnv = __filterInternalBindings(env); return withRuntimeEnv(env, () => scheduled(controller, userEnv, ctx)); }` : `async function handleScheduled(controller, env, ctx) { -${envValidateCall} const userEnv = __filterInternalBindings(env); +${envValidateCall}${isrDeploymentStamp} const userEnv = __filterInternalBindings(env); return withRuntimeEnv(env, () => scheduled(controller, userEnv, ctx)); }`; if (hasQueues) { return `${sandboxImport}${liveRuntimeImport}import app, { scheduled, __voidCollectPrerenderPaths, __voidHandleInternalRoute, __voidHandleWebSocket, __withRawEnv, __processQueue${webSocketClasses.length > 0 ? `, ${webSocketClasses.join(', ')}` : ''}${liveRouteImport} } from 'virtual:void-routes'; -${runtimeEnvImport}${envSchemaImport}${remoteImport}${filterBindingsHelper}${assetHelper} +${runtimeEnvImport}${envSchemaImport}${remoteImport}${isrImport}${filterBindingsHelper}${assetHelper} ${internalRouteHelper} ${scheduledHandler} async function queue(batch, env, ctx) { -${envValidateCall}${remotePath ? ` if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall};\n` : ''} const userEnv = __filterInternalBindings(env); +${envValidateCall}${remotePath ? ` if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall};\n` : ''}${isrDeploymentStamp} const userEnv = __filterInternalBindings(env); const messages = [...batch.messages].map(m => ({ id: m.id, timestamp: m.timestamp.toISOString(), body: m.body, attempts: m.attempts, @@ -2588,7 +2654,7 @@ ${sandboxExport} export default { fetch: ${fetchHandler}, scheduled: handleScheduled, queue };`; } return `${sandboxImport}${liveRuntimeImport}import app, { scheduled, __voidCollectPrerenderPaths, __voidHandleInternalRoute, __voidHandleWebSocket, __withRawEnv${webSocketClasses.length > 0 ? `, ${webSocketClasses.join(', ')}` : ''}${liveRouteImport} } from 'virtual:void-routes'; -${runtimeEnvImport}${envSchemaImport}${remoteImport}${filterBindingsHelper}${assetHelper} +${runtimeEnvImport}${envSchemaImport}${remoteImport}${isrImport}${filterBindingsHelper}${assetHelper} ${internalRouteHelper} ${scheduledHandler} ${webSocketClasses.map((name) => `export { ${name} };`).join('\n')} diff --git a/packages/void/src/runtime/ai.ts b/packages/void/src/runtime/ai.ts index 011bbf11d..e8cfa2c75 100644 --- a/packages/void/src/runtime/ai.ts +++ b/packages/void/src/runtime/ai.ts @@ -11,6 +11,24 @@ const PROXY_URL = 'https://proxy.void.cloud/ai'; const STAGING_PROXY_URL = 'https://proxy.staging.void.cloud/ai'; const VOID_FILE_MARKER = '__voidFile'; +// Per-provider auth header/prefix for the self-host direct gateway path. The +// gateway passes provider auth through unchanged, so each entry mirrors the +// provider's documented native auth convention. Providers NOT listed here fall +// back to `Authorization: Bearer ` (correct for openai, groq, mistral, +// grok, deepseek, openrouter, perplexity, cohere, cerebras, huggingface, +// replicate). A caller's `apiKeyHeader`/`apiKeyPrefix` override still wins. +const PROVIDER_AUTH_DEFAULTS: Record = { + 'google-ai-studio': { header: 'x-goog-api-key', prefix: '' }, + anthropic: { header: 'x-api-key', prefix: '' }, + elevenlabs: { header: 'xi-api-key', prefix: '' }, + deepgram: { header: 'authorization', prefix: 'Token ' }, + fal: { header: 'authorization', prefix: 'Key ' }, + cartesia: { header: 'x-api-key', prefix: '' }, + baseten: { header: 'authorization', prefix: 'Api-Key ' }, + ideogram: { header: 'Api-Key', prefix: '' }, + parallel: { header: 'x-api-key', prefix: '' }, +}; + type SerializedFileInput = { [VOID_FILE_MARKER]: true; data: string; @@ -317,6 +335,62 @@ function getProxyUrl(runtimeEnv: RuntimeEnv): string { return runtimeEnv.__VOID_STAGING ? STAGING_PROXY_URL : PROXY_URL; } +function providerAuthDefaults(provider: string): { header: string; prefix: string } { + return PROVIDER_AUTH_DEFAULTS[provider] ?? { header: 'authorization', prefix: 'Bearer ' }; +} + +function gatewayEndpointUrl(baseUrl: string, endpoint: string): string { + const normalizedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + const normalizedEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint; + return `${normalizedBase}/${normalizedEndpoint}`; +} + +function isUnsafeProviderPath(path: string): boolean { + if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(path) || path.includes('\\')) { + return true; + } + + const pathname = path.split(/[?#]/, 1)[0] ?? ''; + for (const segment of pathname.split('/')) { + try { + if (decodeURIComponent(segment) === '..') { + return true; + } + } catch { + return true; + } + } + + return false; +} + +/** + * Build a real outgoing HTTP body for the user's own AI Gateway (P3 self-host). + * + * Unlike `serializeProviderBody`/`providerRequestBody` (which build the proxy + * envelope decoded on the proxy side), this emits an actual `BodyInit` + + * content-type for a direct `fetch` to the gateway. + */ +function toDirectBody(body: unknown): { body: BodyInit | undefined; contentType?: string } { + if (body === undefined || body === null) { + return { body: undefined }; + } + if (typeof body === 'string') { + return { body }; + } + if ( + body instanceof ReadableStream || + body instanceof FormData || + body instanceof URLSearchParams || + body instanceof Blob || + body instanceof ArrayBuffer || + isBinaryView(body) + ) { + return { body: body as BodyInit }; + } + return { body: JSON.stringify(body), contentType: 'application/json' }; +} + function resolveBackend(): AiBackend { const runtimeEnv = getRawRuntimeEnv(); @@ -336,21 +410,16 @@ function resolveBackend(): AiBackend { // Priority 3: Direct AI binding (user's own CF account) const directAi = runtimeEnv.AI as Ai | undefined; if (directAi) { - return { - run: directAi.run.bind(directAi), - async image(model, inputs, options) { - const { runOptions } = splitImageOptions(options); - return responseFromResult( - await directAi.run(model as keyof AiModels, inputs as never, runOptions as AiOptions), - ); - }, - providerFetch: unsupportedProviderFetch, - models: directAi.models.bind(directAi), - toMarkdown: directAi.toMarkdown?.bind(directAi) ?? unsupported('toMarkdown'), - }; + return makeDirectBackend( + directAi, + runtimeEnv, + runtimeEnv.__VOID_AI_GATEWAY as string | undefined, + ); } - throw new Error('ai: Not available. Run `void auth login` and `void project link` first.'); + throw new Error( + 'ai: Not available. Run `void auth login` and `void project link` first, or bind Workers AI (env.AI) for self-hosted deploys.', + ); } function unsupported(method: string): () => never { @@ -359,10 +428,6 @@ function unsupported(method: string): () => never { }; } -function unsupportedProviderFetch(): Promise { - throw new Error('ai: ai.provider().fetch() is not supported in this environment.'); -} - async function providerRequestBody( provider: string, path: string, @@ -502,6 +567,71 @@ function makeHttpBackend(token: string, projectId: string, runtimeEnv: RuntimeEn }; } +function makeDirectBackend( + directAi: Ai, + runtimeEnv: RuntimeEnv, + gatewayId: string | undefined, +): AiBackend { + return { + run: directAi.run.bind(directAi), + async image(model, inputs, options) { + const { runOptions } = splitImageOptions(options); + return responseFromResult( + await directAi.run(model as keyof AiModels, inputs as never, runOptions as AiOptions), + ); + }, + async providerFetch(provider, path, init, options) { + if (!gatewayId) { + return Response.json( + { + error: + 'ai: ai.provider().fetch() needs an AI Gateway when self-hosting. Set "ai": { "gateway": "" } in void.json.', + }, + { status: 501 }, + ); + } + if (isUnsafeProviderPath(path)) { + return Response.json( + { error: `ai: provider path '${path}' must be relative.` }, + { status: 400 }, + ); + } + let providerKey: string; + try { + providerKey = resolveProviderKey(provider, runtimeEnv, options); + } catch (e) { + return Response.json({ error: (e as Error).message }, { status: 400 }); + } + let baseUrl: string; + try { + baseUrl = await directAi.gateway(gatewayId).getUrl(provider); + } catch (e) { + return Response.json( + { error: `ai: gateway '${gatewayId}': ${(e as Error).message}` }, + { status: 502 }, + ); + } + const { body, contentType } = toDirectBody(init?.body); + const headers = new Headers(init?.headers); + const defaults = providerAuthDefaults(provider); + headers.set( + options?.apiKeyHeader ?? defaults.header, + `${options?.apiKeyPrefix ?? defaults.prefix}${providerKey}`, + ); + if (contentType && !headers.has('content-type')) { + headers.set('content-type', contentType); + } + return fetch(gatewayEndpointUrl(baseUrl, path), { + method: init?.method ?? 'POST', + headers, + body, + }); + }, + models: directAi.models.bind(directAi), + toMarkdown: directAi.toMarkdown?.bind(directAi) ?? unsupported('toMarkdown'), + }; +} + // Lazy-init backend on first access let _backend: AiBackend | undefined; function backend(): AiBackend { diff --git a/packages/void/src/runtime/env-public.ts b/packages/void/src/runtime/env-public.ts index fe401bc4b..44145c323 100644 --- a/packages/void/src/runtime/env-public.ts +++ b/packages/void/src/runtime/env-public.ts @@ -31,8 +31,8 @@ import { isUnmaskActive, redactMessage, type SecretOverride } from '../shared/env-mask.ts'; import type { StandardSchemaV1 } from '../standard-schema.ts'; import { getSecretOverride } from './env-helpers.ts'; -import { getRawRuntimeEnv } from './env-raw.ts'; -import { getRuntimeEnv } from './env.ts'; +import { getRawRuntimeEnv, type RuntimeEnv } from './env-raw.ts'; +import { getRuntimeEnv, isInternalBinding } from './env.ts'; type AnyValidator = StandardSchemaV1; type AnySchema = Record; @@ -129,6 +129,17 @@ export async function _validateEnvOnce(envValues: unknown): Promise { cacheList.push(cache); } for (const [key, value] of report.values) { + // Never seed internal framework bindings (__VOID_*, __PROJECT_*, and — + // while ISR is active — ISR_CACHE) into the sync cache, even when a + // user's schema declares one and boot validation resolved its raw + // value. Exposing it via `env.X` would leak an internal binding (e.g. + // the ISR KV namespace). `readKey` also short-circuits internal names, + // so this is belt-and-suspenders that additionally keeps the cache + // clean. `cacheKey` is the raw runtime env — the same object the + // exact-name (ISR_CACHE) gate consults for the ISR-active check. + if (isInternalBinding(key, cacheKey as RuntimeEnv)) { + continue; + } if (!cache.has(key)) { cache.set(key, value); } @@ -231,6 +242,17 @@ export class EnvValidationError extends Error { } function readKey(key: string): unknown { + // Internal framework bindings (__VOID_*, __PROJECT_*, and — while ISR is + // active — ISR_CACHE) are never exposed through the public `env` proxy — + // treat them as absent BEFORE consulting the schema/cache. This holds even + // when a user's defineEnv schema declares one and boot validation seeded its + // raw value; returning `undefined` matches the "not in schema / not found" + // behavior of the filtered env. The exact-name (ISR_CACHE) gate needs the raw + // env, so resolve it up front and reuse it as the cache key below. + const rawEnv = getRawRuntimeEnv(); + if (isInternalBinding(key, rawEnv)) { + return undefined; + } // Filtered env hides internal __VOID_*/__PROJECT_* bindings from user code. const filtered = getRuntimeEnv() as Record; const rawValue = filtered[key]; @@ -243,7 +265,7 @@ function readKey(key: string): unknown { } // Cache identity must be the underlying env object. `getRuntimeEnv()` // returns a fresh Proxy on every call, so keying by it would miss. - const cacheKey = getRawRuntimeEnv() as object; + const cacheKey = rawEnv as object; let cache = validatedCache.get(cacheKey); if (!cache) { cache = new Map(); diff --git a/packages/void/src/runtime/env-raw.ts b/packages/void/src/runtime/env-raw.ts index c28dd290e..92736d405 100644 --- a/packages/void/src/runtime/env-raw.ts +++ b/packages/void/src/runtime/env-raw.ts @@ -12,6 +12,34 @@ export type RuntimeEnv = Record; /** Prefixes for platform-internal bindings that must be hidden from user code. */ export const INTERNAL_BINDING_PREFIXES = ['__VOID_', '__PROJECT_'] as const; +/** + * Exact binding names that must be hidden from user code even though they carry + * no internal prefix. `ISR_CACHE` (the self-host ISR KV namespace) is a plain + * binding name, but it is a framework internal — route handlers read the ISR + * cache through `handleIsr`/`revalidate()` on the raw env, never via `c.env` or + * the public `void/env` API. Single source of truth shared by the generated + * `c.env` filter (`router/compile.ts`) and the runtime `env` filter (`env.ts`). + * + * Unlike the prefix internals, these are hidden ONLY when ISR is active for the + * isolate (see {@link isIsrActive}). In a non-ISR app, `ISR_CACHE` is an + * ordinary user binding and stays visible everywhere — matching the build-time + * `isr` gate on the generated `c.env` filter. + */ +export const INTERNAL_BINDING_EXACT_NAMES = ['ISR_CACHE'] as const; + +/** + * ISR is active for this isolate iff the framework has stamped the compile-time + * deployment id onto the raw env (`__VOID_ISR_DEPLOYMENT_ID`). The generated + * worker entry stamps it at the top of `fetch()` (and in cron/queue handlers) + * only for ISR-emitting builds, so its presence is a reliable runtime signal + * that `ISR_CACHE` should be treated as a framework internal. The id itself is a + * `__VOID_`-prefixed binding that is always hidden and framework-controlled, so + * a user cannot forge it. + */ +export function isIsrActive(rawEnv: RuntimeEnv): boolean { + return rawEnv.__VOID_ISR_DEPLOYMENT_ID != null; +} + export const envContext: AsyncLocalStorage = new AsyncLocalStorage(); let cloudflareEnv: RuntimeEnv | undefined; diff --git a/packages/void/src/runtime/env.ts b/packages/void/src/runtime/env.ts index efe4d7a37..e76b56e56 100644 --- a/packages/void/src/runtime/env.ts +++ b/packages/void/src/runtime/env.ts @@ -9,7 +9,9 @@ import { envContext, asEnv, getRawRuntimeEnv, + INTERNAL_BINDING_EXACT_NAMES, INTERNAL_BINDING_PREFIXES, + isIsrActive, type RuntimeEnv, } from './env-raw.ts'; @@ -23,31 +25,50 @@ export function withRuntimeEnv(env: unknown, fn: () => T): T { return envContext.run(resolved, fn); } -function isInternalBinding(name: string): boolean { - return INTERNAL_BINDING_PREFIXES.some((p) => name.startsWith(p)); +/** + * Shared internal-binding predicate. A binding is framework-internal — and must + * never be exposed to user code — when it carries an internal prefix + * (`__VOID_`, `__PROJECT_`; always hidden) or is an exact-name internal (e.g. + * `ISR_CACHE`; hidden ONLY while ISR is active for the isolate). `rawEnv` is the + * unfiltered runtime env, consulted via {@link isIsrActive} to gate the + * exact-name check so a non-ISR app's own `ISR_CACHE` binding stays visible — + * matching the build-time `isr` gate on the generated `c.env` filter. + * + * Single source of truth for the runtime `env` filter here and the public + * `void/env` proxy (`env-public.ts`). + */ +export function isInternalBinding(name: string, rawEnv: RuntimeEnv): boolean { + if (INTERNAL_BINDING_PREFIXES.some((p) => name.startsWith(p))) { + return true; + } + return ( + isIsrActive(rawEnv) && (INTERNAL_BINDING_EXACT_NAMES as ReadonlyArray).includes(name) + ); } function filterEnvProxy(env: RuntimeEnv): RuntimeEnv { + // The proxy target IS the raw env, so each trap consults `target` for the + // ISR-active gate — no extra `getRawRuntimeEnv()` call needed. return new Proxy(env, { get(target, prop, receiver) { - if (typeof prop === 'string' && isInternalBinding(prop)) { + if (typeof prop === 'string' && isInternalBinding(prop, target)) { return undefined; } return Reflect.get(target, prop, receiver); }, has(target, prop) { - if (typeof prop === 'string' && isInternalBinding(prop)) { + if (typeof prop === 'string' && isInternalBinding(prop, target)) { return false; } return Reflect.has(target, prop); }, ownKeys(target) { return Reflect.ownKeys(target).filter( - (key) => typeof key !== 'string' || !isInternalBinding(key), + (key) => typeof key !== 'string' || !isInternalBinding(key, target), ); }, getOwnPropertyDescriptor(target, prop) { - if (typeof prop === 'string' && isInternalBinding(prop)) { + if (typeof prop === 'string' && isInternalBinding(prop, target)) { return undefined; } return Reflect.getOwnPropertyDescriptor(target, prop); @@ -61,10 +82,11 @@ export function getRuntimeEnv(): RuntimeEnv { } export function getRuntimeBinding(name: string): T | undefined { - if (isInternalBinding(name)) { + const raw = getRawRuntimeEnv(); + if (isInternalBinding(name, raw)) { return undefined; } - return getRawRuntimeEnv()[name] as T | undefined; + return raw[name] as T | undefined; } export function requireRuntimeBinding(name: string): T { diff --git a/packages/void/src/runtime/isr-cache.ts b/packages/void/src/runtime/isr-cache.ts new file mode 100644 index 000000000..6478ac3c7 --- /dev/null +++ b/packages/void/src/runtime/isr-cache.ts @@ -0,0 +1,940 @@ +/** + * Single-tenant ISR (incremental static regeneration) cache ladder for the + * self-hosted Void worker (`vite build && wrangler deploy`). + * + * On managed Void, ISR lives in the dispatch worker that sits IN FRONT of the + * user worker. Self-host has no dispatch worker, so this module ports that + * caching ladder INTO the user worker, collapsed from multi-tenant to a single + * deployment: + * + * - No `projectId`, no cross-tenant metering. + * - No `#rw=` rewrite variant, no query-string variant fanout, no variant cap. + * - The deployment id is baked at build time as a compile-time literal and + * surfaced on `env.__VOID_ISR_DEPLOYMENT_ID` (Task 7). The KV helpers read + * it from `env` (matching the `revalidate()` purge path in `runtime/isr.ts`), + * while `handleIsr` uses the `deploymentId` option for the edge-cache keys — + * the two are the same value. + * + * Fail-open contract: every `env.ISR_CACHE` (KV) and `caches.default` operation + * is wrapped so a misconfigured or erroring cache degrades to a live render, + * never a 500. Read/hit-path failures bail (caller renders live); write-path + * failures are swallowed. + * + * Worker-only: no `lib.dom` types — globals come from `@cloudflare/workers-types`. + * + * Ported from `void-platform/packages/dispatch` (`isr.ts`, `index.ts`, + * `pattern.ts`) and `void-platform/packages/isr-shared`. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Minimal env surface this module needs. Both fields are optional so a worker + * with no ISR binding (or a caller that only knows the deployment id) still + * typechecks and fails open at runtime. + */ +export interface IsrCacheEnv { + ISR_CACHE?: KVNamespace; + __VOID_ISR_DEPLOYMENT_ID?: string; +} + +export type IsrCacheEntry = Readonly<{ + body: ReadableStream; + status: number; + headers: Record; + createdAt: number; + ttl: number; +}>; + +export type IsrCacheMetadata = Readonly<{ + /** HTTP status code */ + s: number; + /** createdAt (ms) */ + c: number; + /** TTL (seconds) */ + t: number; +}>; + +/** Renders a live response for a request (worker dispatch + asset serving). */ +export type RenderFn = (request: Request) => Promise; + +export type HandleIsrOptions = Readonly<{ + revalidate: number | Record | undefined; + deploymentId: string; +}>; + +// --------------------------------------------------------------------------- +// Path normalization (ported from isr-shared/src/index.ts) +// --------------------------------------------------------------------------- + +/** + * Build the normalized path segment for an ISR-cached page. + * Root path `/` maps to `__index__` to avoid trailing-slash ambiguity. + */ +export function normalizeIsrPath(pathname: string): string { + let normalized = pathname; + if (normalized === '/') { + normalized = '__index__'; + } else { + // Strip leading slash + normalized = normalized.startsWith('/') ? normalized.slice(1) : normalized; + // Strip trailing slash + if (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + } + return normalized; +} + +/** + * Inverse of `normalizeIsrPath`: turn a normalized segment back into a + * pathname starting with `/`. + */ +export function denormalizeIsrPath(normalized: string): string { + return normalized === '__index__' ? '/' : `/${normalized}`; +} + +/** + * Canonicalize an inbound pathname to the form used inside edge cache URLs. + * Equivalent to `denormalizeIsrPath(normalizeIsrPath(pathname))` — strips any + * trailing slash except for root. + */ +export function canonicalIsrPathname(pathname: string): string { + return denormalizeIsrPath(normalizeIsrPath(pathname)); +} + +// --------------------------------------------------------------------------- +// Collapsed single-tenant cache keys +// --------------------------------------------------------------------------- + +/** + * KV cache key for an ISR-cached page. Deployment- AND host-scoped: cached HTML + * cannot outlive the hashed assets of the deployment that produced it (a fresh + * `wrangler deploy` mints a new deployment id → cold cache), and one host's + * cached content never bleeds into another host served by the same worker + * (custom domain + workers.dev, apex + www, etc.). + */ +export function isrKey(deploymentId: string, host: string, pathname: string): string { + return `void-isr/${deploymentId}/${host}/${normalizeIsrPath(pathname)}`; +} + +/** + * Host-registry key. Every host that has written cached content registers itself + * under `void-isr-hosts//` (value `''`) so a purge with no request host + * (cron / queue `revalidate({ paths })`) can enumerate the hosts to purge. + * Scoped by deployment id so registrations from an old deploy do not leak. + */ +export function hostRegistryKey(deploymentId: string, host: string): string { + return `void-isr-hosts/${deploymentId}/${host}`; +} + +/** + * Edge-cache (`caches.default`) key for the HTML variant. The public URL stays + * clean; this internal key carries the deployment id so a new deploy never + * reads HTML produced by an older deploy. + */ +export function htmlEdgeKey(host: string, pathname: string, id: string): string { + return `https://${host}${canonicalIsrPathname(pathname)}?__void_isr=${id}`; +} + +/** + * Edge-cache key for the JSON (pages-protocol) variant. Separated from HTML by + * the `&__void=json` marker. + */ +export function jsonEdgeKey(host: string, pathname: string, id: string): string { + return `${htmlEdgeKey(host, pathname, id)}&__void=json`; +} + +// --------------------------------------------------------------------------- +// Literal path-pattern matching + LRU (ported from dispatch/src/pattern.ts) +// --------------------------------------------------------------------------- + +const MAX_CACHE = 256; +const literalRegexCache = new Map(); + +/** + * Match a pattern against a pathname using LITERAL semantics — no named + * captures. Two wildcards are special: `*` matches any characters INCLUDING `/` + * (a splat), and a WHOLE-segment `:name` (preceded by `/` or start, followed by + * `/` or end) matches exactly ONE path segment (`[^/]+`). A mid-segment literal + * `:` (e.g. `/api/v1:health`) is left alone. This is a strict SUPERSET of the + * old `*`-only matcher (old `*`-only manifests match identically). Malformed + * patterns yield `false` rather than throwing. + */ +export function matchLiteralPattern(pattern: string, pathname: string): boolean { + let regex = literalRegexCache.get(pattern); + if (regex) { + // LRU touch — re-inserting moves the entry to the most-recently-used end. + literalRegexCache.delete(pattern); + literalRegexCache.set(pattern, regex); + } else { + try { + const escaped = pattern + .replace(/(^|\/):[A-Za-z0-9_]+(?=\/|$)/g, '$1') // whole-segment :name -> sentinel + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex specials + .replaceAll('*', '.*') // catch-all crosses '/' + .replaceAll('', '[^/]+'); // sentinel -> single segment + regex = new RegExp(`^${escaped}$`); + } catch { + return false; + } + if (literalRegexCache.size >= MAX_CACHE) { + const first = literalRegexCache.keys().next(); + if (!first.done) { + literalRegexCache.delete(first.value); + } + } + literalRegexCache.set(pattern, regex); + } + try { + return regex.test(pathname); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Pack / unpack (ported verbatim from dispatch/src/isr.ts) +// --------------------------------------------------------------------------- + +/** + * Pack response headers + body into a single ArrayBuffer. + * Format: [4 bytes uint32 LE header length][UTF-8 header JSON][raw body] + */ +export function packIsrValue(headers: Record, body: ArrayBuffer): ArrayBuffer { + const headerJson = new TextEncoder().encode(JSON.stringify(headers)); + const buffer = new ArrayBuffer(4 + headerJson.byteLength + body.byteLength); + const view = new DataView(buffer); + view.setUint32(0, headerJson.byteLength, true); // little-endian + new Uint8Array(buffer, 4, headerJson.byteLength).set(headerJson); + new Uint8Array(buffer, 4 + headerJson.byteLength).set(new Uint8Array(body)); + return buffer; +} + +/** + * Unpack headers + body from a packed ArrayBuffer. + * Returns null if the buffer is too small or malformed. + */ +export function unpackIsrValue( + buffer: ArrayBuffer, +): { headers: Record; body: ArrayBuffer } | null { + if (buffer.byteLength < 4) { + return null; + } + const view = new DataView(buffer); + const headerLen = view.getUint32(0, true); + if (buffer.byteLength < 4 + headerLen) { + return null; + } + try { + const headerBytes = new Uint8Array(buffer, 4, headerLen); + const headers: Record = JSON.parse(new TextDecoder().decode(headerBytes)); + const body = buffer.slice(4 + headerLen); + return { headers, body }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// KV cache (single-tenant; fail-open on every ISR_CACHE op) +// --------------------------------------------------------------------------- + +/** + * Read an ISR cache entry from KV. Returns null on cache miss, malformed + * metadata, or ANY KV error (fail-open → caller renders live). The KV key is + * deployment-scoped via `env.__VOID_ISR_DEPLOYMENT_ID` (see module header) and + * host-scoped via `host` so one host never reads another host's cached bytes. + */ +export async function readIsrCache( + env: IsrCacheEnv, + host: string, + pathname: string, +): Promise { + const kv = env.ISR_CACHE; + if (!kv) { + return null; + } + const key = isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', host, pathname); + let value: ArrayBuffer | null; + let metadata: IsrCacheMetadata | null; + try { + const result = await kv.getWithMetadata(key, 'arrayBuffer'); + value = result.value; + metadata = result.metadata; + } catch { + // Fail open — a broken ISR_CACHE must degrade to a live render, never 500. + return null; + } + if (!value || !metadata) { + return null; + } + + const unpacked = unpackIsrValue(value); + if (!unpacked) { + return null; + } + + // Validate at this single choke point so `isrCacheResponse` — the only consumer + // of the returned entry — can never throw when it builds a Response from it. A + // corrupt/tampered/schema-drifted KV entry degrades to a live render, never 500. + + // Status must be an integer in the valid HTTP Response range; otherwise + // `new Response(body, { status })` throws. + if (!Number.isInteger(metadata.s) || metadata.s < 200 || metadata.s > 599) { + return null; + } + // createdAt (ms) and TTL (s) must be finite positive numbers — rejects + // NaN/Infinity/<=0 (tightens the previous truthy-only check). + if ( + !Number.isFinite(metadata.c) || + metadata.c <= 0 || + !Number.isFinite(metadata.t) || + metadata.t <= 0 + ) { + return null; + } + // Headers must be constructible; an invalid header name/value would otherwise + // throw later in `isrCacheResponse`'s `new Headers(entry.headers)`. + try { + new Headers(unpacked.headers); + } catch { + return null; + } + + return { + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(unpacked.body)); + controller.close(); + }, + }), + status: metadata.s, + headers: unpacked.headers, + createdAt: metadata.c, + ttl: metadata.t, + }; +} + +/** + * Write an ISR cache entry to KV. The `response.clone()` is taken SYNCHRONOUSLY + * before any `await` so a caller that consumes `response.body` for the client + * response (after kicking us off via `ctx.waitUntil`) cannot race the body + * away. Any KV error is swallowed (fail-open on the write path). + */ +export async function writeIsrCache( + env: IsrCacheEnv, + host: string, + pathname: string, + response: Response, + ttl: number, +): Promise { + const kv = env.ISR_CACHE; + if (!kv) { + return; + } + // Clone synchronously (no await before this) — the clone has its own + // independent body; the original is free to be consumed elsewhere. + const responseClone = response.clone(); + const headers = serializeHeaders(responseClone); + const status = responseClone.status; + + const id = env.__VOID_ISR_DEPLOYMENT_ID ?? ''; + try { + const body = await responseClone.arrayBuffer(); + const packed = packIsrValue(headers, body); + await kv.put(isrKey(id, host, pathname), packed, { + metadata: { + s: status, + c: Date.now(), + t: ttl, + } satisfies IsrCacheMetadata, + }); + } catch { + // Fail open — swallow write errors so a broken ISR_CACHE never 500s. + } + // Register this host so a host-less purge (cron / queue) can find it. Guarded + // by an in-isolate Set so it costs one KV write per (isolate, id, host), not + // one per page write. Fail-open — a registry write error never 500s a render. + await registerIsrHost(kv, id, host); +} + +/** + * In-isolate guard: `${id}/${host}` values whose host-registry key this isolate + * has already written (or attempted). Bounds host registration to ~one KV write + * per (isolate, deployment, host) instead of one per page write. + */ +const registeredHosts = new Set(); + +async function registerIsrHost(kv: KVNamespace, deploymentId: string, host: string): Promise { + const guardKey = `${deploymentId}/${host}`; + if (registeredHosts.has(guardKey)) { + return; + } + // Mark BEFORE the await so concurrent page writes in this isolate don't all + // fire the registry put. On failure, CLEAR the guard so a later write in this + // same (possibly long-lived) isolate retries — otherwise one transient error + // would permanently drop this host from `listIsrHosts()`, and a cron/queue + // `revalidate()` (which has no request host) could never purge it. + registeredHosts.add(guardKey); + try { + await kv.put(hostRegistryKey(deploymentId, host), ''); + } catch { + // Fail open — a registry write failure must never 500 a page render — but + // allow a retry on a subsequent write instead of skipping this host forever. + registeredHosts.delete(guardKey); + } +} + +/** + * List every host that has registered cached content for a deployment. Used by + * the on-demand `revalidate({ paths })` purge when there is no request host + * (cron / queue). Fail-open: a registry read error yields the hosts collected so + * far (or none) rather than throwing — degraded purge coverage, never a 500. + */ +export async function listIsrHosts(kv: KVNamespace, deploymentId: string): Promise> { + const prefix = `void-isr-hosts/${deploymentId}/`; + const hosts: Array = []; + try { + let cursor: string | undefined; + do { + const list = await kv.list({ prefix, cursor }); + for (const key of list.keys) { + hosts.push(key.name.slice(prefix.length)); + } + cursor = list.list_complete ? undefined : list.cursor; + } while (cursor); + } catch { + // Fail open — a broken registry degrades purge coverage, never throws. + } + return hosts; +} + +/** + * Delete an ISR cache entry from KV. Used when a worker permanently opts out + * via `x-revalidate: 0`. Any KV error is swallowed (fail-open). + */ +export async function deleteIsrCache( + env: IsrCacheEnv, + host: string, + pathname: string, +): Promise { + const kv = env.ISR_CACHE; + if (!kv) { + return; + } + try { + await kv.delete(isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', host, pathname)); + } catch { + // Fail open. + } +} + +// --------------------------------------------------------------------------- +// Cache-entry helpers (ported verbatim from dispatch/src/isr.ts) +// --------------------------------------------------------------------------- + +/** Check if a cached entry is stale. */ +export function isStale(entry: IsrCacheEntry): boolean { + return Date.now() - entry.createdAt > entry.ttl * 1000; +} + +/** Build a Response from an ISR cache entry. */ +export function isrCacheResponse(entry: IsrCacheEntry, stale: boolean): Response { + const headers = new Headers(entry.headers); + headers.set('x-isr-cache', stale ? 'STALE' : 'HIT'); + + // Edge-cacheable: s-maxage controls CDN TTL based on remaining ISR freshness. + // Browser always revalidates (max-age=0) so users see updates after edge expiry. + if (stale) { + // Stale: short edge TTL while background revalidation runs. + // Cap at entry.ttl so short-TTL configs don't serve stale longer than their ISR window. + const staleTtl = Math.min(10, entry.ttl); + headers.set('Cache-Control', `public, s-maxage=${staleTtl}, max-age=0, must-revalidate`); + } else { + const remainingSeconds = Math.max( + 0, + Math.floor((entry.createdAt + entry.ttl * 1000 - Date.now()) / 1000), + ); + headers.set( + 'Cache-Control', + `public, s-maxage=${remainingSeconds}, max-age=0, must-revalidate`, + ); + } + + return new Response(entry.body, { + status: entry.status, + headers, + }); +} + +/** + * Read the `x-revalidate` header from a worker response. + * Returns the override TTL, or the default. A value of 0 means "do not cache". + */ +export function resolveRevalidateTtl(response: Response, defaultTtl: number): number { + const header = response.headers.get('x-revalidate'); + if (header === null) { + return defaultTtl; + } + + const value = parseInt(header, 10); + if (isNaN(value) || value < 0) { + return defaultTtl; + } + return value; +} + +function acceptsEventStream(request: Request): boolean { + return ( + request.headers + .get('Accept') + ?.toLowerCase() + .split(',') + .some((part) => part.split(';', 1)[0]?.trim() === 'text/event-stream') ?? false + ); +} + +/** + * ISR should be bypassed for requests with credentials and EventSource + * requests. Pages protocol navigation (X-VoidPages) uses a separate JSON edge + * cache. + */ +export function shouldBypassIsr(request: Request): boolean { + return ( + request.headers.has('Cookie') || + request.headers.has('Authorization') || + acceptsEventStream(request) + ); +} + +/** + * Resolve the ISR TTL for a given pathname. + * Accepts the `revalidate` config (number, object, or undefined). For the + * object form, the first matching pattern wins. + */ +export function resolvePathTtl( + revalidate: number | Record | undefined, + pathname: string, +): number { + if (revalidate == null) { + return 0; + } + if (typeof revalidate === 'number') { + return revalidate; + } + for (const [pattern, ttl] of Object.entries(revalidate)) { + if (matchLiteralPattern(pattern, pathname)) { + return ttl; + } + } + return 0; +} + +/** Extract response headers as a plain object, skipping ISR-managed headers. */ +const SKIP_HEADERS = new Set(['cache-control', 'x-isr-cache', 'x-void-page-data', 'set-cookie']); + +export function serializeHeaders(response: Response): Record { + const result: Record = {}; + + response.headers.forEach((value, key) => { + if (!SKIP_HEADERS.has(key.toLowerCase())) { + result[key] = value; + } + }); + + return result; +} + +// --------------------------------------------------------------------------- +// Page-data extraction for the JSON edge variant (ported from dispatch/src/isr.ts) +// --------------------------------------------------------------------------- + +const PAGE_DATA_SCRIPT_RE = + /]*\bid=(["'])__VOID_PAGE_DATA__\1[^>]*>([\s\S]*?)<\/script>/i; + +export function normalizePageDataForJsonCache(raw: string): string | null { + try { + const parsed = JSON.parse(raw) as { deferred?: unknown; deferredKeys?: unknown }; + if (parsed.deferred !== undefined || parsed.deferredKeys !== undefined) { + return null; + } + return raw; + } catch { + return null; + } +} + +export function extractPageDataFromHtml(html: string): string | null { + const match = html.match(PAGE_DATA_SCRIPT_RE); + if (!match) { + return null; + } + return normalizePageDataForJsonCache(match[2]!); +} + +// --------------------------------------------------------------------------- +// Response builders (ported from dispatch/src/index.ts:61–101) +// --------------------------------------------------------------------------- + +const CLIENT_REVALIDATE_CACHE_CONTROL = 'public, max-age=0, must-revalidate'; + +export function clientRevalidateResponse(response: Response): Response { + const res = new Response(response.body, response); + res.headers.set('Cache-Control', CLIENT_REVALIDATE_CACHE_CONTROL); + return res; +} + +export function isEventStreamResponse(response: Response): boolean { + return ( + response.headers.get('content-type')?.toLowerCase().split(';', 1)[0]?.trim() === + 'text/event-stream' + ); +} + +function jsonCacheResponse(pageData: string, ttl: number, state: 'HIT' | 'MISS'): Response { + return new Response(pageData, { + headers: { + 'content-type': 'application/json', + // Mirror the live JSON responder `pageJsonResponse` (pages/protocol.ts): + // it stamps `X-VoidPages: true` + `Vary: X-VoidPages`. The Pages client + // gates JSON parsing on `X-VoidPages` (navigation-engine.ts) — a cached + // JSON variant served on a later navigation without it is treated as + // non-JSON and hard-reloads the whole page. Keep name/value/casing exact. + 'X-VoidPages': 'true', + Vary: 'X-VoidPages', + 'Cache-Control': `public, s-maxage=${ttl}, max-age=0, must-revalidate`, + 'x-isr-cache': state, + }, + }); +} + +/** + * Populate the JSON edge cache from a response's embedded page data (the + * `x-void-page-data` header or the `__VOID_PAGE_DATA__` inline script). No-ops + * when there is no extractable page data. Any cache error is swallowed + * (fail-open) so this is always safe to fire inside `ctx.waitUntil`. + */ +export async function populateJsonEdgeCache( + cache: Cache | undefined, + cacheKey: string, + response: Response, + ttl: number, + state: 'HIT' | 'MISS', +): Promise { + if (!cache) { + return; + } + try { + const fromHeader = response.headers.get('x-void-page-data'); + const pageData = fromHeader + ? normalizePageDataForJsonCache(fromHeader) + : extractPageDataFromHtml(await response.clone().text()); + if (!pageData) { + return; + } + await cache.put(cacheKey, jsonCacheResponse(pageData, ttl, state)); + } catch { + // Fail open — a broken edge cache must never 500. + } +} + +// --------------------------------------------------------------------------- +// Fail-open edge-cache wrappers +// --------------------------------------------------------------------------- + +/** + * The per-colo edge cache (`caches.default`), or `undefined` when the Cache API + * is absent/degraded. Accessed through a cast because this package has both + * `@types/node` and `@cloudflare/workers-types` in scope, and Node's ambient + * `CacheStorage` (which lacks `.default`) otherwise shadows the Workers one on + * the `caches` global. + * + * Non-throwing by contract: dereferencing `caches.default` can throw if the + * Cache API is missing (e.g. a non-worker runtime) or degraded. We swallow that + * here and return `undefined` so `handleIsr` never rejects before its fail-open + * guards run — the serving invariant is a degraded cache must render live, never + * 500. Every call site guards `undefined` (via `if (!cache)` or `cache?.`). + */ +function edgeCache(): Cache | undefined { + try { + return (caches as unknown as { default: Cache }).default; + } catch { + return undefined; + } +} + +async function safeCachePut( + cache: Cache | undefined, + key: string, + response: Response, +): Promise { + if (!cache) { + return; + } + try { + await cache.put(key, response); + } catch { + // Fail open. + } +} + +async function safeCacheDelete(cache: Cache | undefined, key: string): Promise { + if (!cache) { + return; + } + try { + await cache.delete(key); + } catch { + // Fail open. + } +} + +// --------------------------------------------------------------------------- +// Background revalidation (ported from dispatch/src/index.ts:1206–1295, +// stripped of getWorker / applyWorkerFallbackRules / maybeServeWfpAssetFallback +// / rebuildRequest — single-tenant re-renders through the `render` closure) +// --------------------------------------------------------------------------- + +/** + * Build a new Request with the `X-VoidPages` header removed. Headers on an + * existing Request are immutable, so we must clone into a fresh Request. ISR + * only runs for GET, so there is no body to preserve. + */ +export function cloneWithoutVoidPages(request: Request): Request { + const headers = new Headers(request.headers); + headers.delete('X-VoidPages'); + // Construct from the Request OBJECT (not a string URL): workerd copies the + // `cf` blob from the input Request, so `request.cf` is preserved on the clone. + // Do not "simplify" this into `new Request(request.url, { headers })` — the + // string-URL form drops `cf`. + return new Request(request, { headers }); +} + +/** + * Revalidate an ISR page in the background. Re-renders as HTML (X-VoidPages + * stripped), writes fresh HTML to KV + HTML edge cache, and populates the JSON + * edge cache from embedded page data. Errors are caught and logged — serve + * stale, don't retry. `ttl === 0` means the worker permanently opted out: + * delete the KV entry and both edge variants. + */ +async function revalidateInBackground( + env: IsrCacheEnv, + request: Request, + host: string, + pathname: string, + defaultTtl: number, + keys: { htmlKey: string; jsonKey: string }, + render: RenderFn, +): Promise { + try { + const cleanRequest = cloneWithoutVoidPages(request); + const response = await render(cleanRequest); + if (isEventStreamResponse(response)) { + return; + } + const ttl = resolveRevalidateTtl(response, defaultTtl); + const cache = edgeCache(); + if (ttl > 0 && response.status === 200) { + await writeIsrCache(env, host, pathname, response, ttl); + // Update HTML edge cache with the fresh response. + const headers = new Headers(response.headers); + headers.set('x-isr-cache', 'HIT'); + headers.set('Cache-Control', `public, s-maxage=${ttl}, max-age=0, must-revalidate`); + headers.delete('set-cookie'); + headers.delete('x-void-page-data'); + await safeCachePut( + cache, + keys.htmlKey, + new Response(response.clone().body, { + status: response.status, + headers, + }), + ); + await populateJsonEdgeCache(cache, keys.jsonKey, response, ttl, 'HIT'); + } else if (ttl === 0) { + // Worker permanently opted out — remove KV + both edge variants. + await deleteIsrCache(env, host, pathname); + await safeCacheDelete(cache, keys.htmlKey); + await safeCacheDelete(cache, keys.jsonKey); + } + } catch (err) { + console.error('ISR revalidation failed:', err); + } +} + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +/** + * Run the ISR caching ladder for a request. + * + * Returns a cached/rendered `Response` when ISR applies, or `null` when the + * caller should render live itself (request not ISR-eligible, or a read/hit + * cache op failed open). + * + * Ladder: + * - Bail (`null`) if not GET, a Range request, `shouldBypassIsr`, or the path + * has no ISR TTL. Range requests bail BEFORE any cache read/write so they + * render live (correct 206) and never touch the shared full-response entry. + * - JSON (X-VoidPages) path: edge match → hit serves `clientRevalidateResponse`; + * miss renders live JSON and returns it — the JSON path never writes the edge + * cache. The JSON edge variant is warmed only as a side-effect of the HTML + * render path (cold colo re-renders JSON). + * - HTML path: L1 edge match → L2 KV. Hit serves `isrCacheResponse` + * (stale hits also queue a background revalidation). Miss renders live and, + * when cacheable, writes KV + JSON edge + HTML edge in the background. + */ +export async function handleIsr( + request: Request, + env: IsrCacheEnv, + ctx: ExecutionContext, + options: HandleIsrOptions, + render: RenderFn, +): Promise { + const { revalidate, deploymentId } = options; + + if (request.method !== 'GET') { + return null; + } + // Range requests must render LIVE so the origin produces a correct partial + // response (`206 Partial Content`) for the requested byte range. They must + // never read or write the shared ISR entry: reading would serve a full-body + // entry for a partial request, and writing would poison the shared (host,path) + // key with a truncated 206 that a later full request would then be served. + // Return null here (→ caller renders live) BEFORE any cache read or write. + if (request.headers.has('range')) { + return null; + } + if (shouldBypassIsr(request)) { + return null; + } + + const url = new URL(request.url); + const pathname = url.pathname; + const isrTtl = resolvePathTtl(revalidate, pathname); + if (isrTtl <= 0) { + return null; + } + + // Keep the KV helpers (which derive the deployment id from env — matching the + // `revalidate()` purge path) aligned with the edge keys built here. Task 7 + // already sets this at the top of the fetch handler; the `??=` makes the + // module correct in isolation when a caller only passes the option. + env.__VOID_ISR_DEPLOYMENT_ID ??= deploymentId; + + const host = url.hostname; + const cache = edgeCache(); + + // Pages protocol: JSON edge cache only (cold colo re-renders JSON). + if (request.headers.has('X-VoidPages')) { + const jsonKey = jsonEdgeKey(host, pathname, deploymentId); + let jsonCached: Response | undefined; + try { + // `cache?.` — an absent/degraded Cache API (edgeCache() → undefined) is a + // miss, not a throw, so we fall through to a live JSON render. + jsonCached = await cache?.match(jsonKey); + } catch { + return null; // fail open → caller renders live + } + if (jsonCached) { + return clientRevalidateResponse(jsonCached); + } + // Miss — render live JSON and return it. The JSON path never writes the edge + // cache: a pages-protocol JSON response carries no embedded page data, so the + // JSON edge variant is warmed only as a side-effect of the HTML render path + // (cold colo re-renders JSON). Matches the platform dispatch behavior. + return render(request); + } + + // HTML path: L1 edge → L2 KV → live render. + const htmlKey = htmlEdgeKey(host, pathname, deploymentId); + const jsonKey = jsonEdgeKey(host, pathname, deploymentId); + + // L1: edge cache (per-colo, sub-ms). + let edgeCached: Response | undefined; + try { + // `cache?.` — an absent/degraded Cache API (edgeCache() → undefined) is an + // L1 miss, not a throw, so we fall through to the L2 KV read below (KV still + // serves cached content even with no per-colo edge cache). + edgeCached = await cache?.match(htmlKey); + } catch { + return null; // fail open → caller renders live + } + if (edgeCached) { + return clientRevalidateResponse(edgeCached); + } + + // L2: KV (global, authoritative). `readIsrCache` fails open to null. + const cached = await readIsrCache(env, host, pathname); + if (cached) { + // Belt-and-suspenders: `readIsrCache` already validates the entry so the + // Response constructors below can't throw, but the never-500 fail-open + // contract must hold even if a future change slips a throwable past that + // choke point. On any throw, fall open (null → caller renders live). + try { + const stale = isStale(cached); + if (stale) { + ctx.waitUntil( + revalidateInBackground( + env, + request, + host, + pathname, + isrTtl, + { htmlKey, jsonKey }, + render, + ), + ); + } + const cacheResponse = isrCacheResponse(cached, stale); + // Warm the edge cache from KV; client consumes the original body. + ctx.waitUntil(safeCachePut(cache, htmlKey, cacheResponse.clone())); + return clientRevalidateResponse(cacheResponse); + } catch { + return null; // fail open → caller renders live + } + } + + // Miss: render live, cache in the background (only when cacheable). + const response = await render(request); + if (isEventStreamResponse(response)) { + return response; + } + const ttl = resolveRevalidateTtl(response, isrTtl); + if (ttl > 0 && response.status === 200) { + // Every clone below is taken synchronously (before `response.body` is read + // for the client) so no consumer races the body away. + ctx.waitUntil(writeIsrCache(env, host, pathname, response, ttl)); + ctx.waitUntil(populateJsonEdgeCache(cache, jsonKey, response, ttl, 'MISS')); + + const cacheHeaders = new Headers(response.headers); + cacheHeaders.set('Cache-Control', `public, s-maxage=${ttl}, max-age=0, must-revalidate`); + cacheHeaders.set('x-isr-cache', 'MISS'); + cacheHeaders.delete('x-void-page-data'); + cacheHeaders.delete('set-cookie'); + const cacheResponse = new Response(response.clone().body, { + status: response.status, + headers: cacheHeaders, + }); + ctx.waitUntil(safeCachePut(cache, htmlKey, cacheResponse)); + + const clientHeaders = new Headers(cacheHeaders); + clientHeaders.set('Cache-Control', CLIENT_REVALIDATE_CACHE_CONTROL); + // The shared cache/KV/edge copy above is intentionally stripped of Set-Cookie, + // but the CLIENT must still receive the first-render cookies (session/CSRF/ + // prefs). Re-append each Set-Cookie from the ORIGINAL render response — folding + // via `getSetCookie()` preserves multiple distinct cookies. + for (const cookie of response.headers.getSetCookie()) { + clientHeaders.append('set-cookie', cookie); + } + return new Response(response.body, { + status: response.status, + headers: clientHeaders, + }); + } + return response; +} diff --git a/packages/void/src/runtime/isr.ts b/packages/void/src/runtime/isr.ts index a3071ce01..6cdd61bd9 100644 --- a/packages/void/src/runtime/isr.ts +++ b/packages/void/src/runtime/isr.ts @@ -1,4 +1,5 @@ import { getRawRuntimeEnv } from './env-raw.ts'; +import { denormalizeIsrPath, htmlEdgeKey, isrKey, jsonEdgeKey, listIsrHosts } from './isr-cache.ts'; export type RevalidateOptions = { paths?: Array; @@ -8,9 +9,10 @@ export type RevalidateOptions = { /** * Revalidate ISR-cached pages on demand. * - * In production, calls the platform proxy via service binding which forwards - * to the API for R2 deletion + edge cache purge. In local dev, this is a - * no-op (no ISR cache to purge). + * On managed Void, calls the platform proxy via service binding which forwards + * to the API for R2 deletion + edge cache purge. On self-host, purges this + * worker's own ISR_CACHE (KV) entries plus the caches.default edge variants. + * When neither is configured (e.g. local dev with no ISR), this is a no-op. * * @example * ```ts @@ -27,7 +29,72 @@ export async function revalidate(options: RevalidateOptions): Promise { const rawEnv = getRawRuntimeEnv(); const proxy = rawEnv.__VOID_PROXY as Fetcher | undefined; if (!proxy) { - // Local dev — no ISR cache to purge, no-op + // Self-host — no platform proxy. Purge this worker's own ISR cache: KV + // entries (global, authoritative) plus the caches.default edge variants. + // When ISR_CACHE / deployment id are absent (e.g. local dev with no ISR + // binding), this stays a silent no-op — matching the previous behavior. + const kv = rawEnv.ISR_CACHE as KVNamespace | undefined; + const id = rawEnv.__VOID_ISR_DEPLOYMENT_ID as string | undefined; + if (!kv || !id) { + return; + } + const requestHost = rawEnv.__VOID_ISR_HOST as string | undefined; + // Cache keys are host-scoped (`void-isr///`), so a purge must + // target a concrete (host, path). Delete KV first and unconditionally — the + // authoritative purge must never depend on the Cache API — then best-effort + // purge the per-host edge variants, guarded so a degraded `caches.default` + // (scheduled/queue handlers, or no Cache API) cannot fail the KV delete. + const purge = async (host: string, path: string): Promise => { + await kv.delete(isrKey(id, host, path)); + try { + // Cast because this package has both @types/node and @cloudflare/workers-types + // in scope, and Node's ambient CacheStorage (which lacks `.default`) + // otherwise shadows the Workers one — same pattern as isr-cache.ts. + const cache = (caches as unknown as { default: Cache }).default; + await cache.delete(htmlEdgeKey(host, path, id)); + await cache.delete(jsonEdgeKey(host, path, id)); + } catch { + // Fail open — a degraded Cache API must not fail the authoritative KV purge. + } + }; + if (options.all) { + // Enumerate the actual host-scoped cache keys (registry-independent, so it + // is complete even for a host that was never registered). Split the host + // off the key tail BEFORE denormalizing: `void-isr///`. + const prefix = `void-isr/${id}/`; + let cursor: string | undefined; + do { + const list = await kv.list({ prefix, cursor }); + for (const key of list.keys) { + const tail = key.name.slice(prefix.length); + const slash = tail.indexOf('/'); + if (slash < 0) { + continue; + } + const host = tail.slice(0, slash); + const path = denormalizeIsrPath(tail.slice(slash + 1)); + await purge(host, path); + } + cursor = list.list_complete ? undefined : list.cursor; + } while (cursor); + } else { + const paths = options.paths ?? []; + if (paths.length === 0) { + return; + } + // No request host is guaranteed here (cron / queue handlers stamp the id + // WITHOUT a host — the F1 fix), so enumerate every registered host, union + // the request host when present. An empty host set is a silent no-op. + const hosts = new Set(await listIsrHosts(kv, id)); + if (requestHost) { + hosts.add(requestHost); + } + for (const host of hosts) { + for (const path of paths) { + await purge(host, path); + } + } + } return; } diff --git a/packages/void/test/integration/kitchen-sink-build.test.ts b/packages/void/test/integration/kitchen-sink-build.test.ts index 5ba25ade1..97026d8ee 100644 --- a/packages/void/test/integration/kitchen-sink-build.test.ts +++ b/packages/void/test/integration/kitchen-sink-build.test.ts @@ -97,10 +97,25 @@ describe('playground/kitchen-sink build', () => { it('does not leak __VOID_* credentials into dist/ssr/wrangler.json', () => { const wranglerConfig = JSON.parse(readFileSync(join(DIST, 'ssr', 'wrangler.json'), 'utf-8')); const vars = (wranglerConfig.vars ?? {}) as Record; - const leakedKeys = Object.keys(vars).filter((key) => key.startsWith('__VOID_')); + // `__VOID_AI_GATEWAY` is a legitimate, non-credential runtime var emitted from + // `ai.gateway` in void.json (asserted below) — exclude it from the leak filter. + const leakedKeys = Object.keys(vars).filter( + (key) => key.startsWith('__VOID_') && key !== '__VOID_AI_GATEWAY', + ); expect(leakedKeys).toEqual([]); }); + it('binds Workers AI and emits the AI Gateway var for self-host deploys', () => { + const wranglerConfig = JSON.parse(readFileSync(join(DIST, 'ssr', 'wrangler.json'), 'utf-8')); + // kitchen-sink imports `void/ai` at request time, so the build infers + // `needsAI` and injects the Workers AI binding on `command === 'build'`. + expect(wranglerConfig.ai).toEqual({ binding: 'AI' }); + // `ai.gateway` in playground/kitchen-sink/void.json becomes the runtime var + // read by the P3 direct backend. + const vars = (wranglerConfig.vars ?? {}) as Record; + expect(vars.__VOID_AI_GATEWAY).toBe('kitchen-sink-gw'); + }); + it('keeps non-internal .env.production vars in dist/ssr/wrangler.json', () => { const wranglerConfig = JSON.parse(readFileSync(join(DIST, 'ssr', 'wrangler.json'), 'utf-8')); const vars = (wranglerConfig.vars ?? {}) as Record; diff --git a/packages/void/test/integration/kitchen-sink.test.ts b/packages/void/test/integration/kitchen-sink.test.ts index a739bda6a..15f1d8882 100644 --- a/packages/void/test/integration/kitchen-sink.test.ts +++ b/packages/void/test/integration/kitchen-sink.test.ts @@ -55,7 +55,7 @@ describe('playground/kitchen-sink', () => { cwd: ROOT, stdio: 'pipe', }); - disablePreviewContainers(pg); + preparePreviewWranglerConfig(pg); // Start preview server — pre-built assets, no lazy optimization. child = spawn( @@ -539,14 +539,23 @@ describe('playground/kitchen-sink', () => { // Cron: skipped — __scheduled endpoint is only available via wrangler, not Vite dev/preview }); -function disablePreviewContainers(pg: string): void { +// Preview runs the built worker in miniflare with no Cloudflare credentials. +// Two bindings can't run in that environment, so strip them before preview. +// Neither is exercised by this smoke test — both have dedicated coverage: +// - containers (SANDBOX) need a local Docker build. +// - ai (Workers AI) is remote-only, so its presence forces a remote-proxy +// session that needs a CF account/login. The build-time AI binding is +// covered directly by kitchen-sink-build.test.ts. +function preparePreviewWranglerConfig(pg: string): void { const wranglerPath = join(pg, 'dist', 'ssr', 'wrangler.json'); const wranglerConfig = JSON.parse(readFileSync(wranglerPath, 'utf-8')) as { dev?: Record; + ai?: unknown; }; wranglerConfig.dev = { ...(wranglerConfig.dev ?? {}), enable_containers: false, }; + delete wranglerConfig.ai; writeFileSync(wranglerPath, `${JSON.stringify(wranglerConfig, null, 2)}\n`); } diff --git a/packages/void/test/unit/ai.test.ts b/packages/void/test/unit/ai.test.ts index f750b0ef3..258b8e85b 100644 --- a/packages/void/test/unit/ai.test.ts +++ b/packages/void/test/unit/ai.test.ts @@ -1,6 +1,29 @@ import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vite-plus/test'; import type { ai } from '../../src/runtime/ai.ts'; +/** + * Build a fake Workers AI binding (`env.AI`) for the P3 self-host backend. + * + * `gateway(id)` returns an object whose `getUrl(provider)` mock resolves to a + * realistic AI Gateway URL derived from BOTH the gateway id and provider, so + * the appended provider path can be asserted end-to-end. + */ +function makeDirectAi( + opts: { getUrl?: (id: string, provider: string) => Promise; run?: () => unknown } = {}, +) { + const getUrlImpl = + opts.getUrl ?? + (async (id: string, provider: string) => + `https://gateway.ai.cloudflare.com/v1/acct123/${id}/${provider}`); + const run = vi.fn(async (..._args: Array) => (opts.run ? opts.run() : { ok: true })); + const models = vi.fn(async () => ['@cf/meta/llama']); + const toMarkdown = vi.fn(async () => []); + const gateway = vi.fn((id: string) => ({ + getUrl: vi.fn((provider: string) => getUrlImpl(id, provider)), + })); + return { AI: { run, gateway, models, toMarkdown }, run, gateway }; +} + describe('runtime ai', () => { afterEach(() => { vi.restoreAllMocks(); @@ -327,4 +350,363 @@ describe('runtime ai', () => { }, ); }); + + // P3 self-host backend: only `env.AI` is bound (no Void proxy/token). Inference + // runs directly against the user's Workers AI binding, and `ai.provider().fetch` + // routes through the user's OWN AI Gateway using a project provider secret. + describe('direct AI backend (self-host, env.AI only)', () => { + it('selects the direct backend when only env.AI is present (ai.run → directAi.run)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + // Any accidental fall-through to the Void proxy would hit fetch — assert it doesn't. + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response('proxy-should-not-be-used', { status: 500 }), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run({ AI: direct.AI }, async () => { + const result = await ai.run('@cf/meta/llama', { prompt: 'Hi' }); + expect(result).toEqual({ ok: true }); + }); + + expect(direct.run).toHaveBeenCalledWith('@cf/meta/llama', { prompt: 'Hi' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('provider().fetch hits the user gateway with provider auth and no metering header', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ id: 'chatcmpl_direct' }), { + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', OPENAI_API_KEY: 'sk-test' }, + async () => { + const response = await ai.provider('openai').fetch('/chat/completions', { + body: { model: 'gpt-4o', messages: [{ role: 'user', content: 'Hello' }] }, + }); + // Gateway response passes through untouched. + expect(await response.json()).toEqual({ id: 'chatcmpl_direct' }); + }, + ); + + // Resolved the URL from the user's OWN gateway (awaited getUrl), not the Void proxy. + expect(direct.gateway).toHaveBeenCalledWith('my-gw'); + expect(direct.gateway.mock.results[0].value.getUrl).toHaveBeenCalledWith('openai'); + + expect(fetchMock).toHaveBeenCalledOnce(); + const url = fetchMock.mock.calls[0][0]; + const init = fetchMock.mock.calls[0][1]!; + expect(String(url).endsWith('/my-gw/openai/chat/completions')).toBe(true); + expect(init.method).toBe('POST'); + + const headers = new Headers(init.headers); + expect(headers.get('authorization')).toBe('Bearer sk-test'); + expect(headers.get('content-type')).toBe('application/json'); + // Self-host path must NOT attach managed metering / projectId headers. + expect(headers.get('cf-aig-metadata-projectId')).toBeNull(); + expect(headers.get('cf-aig-metadata')).toBeNull(); + + // Real provider payload — NOT the proxy envelope ({ action, provider, providerKey, ... }). + const sent = JSON.parse(init.body as string); + expect(sent).toEqual({ model: 'gpt-4o', messages: [{ role: 'user', content: 'Hello' }] }); + expect(sent).not.toHaveProperty('action'); + expect(sent).not.toHaveProperty('provider'); + expect(sent).not.toHaveProperty('providerKey'); + }); + + it('sends the provider-native auth header for a non-Bearer provider (anthropic → x-api-key, no Bearer)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ id: 'msg_direct' }), { + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', ANTHROPIC_API_KEY: 'sk-ant-test' }, + async () => { + await ai.provider('anthropic').fetch('/v1/messages', { + body: { model: 'claude-3-5-sonnet', messages: [{ role: 'user', content: 'Hi' }] }, + }); + }, + ); + + expect(direct.gateway.mock.results[0].value.getUrl).toHaveBeenCalledWith('anthropic'); + const init = fetchMock.mock.calls[0][1]!; + const headers = new Headers(init.headers); + // Anthropic authenticates with `x-api-key` and NO prefix — sending + // `Authorization: Bearer ` 401s at the gateway pass-through. + expect(headers.get('x-api-key')).toBe('sk-ant-test'); + expect(headers.get('authorization')).toBeNull(); + }); + + it('sends the provider-native auth header for parallel (x-api-key, no Bearer)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ id: 'task_run_direct' }), { + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', PARALLEL_API_KEY: 'pk-test' }, + async () => { + await ai.provider('parallel').fetch('/v1/tasks/runs', { + body: { input: 'What is the capital of France?' }, + }); + }, + ); + + expect(direct.gateway.mock.results[0].value.getUrl).toHaveBeenCalledWith('parallel'); + const init = fetchMock.mock.calls[0][1]!; + const headers = new Headers(init.headers); + // Parallel's AI Gateway authenticates with `x-api-key` and NO prefix — + // sending `Authorization: Bearer ` 401s at the gateway pass-through. + expect(headers.get('x-api-key')).toBe('pk-test'); + expect(headers.get('authorization')).toBeNull(); + }); + + it('sends a prefixed non-Bearer auth header (deepgram → Authorization: Token)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', DEEPGRAM_API_KEY: 'dg-test' }, + async () => { + await ai + .provider('deepgram') + .fetch('/v1/listen', { body: { url: 'https://example.com/a.wav' } }); + }, + ); + + const init = fetchMock.mock.calls[0][1]!; + const headers = new Headers(init.headers); + // Deepgram uses the `Token ` scheme, not `Bearer `. + expect(headers.get('authorization')).toBe('Token dg-test'); + }); + + it('resolves to 501 when no AI gateway is configured', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => new Response('ok'), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run({ AI: direct.AI, OPENAI_API_KEY: 'sk-test' }, async () => { + const response = await ai.provider('openai').fetch('/chat/completions', { + body: { model: 'gpt-4o' }, + }); + expect(response.status).toBe(501); + const body = (await response.json()) as { error: string }; + expect(body.error).toMatch(/gateway/i); + expect(body.error).toContain('void.json'); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(direct.gateway).not.toHaveBeenCalled(); + }); + + it('resolves to 400 naming the missing provider secret', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => new Response('ok'), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + // Gateway present + safe path, but OPENAI_API_KEY is absent. + await envContext.run({ AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw' }, async () => { + const response = await ai.provider('openai').fetch('/chat/completions', { + body: { model: 'gpt-4o' }, + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('OPENAI_API_KEY'); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + // Key check happens before the gateway lookup. + expect(direct.gateway).not.toHaveBeenCalled(); + }); + + it('resolves to 502 when the gateway getUrl lookup rejects', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => new Response('ok'), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi({ + getUrl: async () => { + throw new Error('gateway 404 not found'); + }, + }); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', OPENAI_API_KEY: 'sk-test' }, + async () => { + const response = await ai.provider('openai').fetch('/chat/completions', { + body: { model: 'gpt-4o' }, + }); + expect(response.status).toBe(502); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('my-gw'); + }, + ); + + expect(direct.gateway).toHaveBeenCalledWith('my-gw'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('resolves to 400 for an unsafe absolute provider path', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => new Response('ok'), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', OPENAI_API_KEY: 'sk-test' }, + async () => { + const response = await ai + .provider('openai') + .fetch('https://evil.example.com/v1/chat', { body: { model: 'gpt-4o' } }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toMatch(/must be relative/); + }, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + // Path is rejected before any gateway lookup or network call. + expect(direct.gateway).not.toHaveBeenCalled(); + }); + + it('serializes a direct JSON body as real application/json (not the proxy envelope)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => new Response('ok'), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', OPENAI_API_KEY: 'sk-test' }, + async () => { + await ai.provider('openai').fetch('/chat/completions', { + body: { model: 'gpt-4o', messages: [{ role: 'user', content: 'Hello' }] }, + }); + }, + ); + + const init = fetchMock.mock.calls[0][1]!; + expect(typeof init.body).toBe('string'); + expect(new Headers(init.headers).get('content-type')).toBe('application/json'); + expect(JSON.parse(init.body as string)).toEqual({ + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + }); + }); + + it('forwards a direct multipart/form-data body untouched (fetch sets the boundary)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => new Response('ok'), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', OPENAI_API_KEY: 'sk-test' }, + async () => { + const form = new FormData(); + form.set('model', 'gpt-image-1.5'); + form.set('prompt', 'change the background'); + form.set('image', new Blob(['png-bytes'], { type: 'image/png' }), 'source.png'); + + await ai.provider('openai').fetch('/images/edits', { body: form }); + }, + ); + + const init = fetchMock.mock.calls[0][1]!; + // The self-host path passes FormData straight through — fetch itself sets + // the multipart content-type + boundary, so we must NOT force content-type + // and must NOT base64-envelope the parts like the managed proxy does. + expect(init.body).toBeInstanceOf(FormData); + expect(new Headers(init.headers).get('content-type')).toBeNull(); + expect(new Headers(init.headers).get('authorization')).toBe('Bearer sk-test'); + + const sentForm = init.body as FormData; + expect(sentForm.get('model')).toBe('gpt-image-1.5'); + expect(sentForm.get('prompt')).toBe('change the background'); + const image = sentForm.get('image'); + expect(image).toBeInstanceOf(Blob); + expect(await (image as Blob).text()).toBe('png-bytes'); + }); + }); }); diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 97d028f78..e939bf9bd 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -2829,3 +2829,196 @@ describe('emitted __compilePattern / __matchPattern runtime', () => { } }); }); + +describe('compileWorkerEntry — ISR wiring', () => { + it('is unchanged without the isr param (single render line, no handleIsr)', () => { + const code = compileWorkerEntry(); + expect(code).toContain( + 'return serveWithAssets(request, env, await app.fetch(request, userEnv, ctx));', + ); + expect(code).not.toContain('handleIsr'); + // The `if (env.ISR_CACHE)` render guard is only emitted when ISR is wired. + expect(code).not.toContain('if (env.ISR_CACHE)'); + expect(code).not.toContain('__VOID_ISR_REVALIDATE'); + // The c.env internal-binding filter is prefix-only when ISR is absent — + // byte-identical to the pre-ISR base entry. The ISR_CACHE exact-name checks + // must NOT appear (ISR_CACHE the internal binding only exists when ISR is + // emitted; a user's own binding named ISR_CACHE must not be hidden here). + expect(code).not.toContain('prop === "ISR_CACHE"'); + expect(code).not.toContain('key !== "ISR_CACHE"'); + }); + + it('wires the ISR cache ladder with the two baked imports when the isr param is present', () => { + const code = compileWorkerEntry( + false, // hasQueues + undefined, // remote + 'assets', // assetsDir + [], // webSocketClasses + 'void/_env', // runtimeEnvPath + undefined, // envSchemaModule + undefined, // sandbox + false, // serveSpaFallback + undefined, // live + { runtimePath: 'void/isr' }, + ); + + // Baked import #1: handleIsr from the supplied runtime path. + expect(code).toContain('import { handleIsr } from "void/isr"'); + // Baked import #2: the two consts from the compiled route table (single source of truth). + expect(code).toContain( + "import { __VOID_ISR_REVALIDATE, __VOID_ISR_DEPLOYMENT_ID } from 'virtual:void-routes'", + ); + // Render path threaded through handleIsr, guarded on the ISR_CACHE binding. + expect(code).toContain('if (env.ISR_CACHE)'); + expect(code).toContain( + 'handleIsr(request, env, ctx, { revalidate: __VOID_ISR_REVALIDATE, deploymentId: __VOID_ISR_DEPLOYMENT_ID }, __render)', + ); + // Deployment id + host captured on the raw env so revalidate() can purge even + // on requests that never enter the render ladder. + expect(code).toContain('env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;'); + // Host is assigned every fetch (NOT `??=`) so a warm env reused across + // hostnames does not freeze it to the first host; the id stays `??=`. + expect(code).toContain('env.__VOID_ISR_HOST = new URL(request.url).hostname;'); + expect(code).not.toContain('env.__VOID_ISR_HOST ??= new URL(request.url).hostname;'); + expect(code).toContain('env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;'); + // When ISR is emitted the c.env internal-binding filter gains the ISR_CACHE + // exact-name checks so the self-host ISR KV binding cannot leak into route + // handlers' c.env (matches the managed platform; handleIsr reads raw env). + expect(code).toContain('prop === "ISR_CACHE"'); + expect(code).toContain('key !== "ISR_CACHE"'); + }); + + // FIX 1: cron/queue handlers must stamp the deployment id (only — no host, + // there is no request) so a self-host `revalidate()` from a scheduled or queue + // handler can find it and purge KV instead of no-oping. + it('stamps the deployment id at the top of the scheduled + queue handlers when isr is set', () => { + const code = compileWorkerEntry( + true, // hasQueues — emits the queue() handler too + undefined, // remote + 'assets', // assetsDir + [], // webSocketClasses + 'void/_env', // runtimeEnvPath + undefined, // envSchemaModule + undefined, // sandbox + false, // serveSpaFallback + undefined, // live + { runtimePath: 'void/isr' }, + ); + + // Scheduled handler stamps the id (deployment id only — no __VOID_ISR_HOST). + expect(code).toContain( + 'async function handleScheduled(controller, env, ctx) {\n' + + ' env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;\n' + + ' const userEnv = __filterInternalBindings(env);', + ); + // Queue handler stamps the id (deployment id only — no __VOID_ISR_HOST). + expect(code).toContain( + 'async function queue(batch, env, ctx) {\n' + + ' env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;\n' + + ' const userEnv = __filterInternalBindings(env);', + ); + // No host stamp in cron/queue (there is no request). + expect(code).not.toContain('env.__VOID_ISR_HOST ??= new URL(controller'); + }); + + it('does NOT stamp the deployment id in scheduled/queue handlers when isr is absent', () => { + const code = compileWorkerEntry(true); // hasQueues, no isr + // Byte-identical to today: no deployment-id stamp anywhere. + expect(code).not.toContain('__VOID_ISR_DEPLOYMENT_ID ??='); + expect(code).toContain( + 'async function handleScheduled(controller, env, ctx) {\n' + + ' const userEnv = __filterInternalBindings(env);', + ); + expect(code).toContain( + 'async function queue(batch, env, ctx) {\n const userEnv = __filterInternalBindings(env);', + ); + }); +}); + +describe('compileRouteTable — baked ISR consts', () => { + const scan: ScanResult = { + routes: [], + middleware: [], + jobs: [], + queues: [], + pages: { pages: [], layouts: [], namedLayouts: [] }, + }; + + it('emits __VOID_ISR_DEPLOYMENT_ID as a compile-time string literal (never crypto.randomUUID())', () => { + const code = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + ); + // A quoted UUID literal — baked at build time, not a runtime call. + expect(code).toMatch(/export const __VOID_ISR_DEPLOYMENT_ID = "[0-9a-f-]{36}";/); + expect(code).not.toContain('__VOID_ISR_DEPLOYMENT_ID = crypto.randomUUID()'); + }); + + it('bakes a fresh deployment id per compilation (one shared id per deploy)', () => { + const a = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + ); + const b = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + ); + const idA = a.match(/__VOID_ISR_DEPLOYMENT_ID = "([^"]+)"/)![1]; + const idB = b.match(/__VOID_ISR_DEPLOYMENT_ID = "([^"]+)"/)![1]; + expect(idA).not.toBe(idB); + }); + + it('emits the merged revalidate config verbatim (undefined | number | path map)', () => { + const none = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + ); + expect(none).toContain('export const __VOID_ISR_REVALIDATE = undefined;'); + + const num = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + null, + 60, + ); + expect(num).toContain('export const __VOID_ISR_REVALIDATE = 60;'); + + const map = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + null, + { '/blog/*': 30 }, + ); + expect(map).toContain('export const __VOID_ISR_REVALIDATE = {"/blog/*":30};'); + }); +}); diff --git a/packages/void/test/unit/config.test.ts b/packages/void/test/unit/config.test.ts index 9446cf6a2..a4e99e0ba 100644 --- a/packages/void/test/unit/config.test.ts +++ b/packages/void/test/unit/config.test.ts @@ -444,6 +444,69 @@ describe('readConfig', () => { }); }); +describe('top-level ai config validation', () => { + it('accepts an ai.gateway string', () => { + setup(JSON.stringify({ ai: { gateway: 'my-gateway' } })); + const config = readConfig(tmpDir); + expect(config.ai?.gateway).toBe('my-gateway'); + cleanup(); + }); + + it('accepts an empty top-level ai object', () => { + setup(JSON.stringify({ ai: {} })); + const config = readConfig(tmpDir); + expect(config.ai).toEqual({}); + cleanup(); + }); + + it('throws when ai is not an object (string)', () => { + setup(JSON.stringify({ ai: 'my-gateway' })); + expect(() => readConfig(tmpDir)).toThrow('"ai" in void.json must be an object'); + cleanup(); + }); + + it('throws when ai is an array', () => { + setup(JSON.stringify({ ai: ['my-gateway'] })); + expect(() => readConfig(tmpDir)).toThrow('"ai" in void.json must be an object'); + cleanup(); + }); + + it('throws when ai.gateway is not a string', () => { + setup(JSON.stringify({ ai: { gateway: 123 } })); + expect(() => readConfig(tmpDir)).toThrow( + '"ai.gateway" in void.json must be a non-empty string', + ); + cleanup(); + }); + + it('throws when ai.gateway is an empty string', () => { + setup(JSON.stringify({ ai: { gateway: '' } })); + expect(() => readConfig(tmpDir)).toThrow( + '"ai.gateway" in void.json must be a non-empty string', + ); + cleanup(); + }); + + it('rejects an unknown ai.* key', () => { + setup(JSON.stringify({ ai: { gateway: 'gw', provider: 'openai' } })); + expect(() => readConfig(tmpDir)).toThrow("Unknown key 'ai.provider'"); + cleanup(); + }); + + it('allows ai.gateway and inference.bindings.ai to coexist', () => { + setup( + JSON.stringify({ + ai: { gateway: 'my-gw' }, + inference: { bindings: { ai: true } }, + }), + ); + const config = readConfig(tmpDir); + expect(config.ai?.gateway).toBe('my-gw'); + expect(config.inference?.bindings?.ai).toBe(true); + cleanup(); + }); +}); + describe('readConfig migration errors', () => { it('throws when auth is not an object', () => { setup(JSON.stringify({ auth: ['github'] })); diff --git a/packages/void/test/unit/env-schema.test.ts b/packages/void/test/unit/env-schema.test.ts index 5de6a6846..ae6eb7f14 100644 --- a/packages/void/test/unit/env-schema.test.ts +++ b/packages/void/test/unit/env-schema.test.ts @@ -10,7 +10,7 @@ import { _hasRegisteredSchema, _validateEnvOnce, } from '../../src/runtime/env-public.ts'; -import { withRuntimeEnv } from '../../src/runtime/env.ts'; +import { getRuntimeBinding, requireRuntimeBinding, withRuntimeEnv } from '../../src/runtime/env.ts'; // Reset schema between tests so order doesn't leak state. function resetSchema() { @@ -424,6 +424,83 @@ describe('_validateEnvOnce (boot-time worker check)', () => { }); }); +// FIX C + FIX D: internal framework bindings (e.g. ISR_CACHE) must never be +// exposed through void/env WHILE ISR IS ACTIVE — not even when a user's +// defineEnv schema declares one and boot validation resolved and seeded its RAW +// value into the sync cache. When ISR is inactive, a schema-declared ISR_CACHE +// is an ordinary user binding and IS exposed. +describe('_validateEnvOnce — internal bindings stay hidden from void/env', () => { + beforeEach(() => { + resetSchema(); + }); + + // A passthrough validator so the RAW binding value (a KV-like object) passes + // boot validation and would otherwise be seeded into the sync cache — this is + // exactly the leak path FIX C closes (while ISR is active). + const passthrough = { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: (v: unknown) => ({ value: v }), + }, + }; + + it('never exposes a schema-declared ISR_CACHE while ISR is active, even after boot validation seeds its raw value', async () => { + defineEnv({ ISR_CACHE: passthrough, API_KEY: string() }); + + const fakeIsrKv = { __brand: 'isr-kv' }; + // `__VOID_ISR_DEPLOYMENT_ID` present ⇒ ISR active ⇒ ISR_CACHE is internal. + const rawEnv = { ISR_CACHE: fakeIsrKv, API_KEY: 'sk_live', __VOID_ISR_DEPLOYMENT_ID: 'dep_1' }; + + await withRuntimeEnv(rawEnv, async () => { + // Boot validation runs FIRST — as it does at worker boot, before any + // request reads env.X. It succeeds (raw ISR_CACHE passes the passthrough + // validator) and, without FIX C, seeds the raw KV binding into the sync + // cache. Reading env.ISR_CACHE only AFTER this is what exposes the leak: a + // pre-validation read would mask it by caching `undefined` (the filtered + // value) so the `if (!cache.has(key))` seed guard would skip re-seeding. + await _validateEnvOnce(rawEnv); + + // Hidden after boot validation — the seeded raw KV binding must not + // leak through env.ISR_CACHE / `in` / enumeration. + expect((env as Record).ISR_CACHE).toBeUndefined(); + expect('ISR_CACHE' in env).toBe(false); + expect(Object.keys(env)).not.toContain('ISR_CACHE'); + + // Lower-level binding accessors keep hiding it too. + expect(getRuntimeBinding('ISR_CACHE')).toBeUndefined(); + expect(() => requireRuntimeBinding('ISR_CACHE')).toThrow( + "env: Missing Cloudflare binding 'ISR_CACHE'.", + ); + + // A normal declared key remains readable (the fix does not over-filter). + expect(env.API_KEY).toBe('sk_live'); + }); + }); + + it('exposes a schema-declared ISR_CACHE when ISR is inactive (no __VOID_ISR_DEPLOYMENT_ID)', async () => { + defineEnv({ ISR_CACHE: passthrough, API_KEY: string() }); + + const fakeIsrKv = { __brand: 'user-kv' }; + // No `__VOID_ISR_DEPLOYMENT_ID` ⇒ ISR inactive ⇒ ISR_CACHE is a user binding. + const rawEnv = { ISR_CACHE: fakeIsrKv, API_KEY: 'sk_live' }; + + await withRuntimeEnv(rawEnv, async () => { + await _validateEnvOnce(rawEnv); + + // ISR_CACHE is a normal declared binding here — visible everywhere. + expect((env as Record).ISR_CACHE).toEqual(fakeIsrKv); + expect('ISR_CACHE' in env).toBe(true); + expect(Object.keys(env)).toContain('ISR_CACHE'); + + expect(getRuntimeBinding('ISR_CACHE')).toEqual(fakeIsrKv); + expect(requireRuntimeBinding('ISR_CACHE')).toEqual(fakeIsrKv); + + expect(env.API_KEY).toBe('sk_live'); + }); + }); +}); + describe('env proxy with async validators', () => { beforeEach(() => { resetSchema(); diff --git a/packages/void/test/unit/env.test.ts b/packages/void/test/unit/env.test.ts index f44f7c8d7..13b9252e9 100644 --- a/packages/void/test/unit/env.test.ts +++ b/packages/void/test/unit/env.test.ts @@ -1,5 +1,11 @@ import { afterAll, beforeEach, describe, expect, it } from 'vite-plus/test'; -import { getRuntimeBinding, getRuntimeEnv, withRuntimeEnv } from '../../src/runtime/env.ts'; +import { env } from '../../src/runtime/env-public.ts'; +import { + getRuntimeBinding, + getRuntimeEnv, + requireRuntimeBinding, + withRuntimeEnv, +} from '../../src/runtime/env.ts'; const globalScope = globalThis as { __env__?: unknown; @@ -41,3 +47,90 @@ describe('runtime env resolution', () => { expect(() => getRuntimeEnv()).toThrow('env: Cloudflare env is unavailable'); }); }); + +// FIX 3 + FIX D: ISR_CACHE is a framework-internal binding with no internal +// prefix. It is hidden from every public env surface ONLY while ISR is active +// for the isolate — signalled by `__VOID_ISR_DEPLOYMENT_ID` on the raw env +// (stamped by the generated worker entry on ISR-emitting builds). This mirrors +// the build-time `isr` gate on the generated c.env filter. When ISR is NOT +// active, `ISR_CACHE` is an ordinary user binding and stays visible. Prefix +// internals (`__VOID_*`, `__PROJECT_*`) are hidden unconditionally in both. +describe('internal binding filtering — ISR_CACHE (exact-name)', () => { + describe('ISR active (raw env has __VOID_ISR_DEPLOYMENT_ID)', () => { + it('hides ISR_CACHE from getRuntimeEnv / getRuntimeBinding / requireRuntimeBinding', () => { + withRuntimeEnv( + { + DB: { tag: 'db' }, + ISR_CACHE: { tag: 'isr-kv' }, + __VOID_TOKEN: 'secret', + __VOID_ISR_DEPLOYMENT_ID: 'dep_1', + }, + () => { + const filtered = getRuntimeEnv() as Record; + // Normal binding still visible. + expect(filtered.DB).toEqual({ tag: 'db' }); + expect('DB' in filtered).toBe(true); + // ISR_CACHE hidden by exact name. + expect(filtered.ISR_CACHE).toBeUndefined(); + expect('ISR_CACHE' in filtered).toBe(false); + expect(Object.keys(filtered)).not.toContain('ISR_CACHE'); + // Prefixed internal still hidden (regression guard for the prefix path). + expect('__VOID_TOKEN' in filtered).toBe(false); + expect('__VOID_ISR_DEPLOYMENT_ID' in filtered).toBe(false); + + expect(getRuntimeBinding('DB')).toEqual({ tag: 'db' }); + expect(getRuntimeBinding('ISR_CACHE')).toBeUndefined(); + expect(() => requireRuntimeBinding('ISR_CACHE')).toThrow( + "env: Missing Cloudflare binding 'ISR_CACHE'.", + ); + }, + ); + }); + + it('hides ISR_CACHE from the public `env` proxy (absent + not enumerated), keeps normal bindings', () => { + withRuntimeEnv( + { DB: { tag: 'db' }, ISR_CACHE: { tag: 'isr-kv' }, __VOID_ISR_DEPLOYMENT_ID: 'dep_1' }, + () => { + expect('ISR_CACHE' in env).toBe(false); + expect('DB' in env).toBe(true); + expect(Object.keys(env)).not.toContain('ISR_CACHE'); + expect(Object.keys(env)).toContain('DB'); + expect((env as Record).ISR_CACHE).toBeUndefined(); + expect((env as Record).DB).toEqual({ tag: 'db' }); + }, + ); + }); + }); + + describe('ISR inactive (no __VOID_ISR_DEPLOYMENT_ID)', () => { + it('treats ISR_CACHE as a normal user binding across getRuntimeEnv / getRuntimeBinding / requireRuntimeBinding', () => { + withRuntimeEnv( + { DB: { tag: 'db' }, ISR_CACHE: { tag: 'isr-kv' }, __VOID_TOKEN: 'secret' }, + () => { + const filtered = getRuntimeEnv() as Record; + // ISR_CACHE is visible — the user owns this binding when ISR is off. + expect(filtered.ISR_CACHE).toEqual({ tag: 'isr-kv' }); + expect('ISR_CACHE' in filtered).toBe(true); + expect(Object.keys(filtered)).toContain('ISR_CACHE'); + // Prefixed internal is STILL hidden unconditionally. + expect('__VOID_TOKEN' in filtered).toBe(false); + expect(Object.keys(filtered)).not.toContain('__VOID_TOKEN'); + + expect(getRuntimeBinding('ISR_CACHE')).toEqual({ tag: 'isr-kv' }); + expect(requireRuntimeBinding('ISR_CACHE')).toEqual({ tag: 'isr-kv' }); + // Prefixed internal still blocked. + expect(getRuntimeBinding('__VOID_TOKEN')).toBeUndefined(); + }, + ); + }); + + it('exposes ISR_CACHE through the public `env` proxy (present + enumerated)', () => { + withRuntimeEnv({ DB: { tag: 'db' }, ISR_CACHE: { tag: 'isr-kv' } }, () => { + expect('ISR_CACHE' in env).toBe(true); + expect(Object.keys(env)).toContain('ISR_CACHE'); + expect((env as Record).ISR_CACHE).toEqual({ tag: 'isr-kv' }); + expect((env as Record).DB).toEqual({ tag: 'db' }); + }); + }); + }); +}); diff --git a/packages/void/test/unit/index.test.ts b/packages/void/test/unit/index.test.ts new file mode 100644 index 000000000..c3ea22705 --- /dev/null +++ b/packages/void/test/unit/index.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { shouldBindWorkersAi } from '../../src/index.ts'; + +// `shouldBindWorkersAi(command, needsAI, resolvedAi, resultAi)` is the pure, +// testable surface of the self-host serve-gate (the surrounding `mergeBindings` +// is an unexported closure). It decides whether the built wrangler config gets +// an injected Workers AI binding. Assert the real return values. +describe('shouldBindWorkersAi', () => { + it('never binds Workers AI during dev/serve (miniflare has no local AI)', () => { + // Dev uses the P2 `__VOID_TOKEN` proxy path instead of `env.AI`. + expect(shouldBindWorkersAi('serve', true, undefined, undefined)).toBe(false); + }); + + it('binds Workers AI on a self-host build when the app uses AI', () => { + expect(shouldBindWorkersAi('build', true, undefined, undefined)).toBe(true); + }); + + it("does not override a user's root-wrangler `ai` binding", () => { + // A resolved root-wrangler `ai` binding wins — never clobber it. + expect(shouldBindWorkersAi('build', true, { binding: 'X' }, undefined)).toBe(false); + }); + + it('does not bind Workers AI when the app does not use AI', () => { + expect(shouldBindWorkersAi('build', false, undefined, undefined)).toBe(false); + }); +}); diff --git a/packages/void/test/unit/isr-cache.test.ts b/packages/void/test/unit/isr-cache.test.ts new file mode 100644 index 000000000..3ae54dd83 --- /dev/null +++ b/packages/void/test/unit/isr-cache.test.ts @@ -0,0 +1,1291 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + cloneWithoutVoidPages, + deleteIsrCache, + denormalizeIsrPath, + handleIsr, + hostRegistryKey, + htmlEdgeKey, + isrCacheResponse, + isrKey, + isStale, + jsonEdgeKey, + listIsrHosts, + matchLiteralPattern, + normalizeIsrPath, + packIsrValue, + readIsrCache, + resolvePathTtl, + resolveRevalidateTtl, + unpackIsrValue, + writeIsrCache, + type IsrCacheEntry, + type IsrCacheEnv, + type IsrCacheMetadata, + type RenderFn, +} from '../../src/runtime/isr-cache.ts'; + +// Collapsed self-host deployment id (baked as a compile-time literal on the real +// worker). Every key in this suite is scoped by it — `void-isr//...`. +const DPL = '11111111-1111-4111-8111-111111111111'; +const HOST = 'app.void.app'; +// A fixed, realistic epoch so `Date.now()` (faked) is deterministic. +const BASE = 1_700_000_000_000; + +// --------------------------------------------------------------------------- +// In-memory KV mock (getWithMetadata/put/delete/list with cursor pagination). +// Real behavior: `store` is the authoritative backing map, so assertions read +// actual cache contents rather than merely "the mock was called". +// --------------------------------------------------------------------------- + +type KvRecord = { value: ArrayBuffer; metadata: IsrCacheMetadata | null }; + +function createKvMock(opts: { throwOnGet?: boolean } = {}) { + const store = new Map(); + const getWithMetadata = vi.fn(async (key: string, _type: 'arrayBuffer') => { + if (opts.throwOnGet) { + throw new Error('KV unavailable'); + } + const rec = store.get(key); + return rec ? { value: rec.value, metadata: rec.metadata } : { value: null, metadata: null }; + }); + const put = vi.fn( + async (key: string, value: ArrayBuffer, options?: { metadata?: IsrCacheMetadata }) => { + store.set(key, { value, metadata: options?.metadata ?? null }); + }, + ); + const del = vi.fn(async (key: string) => { + store.delete(key); + }); + const list = vi.fn(async (o: { prefix?: string; cursor?: string; limit?: number } = {}) => { + const prefix = o.prefix ?? ''; + const all = [...store.keys()].filter((k) => k.startsWith(prefix)).sort(); + const limit = o.limit ?? 1000; + const start = o.cursor ? Number.parseInt(o.cursor, 10) : 0; + const slice = all.slice(start, start + limit); + const complete = start + limit >= all.length; + return { + keys: slice.map((name) => ({ name })), + list_complete: complete, + cursor: complete ? '' : String(start + limit), + }; + }); + const kv = { getWithMetadata, put, delete: del, list } as unknown as KVNamespace; + return { store, kv, getWithMetadata, put, delete: del, list }; +} + +function makeEnv(kv: KVNamespace): IsrCacheEnv { + return { ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL }; +} + +function packEntry(headers: Record, body: string): ArrayBuffer { + return packIsrValue(headers, new TextEncoder().encode(body).buffer as ArrayBuffer); +} + +function seedEntry( + store: Map, + path: string, + { + headers = { 'content-type': 'text/html' }, + body = 'cached', + status = 200, + createdAt, + ttl = 60, + value, + }: { + headers?: Record; + body?: string; + status?: number; + createdAt: number; + ttl?: number; + value?: ArrayBuffer; + }, +): void { + store.set(isrKey(DPL, HOST, path), { + value: value ?? packEntry(headers, body), + metadata: { s: status, c: createdAt, t: ttl }, + }); +} + +// --------------------------------------------------------------------------- +// Fake per-colo edge cache (caches.default): match/put/delete over string keys. +// --------------------------------------------------------------------------- + +function createEdgeMock() { + const store = new Map(); + const match = vi.fn(async (key: string | Request) => { + const k = typeof key === 'string' ? key : (key as Request).url; + const res = store.get(k); + // Real Cache.match hands back a fresh Response each call. + return res ? res.clone() : undefined; + }); + const put = vi.fn(async (key: string | Request, response: Response) => { + const k = typeof key === 'string' ? key : (key as Request).url; + store.set(k, response); + }); + const del = vi.fn(async (key: string | Request) => { + const k = typeof key === 'string' ? key : (key as Request).url; + return store.delete(k); + }); + const cache = { match, put, delete: del } as unknown as Cache; + return { store, cache, match, put, delete: del }; +} + +function createCtx() { + const tasks: Array> = []; + const ctx = { + waitUntil: (p: Promise) => { + tasks.push(p); + }, + passThroughOnException: () => {}, + } as unknown as ExecutionContext; + return { + ctx, + tasks, + flush: async () => { + await Promise.all(tasks); + }, + }; +} + +function createRender(makeResponse: (req: Request) => Response) { + const requests: Array = []; + const spy = vi.fn(async (req: Request) => { + requests.push(req); + return makeResponse(req); + }); + return { render: spy as unknown as RenderFn, spy, requests }; +} + +// --------------------------------------------------------------------------- +// Corrupt-KV fail-open table (critical). A KV entry whose metadata/headers make +// a Response un-constructible must be rejected at the `readIsrCache` choke point +// so `isrCacheResponse` can never throw. Shared by two suites: the low-level +// `readIsrCache` fail-open tests AND the end-to-end `handleIsr` live-render +// path (every corrupt shape must degrade to a live render, never a 500). +// --------------------------------------------------------------------------- +const corruptCases: Array<{ name: string; seed: () => KvRecord }> = [ + { + name: 'status out of range (s: 999)', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 999, c: BASE, t: 60 } }), + }, + { + name: 'status out of range (s: 0)', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 0, c: BASE, t: 60 } }), + }, + { + name: 'invalid header name', + seed: () => ({ + value: packEntry({ 'Invalid Header Name': 'x' }, 'x'), + metadata: { s: 200, c: BASE, t: 60 }, + }), + }, + { + name: 'createdAt NaN', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: NaN, t: 60 } }), + }, + { + name: 'createdAt Infinity', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: Infinity, t: 60 } }), + }, + { + name: 'createdAt negative', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: -1, t: 60 } }), + }, + { + name: 'ttl NaN', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: NaN } }), + }, + { + name: 'ttl zero', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: 0 } }), + }, + { + name: 'ttl negative', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: -5 } }), + }, + { + name: 'malformed packed value', + seed: () => ({ value: new ArrayBuffer(2), metadata: { s: 200, c: BASE, t: 60 } }), + }, +]; + +// =========================================================================== +// Pure helpers +// =========================================================================== + +describe('normalizeIsrPath / denormalizeIsrPath', () => { + it('maps root to __index__ and back', () => { + expect(normalizeIsrPath('/')).toBe('__index__'); + expect(denormalizeIsrPath('__index__')).toBe('/'); + }); + + it('strips leading and trailing slashes', () => { + expect(normalizeIsrPath('/about')).toBe('about'); + expect(normalizeIsrPath('/about/')).toBe('about'); + expect(normalizeIsrPath('/blog/hello-world')).toBe('blog/hello-world'); + }); + + it('round-trips non-root paths', () => { + for (const p of ['/', '/about', '/blog/hello']) { + expect(denormalizeIsrPath(normalizeIsrPath(p))).toBe(p); + } + }); +}); + +describe('collapsed self-host cache keys', () => { + it('scopes the KV key by deployment id AND host (void-isr///...), no projectId/isr-v2', () => { + expect(isrKey(DPL, HOST, '/')).toBe(`void-isr/${DPL}/${HOST}/__index__`); + expect(isrKey(DPL, HOST, '/blog/x')).toBe(`void-isr/${DPL}/${HOST}/blog/x`); + expect(isrKey(DPL, HOST, '/about/')).toBe(`void-isr/${DPL}/${HOST}/about`); + }); + + it('host-scopes the KV key: different hosts never share a cache key', () => { + expect(isrKey(DPL, 'foo.example', '/x')).not.toBe(isrKey(DPL, 'bar.example', '/x')); + expect(isrKey(DPL, 'foo.example', '/x')).toBe(`void-isr/${DPL}/foo.example/x`); + }); + + it('builds the host-registry key (void-isr-hosts//), distinct from the cache prefix', () => { + expect(hostRegistryKey(DPL, HOST)).toBe(`void-isr-hosts/${DPL}/${HOST}`); + // The registry prefix must NOT collide with the cache prefix, or an `{ all }` + // list (prefix `void-isr//`) would enumerate registry keys as cache keys. + expect(hostRegistryKey(DPL, HOST).startsWith(`void-isr/${DPL}/`)).toBe(false); + }); + + it('builds HTML edge keys with the deployment id and a canonical pathname', () => { + expect(htmlEdgeKey(HOST, '/about', DPL)).toBe(`https://${HOST}/about?__void_isr=${DPL}`); + // Trailing slash canonicalized (matches normalizeIsrPath). + expect(htmlEdgeKey(HOST, '/about/', DPL)).toBe(`https://${HOST}/about?__void_isr=${DPL}`); + // Root keeps its slash. + expect(htmlEdgeKey(HOST, '/', DPL)).toBe(`https://${HOST}/?__void_isr=${DPL}`); + }); + + it('builds JSON edge keys as the HTML key plus the &__void=json marker', () => { + expect(jsonEdgeKey(HOST, '/about', DPL)).toBe( + `https://${HOST}/about?__void_isr=${DPL}&__void=json`, + ); + }); +}); + +describe('isStale', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const entry = (createdAt: number, ttl = 60): IsrCacheEntry => ({ + body: new ReadableStream(), + status: 200, + headers: {}, + createdAt, + ttl, + }); + + it('is fresh before the TTL elapses', () => { + vi.setSystemTime(BASE); + expect(isStale(entry(BASE))).toBe(false); + }); + + it('is stale past the TTL', () => { + vi.setSystemTime(BASE + 61 * 1000); + expect(isStale(entry(BASE))).toBe(true); + }); + + it('is fresh at the exact TTL boundary', () => { + vi.setSystemTime(BASE + 60 * 1000); + expect(isStale(entry(BASE))).toBe(false); + }); +}); + +describe('resolveRevalidateTtl', () => { + it('returns the default when no header is present', () => { + expect(resolveRevalidateTtl(new Response('ok'), 60)).toBe(60); + }); + + it('returns the header value when present', () => { + expect( + resolveRevalidateTtl(new Response('ok', { headers: { 'x-revalidate': '120' } }), 60), + ).toBe(120); + }); + + it('returns 0 for the opt-out header (x-revalidate: 0 → do not cache)', () => { + expect(resolveRevalidateTtl(new Response('ok', { headers: { 'x-revalidate': '0' } }), 60)).toBe( + 0, + ); + }); + + it('falls back to the default for invalid or negative header values', () => { + expect( + resolveRevalidateTtl(new Response('ok', { headers: { 'x-revalidate': 'abc' } }), 60), + ).toBe(60); + expect( + resolveRevalidateTtl(new Response('ok', { headers: { 'x-revalidate': '-5' } }), 60), + ).toBe(60); + }); +}); + +describe('isrCacheResponse', () => { + it('serves HIT with an edge-cacheable s-maxage for a fresh entry', () => { + const res = isrCacheResponse( + { + body: new ReadableStream(), + status: 200, + headers: { 'content-type': 'text/html' }, + createdAt: Date.now(), + ttl: 60, + }, + false, + ); + expect(res.status).toBe(200); + expect(res.headers.get('x-isr-cache')).toBe('HIT'); + expect(res.headers.get('content-type')).toBe('text/html'); + const cc = res.headers.get('Cache-Control')!; + expect(cc).toContain('public'); + expect(cc).toContain('s-maxage='); + expect(cc).toContain('must-revalidate'); + }); + + it('serves STALE with s-maxage=min(10, ttl)', () => { + const res = isrCacheResponse( + { + body: new ReadableStream(), + status: 200, + headers: {}, + createdAt: Date.now() - 120000, + ttl: 60, + }, + true, + ); + expect(res.headers.get('x-isr-cache')).toBe('STALE'); + expect(res.headers.get('Cache-Control')).toBe( + 'public, s-maxage=10, max-age=0, must-revalidate', + ); + }); + + it('caps the STALE s-maxage at ttl for short ISR windows', () => { + const res = isrCacheResponse( + { + body: new ReadableStream(), + status: 200, + headers: {}, + createdAt: Date.now() - 10000, + ttl: 5, + }, + true, + ); + expect(res.headers.get('Cache-Control')).toBe('public, s-maxage=5, max-age=0, must-revalidate'); + }); +}); + +describe('packIsrValue / unpackIsrValue', () => { + it('round-trips headers and body', () => { + const headers = { 'content-type': 'text/html', etag: '"abc"' }; + const packed = packIsrValue( + headers, + new TextEncoder().encode('hi').buffer as ArrayBuffer, + ); + const result = unpackIsrValue(packed); + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(headers); + expect(new TextDecoder().decode(result!.body)).toBe('hi'); + }); + + it('handles empty body and empty headers', () => { + const emptyBody = unpackIsrValue( + packIsrValue({ 'content-type': 'text/html' }, new ArrayBuffer(0)), + ); + expect(emptyBody!.body.byteLength).toBe(0); + const emptyHeaders = unpackIsrValue( + packIsrValue({}, new TextEncoder().encode('x').buffer as ArrayBuffer), + ); + expect(emptyHeaders!.headers).toEqual({}); + }); + + it('returns null for a buffer too small or a truncated header', () => { + expect(unpackIsrValue(new ArrayBuffer(2))).toBeNull(); + const buf = new ArrayBuffer(4); + new DataView(buf).setUint32(0, 999, true); // claims 999 header bytes it does not have + expect(unpackIsrValue(buf)).toBeNull(); + }); +}); + +// =========================================================================== +// KV cache: write / read round-trip, delete, and corrupt-entry fail-open +// =========================================================================== + +describe('writeIsrCache / readIsrCache / deleteIsrCache', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(BASE); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('writes a packed entry to KV and reads it back with status/headers/ttl/body', async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + await writeIsrCache( + env, + HOST, + '/blog/post', + new Response('fresh', { + status: 200, + headers: { 'content-type': 'text/html', 'set-cookie': 'sid=secret', 'cache-control': 'no' }, + }), + 60, + ); + + // Real KV contents: exactly one entry under the collapsed key. + expect(store.has(isrKey(DPL, HOST, '/blog/post'))).toBe(true); + const meta = store.get(isrKey(DPL, HOST, '/blog/post'))!.metadata!; + expect(meta).toEqual({ s: 200, c: BASE, t: 60 }); + + const entry = await readIsrCache(env, HOST, '/blog/post'); + expect(entry).not.toBeNull(); + expect(entry!.status).toBe(200); + expect(entry!.ttl).toBe(60); + expect(entry!.createdAt).toBe(BASE); + expect(entry!.headers['content-type']).toBe('text/html'); + // serializeHeaders strips cache-control/set-cookie before persisting. + expect(entry!.headers['set-cookie']).toBeUndefined(); + expect(entry!.headers['cache-control']).toBeUndefined(); + expect(await new Response(entry!.body).text()).toBe('fresh'); + }); + + it('returns null on a cache miss', async () => { + const { kv } = createKvMock(); + expect(await readIsrCache(makeEnv(kv), HOST, '/nope')).toBeNull(); + }); + + it('deleteIsrCache removes the entry', async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + seedEntry(store, '/gone', { createdAt: BASE }); + expect(store.has(isrKey(DPL, HOST, '/gone'))).toBe(true); + await deleteIsrCache(env, HOST, '/gone'); + expect(store.has(isrKey(DPL, HOST, '/gone'))).toBe(false); + }); + + it('fails open to null when getWithMetadata throws (never surfaces the error)', async () => { + const { kv } = createKvMock({ throwOnGet: true }); + await expect(readIsrCache(makeEnv(kv), HOST, '/x')).resolves.toBeNull(); + }); + + // Corrupt-KV fail-open: the `corruptCases` table is module-scoped (shared with + // the `handleIsr` live-render suite). readIsrCache must reject every shape. + for (const c of corruptCases) { + it(`readIsrCache returns null for a corrupt entry: ${c.name}`, async () => { + const { store, kv } = createKvMock(); + store.set(isrKey(DPL, HOST, '/p'), c.seed()); + await expect(readIsrCache(makeEnv(kv), HOST, '/p')).resolves.toBeNull(); + }); + } +}); + +// =========================================================================== +// cloneWithoutVoidPages — the background-render request stripper +// =========================================================================== +// A request that carries `X-VoidPages` takes the JSON branch in `handleIsr` and +// returns before ever reaching the HTML stale path where the stripper runs, so +// the public API cannot deliver an X-VoidPages request to it. Test it directly +// so the mandated stripping is actually exercised (the stale-path test can only +// ever hand it a request that already lacks the header — a tautology otherwise). + +describe('cloneWithoutVoidPages', () => { + it('removes X-VoidPages while preserving method, url, and every other header', () => { + const original = new Request(`https://${HOST}/blog/post`, { + method: 'GET', + headers: { + 'X-VoidPages': '1', + 'X-Test-Marker': 'keep', + Accept: 'text/html', + }, + }); + // Precondition: the input genuinely carries the header we expect stripped. + expect(original.headers.has('X-VoidPages')).toBe(true); + + const cleaned = cloneWithoutVoidPages(original); + + // Only X-VoidPages is dropped; everything else is preserved verbatim. This + // fails if `cloneWithoutVoidPages` stops deleting the header (regression). + expect(cleaned.headers.has('X-VoidPages')).toBe(false); + expect(cleaned.headers.get('X-Test-Marker')).toBe('keep'); + expect(cleaned.headers.get('Accept')).toBe('text/html'); + expect(cleaned.method).toBe('GET'); + expect(cleaned.url).toBe(`https://${HOST}/blog/post`); + }); + + it('is a no-op on the header set when X-VoidPages is absent', () => { + const original = new Request(`https://${HOST}/blog/post`, { + method: 'GET', + headers: { 'X-Test-Marker': 'keep' }, + }); + const cleaned = cloneWithoutVoidPages(original); + expect(cleaned.headers.has('X-VoidPages')).toBe(false); + expect(cleaned.headers.get('X-Test-Marker')).toBe('keep'); + expect(cleaned.url).toBe(`https://${HOST}/blog/post`); + }); +}); + +// =========================================================================== +// handleIsr — the cache ladder end to end +// =========================================================================== + +describe('handleIsr', () => { + let edge: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(BASE); + edge = createEdgeMock(); + vi.stubGlobal('caches', { default: edge.cache }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + const OPTS = { revalidate: 60, deploymentId: DPL }; + + function htmlRequest(path = '/blog/post', headers: Record = {}) { + return new Request(`https://${HOST}${path}`, { method: 'GET', headers }); + } + + it('MISS → renders live, returns MISS, and writes KV + HTML edge in the background', async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + const { render, spy } = createRender( + () => new Response('live', { headers: { 'content-type': 'text/html' } }), + ); + const { ctx, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + expect(res!.headers.get('x-isr-cache')).toBe('MISS'); + expect(await res!.text()).toBe('live'); + expect(spy).toHaveBeenCalledTimes(1); + + await flush(); + // Real KV contents: the live render was persisted under the collapsed key. + const entry = await readIsrCache(env, HOST, '/blog/post'); + expect(entry).not.toBeNull(); + expect(await new Response(entry!.body).text()).toBe('live'); + // HTML edge variant warmed; plain HTML (no page data) never warms the JSON variant. + expect(edge.store.has(htmlEdgeKey(HOST, '/blog/post', DPL))).toBe(true); + expect(edge.store.has(jsonEdgeKey(HOST, '/blog/post', DPL))).toBe(false); + // Exactly one PAGE-cache entry persisted (`void-isr/…`); the host-registry + // bookkeeping key (`void-isr-hosts/…`) is separate and excluded here. + expect([...store.keys()].filter((k) => k.startsWith('void-isr/')).length).toBe(1); + }); + + it('MISS whose render sets Set-Cookie → client keeps EVERY cookie; KV + edge copies stay stripped', async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + // The live render emits TWO DISTINCT Set-Cookie headers (session + CSRF). + // A `Headers` folds duplicates, so an array init is the only way to seed both. + const { render, spy } = createRender( + () => + new Response('live', { + headers: [ + ['content-type', 'text/html'], + ['set-cookie', 'sid=secret; Path=/; HttpOnly'], + ['set-cookie', 'csrf=tok; Path=/'], + ], + }), + ); + const { ctx, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res).not.toBeNull(); + expect(res!.headers.get('x-isr-cache')).toBe('MISS'); + expect(spy).toHaveBeenCalledTimes(1); + + // (1) The CLIENT response carries BOTH Set-Cookie headers, verbatim. Without + // the fix the client headers are cloned from the already-stripped cache copy, + // so `getSetCookie()` would be empty here and this assertion would fail. + expect(res!.headers.getSetCookie()).toEqual([ + 'sid=secret; Path=/; HttpOnly', + 'csrf=tok; Path=/', + ]); + + await flush(); + // (2) The shared KV copy is stripped — a shared cache must never store cookies. + const entry = await readIsrCache(env, HOST, '/blog/post'); + expect(entry).not.toBeNull(); + expect(entry!.headers['set-cookie']).toBeUndefined(); + expect(await new Response(entry!.body).text()).toBe('live'); + expect([...store.keys()].filter((k) => k.startsWith('void-isr/')).length).toBe(1); + // (3) The edge (caches.default) copy is stripped too. + const warmed = edge.store.get(htmlEdgeKey(HOST, '/blog/post', DPL)); + expect(warmed).toBeDefined(); + expect(warmed!.headers.getSetCookie()).toEqual([]); + expect(warmed!.headers.has('set-cookie')).toBe(false); + }); + + it('KV HIT (fresh) → serves HIT from KV without a live render and warms the edge', async () => { + const { kv, store } = createKvMock(); + const env = makeEnv(kv); + seedEntry(store, '/blog/post', { body: 'kv', createdAt: BASE, ttl: 60 }); + const { render, spy } = createRender(() => new Response('SHOULD-NOT-RENDER')); + const { ctx, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res!.status).toBe(200); + expect(res!.headers.get('x-isr-cache')).toBe('HIT'); + // Client response revalidates on every hit. + expect(res!.headers.get('Cache-Control')).toBe('public, max-age=0, must-revalidate'); + expect(await res!.text()).toBe('kv'); + expect(spy).not.toHaveBeenCalled(); + + await flush(); + // Edge warmed from KV with the fresh (HIT) response — s-maxage tracks remaining freshness. + const warmed = edge.store.get(htmlEdgeKey(HOST, '/blog/post', DPL)); + expect(warmed).toBeDefined(); + expect(warmed!.headers.get('x-isr-cache')).toBe('HIT'); + expect(warmed!.headers.get('Cache-Control')).toBe( + 'public, s-maxage=60, max-age=0, must-revalidate', + ); + }); + + it('KV HIT (stale) → serves STALE BEFORE revalidation runs, queues it via ctx.waitUntil (background, not inline), then re-puts KV', async () => { + const { kv, store } = createKvMock(); + const env = makeEnv(kv); + // Seed a stale entry: created at BASE, now BASE+200s (> ttl 60s). + seedEntry(store, '/blog/post', { body: 'stale', createdAt: BASE, ttl: 60 }); + vi.setSystemTime(BASE + 200_000); + + // A DEFERRED render we control: the background revalidation cannot produce a + // response until we call `release()`. This is the crux of proving background + // vs. inline — if `handleIsr` awaited the revalidation INLINE (instead of + // `ctx.waitUntil`), the `await handleIsr(...)` below would deadlock on this + // gate and the test would time out. It resolves only because the stale + // response is served WITHOUT waiting on the re-render. + const requests: Array = []; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const spy = vi.fn(async (req: Request) => { + requests.push(req); + await gate; // block until the test explicitly releases the render + return new Response('revalidated', { + headers: { 'content-type': 'text/html' }, + }); + }); + const render = spy as unknown as RenderFn; + const { ctx, tasks, flush } = createCtx(); + + // Original request carries a marker header (and NO X-VoidPages — an + // X-VoidPages request would take the JSON branch and never reach here). + const res = await handleIsr( + htmlRequest('/blog/post', { 'X-Test-Marker': 'keep' }), + env, + ctx, + OPTS, + render, + ); + + // (a) Resolves to the STALE response BEFORE the deferred render resolves — + // the gate is still closed, yet the client already has its answer. + expect(res!.headers.get('x-isr-cache')).toBe('STALE'); + expect(await res!.text()).toBe('stale'); + // (b) BOTH background tasks are registered via ctx.waitUntil on the stale + // path: the revalidation AND the edge-cache warm. Asserting the EXACT count + // (2), not >= 1, is what protects the registration: a regression that fired + // `revalidateInBackground` as an untracked floating promise would leave only + // the edge-warm tracked, dropping this to 1 and failing here. That matters + // because production Workers terminate untracked background work after the + // response, so the revalidation MUST go through waitUntil to survive. + expect(tasks.length).toBe(2); + // The revalidation was KICKED OFF (render invoked) but NOT awaited inline: + // KV still holds the ORIGINAL stale entry because the gate hasn't opened. + expect(spy).toHaveBeenCalledTimes(1); + const midEntry = await readIsrCache(env, HOST, '/blog/post'); + expect(midEntry!.createdAt).toBe(BASE); + expect(isStale(midEntry!)).toBe(true); + expect(await new Response(midEntry!.body).text()).toBe('stale'); + + // Now release the deferred render and drain the queued background task. + release(); + await flush(); + + // Background re-render ran exactly once (queued via ctx.waitUntil). + expect(spy).toHaveBeenCalledTimes(1); + const rerender = requests[0]!; + // The re-render request is derived from the original (marker preserved, GET, + // same URL). X-VoidPages stripping itself is proven directly by the + // `cloneWithoutVoidPages` suite above (an X-VoidPages request cannot reach + // this HTML stale path through the public `handleIsr` API). + // NOTE: cloneWithoutVoidPages uses `new Request(request, { headers })`, which + // preserves `request.cf` on real workerd (verified empirically). These unit + // tests run on Node's default vitest pool where `request.cf` does not exist, + // so cf preservation is not representable here and is intentionally not asserted. + expect(rerender.headers.has('X-VoidPages')).toBe(false); + expect(rerender.headers.get('X-Test-Marker')).toBe('keep'); + expect(rerender.method).toBe('GET'); + expect(rerender.url).toBe(`https://${HOST}/blog/post`); + + // KV re-put with the fresh render (KV is only written by the background task, + // so this is race-free) — createdAt advanced, entry is fresh again. + const entry = await readIsrCache(env, HOST, '/blog/post'); + expect(entry!.createdAt).toBe(BASE + 200_000); + expect(isStale(entry!)).toBe(false); + expect(await new Response(entry!.body).text()).toBe('revalidated'); + }); + + it('HTML edge HIT → serves the edge variant without touching KV or rendering', async () => { + const { kv, store, getWithMetadata } = createKvMock(); + const env = makeEnv(kv); + edge.store.set( + htmlEdgeKey(HOST, '/blog/post', DPL), + new Response('edge', { headers: { 'x-isr-cache': 'HIT' } }), + ); + const { render, spy } = createRender(() => new Response('SHOULD-NOT-RENDER')); + const { ctx } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(await res!.text()).toBe('edge'); + expect(res!.headers.get('Cache-Control')).toBe('public, max-age=0, must-revalidate'); + expect(spy).not.toHaveBeenCalled(); + expect(getWithMetadata).not.toHaveBeenCalled(); + expect(store.size).toBe(0); + }); + + it('JSON (X-VoidPages) MISS → renders live JSON and never writes the edge or KV', async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + const { render, spy, requests } = createRender( + () => + new Response('{"component":"Blog"}', { headers: { 'content-type': 'application/json' } }), + ); + const { ctx, tasks } = createCtx(); + + const res = await handleIsr( + htmlRequest('/blog/post', { 'X-VoidPages': '1' }), + env, + ctx, + OPTS, + render, + ); + + expect(res!.status).toBe(200); + expect(await res!.text()).toBe('{"component":"Blog"}'); + // JSON path renders with the ORIGINAL request (X-VoidPages preserved). + expect(requests[0]!.headers.has('X-VoidPages')).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); + // No writes on the JSON path — the JSON variant is warmed only by the HTML render path. + expect(tasks).toHaveLength(0); + expect(edge.store.size).toBe(0); + expect(store.size).toBe(0); + }); + + it('JSON (X-VoidPages) edge HIT → serves the cached JSON variant, no render', async () => { + const { kv } = createKvMock(); + const env = makeEnv(kv); + edge.store.set(jsonEdgeKey(HOST, '/blog/post', DPL), new Response('{"cached":true}')); + const { render, spy } = createRender(() => new Response('SHOULD-NOT-RENDER')); + const { ctx } = createCtx(); + + const res = await handleIsr( + htmlRequest('/blog/post', { 'X-VoidPages': '1' }), + env, + ctx, + OPTS, + render, + ); + expect(await res!.text()).toBe('{"cached":true}'); + expect(res!.headers.get('Cache-Control')).toBe('public, max-age=0, must-revalidate'); + expect(spy).not.toHaveBeenCalled(); + }); + + // Regression: when an HTML render warms the JSON edge variant, the STORED JSON + // response must mirror the live responder `pageJsonResponse` (pages/protocol.ts) + // by carrying `X-VoidPages: true` + `Vary: X-VoidPages`. The Pages client gates + // JSON parsing on `X-VoidPages` (navigation-engine.ts) — a cached JSON variant + // served on a later navigation WITHOUT it is treated as non-JSON and triggers a + // full-page hard reload. Prove the served header end to end (warm → HIT serve). + it('cached JSON variant (warmed by an HTML render) carries X-VoidPages + Vary on a later navigation HIT', async () => { + const { kv } = createKvMock(); + const env = makeEnv(kv); + // HTML MISS render exposes embedded page data via the x-void-page-data header, + // which `populateJsonEdgeCache` stores as the JSON edge variant. + const { render } = createRender( + () => + new Response('live', { + headers: { + 'content-type': 'text/html', + 'x-void-page-data': '{"component":"Blog","props":{}}', + }, + }), + ); + const { ctx, flush } = createCtx(); + + // 1) HTML navigation MISS → warms the JSON edge variant in the background. + await handleIsr(htmlRequest(), env, ctx, OPTS, render); + await flush(); + expect(edge.store.has(jsonEdgeKey(HOST, '/blog/post', DPL))).toBe(true); + + // 2) A subsequent Pages (X-VoidPages) navigation hits the warmed JSON variant. + const { render: render2, spy: spy2 } = createRender(() => new Response('SHOULD-NOT-RENDER')); + const { ctx: ctx2 } = createCtx(); + const res = await handleIsr( + htmlRequest('/blog/post', { 'X-VoidPages': '1' }), + env, + ctx2, + OPTS, + render2, + ); + + // Served from the cached JSON variant (no live render). + expect(spy2).not.toHaveBeenCalled(); + // The header the client gates on — missing it means a hard reload. + expect(res!.headers.get('X-VoidPages')).toBe('true'); + expect(res!.headers.get('Vary')).toBe('X-VoidPages'); + expect(res!.headers.get('content-type')).toBe('application/json'); + expect(await res!.text()).toBe('{"component":"Blog","props":{}}'); + }); + + it('fails open to a LIVE render (not a 500) when getWithMetadata throws', async () => { + const { kv } = createKvMock({ throwOnGet: true }); + const env = makeEnv(kv); + const { render, spy } = createRender( + () => new Response('live', { headers: { 'content-type': 'text/html' } }), + ); + const { ctx } = createCtx(); + + // readIsrCache degrades to null on the throw... + await expect(readIsrCache(env, HOST, '/blog/post')).resolves.toBeNull(); + // ...and handleIsr degrades to the live render rather than throwing. + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res!.status).toBe(200); + expect(res!.headers.get('x-isr-cache')).toBe('MISS'); + expect(await res!.text()).toBe('live'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + // A corrupt KV entry on the hit path must degrade to a live render, never 500 + // and never throw — proven through the PUBLIC `handleIsr` API for EVERY corrupt + // shape (the full module-scoped `corruptCases` table, including createdAt + // NaN/Infinity/negative and ttl zero/negative), not just a hand-picked subset. + for (const c of corruptCases) { + it(`degrades to a live render on a corrupt KV hit (never 500): ${c.name}`, async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + store.set(isrKey(DPL, HOST, '/blog/post'), c.seed()); + + // Choke point: the corrupt entry is rejected at `readIsrCache` (→ null), + // so `isrCacheResponse` never sees an un-constructible entry. + await expect(readIsrCache(env, HOST, '/blog/post')).resolves.toBeNull(); + + const { render, spy } = createRender( + () => new Response('live', { headers: { 'content-type': 'text/html' } }), + ); + const { ctx, flush } = createCtx(); + + // handleIsr returns the LIVE render (MISS), never a 500 and never a throw. + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res!.status).toBe(200); + expect(res!.headers.get('x-isr-cache')).toBe('MISS'); + expect(await res!.text()).toBe('live'); + expect(spy).toHaveBeenCalledTimes(1); + + await flush(); + // The MISS path overwrote the corrupt entry with a valid, readable one. + const entry = await readIsrCache(env, HOST, '/blog/post'); + expect(entry).not.toBeNull(); + expect(await new Response(entry!.body).text()).toBe('live'); + }); + } + + it('background revalidation with x-revalidate: 0 purges the KV entry and both edge variants', async () => { + const { store, kv, delete: kvDelete } = createKvMock(); + const env = makeEnv(kv); + seedEntry(store, '/blog/post', { createdAt: BASE, ttl: 60 }); + vi.setSystemTime(BASE + 200_000); // stale + + // Pre-warm the JSON edge variant so we can watch it get purged. The HTML + // edge key is left empty on purpose — pre-warming it would hit L1 and + // short-circuit before the KV stale path (which triggers the purge) runs. + edge.store.set(jsonEdgeKey(HOST, '/blog/post', DPL), new Response('{"edge":true}')); + + const { render } = createRender( + () => + new Response('opt-out', { + headers: { 'content-type': 'text/html', 'x-revalidate': '0' }, + }), + ); + const { ctx, flush } = createCtx(); + + await handleIsr(htmlRequest(), env, ctx, OPTS, render); + await flush(); + + // KV entry is only ever deleted here (never re-put) — deterministic real removal. + expect(store.has(isrKey(DPL, HOST, '/blog/post'))).toBe(false); + expect(kvDelete).toHaveBeenCalledWith(isrKey(DPL, HOST, '/blog/post')); + // JSON edge variant is only touched by the purge → deterministically removed. + expect(edge.store.has(jsonEdgeKey(HOST, '/blog/post', DPL))).toBe(false); + expect(edge.delete).toHaveBeenCalledWith(jsonEdgeKey(HOST, '/blog/post', DPL)); + // HTML edge purge is issued; its final state races the concurrent stale-warm + // put, so assert the delete call rather than the (racey) final map state. + expect(edge.delete).toHaveBeenCalledWith(htmlEdgeKey(HOST, '/blog/post', DPL)); + }); + + it('returns null (caller renders live) for non-GET, credentialed, and non-ISR paths', async () => { + const { kv } = createKvMock(); + const env = makeEnv(kv); + const { render } = createRender(() => new Response('x')); + const { ctx } = createCtx(); + + // Non-GET. + expect( + await handleIsr( + new Request(`https://${HOST}/blog/post`, { method: 'POST' }), + env, + ctx, + OPTS, + render, + ), + ).toBeNull(); + // Credentialed (Cookie) requests bypass ISR. + expect( + await handleIsr(htmlRequest('/blog/post', { Cookie: 'sid=1' }), env, ctx, OPTS, render), + ).toBeNull(); + // Path with no configured TTL. + expect( + await handleIsr( + htmlRequest('/blog/post'), + env, + ctx, + { revalidate: { '/other': 60 }, deploymentId: DPL }, + render, + ), + ).toBeNull(); + }); + + // FIX A: the Cache API can be absent/degraded — `edgeCache()` must swallow the + // `caches.default` deref (returning undefined) so `handleIsr` never rejects + // before its fail-open guards run. A degraded cache renders live, never 500. + it('fails open (never throws) when the Cache API is absent — a MISS renders live', async () => { + // Degrade the Cache API entirely: reading `caches.default` throws. + vi.stubGlobal('caches', undefined); + + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + const { render, spy } = createRender( + () => new Response('live', { headers: { 'content-type': 'text/html' } }), + ); + const { ctx, flush } = createCtx(); + + // L1 edge is unreachable → treated as a miss → L2 KV miss → live MISS render. + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + expect(res!.headers.get('x-isr-cache')).toBe('MISS'); + expect(await res!.text()).toBe('live'); + expect(spy).toHaveBeenCalledTimes(1); + + // Background writes (guarded on an undefined edge cache) drain without throwing; + // the KV write still lands. + await expect(flush()).resolves.toBeUndefined(); + const entry = await readIsrCache(env, HOST, '/blog/post'); + expect(entry).not.toBeNull(); + expect(await new Response(entry!.body).text()).toBe('live'); + // Exactly one PAGE-cache entry (`void-isr/…`); the registry key is separate. + expect([...store.keys()].filter((k) => k.startsWith('void-isr/')).length).toBe(1); + }); + + it('serves the L2 KV HIT with no per-colo edge cache (KV ladder independent of caches.default)', async () => { + // `caches.default` throws on access — the KV ladder must still serve. + vi.stubGlobal('caches', { + get default(): Cache { + throw new Error('Cache API degraded'); + }, + }); + + const { kv, store } = createKvMock(); + const env = makeEnv(kv); + seedEntry(store, '/blog/post', { body: 'kv', createdAt: BASE, ttl: 60 }); + const { render, spy } = createRender(() => new Response('SHOULD-NOT-RENDER')); + const { ctx, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res).not.toBeNull(); + expect(res!.headers.get('x-isr-cache')).toBe('HIT'); + expect(await res!.text()).toBe('kv'); + // Served from KV without a live render, despite no reachable edge cache. + expect(spy).not.toHaveBeenCalled(); + // The edge-warm background task (guarded on undefined cache) drains cleanly. + await expect(flush()).resolves.toBeUndefined(); + }); + + // ========================================================================= + // Range bypass + full-200-only cache gate + // A Range GET on an ISR path (e.g. a byte-range media route under a global + // `revalidate: "*"`) must render LIVE so the origin emits a correct `206 + // Partial Content`. It must never read the shared (host,path) entry (a full + // body for a partial request) nor write it (a truncated 206 that a later full + // request would then be served). Defense-in-depth: the write gate is tightened + // from `response.ok` (200–299, INCLUDING 206) to `status === 200`, so even a + // non-range render that returns 206/204/etc. never lands in the shared cache. + // ========================================================================= + describe('Range bypass + full-200-only cache gate', () => { + it('bypasses ISR for a Range request → null before any cache read, render, or write', async () => { + const { store, kv, getWithMetadata, put } = createKvMock(); + const env = makeEnv(kv); + const { render, spy } = createRender( + () => new Response('SHOULD-NOT-RENDER', { status: 206 }), + ); + const { ctx, tasks } = createCtx(); + + const res = await handleIsr( + htmlRequest('/blog/post', { Range: 'bytes=0-1023' }), + env, + ctx, + OPTS, + render, + ); + + // Range requests render live (the caller handles them) — handleIsr bails. + expect(res).toBeNull(); + // No cache READ: neither the L1 edge nor the L2 KV was consulted. + expect(edge.match).not.toHaveBeenCalled(); + expect(getWithMetadata).not.toHaveBeenCalled(); + // No render kicked off inside handleIsr, and no cache WRITE scheduled. + expect(spy).not.toHaveBeenCalled(); + expect(edge.put).not.toHaveBeenCalled(); + expect(put).not.toHaveBeenCalled(); + expect(tasks).toHaveLength(0); + expect(store.size).toBe(0); + expect(edge.store.size).toBe(0); + }); + + it('does NOT cache a forced 206 render (non-range) — only full 200 responses are written', async () => { + const { store, kv, put } = createKvMock(); + const env = makeEnv(kv); + // No Range header on the request, yet the render returns 206 — the tightened + // write gate (status === 200, not response.ok) must still refuse to persist it. + const { render, spy } = createRender( + () => + new Response('partial', { + status: 206, + headers: { 'content-type': 'text/html', 'content-range': 'bytes 0-9/100' }, + }), + ); + const { ctx, tasks, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + + // The live 206 is returned to the client verbatim (never rewritten to 200). + expect(res).not.toBeNull(); + expect(res!.status).toBe(206); + expect(await res!.text()).toBe('partial'); + expect(spy).toHaveBeenCalledTimes(1); + + await flush(); + // Nothing persisted: a 206 partial body must never enter a shared full- + // response cache (KV or edge), and no write was even scheduled. + expect(tasks).toHaveLength(0); + expect(put).not.toHaveBeenCalled(); + expect(edge.put).not.toHaveBeenCalled(); + expect([...store.keys()].filter((k) => k.startsWith('void-isr/')).length).toBe(0); + expect(edge.store.size).toBe(0); + expect(await readIsrCache(env, HOST, '/blog/post')).toBeNull(); + }); + + it('still writes + serves a full 200 render (the tightened gate does not block 200)', async () => { + const { store, kv, put } = createKvMock(); + const env = makeEnv(kv); + const { render, spy } = createRender( + () => + new Response('ok-200', { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + ); + const { ctx, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res!.status).toBe(200); + expect(res!.headers.get('x-isr-cache')).toBe('MISS'); + expect(await res!.text()).toBe('ok-200'); + expect(spy).toHaveBeenCalledTimes(1); + + await flush(); + // The 200 render lands in KV + the HTML edge variant, exactly as before. + expect(put).toHaveBeenCalled(); + const entry = await readIsrCache(env, HOST, '/blog/post'); + expect(entry).not.toBeNull(); + expect(entry!.status).toBe(200); + expect(await new Response(entry!.body).text()).toBe('ok-200'); + expect(edge.store.has(htmlEdgeKey(HOST, '/blog/post', DPL))).toBe(true); + expect([...store.keys()].filter((k) => k.startsWith('void-isr/')).length).toBe(1); + }); + }); +}); + +// =========================================================================== +// matchLiteralPattern — segment-anchored matching (FIX 3) +// =========================================================================== + +describe('matchLiteralPattern — segment-anchored matching', () => { + it('anchors a WHOLE-segment `:name` to exactly ONE path segment', () => { + expect(matchLiteralPattern('/blog/:slug', '/blog/hello')).toBe(true); + // Deeper path — `:slug` must NOT cross a `/`. + expect(matchLiteralPattern('/blog/:slug', '/blog/a/b')).toBe(false); + // Empty segment — `[^/]+` requires at least one char. + expect(matchLiteralPattern('/blog/:slug', '/blog/')).toBe(false); + expect(matchLiteralPattern('/blog/:slug', '/blog')).toBe(false); + }); + + it('lets `*` cross `/` (catch-all splat)', () => { + expect(matchLiteralPattern('/blog/*', '/blog/a/b')).toBe(true); + expect(matchLiteralPattern('/blog/*', '/blog/hello')).toBe(true); + }); + + it('matches a root catch-all `/*` against any path (including root)', () => { + expect(matchLiteralPattern('/*', '/')).toBe(true); + expect(matchLiteralPattern('/*', '/x/y')).toBe(true); + }); + + it('anchors EACH param in a multi-segment `:cat/:id` pattern to one segment', () => { + expect(matchLiteralPattern('/shop/:cat/:id', '/shop/shoes/42')).toBe(true); + expect(matchLiteralPattern('/shop/:cat/:id', '/shop/shoes')).toBe(false); + expect(matchLiteralPattern('/shop/:cat/:id', '/shop/shoes/42/extra')).toBe(false); + }); + + it('preserves a MID-segment literal `:` — only whole-segment `:name` is a wildcard', () => { + // A user literal key `/api/v1:health` must match ONLY itself: the `:health` + // is mid-segment (preceded by `1`, not `/`), so it stays a literal. + expect(matchLiteralPattern('/api/v1:health', '/api/v1:health')).toBe(true); + expect(matchLiteralPattern('/api/v1:health', '/api/v1:other')).toBe(false); + expect(matchLiteralPattern('/api/v1:health', '/api/v1/health')).toBe(false); + }); + + it('is a strict SUPERSET of the old matcher — old `*`-only manifests match identically', () => { + // The OLD emitter produced `/blog/*` for `[slug]`; that manifest must keep + // matching the same paths under the new matcher (rollout safety). + expect(matchLiteralPattern('/blog/*', '/blog/hello')).toBe(true); + expect(matchLiteralPattern('/blog/*', '/blog/a/b')).toBe(true); + }); +}); + +describe('resolvePathTtl — single-segment anchoring', () => { + it('resolves the single-segment TTL for a one-segment path but NOT a deeper one', () => { + // The FIX-3 emitter keeps `/blog/:slug` as-is; the matcher anchors it to one + // segment, so `/blog/hello` gets the TTL while `/blog/a/b` does not. + const revalidate = { '/blog/:slug': 120 }; + expect(resolvePathTtl(revalidate, '/blog/hello')).toBe(120); + expect(resolvePathTtl(revalidate, '/blog/a/b')).toBe(0); + }); +}); + +// =========================================================================== +// Host-scoped KV cache + host registry (FIX 4) +// =========================================================================== + +describe('host-scoped KV cache + host registry', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(BASE); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not bleed across hosts: write under one host, read under another → null', async () => { + const { kv } = createKvMock(); + const env = makeEnv(kv); + await writeIsrCache( + env, + 'foo.example', + '/x', + new Response('foo', { headers: { 'content-type': 'text/html' } }), + 60, + ); + // Same path, different host → miss (keys are host-scoped). + expect(await readIsrCache(env, 'bar.example', '/x')).toBeNull(); + // Same host → hit. + expect(await readIsrCache(env, 'foo.example', '/x')).not.toBeNull(); + }); + + it('registers the host in the registry on write (void-isr-hosts//)', async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + // Unique host so the module-level in-isolate registration guard (shared across + // this file's tests) does not skip the registry write here. + const host = 'registry-write.example'; + await writeIsrCache( + env, + host, + '/p', + new Response('x', { headers: { 'content-type': 'text/html' } }), + 60, + ); + expect(store.has(hostRegistryKey(DPL, host))).toBe(true); + }); + + it('retries host registration on a later write after a transient registry-put failure', async () => { + // Unique host so the module-level in-isolate registration guard does not pre-skip it. + const host = 'registry-retry.example'; + const store = new Map(); + let registryPuts = 0; + const put = vi.fn( + async (key: string, value: ArrayBuffer, options?: { metadata?: IsrCacheMetadata }) => { + if (key.startsWith('void-isr-hosts/')) { + registryPuts++; + if (registryPuts === 1) { + throw new Error('transient registry failure'); + } + } + store.set(key, { value, metadata: options?.metadata ?? null }); + }, + ); + const list = vi.fn(async (o: { prefix?: string } = {}) => ({ + keys: [...store.keys()] + .filter((k) => k.startsWith(o.prefix ?? '')) + .sort() + .map((name) => ({ name })), + list_complete: true, + cursor: '', + })); + const kv = { put, list, getWithMetadata: vi.fn(), delete: vi.fn() } as unknown as KVNamespace; + const env = makeEnv(kv); + const res = () => new Response('x', { headers: { 'content-type': 'text/html' } }); + + // First write: content put succeeds, registry put throws → guard cleared, host unregistered. + await writeIsrCache(env, host, '/p', res(), 60); + expect(store.has(hostRegistryKey(DPL, host))).toBe(false); + + // Second write: the guard was cleared, so the registry put is retried and succeeds. + await writeIsrCache(env, host, '/p', res(), 60); + expect(registryPuts).toBe(2); + expect(store.has(hostRegistryKey(DPL, host))).toBe(true); + expect(await listIsrHosts(kv, DPL)).toContain(host); + }); + + it('listIsrHosts returns the registered hosts for a deployment, scoped by id', async () => { + const { store, kv } = createKvMock(); + store.set(hostRegistryKey(DPL, 'a.example'), { value: new ArrayBuffer(0), metadata: null }); + store.set(hostRegistryKey(DPL, 'b.example'), { value: new ArrayBuffer(0), metadata: null }); + // A different deployment's registration must NOT leak into this list. + store.set(hostRegistryKey('other_dpl', 'c.example'), { + value: new ArrayBuffer(0), + metadata: null, + }); + const hosts = await listIsrHosts(kv, DPL); + expect([...hosts].sort()).toEqual(['a.example', 'b.example']); + }); + + it('listIsrHosts fails open to [] on a broken registry (never throws)', async () => { + const brokenKv = { + list: vi.fn(async () => { + throw new Error('KV unavailable'); + }), + } as unknown as KVNamespace; + await expect(listIsrHosts(brokenKv, DPL)).resolves.toEqual([]); + }); +}); diff --git a/packages/void/test/unit/isr-config.test.ts b/packages/void/test/unit/isr-config.test.ts new file mode 100644 index 000000000..df2474f47 --- /dev/null +++ b/packages/void/test/unit/isr-config.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vite-plus/test'; +import type { VoidConfig } from '../../src/config.ts'; +import { collectRevalidate, hasRevalidate } from '../../src/isr-config.ts'; +import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; +import { resolvePathTtl } from '../../src/runtime/isr-cache.ts'; + +describe('hasRevalidate', () => { + it('treats null / undefined / 0 / negative / {} as no ISR', () => { + expect(hasRevalidate(null)).toBe(false); + expect(hasRevalidate(undefined)).toBe(false); + expect(hasRevalidate(0)).toBe(false); + expect(hasRevalidate(-5)).toBe(false); + expect(hasRevalidate({})).toBe(false); + }); + + it('treats a positive number or a non-empty path map as ISR on', () => { + expect(hasRevalidate(60)).toBe(true); + expect(hasRevalidate({ '/x': 10 })).toBe(true); + }); + + it('treats non-number/non-object values as no ISR', () => { + expect(hasRevalidate('60')).toBe(false); + expect(hasRevalidate(true)).toBe(false); + }); +}); + +function makePage(pattern: string, revalidate?: number): PageDefinition { + return { + pattern, + params: [], + catchAll: false, + componentPath: `${pattern}.vue`, + serverPath: `${pattern}.server.ts`, + methods: ['GET'], + componentId: pattern.replace(/^\//, ''), + revalidate, + ssrMode: 'auto', + prerenderMode: 'auto', + renderMode: 'server', + shouldPrerender: false, + }; +} + +function makeScan(pages: Array): PageScanResult { + return { pages, layouts: [], namedLayouts: [] }; +} + +function configWith(revalidate: number | Record | undefined): VoidConfig { + return (revalidate === undefined ? {} : { routing: { revalidate } }) as VoidConfig; +} + +describe('collectRevalidate', () => { + it('returns undefined with no config and no per-page rules', () => { + expect(collectRevalidate(configWith(undefined), makeScan([]))).toBeUndefined(); + expect(collectRevalidate(configWith(undefined), null)).toBeUndefined(); + }); + + it('passes the void.json config through when there are no per-page rules', () => { + expect(collectRevalidate(configWith(60), makeScan([]))).toBe(60); + expect(collectRevalidate(configWith({ '/docs/*': 30 }), makeScan([]))).toEqual({ + '/docs/*': 30, + }); + }); + + it('uses per-page rules alone when config is absent', () => { + const scan = makeScan([makePage('/blog/:slug', 120), makePage('/about')]); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/:slug': 120 }); + }); + + it('turns a numeric config into a path map with the number as the "*" fallback (per-page first)', () => { + const scan = makeScan([makePage('/blog/:slug', 120)]); + const result = collectRevalidate(configWith(60), scan); + expect(result).toEqual({ '/blog/:slug': 120, '*': 60 }); + // Per-page entry must precede the fallback (first-match-wins ordering). + expect(Object.keys(result as Record)).toEqual(['/blog/:slug', '*']); + }); + + it('merges per-page rules ahead of an object config (per-page first in match order)', () => { + const scan = makeScan([makePage('/blog/:slug', 120)]); + const result = collectRevalidate(configWith({ '/docs/*': 30, '*': 10 }), scan); + // Per-page entries are emitted before the config entries, so first-match + // resolution consults the per-page pattern ahead of the config's catch-alls. + expect(Object.entries(result as Record)).toEqual([ + ['/blog/:slug', 120], + ['/docs/*', 30], + ['*', 10], + ]); + }); + + it('ignores pages that declare no revalidate', () => { + const scan = makeScan([makePage('/about'), makePage('/contact')]); + expect(collectRevalidate(configWith(undefined), scan)).toBeUndefined(); + expect(collectRevalidate(configWith(60), scan)).toBe(60); + }); + + // FIX 3: single-segment params (`:slug`) stay as `:slug` so the matcher can + // anchor them to ONE path segment (`[^/]+`); only named catch-alls (`:slug{.+}`) + // and anonymous catch-alls (`*`) collapse to `*` (a splat that crosses `/`). + describe('route-param → key conversion (generated page keys)', () => { + it('keeps a single dynamic param `[slug]` (`/blog/:slug`) as `/blog/:slug`', () => { + const scan = makeScan([makePage('/blog/:slug', 120)]); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/:slug': 120 }); + }); + + it('converts a catch-all param `[...slug]` (`/blog/:slug{.+}`) to `/blog/*`', () => { + const scan = makeScan([makePage('/blog/:slug{.+}', 90)]); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/*': 90 }); + }); + + it('converts a root catch-all (`/:all{.+}`) to `/*`', () => { + const scan = makeScan([makePage('/:all{.+}', 15)]); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/*': 15 }); + }); + + it('keeps every single-segment param in a multi-param pattern (`/shop/:cat/:id`)', () => { + const scan = makeScan([makePage('/shop/:cat/:id', 45)]); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/shop/:cat/:id': 45 }); + }); + + it('leaves static page patterns untouched', () => { + const scan = makeScan([makePage('/about', 300)]); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/about': 300 }); + }); + + it('does NOT convert user `routing.revalidate` literal keys (only generated page keys)', () => { + // A user-authored key that happens to contain a `:` must pass through + // verbatim — only the scanned page patterns are converted. + const scan = makeScan([makePage('/blog/:slug', 120)]); + const result = collectRevalidate(configWith({ '/legacy/:kept': 30 }), scan); + expect(Object.entries(result as Record)).toEqual([ + ['/blog/:slug', 120], + ['/legacy/:kept', 30], + ]); + }); + }); + + // FIX 3 end-to-end: the emitted `:slug` key must anchor to ONE segment through + // the same resolver the runtime uses (mirrors isr-cache resolvePathTtl). + it('produces keys that resolvePathTtl anchors to a single dynamic path segment', () => { + const scan = makeScan([makePage('/blog/:slug', 120)]); + const revalidate = collectRevalidate(configWith(undefined), scan); + // Single dynamic segment matches. + expect(resolvePathTtl(revalidate, '/blog/hello')).toBe(120); + // A DEEPER path must NOT match the single-segment `:slug` key (would have + // matched the old `/blog/*` wildcard) — proves the segment anchoring. + expect(resolvePathTtl(revalidate, '/blog/a/b')).toBe(0); + // An unrelated path still resolves to 0 (no ISR). + expect(resolvePathTtl(revalidate, '/about')).toBe(0); + }); +}); diff --git a/packages/void/test/unit/isr.test.ts b/packages/void/test/unit/isr.test.ts new file mode 100644 index 000000000..c20aadd2b --- /dev/null +++ b/packages/void/test/unit/isr.test.ts @@ -0,0 +1,328 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { envContext } from '../../src/runtime/env-raw.ts'; +import { hostRegistryKey, htmlEdgeKey, isrKey, jsonEdgeKey } from '../../src/runtime/isr-cache.ts'; +import { revalidate } from '../../src/runtime/isr.ts'; + +const DPL = 'dpl_1'; +const HOST = 'app.example.com'; + +// In-memory KV mock. `pageSize` forces the mock to paginate so revalidate's +// cursor loop (`{ all: true }`) is genuinely exercised with a small key set. +function createKvMock(pageSize = 1000) { + const store = new Map(); + const del = vi.fn(async (key: string) => { + store.delete(key); + }); + // Real KV list is a point-in-time view: snapshot the key set when a new + // list chain starts (cursor absent) so deletions mid-pagination — as + // revalidate({ all: true }) does — don't shift the offset and skip keys. + let snapshot: Array = []; + const list = vi.fn(async (o: { prefix?: string; cursor?: string } = {}) => { + const prefix = o.prefix ?? ''; + if (!o.cursor) { + snapshot = [...store.keys()].filter((k) => k.startsWith(prefix)).sort(); + } + const start = o.cursor ? Number.parseInt(o.cursor, 10) : 0; + const slice = snapshot.slice(start, start + pageSize); + const complete = start + pageSize >= snapshot.length; + return { + keys: slice.map((name) => ({ name })), + list_complete: complete, + cursor: complete ? '' : String(start + pageSize), + }; + }); + const kv = { delete: del, list } as unknown as KVNamespace; + return { store, kv, delete: del, list }; +} + +function createEdgeMock() { + const store = new Map(); + const del = vi.fn(async (key: string | Request) => { + const k = typeof key === 'string' ? key : (key as Request).url; + return store.delete(k); + }); + const cache = { delete: del } as unknown as Cache; + return { store, cache, delete: del }; +} + +describe('revalidate — managed (platform proxy)', () => { + it('forwards { paths } to the proxy /revalidate endpoint (no self-host regression)', async () => { + const fetchMock = vi.fn( + async (_url: string, _init: RequestInit) => new Response('ok', { status: 200 }), + ); + await envContext.run( + { __VOID_PROXY: { fetch: fetchMock }, __PROJECT_ID: 'proj_1', __VOID_PROXY_TOKEN: 'tok_1' }, + async () => { + await revalidate({ paths: ['/', '/blog/x'] }); + }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://proxy/revalidate'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ + projectId: 'proj_1', + aiToken: 'tok_1', + paths: ['/', '/blog/x'], + }); + }); + + it('forwards { all: true } to the proxy', async () => { + const fetchMock = vi.fn( + async (_url: string, _init: RequestInit) => new Response('ok', { status: 200 }), + ); + await envContext.run( + { __VOID_PROXY: { fetch: fetchMock }, __PROJECT_ID: 'proj_1', __VOID_PROXY_TOKEN: 'tok_1' }, + async () => { + await revalidate({ all: true }); + }, + ); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + projectId: 'proj_1', + aiToken: 'tok_1', + all: true, + }); + }); + + it('throws when the proxy responds non-ok', async () => { + const fetchMock = vi.fn(async () => new Response('nope', { status: 500 })); + await expect( + envContext.run( + { __VOID_PROXY: { fetch: fetchMock }, __PROJECT_ID: 'proj_1', __VOID_PROXY_TOKEN: 'tok_1' }, + async () => { + await revalidate({ paths: ['/'] }); + }, + ), + ).rejects.toThrow(/500/); + }); + + it('throws when project id / proxy token bindings are missing', async () => { + const fetchMock = vi.fn(async () => new Response('ok')); + await expect( + envContext.run({ __VOID_PROXY: { fetch: fetchMock } }, async () => { + await revalidate({ paths: ['/'] }); + }), + ).rejects.toThrow(/__PROJECT_ID and __VOID_PROXY_TOKEN/); + }); +}); + +describe('revalidate — self-host (own ISR_CACHE + edge)', () => { + let edge: ReturnType; + + beforeEach(() => { + edge = createEdgeMock(); + vi.stubGlobal('caches', { default: edge.cache }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('{ paths } (request host) deletes EXACTLY the requested (host, path) entries from KV and both edge variants — no over-purge', async () => { + const { store, kv, delete: kvDelete } = createKvMock(); + store.set(isrKey(DPL, HOST, '/'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/blog/x'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/keepme'), new ArrayBuffer(4)); + // Target edge variants (both should be purged). + edge.store.set(htmlEdgeKey(HOST, '/', DPL), new Response('x')); + edge.store.set(jsonEdgeKey(HOST, '/', DPL), new Response('x')); + edge.store.set(htmlEdgeKey(HOST, '/blog/x', DPL), new Response('x')); + edge.store.set(jsonEdgeKey(HOST, '/blog/x', DPL), new Response('x')); + // UNRELATED edge variants for a path NOT being revalidated — an over-purging + // implementation (e.g. deleting sibling variants, or purging by prefix) would + // wrongly remove these. They MUST survive. + edge.store.set(htmlEdgeKey(HOST, '/keepme', DPL), new Response('x')); + edge.store.set(jsonEdgeKey(HOST, '/keepme', DPL), new Response('x')); + + await envContext.run( + { ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL, __VOID_ISR_HOST: HOST }, + async () => { + await revalidate({ paths: ['/', '/blog/x'] }); + }, + ); + + // Exactly the requested (host, path) KV keys are gone; the untouched one survives. + expect(store.has(isrKey(DPL, HOST, '/'))).toBe(false); + expect(store.has(isrKey(DPL, HOST, '/blog/x'))).toBe(false); + expect(store.has(isrKey(DPL, HOST, '/keepme'))).toBe(true); + + // Both edge variants purged for each requested path; unrelated ones survive. + expect(edge.store.has(htmlEdgeKey(HOST, '/', DPL))).toBe(false); + expect(edge.store.has(jsonEdgeKey(HOST, '/', DPL))).toBe(false); + expect(edge.store.has(htmlEdgeKey(HOST, '/blog/x', DPL))).toBe(false); + expect(edge.store.has(jsonEdgeKey(HOST, '/blog/x', DPL))).toBe(false); + expect(edge.store.has(htmlEdgeKey(HOST, '/keepme', DPL))).toBe(true); + expect(edge.store.has(jsonEdgeKey(HOST, '/keepme', DPL))).toBe(true); + + // EXACT KV delete calls: precisely the two requested (host, path) keys, no + // extras. An over-purge (e.g. also deleting `/keepme`) would fail here. + expect(kvDelete).toHaveBeenCalledTimes(2); + expect(kvDelete.mock.calls.map((c) => c[0])).toEqual([ + isrKey(DPL, HOST, '/'), + isrKey(DPL, HOST, '/blog/x'), + ]); + + // EXACT edge delete calls: html + json for each requested path (correct + // host/id), in purge order, with no unrelated deletes. + expect(edge.delete).toHaveBeenCalledTimes(4); + expect(edge.delete.mock.calls.map((c) => c[0])).toEqual([ + htmlEdgeKey(HOST, '/', DPL), + jsonEdgeKey(HOST, '/', DPL), + htmlEdgeKey(HOST, '/blog/x', DPL), + jsonEdgeKey(HOST, '/blog/x', DPL), + ]); + }); + + // Cron/queue handlers stamp the deployment id WITHOUT a request host (the F1 + // fix), so a host-less `revalidate({ paths })` must enumerate EVERY registered + // host from the registry and purge each host's (host, path) entry. + it('{ paths } with NO request host purges every REGISTERED host (cron/queue purge via registry)', async () => { + const { store, kv, delete: kvDelete } = createKvMock(); + const HOST_A = 'a.example'; + const HOST_B = 'b.example'; + // Two hosts have cached the same path; both are registered. + store.set(isrKey(DPL, HOST_A, '/p'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST_B, '/p'), new ArrayBuffer(4)); + store.set(hostRegistryKey(DPL, HOST_A), new ArrayBuffer(0)); + store.set(hostRegistryKey(DPL, HOST_B), new ArrayBuffer(0)); + edge.store.set(htmlEdgeKey(HOST_A, '/p', DPL), new Response('x')); + edge.store.set(htmlEdgeKey(HOST_B, '/p', DPL), new Response('x')); + + // NO __VOID_ISR_HOST — the cron/queue path relies entirely on the registry. + await envContext.run({ ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL }, async () => { + await revalidate({ paths: ['/p'] }); + }); + + // Both registered hosts' KV + edge entries purged. + expect(store.has(isrKey(DPL, HOST_A, '/p'))).toBe(false); + expect(store.has(isrKey(DPL, HOST_B, '/p'))).toBe(false); + expect(edge.store.has(htmlEdgeKey(HOST_A, '/p', DPL))).toBe(false); + expect(edge.store.has(htmlEdgeKey(HOST_B, '/p', DPL))).toBe(false); + // One KV delete per registered host (order-independent). + expect(kvDelete.mock.calls.map((c) => c[0]).sort()).toEqual( + [isrKey(DPL, HOST_A, '/p'), isrKey(DPL, HOST_B, '/p')].sort(), + ); + }); + + it('{ paths } with no request host and an EMPTY registry is a silent no-op (nothing purged)', async () => { + const { store, kv, delete: kvDelete } = createKvMock(); + store.set(isrKey(DPL, HOST, '/'), new ArrayBuffer(4)); + + await envContext.run({ ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL }, async () => { + await revalidate({ paths: ['/'] }); + }); + + // No registered host and no request host → no host to purge → key survives. + expect(store.has(isrKey(DPL, HOST, '/'))).toBe(true); + expect(kvDelete).not.toHaveBeenCalled(); + expect(edge.delete).not.toHaveBeenCalled(); + }); + + // The authoritative KV purge must never depend on the Cache API: the edge purge + // is guarded, so a degraded `caches.default` (scheduled/queue handlers, or no + // Cache API) fails open without blocking the KV delete. + it('purges KV via the registry even when the Cache API is degraded (edge fails open, KV still purged)', async () => { + // Degraded Cache API: reading `caches.default` throws. + vi.stubGlobal('caches', { + get default(): Cache { + throw new Error('Cache API unavailable'); + }, + }); + + const { store, kv, delete: kvDelete } = createKvMock(); + store.set(isrKey(DPL, HOST, '/'), new ArrayBuffer(4)); + store.set(hostRegistryKey(DPL, HOST), new ArrayBuffer(0)); + + // No __VOID_ISR_HOST → host comes from the registry; the degraded edge must + // not throw and must not block the KV purge. + await expect( + envContext.run({ ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL }, async () => { + await revalidate({ paths: ['/'] }); + }), + ).resolves.toBeUndefined(); + + // KV delete completed despite the swallowed edge throw. + expect(store.has(isrKey(DPL, HOST, '/'))).toBe(false); + expect(kvDelete).toHaveBeenCalledWith(isrKey(DPL, HOST, '/')); + }); + + it('{ all: true } lists the void-isr// prefix, SPLITS the host off each key, and purges every (host, path) — cursor-paginated', async () => { + // pageSize 2 forces the cursor loop to iterate across multiple pages. + const { store, kv, list } = createKvMock(2); + const HOST2 = 'other.example'; + store.set(isrKey(DPL, HOST, '/'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/a'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/b/c'), new ArrayBuffer(4)); + // A SECOND host under the same deployment — its key must be purged too, and + // its host must be split back out of the scanned key correctly. + store.set(isrKey(DPL, HOST2, '/d'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/e'), new ArrayBuffer(4)); + // A different deployment's entry must never be touched. + store.set(isrKey('other_dpl', HOST, '/x'), new ArrayBuffer(4)); + + await envContext.run( + { ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL, __VOID_ISR_HOST: HOST }, + async () => { + await revalidate({ all: true }); + }, + ); + + // Every (host, path) under THIS deployment is gone; the other deployment survives. + for (const [h, p] of [ + [HOST, '/'], + [HOST, '/a'], + [HOST, '/b/c'], + [HOST2, '/d'], + [HOST, '/e'], + ] as const) { + expect(store.has(isrKey(DPL, h, p))).toBe(false); + } + expect(store.has(isrKey('other_dpl', HOST, '/x'))).toBe(true); + // Cursor loop ran more than once (5 keys / pageSize 2 → 3 list calls). + expect(list.mock.calls.length).toBeGreaterThan(1); + // EVERY list call is scoped to THIS deployment's prefix. An unscoped + // `kv.list()` (or a wrong/missing prefix) would enumerate — and therefore + // purge — other deployments' keys; assert the prefix on every page. + const listPrefix = `void-isr/${DPL}/`; + for (const [opts] of list.mock.calls) { + expect((opts as { prefix?: string }).prefix).toBe(listPrefix); + } + // The first page starts with no cursor. + expect((list.mock.calls[0][0] as { cursor?: string }).cursor).toBeUndefined(); + // Edge purge issued with the host SPLIT from the scanned key (path round-tripped + // through denormalizeIsrPath), including the second host. + expect(edge.delete).toHaveBeenCalledWith(htmlEdgeKey(HOST, '/', DPL)); + expect(edge.delete).toHaveBeenCalledWith(htmlEdgeKey(HOST, '/b/c', DPL)); + expect(edge.delete).toHaveBeenCalledWith(htmlEdgeKey(HOST2, '/d', DPL)); + }); +}); + +describe('revalidate — unconfigured (silent no-op)', () => { + beforeEach(() => { + // No caches.default stub — an unconfigured worker must not even reach it. + vi.stubGlobal('caches', undefined); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('no-ops when neither proxy nor ISR_CACHE is bound', async () => { + await expect( + envContext.run({}, async () => { + await revalidate({ paths: ['/'] }); + }), + ).resolves.toBeUndefined(); + }); + + it('no-ops when ISR_CACHE is bound but the deployment id is missing', async () => { + const { store, kv, delete: kvDelete } = createKvMock(); + store.set(isrKey(DPL, HOST, '/'), new ArrayBuffer(4)); + await envContext.run({ ISR_CACHE: kv }, async () => { + await revalidate({ paths: ['/'] }); + }); + // Early return before any delete — the key survives. + expect(store.has(isrKey(DPL, HOST, '/'))).toBe(true); + expect(kvDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index 1490c902a..6db1caf1f 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -9,6 +9,10 @@ import { collectWorkerFiles, resolveSpaFallbackChain, } from '../../src/cli/package.ts'; +import type { VoidConfig } from '../../src/config.ts'; +import type { InferredBindings } from '../../src/inference/walk.ts'; +import { collectRevalidate } from '../../src/isr-config.ts'; +import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; const TMP = join(import.meta.dirname, '.tmp-package-test'); @@ -50,6 +54,105 @@ describe('worker packaging', () => { }); }); +// Non-regression: the managed `void deploy` manifest must stay byte-identical +// after the self-host AI + ISR refactors (Tasks 2 & 5). These assert the manifest +// builder still sets `bindings.ai` from `needsAI` and still carries the exact +// `collectRevalidate` output the deploy path feeds it. +describe('buildVoidManifest — self-host non-regression', () => { + function makeBindings(overrides: Partial = {}): InferredBindings { + return { + needsD1: false, + needsKV: false, + needsR2: false, + needsAuth: false, + needsQueues: false, + needsAI: false, + needsSandbox: false, + ...overrides, + }; + } + + function makePage(pattern: string, revalidate?: number): PageDefinition { + return { + pattern, + params: [], + catchAll: false, + componentPath: `${pattern}.vue`, + serverPath: `${pattern}.server.ts`, + methods: ['GET'], + componentId: pattern.replace(/^\//, ''), + revalidate, + ssrMode: 'auto', + prerenderMode: 'auto', + renderMode: 'server', + shouldPrerender: false, + }; + } + + function makeScan(pages: Array): PageScanResult { + return { pages, layouts: [], namedLayouts: [] }; + } + + function configWith(revalidate: number | Record | undefined): VoidConfig { + return (revalidate === undefined ? {} : { routing: { revalidate } }) as VoidConfig; + } + + it('sets bindings.ai === true when the app needs Workers AI', () => { + const manifest = buildVoidManifest({ bindings: makeBindings({ needsAI: true }) }); + expect(manifest.bindings.ai).toBe(true); + }); + + it('omits bindings.ai when the app does not use Workers AI', () => { + const manifest = buildVoidManifest({ bindings: makeBindings({ needsAI: false }) }); + expect(manifest.bindings.ai).toBeUndefined(); + }); + + it('carries collectRevalidate output verbatim into manifest.revalidate (numeric config + per-page)', () => { + // Same inputs the deploy path feeds: void.json config + page-scan per-page rules. + const config = configWith(60); + const scan = makeScan([makePage('/blog/:slug', 120)]); + const revalidate = collectRevalidate(config, scan); + + const manifest = buildVoidManifest({ bindings: makeBindings(), revalidate }); + + // Merge must be non-trivial (numeric config → path map with "*" fallback). + // The single-segment page pattern `/blog/:slug` stays as `/blog/:slug` so the + // matcher anchors it to ONE path segment (`[^/]+`) at runtime. + expect(revalidate).toEqual({ '/blog/:slug': 120, '*': 60 }); + // …and it must land in the manifest byte-for-byte. + expect(manifest.revalidate).toEqual(revalidate); + expect(Object.keys(manifest.revalidate as Record)).toEqual([ + '/blog/:slug', + '*', + ]); + }); + + it('carries collectRevalidate output verbatim into manifest.revalidate (object config + per-page order)', () => { + const config = configWith({ '/docs/*': 30, '*': 10 }); + const scan = makeScan([makePage('/blog/:slug', 120)]); + const revalidate = collectRevalidate(config, scan); + + const manifest = buildVoidManifest({ bindings: makeBindings(), revalidate }); + + expect(manifest.revalidate).toEqual(revalidate); + // Per-page entries precede config entries (first-match-wins ordering preserved). + // The per-page `/blog/:slug` pattern stays `/blog/:slug` (single-segment); the + // user's own `routing.revalidate` literal keys (`/docs/*`, `*`) pass untouched. + expect(Object.entries(manifest.revalidate as Record)).toEqual([ + ['/blog/:slug', 120], + ['/docs/*', 30], + ['*', 10], + ]); + }); + + it('leaves manifest.revalidate unset when collectRevalidate yields nothing', () => { + const revalidate = collectRevalidate(configWith(undefined), makeScan([])); + const manifest = buildVoidManifest({ bindings: makeBindings(), revalidate }); + expect(revalidate).toBeUndefined(); + expect(manifest.revalidate).toBeUndefined(); + }); +}); + describe('resolveSpaFallbackChain', () => { it('returns SPA platform switch with no rules when user has no fallbacks (SPA)', () => { const result = resolveSpaFallbackChain('spa', undefined); diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index a8ad8abe5..af499397e 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -110,6 +110,7 @@ export default defineConfig({ 'src/runtime/queues.ts', 'src/runtime/storage.ts', 'src/runtime/isr.ts', + 'src/runtime/isr-cache.ts', 'src/runtime/log.ts', 'src/runtime/ai.ts', 'src/runtime/sandbox.ts', diff --git a/playground/kitchen-sink/void.json b/playground/kitchen-sink/void.json index a0c527e2b..67f72f7cf 100644 --- a/playground/kitchen-sink/void.json +++ b/playground/kitchen-sink/void.json @@ -5,5 +5,8 @@ }, "auth": { "providers": ["email"] + }, + "ai": { + "gateway": "kitchen-sink-gw" } }