diff --git a/.github/scripts/postgres-concurrency-tests.ts b/.github/scripts/postgres-concurrency-tests.ts index 5e334ea0c..358618382 100644 --- a/.github/scripts/postgres-concurrency-tests.ts +++ b/.github/scripts/postgres-concurrency-tests.ts @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; // eslint-disable-next-line no-restricted-imports -- Native filesystem paths are never embedded in source. import { join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; import { parseSync } from 'vite'; import { isRecord } from '../../packages/deploy-core/src/is-record.ts'; import { compatibilityTestFiles } from './schema-compat-tests.ts'; @@ -38,7 +38,7 @@ function usesPostgresEnvironment(node: unknown): boolean { } /** Select real PostgreSQL cases without repeating the whole in-memory API suite. */ -export function postgresConcurrencyTestFiles(root: string): Array { +function postgresConcurrencyTestFiles(root: string): Array { const files = compatibilityTestFiles(root).filter((file) => { const parsed = parseSync(file, readFileSync(join(root, file), 'utf8')); if (parsed.errors.length > 0) { @@ -52,16 +52,14 @@ export function postgresConcurrencyTestFiles(root: string): Array { return files; } -if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { - const files = postgresConcurrencyTestFiles(process.cwd()); - // Reuse the bounded runner's independent database clones and cleanup for every file. - const result = spawnSync( - process.execPath, - [fileURLToPath(new URL('./schema-compat-tests.ts', import.meta.url)), ...files], - { stdio: 'inherit' }, - ); - if (result.error) { - throw result.error; - } - process.exitCode = result.status ?? 1; +const files = postgresConcurrencyTestFiles(process.cwd()); +// Reuse the bounded runner's independent database clones and cleanup for every file. +const result = spawnSync( + process.execPath, + [fileURLToPath(new URL('./schema-compat-tests.ts', import.meta.url)), ...files], + { stdio: 'inherit' }, +); +if (result.error) { + throw result.error; } +process.exitCode = result.status ?? 1; diff --git a/.github/scripts/production-baseline.ts b/.github/scripts/production-baseline.ts index 060185903..744efc469 100644 --- a/.github/scripts/production-baseline.ts +++ b/.github/scripts/production-baseline.ts @@ -1,12 +1,10 @@ import { appendFileSync } from 'node:fs'; -import { pathToFileURL } from 'node:url'; -export async function productionBaseline(options: { +async function productionBaseline(options: { configuredSha?: string; repository: string; token?: string; - environment?: string; - request?: typeof fetch; + environment: string; }): Promise { if (options.configuredSha) { if (options.configuredSha.length !== 40 || !/^[0-9a-f]{40}$/i.test(options.configuredSha)) { @@ -21,11 +19,10 @@ export async function productionBaseline(options: { 'Provide a GitHub repository and read token, or set VOID_PLATFORM_PRODUCTION_REF to the deployed SHA.', ); } - const request = options.request ?? fetch; const signal = AbortSignal.timeout(60_000); - const environment = options.environment ?? 'Prod'; + const { environment } = options; const get = async (path: string): Promise => { - const response = await request(`https://api.github.com/repos/${options.repository}/${path}`, { + const response = await fetch(`https://api.github.com/repos/${options.repository}/${path}`, { headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${options.token}`, @@ -70,15 +67,13 @@ export async function productionBaseline(options: { ); } -if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { - const sha = await productionBaseline({ - configuredSha: process.env.PRODUCTION_REF, - repository: process.env.GITHUB_REPOSITORY ?? '', - token: process.env.GITHUB_TOKEN, - environment: process.env.PRODUCTION_ENVIRONMENT || 'Prod', - }); - if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `sha=${sha}\n`); - } - console.log(`Previous production runtime: ${sha}`); +const sha = await productionBaseline({ + configuredSha: process.env.PRODUCTION_REF, + repository: process.env.GITHUB_REPOSITORY ?? '', + token: process.env.GITHUB_TOKEN, + environment: process.env.PRODUCTION_ENVIRONMENT || 'Prod', +}); +if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `sha=${sha}\n`); } +console.log(`Previous production runtime: ${sha}`); diff --git a/.github/scripts/schema-compat-tests.ts b/.github/scripts/schema-compat-tests.ts index b85fbd5cd..93aceba9c 100644 --- a/.github/scripts/schema-compat-tests.ts +++ b/.github/scripts/schema-compat-tests.ts @@ -7,9 +7,9 @@ import { createRequire } from 'node:module'; import { join, relative, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -export const legacyZoneFixture = ` const second = await createTestProject(env.db, user.id, 'concurrent-zone-second'); +const legacyZoneFixture = ` const second = await createTestProject(env.db, user.id, 'concurrent-zone-second'); stubCustomerCf({ zones });`; -export const correctedZoneFixture = ` const second = await createTestProject(env.db, user.id, 'concurrent-zone-second'); +const correctedZoneFixture = ` const second = await createTestProject(env.db, user.id, 'concurrent-zone-second'); const mx: Record> = { 'concurrent-one.example.com': [], 'concurrent-two.example.com': [], @@ -32,13 +32,13 @@ export const correctedZoneFixture = ` const second = await createTestProjec }, });`; -export const legacyZoneProvision = ` await runEmailOperation(env.db, bindings(), accepted.value.operationId);`; -export const correctedZoneProvision = ` provisionedDomain = (await env.db.select().from(emailDomainRoutes))[0]!.domain; +const legacyZoneProvision = ` await runEmailOperation(env.db, bindings(), accepted.value.operationId);`; +const correctedZoneProvision = ` provisionedDomain = (await env.db.select().from(emailDomainRoutes))[0]!.domain; await runEmailOperation(env.db, bindings(), accepted.value.operationId);`; -export const legacyDeployBodyFixture = ` expect(res.status).not.toBe(413); +const legacyDeployBodyFixture = ` expect(res.status).not.toBe(413); });`; -export const correctedDeployBodyFixture = ` expect(res.status).not.toBe(413); +const correctedDeployBodyFixture = ` expect(res.status).not.toBe(413); // Finish the deployment's database work before the next fixture resets its tables. await res.text(); });`; @@ -63,7 +63,7 @@ function correctKnownFixture( } /** Correct only the known deployed test fixture; runtime code and every assertion stay intact. */ -export function correctLegacyEmailFixture(source: string): string { +function correctLegacyEmailFixture(source: string): string { return correctKnownFixture( source, 'bf3ec4cd84c85a9e2cd01c7906ca6952dce5d7b957dc38487d6cfe99e9257eed', @@ -74,7 +74,7 @@ export function correctLegacyEmailFixture(source: string): string { ); } -export function correctLegacyDeployFixture(source: string): string { +function correctLegacyDeployFixture(source: string): string { return correctKnownFixture( source, 'a62cdbc79573bba09da2212c441ebdcec74569d5ec4193d494f54fad98722f33', @@ -83,7 +83,7 @@ export function correctLegacyDeployFixture(source: string): string { } /** Only the disposable CI database may be used as a template. */ -export function compatibilityTemplate(value: string): URL { +function compatibilityTemplate(value: string): URL { let url: URL; try { url = new URL(value); @@ -120,18 +120,14 @@ export function compatibilityTestFiles(root: string): Array { } /** Keep the deployed runtime/tests intact, with one upgraded database snapshot per file. */ -export async function runIsolatedCompatibilityTests(options: { +async function runIsolatedCompatibilityTests(options: { templateUrl: string; files: ReadonlyArray; query: (statement: string) => Promise; runTest: (file: string, databaseUrl: string) => Promise; - concurrency?: number; }): Promise> { const template = compatibilityTemplate(options.templateUrl); - const concurrency = options.concurrency ?? 2; - if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) { - throw new Error('Compatibility concurrency must be between 1 and 4.'); - } + const concurrency = 2; if (!options.files.length || new Set(options.files).size !== options.files.length) { throw new Error('Compatibility tests must contain unique test files.'); } diff --git a/.github/scripts/schema-fixture.mjs b/.github/scripts/schema-fixture.mjs index 3984542c5..2562be322 100644 --- a/.github/scripts/schema-fixture.mjs +++ b/.github/scripts/schema-fixture.mjs @@ -9,7 +9,7 @@ const PROJECT_MODE_MIGRATION = { timestamp: 1790841384175, }; -export function isProjectModeMigrationApplied(rows) { +function isProjectModeMigrationApplied(rows) { return ( rows.length === 1 && rows[0].hash === PROJECT_MODE_MIGRATION.hash && @@ -17,7 +17,7 @@ export function isProjectModeMigrationApplied(rows) { ); } -export function assertSchemaFixturePreserved(original, rows, projectModeMigrationApplied = false) { +function assertSchemaFixturePreserved(original, rows, projectModeMigrationApplied = false) { for (const [table, row] of Object.entries(original)) { for (const [key, value] of Object.entries(row)) { // 0054 deliberately normalizes the obsolete Worker mode to the public @@ -40,11 +40,11 @@ export function assertSchemaFixturePreserved(original, rows, projectModeMigratio } } -export async function runSchemaFixture(command, env = process.env) { +async function runSchemaFixture(command) { const snapshot = - env.COMPAT_FIXTURE_PATH ?? - (env.RUNNER_TEMP ? `${env.RUNNER_TEMP}/void-schema-fixture.json` : undefined); - if (!snapshot || !env.COMPAT_DATABASE_URL) { + process.env.COMPAT_FIXTURE_PATH ?? + (process.env.RUNNER_TEMP ? `${process.env.RUNNER_TEMP}/void-schema-fixture.json` : undefined); + if (!snapshot || !process.env.COMPAT_DATABASE_URL) { throw new Error('A disposable compatibility database and fixture path are required.'); } if (command !== 'seed' && command !== 'verify') { @@ -55,7 +55,7 @@ export async function runSchemaFixture(command, env = process.env) { const { Pool } = createRequire( pathToFileURL(`${process.cwd()}/platform/packages/api/package.json`), )('pg'); - const pool = new Pool({ connectionString: env.COMPAT_DATABASE_URL }); + const pool = new Pool({ connectionString: process.env.COMPAT_DATABASE_URL }); try { if (command === 'seed') { await pool.query( @@ -104,6 +104,4 @@ export async function runSchemaFixture(command, env = process.env) { } } -if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { - await runSchemaFixture(process.argv[2]); -} +await runSchemaFixture(process.argv[2]); diff --git a/TESTING.md b/TESTING.md index d1b2814ae..2706b96b6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -51,9 +51,9 @@ The runner adds coverage that imports cannot describe: `--list` does not refresh builds, so built-export coverage can expand when the command executes. Affected selection is conservative, not a proof that all behavior is covered: computed imports, filesystem reads, generated artifacts, and external services can introduce additional dependencies. -Explicitly run a known consumer when changing one of those boundaries, and add a selector regression -when introducing a new fixture layout. Changes to packaging still need the packed checks below; -changes to metaframework output still need `build:all` and the artifact check. +Explicitly run a known consumer when changing one of those boundaries. Changes to packaging still +need the packed checks below; changes to metaframework output still need `build:all` and the +artifact check. For a leaf-module edit with known import-based consumers, Vitest's native related command is useful: @@ -143,16 +143,14 @@ repeat those production builds. `vpr test:all` runs the same artifact check loca Integration sharding uses [measured duration estimates](test/integration-timings.ts), rather than filename hashes. [The sequencer](test/sequencer.ts) packs the longest files first and starts costly -fixtures early. Estimates affect scheduling only: new files receive a fallback estimate, and -membership tests ensure every discovered file appears exactly once. Refresh the estimates when -the suite's costs change materially. Seeded file shuffling remains available for isolation checks. +fixtures early. Estimates affect scheduling only: new files receive a fallback estimate. Refresh +the estimates when the suite's costs change materially. Seeded file shuffling remains available +for isolation checks. The four adapter suites share their client and island test definitions in `test/`. Each runs the full browser behavior contract against its production preview build, plus a dev-mode navigation, island hydration/interaction smoke check, SSR/protocol assertions, and HMR checks. This keeps both -runtime modes covered without repeating the full browser matrix. Before integrations start, -their sources are snapshotted so another test cloning a fixture cannot capture an in-flight HMR -edit. Dependencies stay linked to the installed workspace, and snapshots are removed afterward. +runtime modes covered without repeating the full browser matrix. Use production functions in unit tests. A copy of a hook's implementation inside a test cannot catch regressions in the real hook. Shared form-value equality has one direct suite, and actual @@ -247,8 +245,7 @@ VOID_TEST_BROWSER=webkit vp test run packages/react/test/integration packages/vu Use `waitForIslandsHydrated` from `test/browser-helpers.ts` before asserting island state or interacting with islands. Pass the expected minimum count when checking a multi-island fixture. Each island imports and hydrates independently, so a selector matching one hydrated island does -not establish that its siblings are ready. The helper rejects empty or partially hydrated pages; -`packages/void/test/integration/browser-helpers.test.ts` covers these cases. +not establish that its siblings are ready. The helper rejects empty or partially hydrated pages. For SPA navigation, wait for a client readiness signal before setting the document marker and clicking. The Nuxt and SvelteKit playgrounds expose `h1[data-hydrated="true"]` after client startup. diff --git a/packages/create-void/src/pm.ts b/packages/create-void/src/pm.ts index c6810bc03..76ced6ffa 100644 --- a/packages/create-void/src/pm.ts +++ b/packages/create-void/src/pm.ts @@ -4,7 +4,7 @@ import { join } from 'pathslash'; export type PM = 'npm' | 'pnpm' | 'yarn' | 'bun'; -export const VOID_PNPM_ALLOWED_BUILDS = ['esbuild', 'sharp', 'workerd'] as const; +const VOID_PNPM_ALLOWED_BUILDS = ['esbuild', 'sharp', 'workerd'] as const; /** Write the explicit build-script policy required by a new pnpm project. */ export function writePnpmBuildPolicy(root: string, pm: PM): void { diff --git a/packages/create-void/test/unit/pm.test.ts b/packages/create-void/test/unit/pm.test.ts index 5b6309209..d0d1506fc 100644 --- a/packages/create-void/test/unit/pm.test.ts +++ b/packages/create-void/test/unit/pm.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vite-plus/test'; -import { VOID_PNPM_ALLOWED_BUILDS, writePnpmBuildPolicy } from '../../src/pm.ts'; +import { writePnpmBuildPolicy } from '../../src/pm.ts'; const temporaryDirectories: Array = []; @@ -20,7 +20,7 @@ describe('create-void package manager bootstrap', () => { writePnpmBuildPolicy(root, 'pnpm'); expect(readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf-8')).toBe( - `allowBuilds:\n better-sqlite3: false\n${VOID_PNPM_ALLOWED_BUILDS.map((name) => ` ${name}: true`).join('\n')}\n`, + 'allowBuilds:\n better-sqlite3: false\n esbuild: true\n sharp: true\n workerd: true\n', ); }); @@ -34,20 +34,4 @@ describe('create-void package manager bootstrap', () => { expect(existsSync(join(root, 'pnpm-workspace.yaml'))).toBe(false); } }); - - it('stays aligned with the policy written by void init', () => { - const initSource = readFileSync( - join(import.meta.dirname, '../../../void/src/cli/init.ts'), - 'utf-8', - ); - const declaration = initSource.match( - /const PNPM_ALLOWED_BUILDS = \[([\s\S]*?)\] as const;/, - )?.[1]; - if (!declaration) { - throw new Error('Could not find the void init pnpm build policy.'); - } - const initAllowedBuilds = [...declaration.matchAll(/'([^']+)'/g)].map((match) => match[1]); - - expect(VOID_PNPM_ALLOWED_BUILDS).toEqual(initAllowedBuilds); - }); }); diff --git a/packages/deploy-cloudflare/src/config.ts b/packages/deploy-cloudflare/src/config.ts index 2a8b88157..9add4f8f0 100644 --- a/packages/deploy-cloudflare/src/config.ts +++ b/packages/deploy-cloudflare/src/config.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from 'node:fs'; -import { parse as parseJsoncConfig, type ParseError } from 'jsonc-parser'; +import { applyEdits, modify, parse as parseJsoncConfig, type ParseError } from 'jsonc-parser'; /** Parse a root Wrangler JSON/JSONC config without accepting a partial parse. */ export function parseRootWranglerConfig(configPath: string): Record { @@ -21,6 +21,14 @@ export function parseRootWranglerConfig(configPath: string): Record; } +/** Set one value in JSONC text, keeping comments and formatting elsewhere. */ +export function setJsoncValue(text: string, path: Array, value: unknown): string { + return applyEdits( + text, + modify(text, path, value, { formattingOptions: { insertSpaces: true, tabSize: 2, eol: '\n' } }), + ); +} + export function readWranglerTopLevelString( config: Record, field: string, diff --git a/packages/deploy-cloudflare/src/framework.ts b/packages/deploy-cloudflare/src/framework.ts index acf9b8eb4..c12b462c9 100644 --- a/packages/deploy-cloudflare/src/framework.ts +++ b/packages/deploy-cloudflare/src/framework.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { applyEdits, modify, parse } from 'jsonc-parser'; +import { parse } from 'jsonc-parser'; import { dirname, join, relative } from 'pathslash'; +import { setJsoncValue } from './config.ts'; export type CloudflareFrameworkDeployConfig = Readonly<{ configPath: string; @@ -33,12 +34,7 @@ export function writeCloudflareFrameworkDeployConfig( : '{}\n'; let content = initial; const upsert = (path: Array, value: unknown): void => { - content = applyEdits( - content, - modify(content, path, value, { - formattingOptions: { insertSpaces: true, tabSize: 2, eol: '\n' }, - }), - ); + content = setJsoncValue(content, path, value); }; upsert(['name'], options.name); diff --git a/packages/deploy-cloudflare/src/static.ts b/packages/deploy-cloudflare/src/static.ts index cc02e25f7..f15900353 100644 --- a/packages/deploy-cloudflare/src/static.ts +++ b/packages/deploy-cloudflare/src/static.ts @@ -1,9 +1,7 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import type { AssetConfig, HeaderRule, RedirectRule } from '@void/deploy-core'; import { - applyEdits, createScanner, - modify, parse, parseTree, type Node, @@ -11,6 +9,7 @@ import { SyntaxKind, } from 'jsonc-parser'; import { dirname, join, relative, resolve } from 'pathslash'; +import { setJsoncValue } from './config.ts'; export type CloudflareStaticDeployConfig = Readonly<{ configPath: string; @@ -100,10 +99,7 @@ export function writeCloudflareStaticDeployConfig(options: CloudflareStaticDeplo const originalComments = extractCommentTokens(source); let content = source; const upsert = (path: Array, value: unknown): void => { - const edits = modify(content, path, value, { - formattingOptions: { insertSpaces: true, tabSize: 2, eol: '\n' }, - }); - content = applyEdits(content, edits); + content = setJsoncValue(content, path, value); }; upsert(['name'], options.name); diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index 95457ff3a..1b980cd96 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -1,8 +1,9 @@ import { createHash } from 'node:crypto'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import { setTimeout as delay } from 'node:timers/promises'; import { isDeepStrictEqual } from 'node:util'; -import { isRecord } from '@void/deploy-core'; +import { canonicalJson, isRecord } from '@void/deploy-core'; import { join } from 'pathslash'; import { parseRootWranglerConfig } from './config.ts'; import { cloudflareCommandDiagnostic } from './diagnostics.ts'; @@ -343,7 +344,6 @@ export async function stabilizeCloudflareVersionTriggers(options: { manageRoutes?: boolean; attempts?: number; }): Promise { - const configPath = options.configPath; let cleanupBaseline = mergeTriggerCleanupBaselines( options.previousTriggerConfig, options.targetTriggerConfig, @@ -351,7 +351,7 @@ export async function stabilizeCloudflareVersionTriggers(options: { const attempts = Math.max(1, options.attempts ?? 4); for (let attempt = 0; attempt < attempts; attempt++) { - const owner = await readCurrentProductionVersion(options, configPath); + const owner = await readCurrentProductionVersion(options); if (owner.kind === 'failed') { return { ok: false, reason: 'status-failed', status: owner.status }; } @@ -368,7 +368,7 @@ export async function stabilizeCloudflareVersionTriggers(options: { ? options.targetTriggerConfig : options.knownTriggerConfigs?.[activeVersionId]; if (!activeTriggerConfig && activeVersionId !== options.targetVersionId) { - const metadata = await readCloudflareVersionMetadata(options, configPath, activeVersionId); + const metadata = await readCloudflareVersionMetadata(options, activeVersionId); activeTriggerConfig = metadata?.schema === 2 ? metadata.triggerConfig : undefined; } if (!activeTriggerConfig) { @@ -395,7 +395,7 @@ export async function stabilizeCloudflareVersionTriggers(options: { } cleanupBaseline = mergeTriggerCleanupBaselines(cleanupBaseline, activeTriggerConfig); - const after = await readCurrentProductionVersion(options, configPath); + const after = await readCurrentProductionVersion(options); if (after.kind === 'failed') { return { ok: false, reason: 'status-failed', status: after.status }; } @@ -440,13 +440,7 @@ function mergeTriggerCleanupBaselines( return true; }); }; - // The baseline is only ever the `previousTriggerConfig` of `deployCloudflareVersionTriggers`, - // which mines it for state the active version no longer wants (queue consumers, workflow - // schedules, custom domains); it is never replayed as a version's own config. Email Routing - // needs no such subtraction — Wrangler plans deletions from rule ownership, not from a previous - // list — so `addresses` here is inert today. Carry it the same way as the other lists (the union - // of every published plan) so the baseline stays a faithful record, and keep the key absent when - // no version carried a plan: an absent key and `[]` are different plans. + // Keep `addresses` absent when no config carried a plan (see CloudflareTriggerConfig). const withAddresses = present.filter((config) => config.addresses !== undefined); return { workers_dev: present.some((config) => config.workers_dev), @@ -492,11 +486,7 @@ function triggerConfigWithQueueConsumerCleanup( if (queues.length === 0) { return previous; } - // This copy becomes the previous version's known trigger config, which - // `stabilizeCloudflareVersionTriggers` replays verbatim when a concurrent rollback returns - // traffic to that version. Spread `previous` so every snapshot field survives — including an - // optional one such as `addresses`, where an absent key and `[]` are different Email Routing - // plans — rather than re-listing fields and silently dropping a newer one. + // A concurrent rollback replays this copy verbatim, so spread every snapshot field. const base: CloudflareTriggerConfig = previous ?? { workers_dev: false, preview_urls: false, @@ -629,20 +619,7 @@ function nonVersionedSettingsHash(config: Record): string { .filter((key) => config[key] !== undefined) .map((key) => [key, config[key]]), ); - return createHash('sha256').update(stableJson(settings)).digest('hex'); -} - -function stableJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map(stableJson).join(',')}]`; - } - if (isRecord(value)) { - return `{${Object.keys(value) - .sort() - .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) - .join(',')}}`; - } - return JSON.stringify(value); + return createHash('sha256').update(canonicalJson(settings)).digest('hex'); } function parseTriggerConfig(value: unknown): CloudflareTriggerConfig | null { @@ -700,12 +677,7 @@ function triggerWranglerConfig( if (manageRoutes && !triggerConfig.preserveRoutes) { config.routes = [...triggerConfig.routes]; } - // Only a snapshot that carried `addresses` may reach Wrangler's Email Routing plan: an - // absent key skips the plan, so rolling back to a version recorded without the key leaves - // existing rules in place. Never send `[]` for that case: Wrangler plans deletion of every - // rule this Worker owns, confirms it in a TTY and refuses it in CI, which fails the rollback - // as `triggers-failed`. Whether a rollback should touch Email Routing is a product call - // (design doc 0078, decision 13); this code replays the snapshot verbatim. + // Replay only a recorded plan; `[]` would delete every Email Routing rule this Worker owns. if (triggerConfig.addresses !== undefined) { config.addresses = [...triggerConfig.addresses]; } @@ -742,9 +714,7 @@ function resolvePreservedTraffic( crons: desired.crons, queueConsumers: desired.queueConsumers, workflows: desired.workflows, - // Not a traffic surface, but this rebuild is on the path of every versioned - // deploy: dropping the key here would skip wrangler's Email Routing plan and - // record the version without its addresses. + // Not a traffic surface, but dropping it would skip Wrangler's Email Routing plan. ...(desired.addresses === undefined ? {} : { addresses: desired.addresses }), }; } @@ -866,12 +836,11 @@ export async function activateCloudflareVersion( async function activateCloudflareVersionWithSecretsFile( options: InternalCloudflareVersionActivationOptions, ): Promise { - const configPath = options.configPath; if (!options.verifyUploader()) { return { ok: false, reason: 'uploader-changed' }; } options.onProgress?.('Checking the current Worker deployment...'); - const previous = await readCurrentProductionVersion(options, configPath); + const previous = await readCurrentProductionVersion(options); if (previous.kind === 'failed') { return { ok: false, reason: 'status-failed', status: previous.status }; } @@ -916,7 +885,7 @@ async function activateCloudflareVersionWithSecretsFile( let activeSecretsPinned = false; let pinnedActiveSecrets: Array<{ name: string; type: 'secret_key' | 'secret_text' }> | undefined; if (options.expectedPreviousVersionId !== undefined || options.verifyInheritedSecrets === true) { - const recent = await readRecentCloudflareVersionIds(options, configPath); + const recent = await readRecentCloudflareVersionIds(options); if (recent === null) { return { ok: false, reason: 'inheritance-source-unavailable' }; } @@ -956,7 +925,6 @@ async function activateCloudflareVersionWithSecretsFile( ) { const replacesAllRemoteSecrets = await candidateReplacesAllRemoteSecrets( options, - configPath, previous.versionId, latestVersionId, ); @@ -965,7 +933,7 @@ async function activateCloudflareVersionWithSecretsFile( } if (!replacesAllRemoteSecrets) { const activeSecrets = previous.versionId - ? await readCloudflareVersionSecretBindings(options, configPath, previous.versionId) + ? await readCloudflareVersionSecretBindings(options, previous.versionId) : null; if ( !activeSecrets || @@ -991,7 +959,6 @@ async function activateCloudflareVersionWithSecretsFile( previousVersionId && desiredMetadata?.schema === 2 ? await readCloudflareVersionInspection( options, - configPath, previousVersionId, inheritanceBaseline?.map((version) => version.inspection), ) @@ -1002,7 +969,6 @@ async function activateCloudflareVersionWithSecretsFile( } previousVersion ??= await readCloudflareVersionInspection( options, - configPath, previousVersionId, inheritanceBaseline?.map((version) => version.inspection), ); @@ -1084,11 +1050,7 @@ async function activateCloudflareVersionWithSecretsFile( if (bootstrapVoidDraft || (!activeSecretsPinned && options.pinActiveSecrets)) { let activeSecrets: Awaited>; try { - activeSecrets = await readCloudflareVersionSecretBindings( - options, - configPath, - previous.versionId, - ); + activeSecrets = await readCloudflareVersionSecretBindings(options, previous.versionId); } catch { return { ok: false, reason: 'inheritance-source-unavailable' }; } @@ -1125,7 +1087,6 @@ async function activateCloudflareVersionWithSecretsFile( } return bootstrapFirstDeployment( { ...options, message }, - configPath, previous.versionId, options.atomic === true, previousTriggerConfig, @@ -1143,11 +1104,7 @@ async function activateCloudflareVersionWithSecretsFile( ) { let activeSecrets: Awaited>; try { - activeSecrets = await readCloudflareVersionSecretBindings( - options, - configPath, - previous.versionId, - ); + activeSecrets = await readCloudflareVersionSecretBindings(options, previous.versionId); } catch { return { ok: false, reason: 'inheritance-source-unavailable' }; } @@ -1177,7 +1134,7 @@ async function activateCloudflareVersionWithSecretsFile( if (!options.verifyUploader()) { return { ok: false, reason: 'uploader-changed' }; } - const owner = await readCurrentProductionVersion(options, configPath); + const owner = await readCurrentProductionVersion(options); if (owner.kind === 'failed') { return { ok: false, reason: 'status-failed', status: owner.status }; } @@ -1200,7 +1157,7 @@ async function activateCloudflareVersionWithSecretsFile( const upload = await options.runWrangler( { type: 'versions.upload', - configPath, + configPath: options.configPath, workerName: options.workerName, message, secretsFilePath: options.candidateSecretsFilePath, @@ -1226,7 +1183,6 @@ async function activateCloudflareVersionWithSecretsFile( ) { return bootstrapFirstDeployment( { ...options, message }, - configPath, previous.versionId, false, previousTriggerConfig, @@ -1249,7 +1205,7 @@ async function activateCloudflareVersionWithSecretsFile( } options.onProgress?.('Verifying the uploaded Worker...'); if (inheritanceBaseline) { - const recent = await readRecentCloudflareVersionIds(options, configPath); + const recent = await readRecentCloudflareVersionIds(options); const previousLatest = inheritanceBaseline.at(-1); const expected = [...inheritanceBaseline.map((version) => version.id), versionId].slice(-10); if (recent === null) { @@ -1326,7 +1282,6 @@ async function activateCloudflareVersionWithSecretsFile( ) { return bootstrapFirstDeployment( { ...options, message }, - configPath, previous.versionId, false, previousTriggerConfig, @@ -1350,7 +1305,6 @@ async function activateCloudflareVersionWithSecretsFile( } const stagingFailure = await stageCandidateForReadiness( options, - configPath, previous.versionId, versionId, message, @@ -1369,7 +1323,7 @@ async function activateCloudflareVersionWithSecretsFile( authenticatedWorkersDevSubdomain, stagedVersionOverride ? versionId : undefined, options.fetch ?? globalThis.fetch, - options.sleep ?? defaultSleep, + options.sleep ?? delay, options.readinessAttempts ?? 6, Number.POSITIVE_INFINITY, options.requireVersionMetadata ? versionId : undefined, @@ -1399,7 +1353,6 @@ async function activateCloudflareVersionWithSecretsFile( if (stableReadinessUrl && stableReadinessUrl !== readinessUrl) { const stagingFailure = await stageCandidateForReadiness( options, - configPath, previous.versionId, versionId, message, @@ -1421,13 +1374,9 @@ async function activateCloudflareVersionWithSecretsFile( versionId, previousVersionId: previous.versionId, rollback: stagedVersionOverride - ? await restorePreviousVersion( - options, - configPath, - previous.versionId, - 'readiness failed', - [stagedVersionPercentages(previous.versionId, versionId)], - ) + ? await restorePreviousVersion(options, previous.versionId, 'readiness failed', [ + stagedVersionPercentages(previous.versionId, versionId), + ]) : 'not-needed', }; } @@ -1440,7 +1389,6 @@ async function activateCloudflareVersionWithSecretsFile( rollback: stagedVersionOverride ? await restorePreviousVersion( options, - configPath, previous.versionId, 'uploader verification failed', [stagedVersionPercentages(previous.versionId, versionId)], @@ -1451,7 +1399,7 @@ async function activateCloudflareVersionWithSecretsFile( // Readiness can take long enough for another deploy or rollback to finish. Never flatten that // newer operation using the stale `previous` snapshot captured before this upload began. - const ownerBeforeActivation = await readCurrentProductionVersion(options, configPath); + const ownerBeforeActivation = await readCurrentProductionVersion(options); if (ownerBeforeActivation.kind === 'failed') { return { ok: false, @@ -1493,12 +1441,7 @@ async function activateCloudflareVersionWithSecretsFile( } options.onProgress?.('Activating the Worker...'); - const activate = await deployVersionPercentages( - options, - configPath, - [`${versionId}@100`], - message, - ); + const activate = await deployVersionPercentages(options, [`${versionId}@100`], message); if (!isCleanCommandExit(activate)) { return { ok: false, @@ -1507,16 +1450,10 @@ async function activateCloudflareVersionWithSecretsFile( versionId, previousVersionId: previous.versionId, rollback: stagedVersionOverride - ? await restorePreviousVersion( - options, - configPath, - previous.versionId, - 'version activation failed', - [ - stagedVersionPercentages(previous.versionId, versionId), - [{ versionId, percentage: 100 }], - ], - ) + ? await restorePreviousVersion(options, previous.versionId, 'version activation failed', [ + stagedVersionPercentages(previous.versionId, versionId), + [{ versionId, percentage: 100 }], + ]) : 'not-needed', }; } @@ -1534,7 +1471,7 @@ async function activateCloudflareVersionWithSecretsFile( }; } if (options.preserveTriggers) { - const owner = await readCurrentProductionVersion(options, configPath); + const owner = await readCurrentProductionVersion(options); if (owner.kind !== 'ok' || owner.versionId !== versionId) { return { ok: false, @@ -1595,7 +1532,7 @@ async function activateCloudflareVersionWithSecretsFile( rollback: 'unsafe', }; } - const ownerBeforeCompensation = await readCurrentProductionVersion(options, configPath); + const ownerBeforeCompensation = await readCurrentProductionVersion(options); if (ownerBeforeCompensation.kind !== 'ok' || ownerBeforeCompensation.versionId === null) { return { ok: false, @@ -1631,7 +1568,6 @@ async function activateCloudflareVersionWithSecretsFile( } const rollback = await restorePreviousVersionAndTriggers( options, - configPath, previous.versionId, 'trigger activation failed', desiredTriggerConfig, @@ -1689,14 +1625,13 @@ async function restorePreviousVersionAndTriggers( | 'publishEmptyCustomDomains' | 'publishEmptyCrons' >, - configPath: string | undefined, previousVersionId: string | null, cause: string, failedTriggerConfig: CloudflareTriggerConfig, failedVersionId: string, capturedTriggerConfig?: CloudflareTriggerConfig, ): Promise<'succeeded' | 'failed' | 'unavailable' | 'unsafe'> { - const traffic = await restorePreviousVersion(options, configPath, previousVersionId, cause, [ + const traffic = await restorePreviousVersion(options, previousVersionId, cause, [ [{ versionId: failedVersionId, percentage: 100 }], ]); if (traffic !== 'succeeded' || !previousVersionId) { @@ -1711,19 +1646,14 @@ async function restorePreviousVersionAndTriggers( // part of this target snapshot. let targetTriggerConfig = capturedTriggerConfig; if (!targetTriggerConfig) { - const metadata = await readCloudflareVersionMetadata(options, configPath, previousVersionId); + const metadata = await readCloudflareVersionMetadata(options, previousVersionId); if (metadata?.schema !== 2) { return 'unavailable'; } targetTriggerConfig = metadata.triggerConfig; } - // A preserve marker records that the previous version inherited live trigger - // state; its routes, schedules, queue consumers, and workflows are therefore - // intentionally opaque. The traffic snapshot can restore workers.dev, preview - // visibility, and routes for version activation, but merging those fields into - // this marker does not turn the unknown trigger surfaces into a restorable - // snapshot. Traffic is already back on the previous version at this point, so - // report trigger recovery as unavailable without publishing invented state. + // A preserve marker leaves routes, schedules, queue consumers and workflows opaque, so there + // is no trigger snapshot to restore. Traffic is already back on the previous version. if (targetTriggerConfig.preserve) { return 'unavailable'; } @@ -1745,11 +1675,13 @@ async function restorePreviousVersionAndTriggers( } async function readCloudflareVersionMetadata( - options: Pick[0], 'runWrangler' | 'workerName'>, - configPath: string | undefined, + options: Pick< + Parameters[0], + 'configPath' | 'runWrangler' | 'workerName' + >, versionId: string, ): Promise { - return (await readCloudflareVersionInspection(options, configPath, versionId))?.metadata ?? null; + return (await readCloudflareVersionInspection(options, versionId))?.metadata ?? null; } type CloudflareVersionInspection = Readonly<{ @@ -1760,8 +1692,10 @@ type CloudflareVersionInspection = Readonly<{ }>; async function readCloudflareVersionInspection( - options: Pick[0], 'runWrangler' | 'workerName'>, - configPath: string | undefined, + options: Pick< + Parameters[0], + 'configPath' | 'runWrangler' | 'workerName' + >, versionId: string, knownVersions?: ReadonlyArray, ): Promise { @@ -1771,7 +1705,7 @@ async function readCloudflareVersionInspection( let versions = knownVersions; if (versions === undefined) { const result = await options.runWrangler( - { type: 'versions.list', configPath, workerName: options.workerName }, + { type: 'versions.list', configPath: options.configPath, workerName: options.workerName }, { capture: true }, ); if (!isCleanCommandExit(result)) { @@ -1800,7 +1734,7 @@ async function readCloudflareVersionInspection( { type: 'versions.view', versionId, - configPath, + configPath: options.configPath, workerName: options.workerName, includeSource: true, }, @@ -1830,11 +1764,13 @@ async function readCloudflareVersionInspection( } async function readRecentCloudflareVersionIds( - options: Pick[0], 'runWrangler' | 'workerName'>, - configPath: string | undefined, + options: Pick< + Parameters[0], + 'configPath' | 'runWrangler' | 'workerName' + >, ): Promise | null> { const result = await options.runWrangler( - { type: 'versions.list', configPath, workerName: options.workerName }, + { type: 'versions.list', configPath: options.configPath, workerName: options.workerName }, { capture: true }, ); if (!isCleanCommandExit(result)) { @@ -1870,9 +1806,8 @@ function arraysEqual(left: ReadonlyArray, right: ReadonlyArray): async function candidateReplacesAllRemoteSecrets( options: Pick< Parameters[0], - 'candidateSecrets' | 'runWrangler' | 'workerName' + 'candidateSecrets' | 'configPath' | 'runWrangler' | 'workerName' >, - configPath: string | undefined, activeVersionId: string | null, latestVersionId: string | null, ): Promise { @@ -1881,7 +1816,7 @@ async function candidateReplacesAllRemoteSecrets( ] as Array; const secretBindings: Array> = []; for (const versionId of versionIds) { - const bindings = await readCloudflareVersionSecretBindings(options, configPath, versionId); + const bindings = await readCloudflareVersionSecretBindings(options, versionId); if (bindings === null) { return null; } @@ -1897,12 +1832,19 @@ async function candidateReplacesAllRemoteSecrets( } async function readCloudflareVersionSecretBindings( - options: Pick[0], 'runWrangler' | 'workerName'>, - configPath: string | undefined, + options: Pick< + Parameters[0], + 'configPath' | 'runWrangler' | 'workerName' + >, versionId: string, ): Promise | null> { const result = await options.runWrangler( - { type: 'versions.view', versionId, configPath, workerName: options.workerName }, + { + type: 'versions.view', + versionId, + configPath: options.configPath, + workerName: options.workerName, + }, { capture: true }, ); if (!isCleanCommandExit(result)) { @@ -1937,7 +1879,6 @@ async function readCloudflareVersionSecretBindings( */ async function bootstrapFirstDeployment( options: InternalCloudflareVersionActivationOptions, - configPath: string | undefined, previousVersionId: string | null, atomic = false, previousTriggerConfig?: CloudflareTriggerConfig, @@ -1946,7 +1887,7 @@ async function bootstrapFirstDeployment( return { ok: false, reason: 'uploader-changed' }; } { - const current = await readCurrentProductionVersion(options, configPath); + const current = await readCurrentProductionVersion(options); if (current.kind === 'failed') { return { ok: false, reason: 'status-failed', status: current.status }; } @@ -1972,7 +1913,7 @@ async function bootstrapFirstDeployment( const bootstrap = await options.runWrangler( { type: 'worker.deploy', - configPath, + configPath: options.configPath, workerName: options.workerName, message: options.message, secretsFilePath: options.candidateSecretsFilePath, @@ -2034,7 +1975,7 @@ async function bootstrapFirstDeployment( publishEmptyCrons: options.publishEmptyCrons, }; const verifyPublishedOwner = async (): Promise => { - const owner = await readCurrentProductionVersion(options, configPath); + const owner = await readCurrentProductionVersion(options); if (owner.kind === 'failed') { return { ok: false, @@ -2165,7 +2106,7 @@ async function bootstrapFirstDeployment( extractWorkersDevSubdomain(previewUrl), undefined, options.fetch ?? globalThis.fetch, - options.sleep ?? defaultSleep, + options.sleep ?? delay, // A first Custom Domain needs DNS and certificate propagation. Existing // previews and explicitly atomic releases retain their shorter window. // Keep an overall deadline even when requests time out individually. @@ -2206,7 +2147,7 @@ async function bootstrapFirstDeployment( } // DNS propagation can outlast another deploy. A successful response alone // must not record this version as live after a newer owner took over. - const ownerAfterReadiness = await readCurrentProductionVersion(options, configPath); + const ownerAfterReadiness = await readCurrentProductionVersion(options); if (ownerAfterReadiness.kind === 'failed') { return { ok: false, @@ -2294,11 +2235,13 @@ function isContainersUnavailable(result: CloudflareCommandResult): boolean { } async function readCurrentProductionVersion( - options: Pick[0], 'runWrangler' | 'workerName'>, - configPath: string | undefined, + options: Pick< + Parameters[0], + 'configPath' | 'runWrangler' | 'workerName' + >, ): Promise { const result = await options.runWrangler( - { type: 'deployments.status', configPath, workerName: options.workerName }, + { type: 'deployments.status', configPath: options.configPath, workerName: options.workerName }, { capture: true }, ); if (!isCleanCommandExit(result)) { @@ -2372,7 +2315,6 @@ function stagedVersionPercentages( /** Stage the candidate at 0% beside the unchanged previous version so readiness can select it. */ async function stageCandidateForReadiness( options: CloudflareVersionActivationOptions, - configPath: string | undefined, previousVersionId: string | null, versionId: string, message: string, @@ -2386,18 +2328,12 @@ async function stageCandidateForReadiness( rollback: 'not-needed', }; } - const ownerFailure = await verifyReadinessStagingOwner( - options, - configPath, - previousVersionId, - versionId, - ); + const ownerFailure = await verifyReadinessStagingOwner(options, previousVersionId, versionId); if (ownerFailure) { return ownerFailure; } const staged = await deployVersionPercentages( options, - configPath, [`${previousVersionId}@100`, `${versionId}@0`], message, ); @@ -2418,12 +2354,14 @@ async function stageCandidateForReadiness( } async function verifyReadinessStagingOwner( - options: Pick[0], 'runWrangler' | 'workerName'>, - configPath: string | undefined, + options: Pick< + Parameters[0], + 'configPath' | 'runWrangler' | 'workerName' + >, previousVersionId: string | null, versionId: string, ): Promise | null> { - const owner = await readCurrentProductionVersion(options, configPath); + const owner = await readCurrentProductionVersion(options); if (owner.kind === 'failed') { return { ok: false, @@ -2521,9 +2459,9 @@ async function probeReadiness( } } if (attempt + 1 < attempts) { - const delay = Math.min(250 * 2 ** attempt, 2_000, deadline - Date.now()); - if (delay > 0) { - await sleep(delay); + const waitMs = Math.min(250 * 2 ** attempt, 2_000, deadline - Date.now()); + if (waitMs > 0) { + await sleep(waitMs); } } } @@ -2586,7 +2524,6 @@ async function deployVersionPercentages( Parameters[0], 'runWrangler' | 'workerName' | 'preserveTriggers' | 'configPath' >, - configPath: string | undefined, percentages: Array, message: string, ): Promise { @@ -2606,7 +2543,7 @@ async function deployVersionPercentages( { type: 'versions.deploy', versions: versionPercentages(percentages), - configPath, + configPath: options.configPath, workerName: options.workerName, message, }, @@ -2645,9 +2582,8 @@ export async function deployCloudflareVersionTraffic(options: { async function restorePreviousVersion( options: Pick< Parameters[0], - 'runWrangler' | 'verifyUploader' | 'workerName' + 'configPath' | 'runWrangler' | 'verifyUploader' | 'workerName' >, - configPath: string | undefined, previousVersionId: string | null, cause: string, expectedOwnedTraffic: ReadonlyArray>, @@ -2658,7 +2594,7 @@ async function restorePreviousVersion( if (!options.verifyUploader()) { return 'unsafe'; } - const owner = await readCurrentProductionVersion(options, configPath); + const owner = await readCurrentProductionVersion(options); if ( owner.kind !== 'ok' || !expectedOwnedTraffic.some((expected) => @@ -2669,7 +2605,6 @@ async function restorePreviousVersion( } const result = await deployVersionPercentages( options, - configPath, [`${previousVersionId}@100`], `Void automatic rollback after ${cause}.`, ); @@ -2733,7 +2668,3 @@ function workersDevSubdomainResult(url: string): { workersDevSubdomain?: string const workersDevSubdomain = extractWorkersDevSubdomain(url); return workersDevSubdomain ? { workersDevSubdomain } : {}; } - -function defaultSleep(milliseconds: number): Promise { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); -} diff --git a/packages/deploy-cloudflare/test/operation-fixture.ts b/packages/deploy-cloudflare/test/operation-fixture.ts index 00fc57458..e4a1b057c 100644 --- a/packages/deploy-cloudflare/test/operation-fixture.ts +++ b/packages/deploy-cloudflare/test/operation-fixture.ts @@ -3,7 +3,7 @@ import { vi } from 'vite-plus/test'; import type { CloudflareCommandResult } from '../src/execute.ts'; import type { CloudflareOperation } from '../src/operations.ts'; -/** Replay the existing failure/race fixtures through the typed operation boundary. */ +/** Run an argv-style test callback behind the typed operation runner. */ export function operationFixture< T extends ( args: Array, @@ -295,7 +295,6 @@ export function operationFixtureArgs(operation: CloudflareOperation): Array): CloudflareOperation { const value = (flag: string) => { const i = args.indexOf(flag); diff --git a/packages/deploy-cloudflare/test/unit/existing.test.ts b/packages/deploy-cloudflare/test/unit/existing.test.ts index a3b411665..439747dff 100644 --- a/packages/deploy-cloudflare/test/unit/existing.test.ts +++ b/packages/deploy-cloudflare/test/unit/existing.test.ts @@ -33,6 +33,32 @@ const config = { kv_namespaces: [{ binding: 'KV', id: 'existing-id' }], }; +async function withMigrationsConfig( + migrations: Array>, + run: (configPath: string) => Promise, +): Promise { + const root = mkdtempSync(join(tmpdir(), 'void-do-lifecycle-')); + try { + const configPath = join(root, 'wrangler.jsonc'); + writeFileSync(configPath, JSON.stringify({ migrations })); + await run(configPath); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function activeWorkerRunner(version: Record) { + return vi.fn(async (operation: CloudflareOperation): Promise => ({ + status: 0, + stdout: '', + stderr: '', + data: + operation.type === 'deployments.status' + ? { versions: [{ version_id: 'active', percentage: 100 }] } + : { id: 'active', ...version }, + })); +} + it('preserves an existing authored version metadata binding without permitting additions', () => { const metadata = { type: 'version_metadata', name: 'APP_VERSION' }; const existing = { ...worker, bindings: [...worker.bindings, metadata] }; @@ -358,7 +384,6 @@ describe('existing Worker preservation', () => { }), ).rejects.toThrow('production changed'); expect(runWrangler.mock.calls[1]![0]).toContain('original'); - expect(runWrangler.mock.calls.every(([args]) => args.includes('--json'))).toBe(true); }); it('does not infer an active migration tag from an explicitly requested candidate', async () => { @@ -397,34 +422,18 @@ describe('existing Worker preservation', () => { }); it('rejects a pending class migration on an existing Worker', async () => { - const root = mkdtempSync(join(tmpdir(), 'void-do-lifecycle-')); - const configPath = join(root, 'wrangler.jsonc'); - writeFileSync( - configPath, - JSON.stringify({ - migrations: [ - { tag: 'v1', new_sqlite_classes: ['Room'] }, - { tag: 'v2', new_sqlite_classes: ['Counter'] }, - ], - }), - ); - const runWrangler = operationFixture((args: Array) => ({ - status: 0, - stderr: '', - stdout: JSON.stringify( - args[0] === 'deployments' - ? { versions: [{ version_id: 'active', percentage: 100 }] } - : { - id: 'active', - resources: { - bindings: [], - script: { handlers: ['fetch'] }, - script_runtime: { migration_tag: 'v1' }, - }, - }, - ), - })); - try { + const migrations = [ + { tag: 'v1', new_sqlite_classes: ['Room'] }, + { tag: 'v2', new_sqlite_classes: ['Counter'] }, + ]; + const runWrangler = activeWorkerRunner({ + resources: { + bindings: [], + script: { handlers: ['fetch'] }, + script_runtime: { migration_tag: 'v1' }, + }, + }); + await withMigrationsConfig(migrations, async (configPath) => { await expect( assertCloudflareDurableObjectLifecycleSupported({ runWrangler, @@ -447,73 +456,51 @@ describe('existing Worker preservation', () => { }), ).resolves.toBeUndefined(); expect(runWrangler).toHaveBeenCalledTimes(12); - } finally { - rmSync(root, { recursive: true, force: true }); - } + }); }); it('allows first deployment and an already-active class migration', async () => { - const root = mkdtempSync(join(tmpdir(), 'void-do-lifecycle-')); - const configPath = join(root, 'wrangler.jsonc'); - writeFileSync( - configPath, - JSON.stringify({ migrations: [{ tag: 'v1', new_sqlite_classes: ['Room'] }] }), - ); const missing = operationFixture(() => ({ status: 1, stdout: '', stderr: 'This Worker does not exist. [code: 10007]', })); - const active = operationFixture((args: Array) => ({ - status: 0, - stderr: '', - stdout: JSON.stringify( - args[0] === 'deployments' - ? { versions: [{ version_id: 'active', percentage: 100 }] } - : { - id: 'active', - resources: { - bindings: [], - script: { handlers: ['fetch'] }, - script_runtime: { migration_tag: 'v1' }, - }, - }, - ), - })); - try { - await expect( - assertCloudflareDurableObjectLifecycleSupported({ - runWrangler: missing, - configPath, - workerName: 'first', - accountId: 'account', - apiBaseUrl: 'https://api.cloudflare.test/client/v4', - }), - ).resolves.toBeUndefined(); - await expect( - assertCloudflareDurableObjectLifecycleSupported({ - runWrangler: active, - configPath, - workerName: 'app', - accountId: 'account', - apiBaseUrl: 'https://api.cloudflare.test/client/v4', - readMigrationTag: async () => 'v1', - }), - ).resolves.toBeUndefined(); - } finally { - rmSync(root, { recursive: true, force: true }); - } + const active = activeWorkerRunner({ + resources: { + bindings: [], + script: { handlers: ['fetch'] }, + script_runtime: { migration_tag: 'v1' }, + }, + }); + await withMigrationsConfig( + [{ tag: 'v1', new_sqlite_classes: ['Room'] }], + async (configPath) => { + await expect( + assertCloudflareDurableObjectLifecycleSupported({ + runWrangler: missing, + configPath, + workerName: 'first', + accountId: 'account', + apiBaseUrl: 'https://api.cloudflare.test/client/v4', + }), + ).resolves.toBeUndefined(); + await expect( + assertCloudflareDurableObjectLifecycleSupported({ + runWrangler: active, + configPath, + workerName: 'app', + accountId: 'account', + apiBaseUrl: 'https://api.cloudflare.test/client/v4', + readMigrationTag: async () => 'v1', + }), + ).resolves.toBeUndefined(); + }, + ); }); it.each(['placeholder', 'real-code', 'owner-drift'] as const)( 'verifies immutable SDK secret-draft proof before its first DO migration: %s', async (scenario) => { - const root = mkdtempSync(join(tmpdir(), 'void-sdk-draft-lifecycle-')); - const configPath = join(root, 'wrangler.jsonc'); - writeFileSync( - configPath, - JSON.stringify({ migrations: [{ tag: 'v1', new_sqlite_classes: ['Room'] }] }), - ); const source = voidSecretDraftVersion(); if (scenario === 'real-code') { source.modules[0]!.content_base64 = Buffer.from( @@ -559,30 +546,31 @@ describe('existing Worker preservation', () => { throw new Error(`Unexpected read-only lifecycle operation: ${operation.type}`); }, ); - try { - const inspection = assertCloudflareDurableObjectLifecycleSupported({ - runWrangler, - configPath, - workerName: 'app', - accountId: 'account', - apiBaseUrl: 'https://api.cloudflare.test/client/v4', - readMigrationTag: async () => null, - }); - if (scenario === 'placeholder') { - await expect(inspection).resolves.toBeUndefined(); - } else { - await expect(inspection).rejects.toThrow( - scenario === 'owner-drift' ? 'production changed' : 'atomic Cloudflare deployment', - ); - } - expect( - runWrangler.mock.calls.filter( - ([operation]) => operation.type === 'versions.view' && operation.includeSource, - ), - ).toHaveLength(scenario === 'owner-drift' ? 1 : 2); - } finally { - rmSync(root, { recursive: true, force: true }); - } + await withMigrationsConfig( + [{ tag: 'v1', new_sqlite_classes: ['Room'] }], + async (configPath) => { + const inspection = assertCloudflareDurableObjectLifecycleSupported({ + runWrangler, + configPath, + workerName: 'app', + accountId: 'account', + apiBaseUrl: 'https://api.cloudflare.test/client/v4', + readMigrationTag: async () => null, + }); + if (scenario === 'placeholder') { + await expect(inspection).resolves.toBeUndefined(); + } else { + await expect(inspection).rejects.toThrow( + scenario === 'owner-drift' ? 'production changed' : 'atomic Cloudflare deployment', + ); + } + expect( + runWrangler.mock.calls.filter( + ([operation]) => operation.type === 'versions.view' && operation.includeSource, + ), + ).toHaveLength(scenario === 'owner-drift' ? 1 : 2); + }, + ); }, ); @@ -622,65 +610,44 @@ describe('existing Worker preservation', () => { { error: 'provider process failed' }, { status: Number.NaN }, ])('never treats an interrupted lifecycle probe as an absent Worker: %j', async (provenance) => { - const root = mkdtempSync(join(tmpdir(), 'void-do-interrupted-')); - const configPath = join(root, 'wrangler.jsonc'); - writeFileSync( - configPath, - JSON.stringify({ migrations: [{ tag: 'v1', new_sqlite_classes: ['Room'] }] }), - ); - try { - await expect( - assertCloudflareDurableObjectLifecycleSupported({ - runWrangler: () => ({ - status: 1, - stdout: '', - stderr: '', - errorCode: 10007, - failure: 'not-found', - ...provenance, + await withMigrationsConfig( + [{ tag: 'v1', new_sqlite_classes: ['Room'] }], + async (configPath) => { + await expect( + assertCloudflareDurableObjectLifecycleSupported({ + runWrangler: () => ({ + status: 1, + stdout: '', + stderr: '', + errorCode: 10007, + failure: 'not-found', + ...provenance, + }), + configPath, + workerName: 'app', + accountId: 'account', + apiBaseUrl: 'https://api.cloudflare.test/client/v4', }), - configPath, - workerName: 'app', - accountId: 'account', - apiBaseUrl: 'https://api.cloudflare.test/client/v4', - }), - ).rejects.toBeInstanceOf(CloudflareWorkerInspectionError); - } finally { - rmSync(root, { recursive: true, force: true }); - } + ).rejects.toBeInstanceOf(CloudflareWorkerInspectionError); + }, + ); }); it('accepts an already-published declarative Durable Object state without a legacy tag', async () => { - const root = mkdtempSync(join(tmpdir(), 'void-do-exports-')); - const configPath = join(root, 'wrangler.jsonc'); - writeFileSync( - configPath, - JSON.stringify({ - migrations: [ - { tag: 'fate-v1', new_sqlite_classes: ['Fate'] }, - { tag: 'live-v1', deleted_classes: ['Fate'], new_sqlite_classes: ['Live'] }, - ], - }), - ); - const runWrangler = operationFixture((args: Array) => ({ - status: 0, - stderr: '', - stdout: JSON.stringify( - args[0] === 'deployments' - ? { versions: [{ version_id: 'active', percentage: 100 }] } - : { - id: 'active', - resources: { - bindings: [], - script: { handlers: ['fetch'] }, - script_runtime: { - exports: { Live: { type: 'durable-object', storage: 'sqlite' } }, - }, - }, - }, - ), - })); - try { + const migrations = [ + { tag: 'fate-v1', new_sqlite_classes: ['Fate'] }, + { tag: 'live-v1', deleted_classes: ['Fate'], new_sqlite_classes: ['Live'] }, + ]; + const runWrangler = activeWorkerRunner({ + resources: { + bindings: [], + script: { handlers: ['fetch'] }, + script_runtime: { + exports: { Live: { type: 'durable-object', storage: 'sqlite' } }, + }, + }, + }); + await withMigrationsConfig(migrations, async (configPath) => { await expect( assertCloudflareDurableObjectLifecycleSupported({ runWrangler, @@ -691,19 +658,14 @@ describe('existing Worker preservation', () => { readMigrationTag: async () => null, }), ).resolves.toBeUndefined(); - } finally { - rmSync(root, { recursive: true, force: true }); - } + }); }); it('does not authenticate or read metadata without a class migration', async () => { - const root = mkdtempSync(join(tmpdir(), 'void-do-lifecycle-')); - const configPath = join(root, 'wrangler.jsonc'); - writeFileSync(configPath, JSON.stringify({ migrations: [{ tag: 'v1' }] })); const runWrangler = operationFixture(() => { throw new Error('must not run'); }); - try { + await withMigrationsConfig([{ tag: 'v1' }], async (configPath) => { await expect( assertCloudflareDurableObjectLifecycleSupported({ runWrangler, @@ -714,9 +676,7 @@ describe('existing Worker preservation', () => { }), ).resolves.toBeUndefined(); expect(runWrangler).not.toHaveBeenCalled(); - } finally { - rmSync(root, { recursive: true, force: true }); - } + }); }); it.each([ @@ -747,30 +707,16 @@ describe('existing Worker preservation', () => { ])( 'does not treat an established Worker as an initial-secret bootstrap: %j', async ({ tag, runtime, binding, annotation }) => { - const root = mkdtempSync(join(tmpdir(), 'void-do-existing-secret-')); - const configPath = join(root, 'wrangler.jsonc'); - writeFileSync( - configPath, - JSON.stringify({ migrations: [{ tag: 'live-v1', new_sqlite_classes: ['Room'] }] }), - ); - const runWrangler = operationFixture((args: Array) => ({ - status: 0, - stderr: '', - stdout: JSON.stringify( - args[0] === 'deployments' - ? { versions: [{ version_id: 'active', percentage: 100 }] } - : { - id: 'active', - annotations: { 'workers/triggered_by': annotation }, - resources: { - bindings: [binding], - script: { handlers: ['fetch'] }, - script_runtime: runtime, - }, - }, - ), - })); - try { + const runWrangler = activeWorkerRunner({ + annotations: { 'workers/triggered_by': annotation }, + resources: { + bindings: [binding], + script: { handlers: ['fetch'] }, + script_runtime: runtime, + }, + }); + const migrations = [{ tag: 'live-v1', new_sqlite_classes: ['Room'] }]; + await withMigrationsConfig(migrations, async (configPath) => { await expect( assertCloudflareDurableObjectLifecycleSupported({ runWrangler, @@ -783,12 +729,11 @@ describe('existing Worker preservation', () => { ).rejects.toThrow(/atomic Cloudflare deployment[\s\S]*live-v1/); expect( runWrangler.mock.calls.every( - ([args]) => args[0] === 'deployments' || (args[0] === 'versions' && args[1] === 'view'), + ([operation]) => + operation.type === 'deployments.status' || operation.type === 'versions.view', ), ).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - } + }); }, ); @@ -844,47 +789,33 @@ describe('existing Worker preservation', () => { }); it('rejects migration state that changes during lifecycle verification', async () => { - const root = mkdtempSync(join(tmpdir(), 'void-do-lifecycle-')); - const configPath = join(root, 'wrangler.jsonc'); - writeFileSync( - configPath, - JSON.stringify({ migrations: [{ tag: 'v1', new_sqlite_classes: ['Room'] }] }), - ); - const runWrangler = operationFixture((args: Array) => ({ - status: 0, - stderr: '', - stdout: JSON.stringify( - args[0] === 'deployments' - ? { versions: [{ version_id: 'active', percentage: 100 }] } - : { - id: 'active', - annotations: { 'workers/triggered_by': 'secret' }, - resources: { - bindings: [{ name: 'TOKEN', type: 'secret_text' }], - script: { handlers: ['fetch'] }, - script_runtime: { usage_model: 'standard' }, - }, - }, - ), - })); + const runWrangler = activeWorkerRunner({ + annotations: { 'workers/triggered_by': 'secret' }, + resources: { + bindings: [{ name: 'TOKEN', type: 'secret_text' }], + script: { handlers: ['fetch'] }, + script_runtime: { usage_model: 'standard' }, + }, + }); const readMigrationTag = vi .fn<() => Promise>() .mockResolvedValueOnce(null) .mockResolvedValueOnce('v1'); - try { - await expect( - assertCloudflareDurableObjectLifecycleSupported({ - runWrangler, - configPath, - workerName: 'app', - accountId: 'account', - apiBaseUrl: 'https://api.cloudflare.test/client/v4', - readMigrationTag, - }), - ).rejects.toThrow('migration state changed'); - expect(readMigrationTag).toHaveBeenCalledTimes(2); - } finally { - rmSync(root, { recursive: true, force: true }); - } + await withMigrationsConfig( + [{ tag: 'v1', new_sqlite_classes: ['Room'] }], + async (configPath) => { + await expect( + assertCloudflareDurableObjectLifecycleSupported({ + runWrangler, + configPath, + workerName: 'app', + accountId: 'account', + apiBaseUrl: 'https://api.cloudflare.test/client/v4', + readMigrationTag, + }), + ).rejects.toThrow('migration state changed'); + expect(readMigrationTag).toHaveBeenCalledTimes(2); + }, + ); }); }); diff --git a/packages/deploy-cloudflare/test/unit/metadata.test.ts b/packages/deploy-cloudflare/test/unit/metadata.test.ts index 2545423ca..f4489cf84 100644 --- a/packages/deploy-cloudflare/test/unit/metadata.test.ts +++ b/packages/deploy-cloudflare/test/unit/metadata.test.ts @@ -162,6 +162,10 @@ describe('Cloudflare metadata transport', () => { ['lower-case HTTPS proxy', { https_proxy: 'http://proxy.test:8080' }], ['upper-case HTTPS proxy', { HTTPS_PROXY: 'http://proxy.test:8080' }], ['HTTP proxy inherited for HTTPS', { HTTP_PROXY: 'http://proxy.test:8080' }], + [ + 'HTTPS proxy with a matching NO_PROXY', + { HTTPS_PROXY: 'http://proxy.test', NO_PROXY: 'api.example.com' }, + ], ])('uses Wrangler before authentication with a configured %s', async (_name, env) => { const { run, runWrangler, getToken, verifyUploader, fetcher } = setup( vi.fn(), @@ -187,21 +191,4 @@ describe('Cloudflare metadata transport', () => { expect(getToken).not.toHaveBeenCalled(); expect(fetcher).not.toHaveBeenCalled(); }); - - it.each([ - ['exact host', { HTTPS_PROXY: 'http://proxy.test', NO_PROXY: 'api.example.com' }], - ['leading wildcard', { https_proxy: 'http://proxy.test', no_proxy: '*.example.com' }], - ['explicit port', { HTTPS_PROXY: 'http://proxy.test', NO_PROXY: 'api.example.com:443' }], - ['wildcard', { HTTPS_PROXY: 'http://proxy.test', NO_PROXY: '*' }], - ['non-matching host', { HTTPS_PROXY: 'http://proxy.test', NO_PROXY: 'other.example.com' }], - ])( - 'leaves NO_PROXY %s semantics to Wrangler without an auth or HTTP read', - async (_name, env) => { - const { run, runWrangler, getToken, fetcher } = setup(vi.fn(), env); - expect(await run(statusArgs, capture)).toBe(fallbackResult); - expect(runWrangler).toHaveBeenCalledOnce(); - expect(getToken).not.toHaveBeenCalled(); - expect(fetcher).not.toHaveBeenCalled(); - }, - ); }); diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index 771f43430..9b53cf03e 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -13,6 +13,7 @@ import { readCloudflareTriggerConfig, type CloudflareCommandResult, type CloudflareOperation, + type CloudflareTriggerConfig, } from '../../src/index.ts'; import { operationFixture } from '../operation-fixture.ts'; import { voidSecretDraftVersion } from '../secret-draft-fixture.ts'; @@ -21,18 +22,34 @@ const CURRENT = '11111111-1111-4111-8111-111111111111'; const NEXT = '22222222-2222-4222-8222-222222222222'; const CONCURRENT = '33333333-3333-4333-8333-333333333333'; -function sdkDraftActivation(source: Record = voidSecretDraftVersion()) { - const events: Array = []; - const operations: Array = []; - const triggerConfig = { +function triggersWith(overrides: Partial = {}): CloudflareTriggerConfig { + return { workers_dev: true, preview_urls: true, routes: [], crons: [], queueConsumers: [], workflows: [], + ...overrides, }; - const message = encodeCloudflareVersionMessage([], triggerConfig); +} + +function customDomainTriggers(pattern: string): CloudflareTriggerConfig { + return triggersWith({ + workers_dev: false, + preview_urls: false, + routes: [{ pattern, custom_domain: true }], + }); +} + +function readConfigArg(args: Array): Record { + return JSON.parse(readFileSync(args[args.indexOf('-c') + 1]!, 'utf8')) as Record; +} + +function sdkDraftActivation(source: Record = voidSecretDraftVersion()) { + const events: Array = []; + const operations: Array = []; + const message = encodeCloudflareVersionMessage([], triggersWith()); let traffic = [{ version_id: CURRENT, percentage: 100 }]; let uploaded = false; let published = false; @@ -158,10 +175,6 @@ function sdkDraftActivation(source: Record = voidSecretDraftVer // The bundled uploader rejects locally without an API error code; keep its actual diagnostic. const LOCAL_DURABLE_MIGRATION_ERROR = 'This Worker has a pending Durable Object migration, which cannot be applied by `wrangler versions upload`. Durable Object migrations must be applied with `wrangler deploy`. Run `wrangler deploy` to apply the migration, then retry `wrangler versions upload`.'; -const DURABLE_MIGRATION_ERRORS = [ - 'Durable Object migrations require deploy. [code: 10211]', - LOCAL_DURABLE_MIGRATION_ERROR, -]; function secretOnlyDraftVersion() { return { @@ -253,14 +266,10 @@ function runner(overrides?: { id: CURRENT, number: 1, annotations: { - 'workers/message': encodeCloudflareVersionMessage([], { - workers_dev: true, - preview_urls: true, - routes: [], - crons: ['0 * * * *'], - queueConsumers: [], - workflows: [], - }), + 'workers/message': encodeCloudflareVersionMessage( + [], + triggersWith({ crons: ['0 * * * *'] }), + ), }, }, ...(uploaded ? [{ id: NEXT, number: 2, annotations: {} }] : []), @@ -657,8 +666,9 @@ describe('Cloudflare version activation', () => { expect(fixture.fetcher).not.toHaveBeenCalled(); }); - it.each([ - { + it('retains preview-first activation for a secret-triggered real app', async () => { + const fixture = sdkDraftActivation({ + ...voidSecretDraftVersion(), modules: [ { ...voidSecretDraftVersion().modules[0], @@ -667,16 +677,7 @@ describe('Cloudflare version activation', () => { ).toString('base64'), }, ], - }, - { - bindings: [ - ...voidSecretDraftVersion().bindings, - { name: 'KV', type: 'kv_namespace', namespace_id: 'foreign' }, - ], - }, - { assets: {} }, - ])('retains preview-first activation for a secret-triggered real app with %j', async (patch) => { - const fixture = sdkDraftActivation({ ...voidSecretDraftVersion(), ...patch }); + }); expect(await activateCloudflareVersion(fixture.options)).toMatchObject({ ok: true, versionId: NEXT, @@ -795,14 +796,7 @@ describe('Cloudflare version activation', () => { workerName: 'my-worker', verifyUploader: () => true, verifyInheritedSecrets: true, - message: encodeCloudflareVersionMessage([], { - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - }), + message: encodeCloudflareVersionMessage([], triggersWith()), fetch: async () => Response.json({ ready: true }), }); expect(result).toMatchObject({ ok: true, versionId: NEXT }); @@ -832,8 +826,7 @@ describe('Cloudflare version activation', () => { const validateUploadedVersion = vi.fn().mockResolvedValue(undefined); const runWrangler = operationFixture((args: Array) => { if (args[0] === 'versions' && args[1] === 'deploy') { - const config = JSON.parse(readFileSync(args[args.indexOf('-c') + 1]!, 'utf8')); - expect(config).toEqual({ account_id: 'account', name: 'my-worker' }); + expect(readConfigArg(args)).toEqual({ account_id: 'account', name: 'my-worker' }); } return base(args); }); @@ -1371,11 +1364,7 @@ describe('Cloudflare version activation', () => { let applied: Record | undefined; await deployCloudflareVersionTriggers({ runWrangler: operationFixture(async (args) => { - const configIndex = args.indexOf('-c'); - applied = JSON.parse(readFileSync(args[configIndex + 1]!, 'utf-8')) as Record< - string, - unknown - >; + applied = readConfigArg(args); return ok(); }), workerName: 'app', @@ -1420,17 +1409,12 @@ describe('Cloudflare version activation', () => { ]), ), }); - const desired = { + const desired = triggersWith({ preserveRoutes: true, preserveWorkersDev: true, preservePreviewUrls: true, - workers_dev: true, - preview_urls: true, - routes: [], crons: ['0 * * * *'], - queueConsumers: [], - workflows: [], - }; + }); await expect( activateCloudflareVersion({ runWrangler, @@ -1447,25 +1431,17 @@ describe('Cloudflare version activation', () => { const base = runner(); const runWrangler = operationFixture((args: Array) => { if (args[0] === 'triggers') { - applied = JSON.parse(readFileSync(args[args.indexOf('-c') + 1]!, 'utf8')) as Record< - string, - unknown - >; + applied = readConfigArg(args); return ok(); } return base(args); }); - const desired = { + const desired = triggersWith({ preserveRoutes: true, preserveWorkersDev: true, preservePreviewUrls: true, - workers_dev: true, - preview_urls: true, - routes: [], crons: ['0 * * * *'], - queueConsumers: [], - workflows: [], - }; + }); await expect( activateCloudflareVersion({ runWrangler, @@ -1489,18 +1465,12 @@ describe('Cloudflare version activation', () => { }); it('continues an opaque handoff for a later code-only deployment', async () => { - const preserved = { + const preserved = triggersWith({ preserve: true, preserveRoutes: true, preserveWorkersDev: true, preservePreviewUrls: true, - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - }; + }); const runWrangler = runner({ versions: ok( JSON.stringify([ @@ -1526,17 +1496,14 @@ describe('Cloudflare version activation', () => { it('continues an opaque handoff with unchanged explicit routes', async () => { const route = { pattern: 'app.example.com', custom_domain: true }; - const preserved = { + const preserved = triggersWith({ preserve: true, preserveWorkersDev: true, preservePreviewUrls: true, workers_dev: false, preview_urls: false, routes: [route], - crons: [], - queueConsumers: [], - workflows: [], - }; + }); const runWrangler = runner({ versions: ok( JSON.stringify([ @@ -1562,19 +1529,13 @@ describe('Cloudflare version activation', () => { }); it('does not silently suppress changed settings after an opaque handoff', async () => { - const preserved = { + const preserved = triggersWith({ preserve: true, preserveRoutes: true, preserveWorkersDev: true, preservePreviewUrls: true, settingsHash: 'before', - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - }; + }); const runWrangler = runner({ versions: ok( JSON.stringify([ @@ -1619,22 +1580,16 @@ describe('Cloudflare version activation', () => { await deployCloudflareVersionTriggers({ manageRoutes: false, runWrangler: operationFixture(async (args) => { - const configIndex = args.indexOf('-c'); - applied = JSON.parse(readFileSync(args[configIndex + 1]!, 'utf-8')) as Record< - string, - unknown - >; + applied = readConfigArg(args); return ok(); }), workerName: 'app', - triggerConfig: { + triggerConfig: triggersWith({ workers_dev: false, preview_urls: false, routes: [{ pattern: 'apps.example.com/*', zone_name: 'example.com' }], crons: ['0 * * * *'], - queueConsumers: [], - workflows: [], - }, + }), }); expect(applied).not.toHaveProperty('routes'); @@ -1649,22 +1604,8 @@ describe('Cloudflare version activation', () => { return ok(); }), workerName: 'app', - triggerConfig: { - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - }, - previousTriggerConfig: { - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [{ queue: 'jobs' }], - workflows: [], - }, + triggerConfig: triggersWith(), + previousTriggerConfig: triggersWith({ queueConsumers: [{ queue: 'jobs' }] }), }); expect(result.status).toBe(0); @@ -1684,30 +1625,14 @@ describe('Cloudflare version activation', () => { let applied: Record | undefined; await deployCloudflareVersionTriggers({ runWrangler: operationFixture(async (args) => { - const configIndex = args.indexOf('-c'); - applied = JSON.parse(readFileSync(args[configIndex + 1]!, 'utf-8')) as Record< - string, - unknown - >; + applied = readConfigArg(args); return ok(); }), workerName: 'app', - triggerConfig: { - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - }, - previousTriggerConfig: { - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], + triggerConfig: triggersWith(), + previousTriggerConfig: triggersWith({ workflows: [{ name: 'nightly', class_name: 'NightlyWorkflow', schedules: ['0 0 * * *'] }], - }, + }), }); expect(applied?.workflows).toEqual([ @@ -1720,22 +1645,8 @@ describe('Cloudflare version activation', () => { const result = await deployCloudflareVersionTriggers({ runWrangler: operationFixture(async () => ok()), workerName: 'app', - triggerConfig: { - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - }, - previousTriggerConfig: { - workers_dev: false, - preview_urls: false, - routes: [{ pattern: 'app.example.com', custom_domain: true }], - crons: [], - queueConsumers: [], - workflows: [], - }, + triggerConfig: triggersWith(), + previousTriggerConfig: customDomainTriggers('app.example.com'), publishEmptyCustomDomains, }); @@ -1752,22 +1663,8 @@ describe('Cloudflare version activation', () => { const result = await deployCloudflareVersionTriggers({ runWrangler: operationFixture(async () => ok()), workerName: 'app', - triggerConfig: { - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - }, - previousTriggerConfig: { - workers_dev: true, - preview_urls: true, - routes: [], - crons: ['0 * * * *'], - queueConsumers: [], - workflows: [], - }, + triggerConfig: triggersWith(), + previousTriggerConfig: triggersWith({ crons: ['0 * * * *'] }), publishEmptyCrons, }); @@ -1804,14 +1701,7 @@ describe('Cloudflare version activation', () => { }); it('round-trips an upload digest only when it is a valid SHA-256 value', () => { - const triggers = { - workers_dev: true, - preview_urls: false, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - }; + const triggers = triggersWith({ preview_urls: false }); const digest = 'a'.repeat(64); expect( parseCloudflareVersionMessage(encodeCloudflareVersionMessage([], triggers, digest)), @@ -1836,11 +1726,7 @@ describe('Cloudflare version activation', () => { let config: Record | undefined; await deployCloudflareVersionTriggers({ runWrangler: operationFixture(async (args) => { - const configIndex = args.indexOf('-c'); - config = JSON.parse(readFileSync(args[configIndex + 1]!, 'utf-8')) as Record< - string, - unknown - >; + config = readConfigArg(args); return ok(); }), workerName: 'app', @@ -1885,11 +1771,7 @@ describe('Cloudflare version activation', () => { let rollbackConfig: Record | undefined; await deployCloudflareVersionTriggers({ runWrangler: operationFixture(async (args) => { - const configIndex = args.indexOf('-c'); - rollbackConfig = JSON.parse(readFileSync(args[configIndex + 1]!, 'utf-8')) as Record< - string, - unknown - >; + rollbackConfig = readConfigArg(args); return ok(); }), workerName: 'app', @@ -1960,7 +1842,6 @@ describe('Cloudflare version activation', () => { ['triggers', 'deploy'], ['deployments', 'status'], ]); - expect(runWrangler.mock.calls[1]![0]).toContain('--strict'); expect(runWrangler.mock.calls[1]![0]).toContain('--assets'); expect(runWrangler.mock.calls[1]![0]).toContain('/app/.void/deploy/cloudflare/assets'); expect(runWrangler.mock.calls[3]![0]).toContain(`${NEXT}@100`); @@ -1980,14 +1861,10 @@ describe('Cloudflare version activation', () => { const activationStarted = deferred(); let activeVersionId = CURRENT; let appliedTriggerConfig: Record | undefined; - const concurrentTriggerConfig = { - workers_dev: true, - preview_urls: true, - routes: [], + const concurrentTriggerConfig = triggersWith({ crons: ['concurrent'], queueConsumers: [{ queue: 'concurrent-jobs' }], - workflows: [], - }; + }); const runWrangler = operationFixture(async (args: Array) => { if (args[0] === 'deployments') { return ok(JSON.stringify({ versions: [{ version_id: activeVersionId, percentage: 100 }] })); @@ -2015,11 +1892,7 @@ describe('Cloudflare version activation', () => { ); } if (args[0] === 'triggers') { - const configIndex = args.indexOf('-c'); - appliedTriggerConfig = JSON.parse(readFileSync(args[configIndex + 1]!, 'utf8')) as Record< - string, - unknown - >; + appliedTriggerConfig = readConfigArg(args); return ok(); } throw new Error(`Unexpected command: ${args.join(' ')}`); @@ -2029,14 +1902,7 @@ describe('Cloudflare version activation', () => { runWrangler, verifyUploader: () => true, workerName: 'my-worker', - message: encodeCloudflareVersionMessage([], { - workers_dev: true, - preview_urls: true, - routes: [], - crons: ['deploy'], - queueConsumers: [], - workflows: [], - }), + message: encodeCloudflareVersionMessage([], triggersWith({ crons: ['deploy'] })), fetch: async () => Response.json({ ready: true }), }); await activationStarted.promise; @@ -2072,14 +1938,7 @@ describe('Cloudflare version activation', () => { runWrangler, verifyUploader: () => true, workerName: 'my-worker', - message: encodeCloudflareVersionMessage([], { - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - }), + message: encodeCloudflareVersionMessage([], triggersWith()), queueConsumerCleanup: ['legacy-jobs'], fetch: async () => Response.json({ ready: true }), }), @@ -2115,15 +1974,9 @@ describe('Cloudflare version activation', () => { const activationStarted = deferred(); let activeVersionId = CURRENT; let appliedTriggerConfig: Record | undefined; - const previousTriggerConfig = { - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - ...(previousAddresses === undefined ? {} : { addresses: previousAddresses }), - }; + const previousTriggerConfig = triggersWith( + previousAddresses === undefined ? {} : { addresses: previousAddresses }, + ); const runWrangler = operationFixture(async (args: Array) => { if (args[0] === 'deployments') { return ok( @@ -2153,11 +2006,7 @@ describe('Cloudflare version activation', () => { ); } if (args[0] === 'triggers') { - const configIndex = args.indexOf('-c'); - appliedTriggerConfig = JSON.parse(readFileSync(args[configIndex + 1]!, 'utf8')) as Record< - string, - unknown - >; + appliedTriggerConfig = readConfigArg(args); return ok(); } if (args[0] === 'queues') { @@ -2170,15 +2019,10 @@ describe('Cloudflare version activation', () => { runWrangler, verifyUploader: () => true, workerName: 'my-worker', - message: encodeCloudflareVersionMessage([], { - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - addresses: ['billing@mail.acme.com'], - }), + message: encodeCloudflareVersionMessage( + [], + triggersWith({ addresses: ['billing@mail.acme.com'] }), + ), queueConsumerCleanup: ['jobs'], fetch: async () => Response.json({ ready: true }), }); @@ -2210,14 +2054,7 @@ describe('Cloudflare version activation', () => { let activeVersionId = CURRENT; let triggerCalls = 0; let repairedConfig: Record | undefined; - const configFor = (cron: string) => ({ - workers_dev: true, - preview_urls: true, - routes: [], - crons: [cron], - queueConsumers: [], - workflows: [], - }); + const configFor = (cron: string) => triggersWith({ crons: [cron] }); const runWrangler = operationFixture(async (args: Array) => { if (args[0] === 'deployments') { return ok(JSON.stringify({ versions: [{ version_id: activeVersionId, percentage: 100 }] })); @@ -2255,11 +2092,7 @@ describe('Cloudflare version activation', () => { triggerStarted.resolve(undefined); return failedTriggers.promise; } - const configIndex = args.indexOf('-c'); - repairedConfig = JSON.parse(readFileSync(args[configIndex + 1]!, 'utf8')) as Record< - string, - unknown - >; + repairedConfig = readConfigArg(args); return ok(); } throw new Error(`Unexpected command: ${args.join(' ')}`); @@ -2290,14 +2123,7 @@ describe('Cloudflare version activation', () => { }); it('uses newer published traffic provenance instead of a stale version annotation', async () => { - const staleTriggerConfig = { - workers_dev: false, - preview_urls: false, - routes: [{ pattern: 'old.example.com', custom_domain: true }], - crons: [], - queueConsumers: [], - workflows: [], - }; + const staleTriggerConfig = customDomainTriggers('old.example.com'); const desiredTriggerConfig = { ...staleTriggerConfig, workers_dev: true, @@ -2973,14 +2799,7 @@ describe('Cloudflare version activation', () => { verifyUploader: () => true, workerName: 'custom-app', atomic, - message: encodeCloudflareVersionMessage([], { - workers_dev: false, - preview_urls: false, - routes: [{ pattern: 'app.example.com', custom_domain: true }], - crons: [], - queueConsumers: [], - workflows: [], - }), + message: encodeCloudflareVersionMessage([], customDomainTriggers('app.example.com')), sleep: async (milliseconds) => { time += milliseconds; }, @@ -3039,14 +2858,10 @@ describe('Cloudflare version activation', () => { }); it('restores live traffic changes made after the previous version upload', async () => { - const immutable = { - workers_dev: true, - preview_urls: true, + const immutable = triggersWith({ routes: [{ pattern: 'old.example.com', custom_domain: true }], crons: ['0 * * * *'], - queueConsumers: [], - workflows: [], - }; + }); const liveTraffic = { workers_dev: false, preview_urls: false, @@ -3069,7 +2884,7 @@ describe('Cloudflare version activation', () => { const publications: Array> = []; const runWrangler = operationFixture((args: Array) => { if (args[0] === 'triggers') { - publications.push(JSON.parse(readFileSync(args[args.indexOf('-c') + 1]!, 'utf8'))); + publications.push(readConfigArg(args)); } return base(args); }); @@ -3099,18 +2914,12 @@ describe('Cloudflare version activation', () => { }); it('does not claim opaque trigger recovery succeeded after restoring live traffic', async () => { - const opaque = { + const opaque = triggersWith({ preserve: true, preserveRoutes: true, preserveWorkersDev: true, preservePreviewUrls: true, - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - }; + }); const liveTraffic = { workers_dev: false, preview_urls: false, @@ -3223,26 +3032,6 @@ describe('Cloudflare version activation', () => { ).resolves.toMatchObject({ ok: true, previousVersionId: null }); }); - it('accepts Wrangler error 10007 as a Worker that has not been created yet', async () => { - const runWrangler = runner({ - status: { - stdout: '', - stderr: 'This Worker does not exist on your account. [code: 10007]', - status: 1, - }, - }); - - await expect( - activateCloudflareVersion({ - runWrangler, - verifyUploader: () => true, - workerName: 'first', - message: encodeCloudflareVersionMessage([]), - fetch: async () => Response.json({ ready: true }), - }), - ).resolves.toMatchObject({ ok: true, previousVersionId: null }); - }); - it('treats a missing Worker version list as an empty inheritance baseline', async () => { const base = runner({ status: { @@ -3286,14 +3075,7 @@ describe('Cloudflare version activation', () => { workerName: 'private-app', requireVersionMetadata: true, readinessAttempts: 1, - message: encodeCloudflareVersionMessage([], { - workers_dev: false, - preview_urls: false, - routes: [{ pattern: 'app.example.com', custom_domain: true }], - crons: [], - queueConsumers: [], - workflows: [], - }), + message: encodeCloudflareVersionMessage([], customDomainTriggers('app.example.com')), fetch: async () => Response.json({ ready: true, versionId }), }); expect(result).toMatchObject({ @@ -3323,14 +3105,7 @@ describe('Cloudflare version activation', () => { requireVersionMetadata: true, readinessAttempts: 2, readinessHeaders: { 'CF-Access-Client-Secret': 'must-not-leak' }, - message: encodeCloudflareVersionMessage([], { - workers_dev: false, - preview_urls: false, - routes: [{ pattern: 'app.example.com', custom_domain: true }], - crons: [], - queueConsumers: [], - workflows: [], - }), + message: encodeCloudflareVersionMessage([], customDomainTriggers('app.example.com')), fetch: fetcher, sleep, }); @@ -3409,14 +3184,11 @@ describe('Cloudflare version activation', () => { upload: ok(`Worker Version ID: ${NEXT}`), }); const fetcher = vi.fn(async () => Response.json({ ready: true })); - const triggerConfig = { + const triggerConfig = triggersWith({ workers_dev: false, preview_urls: false, routes: [{ pattern: 'app.example.com/*', zone_name: 'example.com' }], - crons: [], - queueConsumers: [], - workflows: [], - }; + }); await expect( activateCloudflareVersion({ @@ -3468,14 +3240,7 @@ describe('Cloudflare version activation', () => { }); it('probes a custom route after first-deployment bootstrap', async () => { - const triggerConfig = { - workers_dev: false, - preview_urls: false, - routes: [{ pattern: 'bootstrap.example.com', custom_domain: true }], - crons: [], - queueConsumers: [], - workflows: [], - }; + const triggerConfig = customDomainTriggers('bootstrap.example.com'); const runWrangler = runner({ status: { stdout: '', stderr: 'This Worker does not exist. [code: 10007]', status: 1 }, upload: { @@ -3518,14 +3283,7 @@ describe('Cloudflare version activation', () => { runWrangler, verifyUploader: () => true, workerName: 'first-private', - message: encodeCloudflareVersionMessage([], { - workers_dev: false, - preview_urls: false, - routes: [{ pattern: 'app.example.com', custom_domain: true }], - crons: [], - queueConsumers: [], - workflows: [], - }), + message: encodeCloudflareVersionMessage([], customDomainTriggers('app.example.com')), fetch: fetcher, }); expect(result).toMatchObject({ @@ -3541,14 +3299,7 @@ describe('Cloudflare version activation', () => { it.each([false, true])( 'repairs a concurrent bootstrap owner (during readiness=%s)', async (duringReadiness) => { - const concurrentConfig = { - workers_dev: false, - preview_urls: false, - routes: [{ pattern: 'other.example.com', custom_domain: true }], - crons: [], - queueConsumers: [], - workflows: [], - }; + const concurrentConfig = customDomainTriggers('other.example.com'); const base = runner({ status: { stdout: '', stderr: 'Worker not found. [code: 10007]', status: 1 }, upload: { stdout: '', stderr: LOCAL_DURABLE_MIGRATION_ERROR, status: 1 }, @@ -3574,7 +3325,7 @@ describe('Cloudflare version activation', () => { ); } if (args[0] === 'triggers') { - published.push(JSON.parse(readFileSync(args[args.indexOf('-c') + 1]!, 'utf8'))); + published.push(readConfigArg(args)); } const result = base(args); if (args[0] === 'deploy') { @@ -3624,80 +3375,69 @@ describe('Cloudflare version activation', () => { }, ); - it.each(DURABLE_MIGRATION_ERRORS)( - 'bootstraps first-deploy Durable Object migrations for %s', - async (error) => { - const base = runner({ - status: { stdout: '', stderr: 'The Worker first has no deployments.', status: 1 }, - upload: { - stdout: '', - stderr: error, - status: 1, - }, - bootstrap: ok( - `Current Version ID: ${NEXT}\nVersion Preview URL: https://2e209d56-first.example.workers.dev`, - ), - }); - const secretPayloads: Array = []; - const secretPaths: Array = []; - const runWrangler = operationFixture((args: Array) => { - if ( - (args[0] === 'deploy' || (args[0] === 'versions' && args[1] === 'upload')) && - args.includes('--secrets-file') - ) { - const path = args[args.indexOf('--secrets-file') + 1]!; - secretPaths.push(path); - secretPayloads.push(JSON.parse(readFileSync(path, 'utf8'))); - } - return base(args); - }); - const fetcher = vi.fn(async () => Response.json({ ready: true })); + it('bootstraps first-deploy Durable Object migrations after Cloudflare error 10211', async () => { + const base = runner({ + status: { stdout: '', stderr: 'The Worker first has no deployments.', status: 1 }, + upload: { stdout: '', stderr: '', status: 1, errorCode: 10211 }, + bootstrap: ok( + `Current Version ID: ${NEXT}\nVersion Preview URL: https://2e209d56-first.example.workers.dev`, + ), + }); + const secretPayloads: Array = []; + const secretPaths: Array = []; + const runWrangler = operationFixture((args: Array) => { + if ( + (args[0] === 'deploy' || (args[0] === 'versions' && args[1] === 'upload')) && + args.includes('--secrets-file') + ) { + const path = args[args.indexOf('--secrets-file') + 1]!; + secretPaths.push(path); + secretPayloads.push(JSON.parse(readFileSync(path, 'utf8'))); + } + return base(args); + }); + const fetcher = vi.fn(async () => Response.json({ ready: true })); - await expect( - activateCloudflareVersion({ - runWrangler, - verifyUploader: () => true, - workerName: 'first', - configPath: '/app/wrangler.jsonc', - assetsDirectoryOverride: '/app/.void/deploy/cloudflare/assets', - message: encodeCloudflareVersionMessage(['0001_init.sql']), - candidateSecrets: { API_TOKEN: 'first-secret' }, - fetch: fetcher, - }), - ).resolves.toEqual({ - ok: true, - versionId: NEXT, - previewUrl: 'https://first.example.workers.dev', - previousVersionId: null, - workersDevSubdomain: 'example', - }); - expect(runWrangler.mock.calls.map(([args]) => args.slice(0, 2))).toEqual([ - ['deployments', 'status'], - ['versions', 'upload'], - ['deployments', 'status'], - ['deploy', '-c'], - ['deployments', 'status'], - ['deployments', 'status'], - ['triggers', 'deploy'], - ['deployments', 'status'], - ['deployments', 'status'], - ]); - const deployArgs = runWrangler.mock.calls.find(([args]) => args[0] === 'deploy')![0]; - expect(deployArgs).toContain('--autoconfig=false'); - expect(deployArgs).toContain('--assets'); - expect(deployArgs).toContain('/app/.void/deploy/cloudflare/assets'); - expect(secretPayloads).toEqual([ - { API_TOKEN: 'first-secret' }, - { API_TOKEN: 'first-secret' }, - ]); - expect(new Set(secretPaths).size).toBe(1); - expect(secretPaths.every((path) => !existsSync(path))).toBe(true); - expect(fetcher).toHaveBeenCalledWith( - 'https://first.example.workers.dev/__void/ready', - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - }, - ); + await expect( + activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'first', + configPath: '/app/wrangler.jsonc', + assetsDirectoryOverride: '/app/.void/deploy/cloudflare/assets', + message: encodeCloudflareVersionMessage(['0001_init.sql']), + candidateSecrets: { API_TOKEN: 'first-secret' }, + fetch: fetcher, + }), + ).resolves.toEqual({ + ok: true, + versionId: NEXT, + previewUrl: 'https://first.example.workers.dev', + previousVersionId: null, + workersDevSubdomain: 'example', + }); + expect(runWrangler.mock.calls.map(([args]) => args.slice(0, 2))).toEqual([ + ['deployments', 'status'], + ['versions', 'upload'], + ['deployments', 'status'], + ['deploy', '-c'], + ['deployments', 'status'], + ['deployments', 'status'], + ['triggers', 'deploy'], + ['deployments', 'status'], + ['deployments', 'status'], + ]); + const deployArgs = runWrangler.mock.calls.find(([args]) => args[0] === 'deploy')![0]; + expect(deployArgs).toContain('--assets'); + expect(deployArgs).toContain('/app/.void/deploy/cloudflare/assets'); + expect(secretPayloads).toEqual([{ API_TOKEN: 'first-secret' }, { API_TOKEN: 'first-secret' }]); + expect(new Set(secretPaths).size).toBe(1); + expect(secretPaths.every((path) => !existsSync(path))).toBe(true); + expect(fetcher).toHaveBeenCalledWith( + 'https://first.example.workers.dev/__void/ready', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); it('reports Access blocking the already-active first Worker bootstrap', async () => { const runWrangler = runner({ @@ -3992,23 +3732,17 @@ describe('Cloudflare version activation', () => { expect(runWrangler.mock.calls.some(([args]) => args[0] === 'deploy')).toBe(false); }); - const firstSecretTriggers = { + const firstSecretTriggers = triggersWith({ preserveRoutes: true, preserveWorkersDev: true, preservePreviewUrls: true, - workers_dev: true, - preview_urls: true, - routes: [], - crons: [], - queueConsumers: [], - workflows: [], - }; + }); function firstSecretRunner( inspection: CloudflareCommandResult, upload: CloudflareCommandResult = { stdout: '', - stderr: DURABLE_MIGRATION_ERRORS[0]!, + stderr: 'Durable Object migrations require deploy. [code: 10211]', status: 1, }, ) { @@ -4032,21 +3766,9 @@ describe('Cloudflare version activation', () => { }); } - const uploadFailures = [ - { stdout: '', stderr: DURABLE_MIGRATION_ERRORS[0]!, status: 1 }, - { stdout: '', stderr: LOCAL_DURABLE_MIGRATION_ERROR, status: 1 }, - { stdout: LOCAL_DURABLE_MIGRATION_ERROR, stderr: '', status: 1 }, - { - stdout: '', - stderr: `\u001b[31m${LOCAL_DURABLE_MIGRATION_ERROR.replace('pending Durable', 'pending\n Durable')}\u001b[0m`, - status: 1, - }, - ]; - it.each( - [{}, { limits: {} }].flatMap((limits) => uploadFailures.map((upload) => ({ limits, upload }))), - )( - 'passes lifecycle preflight and retries a first secret-only deploy for %j', - async ({ limits, upload }) => { + it.each([{}, { limits: {} }])( + 'passes lifecycle preflight and retries a first secret-only deploy for runtime %j', + async (limits) => { const draft = secretOnlyDraftVersion(); const runWrangler = firstSecretRunner( ok( @@ -4058,7 +3780,6 @@ describe('Cloudflare version activation', () => { }, }), ), - upload, ); const root = mkdtempSync(join(tmpdir(), 'void-secret-lifecycle-retry-')); const configPath = join(root, 'wrangler.jsonc'); diff --git a/packages/deploy-core/src/validate.ts b/packages/deploy-core/src/validate.ts index 19d97806c..89e0a2c40 100644 --- a/packages/deploy-core/src/validate.ts +++ b/packages/deploy-core/src/validate.ts @@ -4,73 +4,130 @@ export const MANIFEST_REDIRECT_RULE_STATUSES: ReadonlySet = new Set([ 200, 301, 302, 303, 307, 308, ]); -// Caps on user-supplied routing rule lists. Each rule compiles a regex per -// request in dispatch (see `routing-rules.ts`), so an unbounded list lets a -// 100k-entry manifest exhaust CPU on every request to the project. The limit -// is per-list (redirect + fallback are independent), well above what real -// frameworks emit (Vercel/Netlify both ship in the low thousands at most), -// and validated at deploy ingest so dispatch never sees an oversized list. +// Each rule compiles a regex per request in dispatch, so cap every list at deploy ingest. export const MAX_ROUTING_RULES = 2000; export const MAX_REVALIDATE_QUERY_ALLOWLIST_ENTRIES: number = MAX_ROUTING_RULES; const MAX_PAGES = 5000; -// Each distinct cron expression becomes one message on the platform's shared -// cron queue, and one user-worker call, every minute it matches. The scheduler -// dedupes only identical strings. 100 matches Vercel's per-project cap and is -// far above what real apps declare. +// Each distinct cron expression becomes one queue message and one Worker call per matching minute. const MAX_CRON_EXPRESSIONS = 100; // The scheduler parses every entry, shared expression or not, on each alarm. const MAX_SCHEDULES = 1000; -/** - * Strict literal-hostname regex used to validate `redirectRules[].host` / - * `fallbackRules[].host` on the deploy manifest. Mirrors the regex in - * `routes/domains.ts` (custom-domain create endpoint) — the source of truth - * for what counts as a valid project-owned hostname. Rejects scheme prefixes, - * ports, paths, whitespace, control chars, wildcards, single-label hosts, - * IPv4 literals, and userinfo. Hostnames are lowercased before testing so - * the same byte-exact form is what dispatch compares against `url.hostname` - * (also lowercase per WHATWG). - */ const LITERAL_HOSTNAME_REGEX = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/; -/** - * Validate and lowercase-normalize an optional `host` field on a redirect or - * fallback rule. Mutates `ruleObject.host` in place so the persisted manifest carries - * the canonical form. Returns an error message string on failure, or `null` - * on success (including the absent-field case). - */ +type RoutingRuleKind = 'redirectRules' | 'fallbackRules'; + +// Lowercases `host` in place: dispatch compares the stored form with the lowercase `url.hostname`. function validateRuleHost( - ruleObject: Record, - ruleKind: 'redirectRules' | 'fallbackRules', + rule: Record, + kind: RoutingRuleKind, sourceRef: string, ): string | null { - if (ruleObject.host === undefined) { + if (rule.host === undefined) { return null; } - if (typeof ruleObject.host !== 'string') { - return `invalid manifest shape: ${ruleKind} entry ${sourceRef} host must be a string`; + if (typeof rule.host !== 'string') { + return `invalid manifest shape: ${kind} entry ${sourceRef} host must be a string`; } - const normalized = ruleObject.host.toLowerCase(); + const normalized = rule.host.toLowerCase(); if (!LITERAL_HOSTNAME_REGEX.test(normalized)) { - return `invalid manifest shape: ${ruleKind} entry ${sourceRef} host must be a literal hostname (e.g. "www.example.com"; no scheme, port, path, or wildcards): ${JSON.stringify(ruleObject.host)}`; + return `invalid manifest shape: ${kind} entry ${sourceRef} host must be a literal hostname (e.g. "www.example.com"; no scheme, port, path, or wildcards): ${JSON.stringify(rule.host)}`; + } + rule.host = normalized; + return null; +} + +function validateRoutingRules(rules: unknown, kind: RoutingRuleKind): string | null { + if (rules === undefined) { + return null; + } + if (!Array.isArray(rules)) { + return `invalid manifest shape: ${kind} must be an array`; + } + if (rules.length > MAX_ROUTING_RULES) { + return `invalid manifest shape: ${kind} has ${rules.length} entries, exceeds the ${MAX_ROUTING_RULES} per-list limit`; + } + const rewritesOnly = kind === 'fallbackRules'; + for (const rule of rules) { + if (!isRecord(rule)) { + return `invalid manifest shape: ${kind} entries must be objects`; + } + if ( + typeof rule.source !== 'string' || + typeof rule.destination !== 'string' || + typeof rule.status !== 'number' + ) { + return `invalid manifest shape: ${kind} entries must have string source, string destination, and numeric status`; + } + const sourceRef = JSON.stringify(rule.source); + if (rewritesOnly && rule.status !== 200) { + return `invalid manifest shape: ${kind} entry ${sourceRef} status must be 200 (fallbacks are rewrites only)`; + } + if (!rewritesOnly && !MANIFEST_REDIRECT_RULE_STATUSES.has(rule.status)) { + return `invalid manifest shape: ${kind} entry ${sourceRef} status must be one of ${[...MANIFEST_REDIRECT_RULE_STATUSES].join(', ')} (200 = rewrite, 3xx = redirect)`; + } + // Rewrites (status 200) must stay internal: dispatch merges the request query itself, and + // `//evil.com/x` parses cross-origin. 3xx redirects may carry a query and external Location. + if (rule.status === 200) { + const condition = rewritesOnly ? '' : ' when status is 200'; + if (rule.destination.includes('?')) { + return `invalid manifest shape: ${kind} entry ${sourceRef} destination must not contain a query string${condition}`; + } + if (!rule.destination.startsWith('/')) { + return `invalid manifest shape: ${kind} entry ${sourceRef} destination must be path-absolute (start with "/")${condition}`; + } + if (rule.destination.startsWith('//')) { + return `invalid manifest shape: ${kind} entry ${sourceRef} destination must not start with "//" (protocol-relative URLs parse cross-origin; use a single-slash path-absolute destination)`; + } + } + const hostError = validateRuleHost(rule, kind, sourceRef); + if (hostError) { + return hostError; + } + } + return null; +} + +function validateClassMigrations( + path: string, + migrations: unknown, + fields: ReadonlyArray, + requireNonEmptyTag = false, +): string | null { + if (migrations === undefined) { + return null; + } + if (!Array.isArray(migrations)) { + return `invalid manifest shape: ${path} must be an array`; + } + for (const migration of migrations) { + if (!isRecord(migration)) { + return `invalid manifest shape: ${path} entries must be objects`; + } + if (typeof migration.tag !== 'string' || (requireNonEmptyTag && migration.tag.length === 0)) { + return `invalid manifest shape: ${path} entries must have a ${requireNonEmptyTag ? 'non-empty ' : ''}string tag`; + } + for (const field of fields) { + const value = migration[field]; + if ( + value !== undefined && + (!Array.isArray(value) || !value.every((className) => typeof className === 'string')) + ) { + return `invalid manifest shape: ${path} ${field} must be an array of strings`; + } + } } - ruleObject.host = normalized; return null; } /** - * Runtime shape validation for incoming deploy manifests. Downstream code - * indexes deeply into the manifest (bindings.d1, queues[i].name, - * websocket.classes, etc.) without re-guarding, so a malformed-but-JSON-valid - * payload would otherwise crash with a 500 — sometimes after a deployment row - * or CF resources were created. Everything the deploy pipeline reads must be - * validated here BEFORE createDeployment runs. + * Check the shape of an incoming deploy manifest. Returns an error message, or `null` when valid. + * Deploy code reads the manifest without re-checking, so run this before creating any deploy state. */ -export function validateManifestShape(parsed: unknown): string | null { - if (!isRecord(parsed)) { +export function validateManifestShape(manifest: unknown): string | null { + if (!isRecord(manifest)) { return 'invalid manifest shape: expected a JSON object'; } - const manifest = parsed; if (manifest.emailProtocolVersion !== undefined && manifest.emailProtocolVersion !== 1) { return 'invalid manifest shape: emailProtocolVersion must be 1'; } @@ -97,8 +154,7 @@ export function validateManifestShape(parsed: unknown): string | null { if (!isRecord(queue)) { return 'invalid manifest shape: queues entries must be objects'; } - const queueObject = queue; - if (typeof queueObject.name !== 'string' || typeof queueObject.bindingName !== 'string') { + if (typeof queue.name !== 'string' || typeof queue.bindingName !== 'string') { return 'invalid manifest shape: queues entries must have string name and bindingName'; } for (const tuning of [ @@ -107,7 +163,7 @@ export function validateManifestShape(parsed: unknown): string | null { 'maxRetries', 'retryDelay', ] as const) { - const tuningValue = queueObject[tuning]; + const tuningValue = queue[tuning]; if (tuningValue === undefined) { continue; } @@ -130,11 +186,10 @@ export function validateManifestShape(parsed: unknown): string | null { if (!isRecord(schedule)) { return 'invalid manifest shape: schedules entries must be objects'; } - const scheduleObject = schedule; - if (typeof scheduleObject.name !== 'string' || typeof scheduleObject.cron !== 'string') { + if (typeof schedule.name !== 'string' || typeof schedule.cron !== 'string') { return 'invalid manifest shape: schedules entries must have string name and cron'; } - cronExpressions.add(scheduleObject.cron); + cronExpressions.add(schedule.cron); } if (cronExpressions.size > MAX_CRON_EXPRESSIONS) { return `invalid manifest shape: schedules has ${cronExpressions.size} distinct cron expressions, exceeds the ${MAX_CRON_EXPRESSIONS} per-project limit`; @@ -283,16 +338,13 @@ export function validateManifestShape(parsed: unknown): string | null { } } - if (manifest.revalidate !== undefined) { - if (typeof manifest.revalidate === 'number') { - // ok - } else if (!isRecord(manifest.revalidate)) { + if (manifest.revalidate !== undefined && typeof manifest.revalidate !== 'number') { + if (!isRecord(manifest.revalidate)) { return 'invalid manifest shape: revalidate must be a number or object'; - } else { - for (const [pathname, value] of Object.entries(manifest.revalidate)) { - if (typeof value !== 'number') { - return `invalid manifest shape: revalidate.${pathname} must be a number`; - } + } + for (const [pathname, value] of Object.entries(manifest.revalidate)) { + if (typeof value !== 'number') { + return `invalid manifest shape: revalidate.${pathname} must be a number`; } } } @@ -305,14 +357,13 @@ export function validateManifestShape(parsed: unknown): string | null { if (!isRecord(rule)) { return 'invalid manifest shape: headerRules entries must be objects'; } - const ruleObject = rule; - if (typeof ruleObject.pattern !== 'string') { + if (typeof rule.pattern !== 'string') { return 'invalid manifest shape: headerRules entries must have a string pattern'; } - if (!Array.isArray(ruleObject.headers)) { + if (!Array.isArray(rule.headers)) { return 'invalid manifest shape: headerRules entries must have a headers array'; } - for (const pair of ruleObject.headers) { + for (const pair of rule.headers) { if ( !Array.isArray(pair) || pair.length !== 2 || @@ -325,60 +376,11 @@ export function validateManifestShape(parsed: unknown): string | null { } } - // redirectRules is the pre-asset rule list. `status: 200` means a force - // rewrite (internal URL rewrite, no Location header); `status: 3xx` means a - // redirect. Destinations may not contain `?` — request query is merged - // separately at dispatch time (see `mergeQueryIntoLocation`). - if (manifest.redirectRules !== undefined) { - if (!Array.isArray(manifest.redirectRules)) { - return 'invalid manifest shape: redirectRules must be an array'; - } - if (manifest.redirectRules.length > MAX_ROUTING_RULES) { - return `invalid manifest shape: redirectRules has ${manifest.redirectRules.length} entries, exceeds the ${MAX_ROUTING_RULES} per-list limit`; - } - for (const rule of manifest.redirectRules) { - if (!isRecord(rule)) { - return 'invalid manifest shape: redirectRules entries must be objects'; - } - const ruleObject = rule; - if ( - typeof ruleObject.source !== 'string' || - typeof ruleObject.destination !== 'string' || - typeof ruleObject.status !== 'number' - ) { - return 'invalid manifest shape: redirectRules entries must have string source, string destination, and numeric status'; - } - const sourceRef = JSON.stringify(ruleObject.source as string); - if (!MANIFEST_REDIRECT_RULE_STATUSES.has(ruleObject.status)) { - return `invalid manifest shape: redirectRules entry ${sourceRef} status must be one of ${[...MANIFEST_REDIRECT_RULE_STATUSES].join(', ')} (200 = rewrite, 3xx = redirect)`; - } - // Rewrites (status=200) must stay internal: no query string, path-absolute, - // and not protocol-relative (`//evil.com/x` parses cross-origin). 3xx - // redirects may include query strings and external Location values. - const destination = ruleObject.destination as string; - const isRewrite = ruleObject.status === 200; - if (isRewrite && destination.includes('?')) { - return `invalid manifest shape: redirectRules entry ${sourceRef} destination must not contain a query string when status is 200`; - } - if (isRewrite && !destination.startsWith('/')) { - return `invalid manifest shape: redirectRules entry ${sourceRef} destination must be path-absolute (start with "/") when status is 200`; - } - if (isRewrite && destination.startsWith('//')) { - return `invalid manifest shape: redirectRules entry ${sourceRef} destination must not start with "//" (protocol-relative URLs parse cross-origin; use a single-slash path-absolute destination)`; - } - const hostError = validateRuleHost(ruleObject, 'redirectRules', sourceRef); - if (hostError) { - return hostError; - } - } + const redirectRulesError = validateRoutingRules(manifest.redirectRules, 'redirectRules'); + if (redirectRulesError) { + return redirectRulesError; } - // revalidateQueryAllowlist gates which query params participate in the - // ISR variant key on a rewrite. Shape: `{ "": ["param", …] }`. - // The default (no entry / no allowlist) drops every param so a fanout - // attack via `?utm=…`/`?cb=…` can't multiply cache slots. The validator - // only enforces shape; semantic correctness (does the pattern match a - // real route?) is the user's responsibility. if (manifest.revalidateQueryAllowlist !== undefined) { if (!isRecord(manifest.revalidateQueryAllowlist)) { return 'invalid manifest shape: revalidateQueryAllowlist must be an object'; @@ -402,10 +404,6 @@ export function validateManifestShape(parsed: unknown): string | null { } } - // pages is the ordered route table (`[{ pattern, revalidate? }]`) the - // framework emits so dispatch can resolve a per-route ISR TTL by segment - // pattern. Validator only enforces shape; the framework owns the ordering - // and pattern semantics. if (manifest.pages !== undefined) { if (!Array.isArray(manifest.pages)) { return 'invalid manifest shape: pages must be an array'; @@ -417,20 +415,15 @@ export function validateManifestShape(parsed: unknown): string | null { if (!isRecord(entry)) { return 'invalid manifest shape: each pages entry must be an object'; } - const e = entry; - if (typeof e.pattern !== 'string' || e.pattern === '') { + if (typeof entry.pattern !== 'string' || entry.pattern === '') { return 'invalid manifest shape: pages entry pattern must be a string'; } - if (e.revalidate !== undefined && typeof e.revalidate !== 'number') { + if (entry.revalidate !== undefined && typeof entry.revalidate !== 'number') { return 'invalid manifest shape: pages entry revalidate must be a number'; } } } - // pathRevalidate is the RAW per-path OBJECT config shipped alongside `pages` - // so dispatch can resolve an inherit-eligible page (a pages entry with no - // baked revalidate) against the real request path. Same shape as the object - // form of `revalidate`: a map of pattern → number. if (manifest.pathRevalidate !== undefined) { if (!isRecord(manifest.pathRevalidate)) { return 'invalid manifest shape: pathRevalidate must be an object'; @@ -442,47 +435,9 @@ export function validateManifestShape(parsed: unknown): string | null { } } - // fallbackRules fire after the static asset resolver 404s. Rewrites only, - // so every entry carries the same internal-destination constraints as a - // status=200 redirectRule. - if (manifest.fallbackRules !== undefined) { - if (!Array.isArray(manifest.fallbackRules)) { - return 'invalid manifest shape: fallbackRules must be an array'; - } - if (manifest.fallbackRules.length > MAX_ROUTING_RULES) { - return `invalid manifest shape: fallbackRules has ${manifest.fallbackRules.length} entries, exceeds the ${MAX_ROUTING_RULES} per-list limit`; - } - for (const rule of manifest.fallbackRules) { - if (!isRecord(rule)) { - return 'invalid manifest shape: fallbackRules entries must be objects'; - } - const ruleObject = rule; - if ( - typeof ruleObject.source !== 'string' || - typeof ruleObject.destination !== 'string' || - typeof ruleObject.status !== 'number' - ) { - return 'invalid manifest shape: fallbackRules entries must have string source, string destination, and numeric status'; - } - const sourceRef = JSON.stringify(ruleObject.source as string); - if (ruleObject.status !== 200) { - return `invalid manifest shape: fallbackRules entry ${sourceRef} status must be 200 (fallbacks are rewrites only)`; - } - const destination = ruleObject.destination as string; - if (destination.includes('?')) { - return `invalid manifest shape: fallbackRules entry ${sourceRef} destination must not contain a query string`; - } - if (!destination.startsWith('/')) { - return `invalid manifest shape: fallbackRules entry ${sourceRef} destination must be path-absolute (start with "/")`; - } - if (destination.startsWith('//')) { - return `invalid manifest shape: fallbackRules entry ${sourceRef} destination must not start with "//" (protocol-relative URLs parse cross-origin; use a single-slash path-absolute destination)`; - } - const hostError = validateRuleHost(ruleObject, 'fallbackRules', sourceRef); - if (hostError) { - return hostError; - } - } + const fallbackRulesError = validateRoutingRules(manifest.fallbackRules, 'fallbackRules'); + if (fallbackRulesError) { + return fallbackRulesError; } if (manifest.websocket !== undefined) { @@ -497,11 +452,10 @@ export function validateManifestShape(parsed: unknown): string | null { if (!isRecord(route)) { return 'invalid manifest shape: websocket.routes entries must be objects'; } - const routeObject = route; - if (typeof routeObject.className !== 'string') { + if (typeof route.className !== 'string') { return 'invalid manifest shape: websocket.routes entries must have a string className'; } - if (routeObject.bindingName !== undefined && typeof routeObject.bindingName !== 'string') { + if (route.bindingName !== undefined && typeof route.bindingName !== 'string') { return 'invalid manifest shape: websocket.routes entry bindingName must be a string'; } } @@ -511,38 +465,19 @@ export function validateManifestShape(parsed: unknown): string | null { ) { return 'invalid manifest shape: websocket.classes must be an array of strings'; } - if (websocket.migrations !== undefined) { - if (!Array.isArray(websocket.migrations)) { - return 'invalid manifest shape: websocket.migrations must be an array'; - } - for (const migration of websocket.migrations) { - if (!isRecord(migration)) { - return 'invalid manifest shape: websocket.migrations entries must be objects'; - } - const migrationObject = migration; - if (typeof migrationObject.tag !== 'string' || migrationObject.tag.length === 0) { - return 'invalid manifest shape: websocket.migrations entries must have a non-empty string tag'; - } - for (const field of ['newClasses', 'newSqliteClasses', 'deletedClasses'] as const) { - const value = migrationObject[field]; - if ( - value !== undefined && - (!Array.isArray(value) || !value.every((className) => typeof className === 'string')) - ) { - return `invalid manifest shape: websocket.migrations ${field} must be an array of strings`; - } - } - } + const migrationsError = validateClassMigrations( + 'websocket.migrations', + websocket.migrations, + ['newClasses', 'newSqliteClasses', 'deletedClasses'], + true, + ); + if (migrationsError) { + return migrationsError; } } if (manifest.sandbox !== undefined) { - // sandbox is meaningless without a user worker. Static/SPA deploys are - // served straight from KV/R2 by dispatch — no script is created, so a - // `sandbox` block can't be wired to anything. Reject up-front rather - // than silently dropping the block; a stale manifest carrying `sandbox` - // after the project type flips would otherwise surface as a confusing - // runtime no-op rather than a clear deploy-time error. + // Static and SPA deploys create no Worker script, so a sandbox could never be wired. if (manifest.type === 'static' || manifest.type === 'spa') { return 'sandbox is not supported for static/spa deployments'; } @@ -569,33 +504,12 @@ export function validateManifestShape(parsed: unknown): string | null { ) { return 'invalid manifest shape: sandbox.maxInstances must be a positive integer'; } - if (sandbox.migrations !== undefined) { - if (!Array.isArray(sandbox.migrations)) { - return 'invalid manifest shape: sandbox.migrations must be an array'; - } - for (const migration of sandbox.migrations) { - if (!isRecord(migration)) { - return 'invalid manifest shape: sandbox.migrations entries must be objects'; - } - const migrationObject = migration; - if (typeof migrationObject.tag !== 'string') { - return 'invalid manifest shape: sandbox.migrations entries must have a string tag'; - } - if ( - migrationObject.newSqliteClasses !== undefined && - (!Array.isArray(migrationObject.newSqliteClasses) || - !migrationObject.newSqliteClasses.every((value) => typeof value === 'string')) - ) { - return 'invalid manifest shape: sandbox.migrations newSqliteClasses must be an array of strings'; - } - if ( - migrationObject.deletedClasses !== undefined && - (!Array.isArray(migrationObject.deletedClasses) || - !migrationObject.deletedClasses.every((value) => typeof value === 'string')) - ) { - return 'invalid manifest shape: sandbox.migrations deletedClasses must be an array of strings'; - } - } + const migrationsError = validateClassMigrations('sandbox.migrations', sandbox.migrations, [ + 'newSqliteClasses', + 'deletedClasses', + ]); + if (migrationsError) { + return migrationsError; } } @@ -615,26 +529,11 @@ export function validateManifestShape(parsed: unknown): string | null { if (!Array.isArray(live.classes) || !live.classes.every((x) => typeof x === 'string')) { return 'invalid manifest shape: live.classes must be an array of strings'; } - if (live.migrations !== undefined) { - if (!Array.isArray(live.migrations)) { - return 'invalid manifest shape: live.migrations must be an array'; - } - for (const migration of live.migrations) { - if (!isRecord(migration)) { - return 'invalid manifest shape: live.migrations entries must be objects'; - } - const migrationObject = migration; - if (typeof migrationObject.tag !== 'string') { - return 'invalid manifest shape: live.migrations entries must have a string tag'; - } - if ( - migrationObject.newSqliteClasses !== undefined && - (!Array.isArray(migrationObject.newSqliteClasses) || - !migrationObject.newSqliteClasses.every((value) => typeof value === 'string')) - ) { - return 'invalid manifest shape: live.migrations newSqliteClasses must be an array of strings'; - } - } + const migrationsError = validateClassMigrations('live.migrations', live.migrations, [ + 'newSqliteClasses', + ]); + if (migrationsError) { + return migrationsError; } } diff --git a/packages/deploy-core/test/unit/canonical-json.test.ts b/packages/deploy-core/test/unit/canonical-json.test.ts index 97509d3b6..6fad929fe 100644 --- a/packages/deploy-core/test/unit/canonical-json.test.ts +++ b/packages/deploy-core/test/unit/canonical-json.test.ts @@ -1,4 +1,25 @@ -import { defineCanonicalJsonTests } from '../../../../test/canonical-json-contract.ts'; +import { describe, expect, it } from 'vite-plus/test'; import { canonicalJson } from '../../src/index.ts'; -defineCanonicalJsonTests(canonicalJson); +describe('canonicalJson', () => { + it('sorts object keys recursively while preserving array order', () => { + expect( + canonicalJson({ + z: 1, + a: { b: true, a: 'x' }, + list: [{ y: 2, x: 1 }, 'tail'], + }), + ).toBe('{"a":{"a":"x","b":true},"list":[{"x":1,"y":2},"tail"],"z":1}'); + }); + + it('omits undefined object fields and preserves JSON string escaping', () => { + expect(canonicalJson({ b: undefined, a: 'quote " slash \\\\ newline\n' })).toBe( + '{"a":"quote \\" slash \\\\\\\\ newline\\n"}', + ); + }); + + it('throws for unsupported values instead of silently hashing them', () => { + expect(() => canonicalJson(undefined)).toThrow('Unsupported canonical JSON value'); + expect(() => canonicalJson({ a: () => undefined })).toThrow('Unsupported canonical JSON value'); + }); +}); diff --git a/packages/edge/src/headers.ts b/packages/edge/src/headers.ts index 79d74baf4..9bf93801e 100644 --- a/packages/edge/src/headers.ts +++ b/packages/edge/src/headers.ts @@ -12,20 +12,10 @@ export type HeaderRulePolicy = Readonly<{ }>; /** - * Headers that user header rules may not set at all, because they are always a - * cookie operation. On the shared *.void.app domain (not on the PSL) void.app - * is the registrable domain, so a `Set-Cookie` with `Domain=.void.app` is - * readable by every tenant's subdomain. Dropped wholesale. - * - * `Clear-Site-Data` is the other cookie trust-boundary header, but it is NOT a - * blanket block — see {@link sanitizeClearSiteData}: only its cookie-clearing - * directives are stripped, since its other directives are origin-scoped. - * - * HSTS and other per-host headers are safe — they don't cross subdomain - * boundaries (includeSubDomains scopes to the responding host's subtree). - * - * TODO: Once void.app is registered on the Public Suffix List, these defenses - * can be removed — browsers will scope cross-subdomain cookies automatically. + * Headers that rules may never set on a shared domain. void.app is not on the + * Public Suffix List, so a `Set-Cookie` with `Domain=.void.app` reaches every + * tenant. `Clear-Site-Data` is filtered per directive by + * {@link sanitizeClearSiteData}; per-host headers such as HSTS stay allowed. */ const BLOCKED_HEADERS = new Set(['set-cookie']); @@ -162,13 +152,8 @@ export function applyHeaderRules( } const newResponse = new Response(response.body, response); - // Fallback for a Clear-Site-Data rule that sanitizes to nothing: last-match-wins - // means such a rule discards an earlier rule's value, so we fall back to the - // origin's own Clear-Site-Data rather than leaving the earlier rule's value - // behind. It is sanitized too, so rule processing never *resurfaces* a - // cross-tenant cookie directive — not from a rule, and not from the origin - // response either. (The origin's own header is otherwise ungated here; gating - // it across the board is a separate dispatch-layer concern.) + // A Clear-Site-Data rule that sanitizes to nothing falls back to the origin's + // own value (also sanitized) instead of keeping an earlier rule's value. const isolateSharedDomainCookies = policy?.cookieIsolation !== 'origin'; const rawOriginClearSiteData = isolateSharedDomainCookies ? response.headers.get('clear-site-data') diff --git a/packages/edge/src/pattern.ts b/packages/edge/src/pattern.ts index 1060fd48e..a3f465b04 100644 --- a/packages/edge/src/pattern.ts +++ b/packages/edge/src/pattern.ts @@ -1,27 +1,46 @@ /** - * Shared path-pattern compiler used by header rules and routing rules. + * Path-pattern matchers shared by routing, header, and revalidate rules. * - * Syntax: + * Each pattern is parsed once into tokens, and matching walks those tokens + * over the pathname. No RegExp is built from pattern text, so a pattern cannot + * hit engine size limits or backtrack: a match costs at most + * (steps) x (path length - pattern minimum + 1). + * + * `compilePattern` syntax: * `:name` — single path segment, captured as `name` * `:name*` — zero or more path segments, captured as `name` * `*` — zero or more path segments, captured as `splat` (legacy) * * When a legacy pattern contains multiple anonymous `*`, the first captures - * as `splat` and later wildcards are non-capturing. Boolean matching remains - * backward compatible for existing header/revalidate patterns. + * as `splat` and later wildcards are non-capturing. */ const MAX_CACHE = 256; export type CompiledPattern = Readonly<{ + /** + * @deprecated Built on first read for older callers; Void does not use it. + * It can backtrack and fails past engine size limits. Use `matchPattern` or + * `matchPatternCaptures`. + */ regex: RegExp; paramNames: ReadonlyArray; }>; -const compileCache = new Map(); +// `text` matches itself. The others match a run of UTF-16 code units: +// seg — 1 or more, none is '/' (regex `[^/]+`) +// any — 0 or more, none is \n \r U+2028 U+2029 (regex `.*`) +// plus — 1 or more, same units as `any` (regex `.+`) +type Token = { kind: 'text'; text: string } | { kind: 'seg' | 'any' | 'plus'; name?: string }; + +type Prepared = Readonly<{ steps: ReadonlyArray; starts: Int32Array; minLength: number }>; -const REGEX_SPECIAL = /[.+?^${}()|[\]\\]/; -const PARAM_CHAR = /[A-Za-z0-9_]/; +type Compiled = CompiledPattern & + Prepared & { + error: string | undefined; + /** Index in `steps` of the last named step, or -1. */ + lastNamed: number; + }; /** LRU read: re-inserting a hit moves it to the most-recently-used end of Map order. */ function lruGet(cache: Map, key: string): V | undefined { @@ -44,83 +63,399 @@ function lruSet(cache: Map, key: string, value: V): void { cache.set(key, value); } -export function compilePattern(source: string): CompiledPattern { - const cached = lruGet(compileCache, source); - if (cached) { - return cached; +/** `[A-Za-z0-9_]` */ +function isNameChar(code: number): boolean { + return ( + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + code === 95 + ); +} + +/** Whether a wildcard of this kind may take the code unit. */ +function accepts(kind: Token['kind'], code: number): boolean { + if (kind === 'seg') { + return code !== 47; // '/' } + return code !== 10 && code !== 13 && code !== 0x2028 && code !== 0x2029; +} +function pushText(tokens: Array, text: string): void { + if (!text) { + return; + } + const last = tokens[tokens.length - 1]; + if (last?.kind === 'text') { + last.text += text; + } else { + tokens.push({ kind: 'text', text }); + } +} + +/** `compilePattern` syntax, read left to right. */ +function parseCapture(source: string): { tokens: Array; paramNames: Array } { + const tokens: Array = []; const paramNames: Array = []; - let legacySplatCaptured = false; - let pattern = ''; + let splatTaken = false; let index = 0; while (index < source.length) { const character = source[index]!; if (character === ':') { - let nameEnd = index + 1; - while (nameEnd < source.length && PARAM_CHAR.test(source[nameEnd]!)) { - nameEnd++; + let end = index + 1; + while (end < source.length && isNameChar(source.charCodeAt(end))) { + end++; } - const name = source.slice(index + 1, nameEnd); - if (!name) { - pattern += '\\:'; + if (end === index + 1) { + // No name follows, so the colon is literal. + pushText(tokens, ':'); index++; continue; } + const name = source.slice(index + 1, end); paramNames.push(name); - if (source[nameEnd] === '*') { - pattern += `(?<${name}>.*)`; - index = nameEnd + 1; - } else { - pattern += `(?<${name}>[^/]+)`; - index = nameEnd; - } + const rest = source[end] === '*'; + tokens.push({ kind: rest ? 'any' : 'seg', name }); + index = rest ? end + 1 : end; } else if (character === '*') { - if (legacySplatCaptured) { - pattern += `.*`; + if (splatTaken) { + tokens.push({ kind: 'any' }); } else { paramNames.push('splat'); - pattern += `(?.*)`; - legacySplatCaptured = true; + tokens.push({ kind: 'any', name: 'splat' }); + splatTaken = true; } index++; } else { - if (REGEX_SPECIAL.test(character)) { - pattern += '\\'; + let end = index + 1; + while (end < source.length && source[end] !== ':' && source[end] !== '*') { + end++; } - pattern += character; - index++; + pushText(tokens, source.slice(index, end)); + index = end; + } + } + return { tokens, paramNames }; +} + +/** Text in which only `*` is special. */ +function parseStars(tokens: Array, text: string): void { + const parts = text.split('*'); + pushText(tokens, parts[0]!); + for (let k = 1; k < parts.length; k++) { + tokens.push({ kind: 'any' }); + pushText(tokens, parts[k]!); + } +} + +/** `routing.revalidate` / page syntax: params are whole segments. */ +function parseSegments(source: string): Array { + const tokens: Array = []; + const segments = source.split('/'); + for (let k = 0; k < segments.length; k++) { + const segment = segments[k]!; + if (k > 0) { + pushText(tokens, '/'); + } + const name = segment.slice(1, -4); + if (segment[0] !== ':' || segment.length < 2) { + parseStars(tokens, segment); + } else if ( + segment.length > 5 && + segment.endsWith('{.+}') && + !name.includes('{') && + !name.includes('}') + ) { + tokens.push({ kind: 'plus' }); + } else { + // Whatever follows the ':' only names the param. + tokens.push({ kind: 'seg' }); + } + } + return tokens; +} + +/** + * Drop an `any` that directly follows another `any`: the first takes every + * unit the pair could take, so the second always matches ''. Every other step + * needs at least one unit, so a path of length m faces at most 2m + 1 steps. + */ +function prepare(tokens: ReadonlyArray): Prepared { + const steps: Array = []; + for (const token of tokens) { + if (!(token.kind === 'any' && steps[steps.length - 1]?.kind === 'any')) { + steps.push(token); + } + } + // starts[i]: the fewest units the steps before i consume, i.e. the earliest + // position where step i can begin. + const starts = new Int32Array(steps.length + 1); + steps.forEach((step, i) => { + const shortest = step.kind === 'text' ? step.text.length : step.kind === 'any' ? 0 : 1; + starts[i + 1] = starts[i]! + shortest; + }); + return { steps, starts, minLength: starts[steps.length]! }; +} + +/* + * Rows. Step i can only begin at a position p in [starts[i], starts[i] + slack], + * where slack = path.length - minLength: earlier and the steps before it could + * not fit, later and the steps after it could not. So every row has + * slack + 1 entries and row[d] is about position starts[i] + d. + * + * row[d] is 1 when steps i..n-1 can match path from that position to the end. + * A text step of length L moves both the position and starts[] by L, and a + * one-unit wildcard moves both by 1, so the index d lines up between a row and + * the next one: + * + * text row[d] = next[d] && path has the text at p + * seg/plus row[d] = path[p] allowed && (next[d] || row[d + 1]) + * any row[d] = next[d] || (path[p] allowed && row[d + 1]) + * + * (row[slack + 1] counts as 0.) Returns whether any entry is 1. + */ +function stepBack( + step: Token, + path: string, + start: number, + next: Uint8Array, + row: Uint8Array, +): boolean { + let found = 0; + if (step.kind === 'text') { + for (let d = row.length - 1; d >= 0; d--) { + const value = next[d] === 1 && path.startsWith(step.text, start + d) ? 1 : 0; + row[d] = value; + found |= value; + } + return found === 1; + } + const kind = step.kind; + let after = 0; // row[d + 1]; 0 past the window + for (let d = row.length - 1; d >= 0; d--) { + // Path reads stay inside the path: seg/plus need a unit at every p in the + // window, and `any` reads only when after === 1, i.e. p is not the last slot. + const value = + kind === 'any' + ? next[d] === 1 || (after === 1 && accepts(kind, path.charCodeAt(start + d))) + : (next[d] === 1 || after === 1) && accepts(kind, path.charCodeAt(start + d)); + row[d] = value ? 1 : 0; + after = row[d]!; + found |= after; + } + return found === 1; +} + +/** The row after the last step: only the end of the path (d = slack) finishes. */ +function endRow(width: number): Uint8Array { + const row = new Uint8Array(width); + row[width - 1] = 1; + return row; +} + +/** Checks that reject most paths before any row is built. */ +function quickReject({ steps, minLength }: Prepared, path: string): boolean { + const first = steps[0]; + const last = steps[steps.length - 1]; + return ( + path.length < minLength || + (first?.kind === 'text' && !path.startsWith(first.text)) || + (last?.kind === 'text' && !path.endsWith(last.text)) + ); +} + +/** Whether the steps match the whole path. Keeps two rows. */ +function matches(prepared: Prepared, path: string): boolean { + if (quickReject(prepared, path)) { + return false; + } + const { steps, starts, minLength } = prepared; + let next: Uint8Array = endRow(path.length - minLength + 1); + let row: Uint8Array = new Uint8Array(next.length); + // quickReject proved a leading text step, and only position 0 matters for it. + const first = steps[0]?.kind === 'text' ? 1 : 0; + for (let i = steps.length - 1; i >= first; i--) { + if (!stepBack(steps[i]!, path, starts[i]!, next, row)) { + return false; + } + const done = next; + next = row; + row = done; + } + return next[0] === 1; +} + +/** + * Where a step that begins at p (row index d) ends: the longest choice after + * which the rest still matches. `next` is the row of the following step. + */ +function greedyEnd(step: Token, path: string, p: number, d: number, next: Uint8Array): number { + if (step.kind === 'text') { + return p + step.text.length; + } + const shortest = step.kind === 'any' ? 0 : 1; + const longest = next.length - 1 - d + shortest; // keeps d + k - shortest inside `next` + let k = 0; + while (k < longest && accepts(step.kind, path.charCodeAt(p + k))) { + k++; + } + while (k > shortest && next[d + k - shortest] !== 1) { + k--; + } + return p + k; +} + +/** + * End positions of steps 0..upTo in the match that JS backtracking returns + * (each wildcard, left to right, takes the longest run that still lets the + * rest match), or null. The backward pass keeps a copy of every `size`-th + * row; the forward pass rebuilds one block of rows at a time into a fixed + * pool. About 2 * sqrt(n) rows are alive at once, and each row is computed at + * most twice. + */ +function greedyEnds(prepared: Prepared, path: string, upTo: number): Int32Array | null { + if (quickReject(prepared, path)) { + return null; + } + const { steps, starts, minLength } = prepared; + const n = steps.length; + const width = path.length - minLength + 1; + // Small tables keep every row (size 1), so the forward pass rebuilds nothing. + const size = n * width <= 1 << 16 ? 1 : Math.ceil(Math.sqrt(n)); + const saved = new Map([[n, endRow(width)]]); + let next: Uint8Array = endRow(width); + let row: Uint8Array = new Uint8Array(width); + const first = steps[0]?.kind === 'text' ? 1 : 0; + for (let i = n - 1; i >= first; i--) { + if (!stepBack(steps[i]!, path, starts[i]!, next, row)) { + return null; + } + if (i % size === 0) { + saved.set(i, row.slice()); + } + const done = next; + next = row; + row = done; + } + if (next[0] !== 1) { + return null; + } + const pool = Array.from({ length: size }, () => new Uint8Array(width)); + let top = 0; + const rowAt = (r: number) => (r === top ? saved.get(r)! : pool[r % size]!); + const ends = new Int32Array(upTo + 1); + let p = 0; + for (let i = 0; i <= upTo; i++) { + if (i + 1 > top) { + // Blocks are entered in order at their first row, so each is rebuilt once. + top = Math.min(n, Math.ceil((i + 1) / size) * size); + for (let r = top - 1; r > i; r--) { + stepBack(steps[r]!, path, starts[r]!, rowAt(r + 1), pool[r % size]!); + } + } + p = greedyEnd(steps[i]!, path, p, p - starts[i]!, rowAt(i + 1)); + ends[i] = p; + } + return ends; +} + +/** The regex the earlier string-building compiler produced, for the deprecated `regex` view. */ +function toRegExp(tokens: ReadonlyArray): RegExp { + let body = ''; + for (const token of tokens) { + if (token.kind === 'text') { + body += token.text.replace(/[.+?^${}()|[\]\\:]/g, '\\$&'); + } else if (token.kind === 'seg') { + body += `(?<${token.name}>[^/]+)`; + } else { + body += token.name ? `(?<${token.name}>.*)` : '.*'; } } + return new RegExp(`^${body}$`); +} - const compiled: CompiledPattern = { regex: new RegExp(`^${pattern}$`), paramNames }; +const compileCache = new Map(); + +function compile(source: string): Compiled { + const cached = lruGet(compileCache, source); + if (cached) { + return cached; + } + const { tokens, paramNames } = parseCapture(source); + let error: string | undefined; + const seen = new Set(); + for (const name of paramNames) { + if (seen.has(name)) { + error ??= `Duplicate capture group name "${name}"`; + } else if (name.charCodeAt(0) >= 48 && name.charCodeAt(0) <= 57) { + // A JS group name cannot start with a digit; keep rejecting those. + error ??= `Invalid capture group name "${name}"`; + } + seen.add(name); + } + const prepared = prepare(tokens); + let lastNamed = -1; + prepared.steps.forEach((step, i) => { + if (step.kind !== 'text' && step.name) { + lastNamed = i; + } + }); + let regex: RegExp | undefined; + const compiled: Compiled = { + ...prepared, + paramNames, + error, + lastNamed, + get regex() { + return (regex ??= toRegExp(parseCapture(source).tokens)); + }, + }; lruSet(compileCache, source, compiled); return compiled; } +/** + * Parse a capture pattern. Throws a SyntaxError for a malformed one: a repeated + * name (`/:id/:id`, `/:splat/*`) or a name that starts with a digit (`/:1`). + */ +export function compilePattern(source: string): CompiledPattern { + const compiled = compile(source); + if (compiled.error) { + throw new SyntaxError(compiled.error); + } + return compiled; +} + /** * Match a pattern against a pathname. Returns captured params, or null. - * - * Returns null (rather than throwing) when the pattern itself is malformed — - * e.g. multiple `*` producing duplicate `splat` capture names. Framework and - * API ingest both reject those, but a hand-crafted manifest could still reach - * the edge, and a single bad rule shouldn't 500 unrelated traffic. + * A malformed pattern (e.g. a repeated name like `/:splat/*`) also returns + * null, so one bad rule cannot fail unrelated requests. */ export function matchPatternCaptures( pattern: string, pathname: string, ): Record | null { - let compiled: CompiledPattern; - try { - compiled = compilePattern(pattern); - } catch { + const compiled = compile(pattern); + if (compiled.error) { return null; } - const match = compiled.regex.exec(pathname); - if (!match) { + const { steps, lastNamed } = compiled; + const ends = lastNamed === -1 ? undefined : greedyEnds(compiled, pathname, lastNamed); + if (ends === null || (ends === undefined && !matches(compiled, pathname))) { return null; } - return match.groups ?? {}; + // Every name gets a value, in source order; one dropped by `prepare` matched ''. + const params: Record = Object.create(null); + for (const name of compiled.paramNames) { + params[name] = ''; + } + for (let i = 0; ends && i <= lastNamed; i++) { + const step = steps[i]!; + if (step.kind !== 'text' && step.name) { + params[step.name] = pathname.slice(i === 0 ? 0 : ends[i - 1], ends[i]); + } + } + return params; } /** @@ -128,14 +463,11 @@ export function matchPatternCaptures( * `false` rather than throwing (see `matchPatternCaptures`). */ export function matchPattern(pattern: string, pathname: string): boolean { - try { - return compilePattern(pattern).regex.test(pathname); - } catch { - return false; - } + const compiled = compile(pattern); + return compiled.error === undefined && matches(compiled, pathname); } -const literalRegexCache = new Map(); +const literalCache = new Map(); /** * Match a pattern against a pathname using LITERAL semantics — no named @@ -148,87 +480,34 @@ const literalRegexCache = new Map(); * (`redirects`/`rewrites`/`fallbacks`) use the capture-aware `compilePattern`. */ export function matchLiteralPattern(pattern: string, pathname: string): boolean { - let regex = lruGet(literalRegexCache, pattern); - if (!regex) { - try { - const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*'); - regex = new RegExp(`^${escaped}$`); - } catch { - return false; - } - lruSet(literalRegexCache, pattern, regex); - } - try { - return regex.test(pathname); - } catch { - return false; + let prepared = lruGet(literalCache, pattern); + if (!prepared) { + const tokens: Array = []; + parseStars(tokens, pattern); + prepared = prepare(tokens); + lruSet(literalCache, pattern, prepared); } + return matches(prepared, pathname); } -const segmentRegexCache = new Map(); +const segmentCache = new Map(); /** - * Match a REVALIDATE ROUTE pattern against a pathname using SEGMENT-ANCHORED - * semantics. Like `matchLiteralPattern` except a WHOLE-SEGMENT `:name` (a `:` - * right after `/` or at the start of the pattern, whose name is any non-empty - * run of non-`/` characters — NOT just JS-identifier chars, so `:post-id`, - * `:post.id`, etc. all anchor — running up to the next `/` or end of string) - * matches exactly ONE non-empty path segment (`[^/]+`). A whole-segment NAMED - * CATCH-ALL `:name{.+}` (a named catch-all page `[...path]` → `/docs/:path{.+}`) - * matches ONE-OR-MORE characters that MAY cross `/` (`.+`, so `/docs/a` and - * `/docs/a/b` both match, but bare `/docs` / `/docs/` do NOT). Only the exact - * `{.+}` constraint widens — a REGEX-constrained param like `:path{[0-9]+}` - * keeps its single-segment `[^/]+` meaning. `*` still expands to a catch-all - * that crosses `/`. A mid-segment `:` (e.g. `/api/v1:health`, - * `/api/documents:batchGet`) stays a LITERAL — only whole-segment `:name` / - * `:name{.+}` anchors. - * - * This is for `routing.revalidate` keys ONLY. Those keys are machine-generated - * from page routes on the deploy write side (`[slug]` → `/blog/:slug`, - * `[...path]` → `/docs/:path{.+}`), so the `:name` here is a real route param - * and MUST bind the same way the framework's own client route matcher - * (`patternToRegex` in `pages/manifest.ts`) binds it. - * - * It is intentionally NOT used for user-authored header rules - * (`applyHeaderRules`) or `revalidateQueryAllowlist` keys - * (`buildVariantOriginalUrl`), where `:name` must stay a literal — those keep - * using `matchLiteralPattern`. Sharing the segment behaviour there would make a - * user rule like `/:batchGet` over-match every single-segment path. - * - * NOTE: this matcher has its OWN regex cache — the same pattern string compiles - * to a DIFFERENT regex than `matchLiteralPattern` produces, so the caches must - * not be shared or they would cross-contaminate. + * Match a `routing.revalidate` key or page pattern against a pathname. Like + * `matchLiteralPattern`, except for whole-segment params: + * `:name` — exactly one non-empty path segment (any non-`/` name) + * `:name{.+}` — one or more characters that may cross `/` + * A mid-segment `:` (`/api/v1:health`) stays literal, and any other `{...}` + * constraint keeps the single-segment meaning. Has its own cache because the + * same source parses differently here. */ export function matchSegmentPattern(pattern: string, pathname: string): boolean { - let regex = lruGet(segmentRegexCache, pattern); - if (!regex) { - try { - // Two private-use sentinels stand in for route params so the regex-special - // escape step below can't touch the classes we swap in last: - // \uE000 - a whole-segment `:name` -> one path segment (`[^/]+`) - // \uE001 - a named catch-all `:name{.+}` -> >=1 char, crosses `/` (`.+`) - // The named-catch-all rule runs FIRST so `:name{.+}` is consumed as a unit - // before the plain `:name` rule can grab `:name` and strand the `{.+}`. The - // `{.+}` name class is `[^/{}]+` (excludes braces) so a REGEX-constrained - // param like `:path{[0-9]+}` does NOT match and stays a single segment. - const escaped = pattern - .replace(/(^|\/):[^/{}]+\{\.\+\}(?=\/|$)/g, '$1\uE001') // named catch-all :name{.+} -> catch-all sentinel - .replace(/(^|\/):[^/]+(?=\/|$)/g, '$1\uE000') // whole-segment :name -> segment sentinel - .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex specials - .replaceAll('*', '.*') // legacy `*` crosses '/' - .replaceAll('\uE000', '[^/]+') // segment sentinel -> one segment - .replaceAll('\uE001', '.+'); // catch-all sentinel -> >=1 char, crosses '/' - regex = new RegExp(`^${escaped}$`); - } catch { - return false; - } - lruSet(segmentRegexCache, pattern, regex); - } - try { - return regex.test(pathname); - } catch { - return false; + let prepared = lruGet(segmentCache, pattern); + if (!prepared) { + prepared = prepare(parseSegments(pattern)); + lruSet(segmentCache, pattern, prepared); } + return matches(prepared, pathname); } /** diff --git a/packages/edge/src/routing-rules.ts b/packages/edge/src/routing-rules.ts index 8b9655f3b..b52206184 100644 --- a/packages/edge/src/routing-rules.ts +++ b/packages/edge/src/routing-rules.ts @@ -1,5 +1,5 @@ import type { RedirectRule } from '@void/deploy-core'; -import { compilePattern, substituteParams } from './pattern.ts'; +import { compilePattern, matchPatternCaptures, substituteParams } from './pattern.ts'; export type { RedirectRule } from '@void/deploy-core'; @@ -37,25 +37,24 @@ export function applyRoutingRules( if (rule.host && rule.host !== requestHost) { continue; } - let regex: RegExp; try { - ({ regex } = compilePattern(rule.source)); + compilePattern(rule.source); } catch (err) { - // A malformed `source` (e.g. duplicate named params) throws inside - // `new RegExp`. Framework validation rejects those at build, but a - // hand-crafted manifest could still reach the edge. Skip the offending - // rule and continue rather than 500ing the whole request. + // A malformed `source` (e.g. duplicate named params) throws here. + // Framework validation rejects those at build, but a hand-crafted + // manifest could still reach the edge. Skip the offending rule and + // continue rather than 500ing the whole request. console.warn( `[dispatch] skipping routing rule with invalid source ${JSON.stringify(rule.source)}: ${err instanceof Error ? err.message : String(err)}`, ); continue; } - const match = regex.exec(pathname); - if (!match) { + const params = matchPatternCaptures(rule.source, pathname); + if (!params) { continue; } - const destination = substituteParams(rule.destination, match.groups ?? {}); + const destination = substituteParams(rule.destination, params); if (rule.status === 200) { return { type: 'rewrite', destination }; diff --git a/packages/edge/src/runtime.ts b/packages/edge/src/runtime.ts index 7dff960ca..4b8316f21 100644 --- a/packages/edge/src/runtime.ts +++ b/packages/edge/src/runtime.ts @@ -101,8 +101,7 @@ function rewriteUrl(url: URL, destination: string): URL { } function rewriteRequest(request: Request, url: URL, originalUrl: string): Request { - const headers = new Headers(request.headers); - headers.set('X-Void-Original-URL', originalUrl); const rewritten = new Request(url.toString(), request); - return new Request(rewritten, { headers }); + rewritten.headers.set('X-Void-Original-URL', originalUrl); + return rewritten; } diff --git a/packages/isr/src/core.ts b/packages/isr/src/core.ts index 2f238d2ba..ccb94da5c 100644 --- a/packages/isr/src/core.ts +++ b/packages/isr/src/core.ts @@ -18,6 +18,10 @@ export function hasCurrentSharedCachePolicy(response: Response): boolean { return response.headers.get(SHARED_CACHE_POLICY_HEADER) === String(SHARED_CACHE_POLICY_VERSION); } +/** + * KV metadata stored with every ISR cache entry. Writers should annotate it with + * `satisfies IsrCacheMetadata` so a renamed field fails typecheck. + */ export type IsrCacheMetadata = Readonly<{ /** HTTP status code. */ s: number; diff --git a/packages/isr/src/index.ts b/packages/isr/src/index.ts index a9b161f8f..4d1fc7808 100644 --- a/packages/isr/src/index.ts +++ b/packages/isr/src/index.ts @@ -1,48 +1,20 @@ /** - * Shared ISR key/URL helpers used by both the API (purge) and the dispatch - * worker (read/write). These functions MUST produce byte-identical output: - * the API purges KV keys and edge URLs that dispatch writes and reads. + * ISR cache keys and edge-cache URLs. Writers, readers, and purgers must build + * byte-identical keys, so all of them use these helpers. * - * Dispatch derives the deployment id for its keys via - * `routing.version ?? 'legacy'`, which allows any non-nullish value — - * including the empty string — to pass through unchanged. The helpers below - * preserve that shape so the API can compute the exact same key for purge. + * Key shapes: `v3` = `/isr/v3///[#rw=]`; + * `v2` = hostless `/isr/v2//`, kept only to purge old entries. */ const ISR_CACHE_KEY_VERSION = 'v3'; /** - * Previous key version. `v3` keys are ALWAYS host-scoped - * (`/isr/v3///`); `v2` keys are ALWAYS hostless - * (`/isr/v2//`) — the `` segment did not exist yet. - * - * The bump to `v3` was forced by a real aliasing hazard: under a single - * version, a host-scoped key `//` and a legacy hostless key - * `//` are byte-identical whenever the legacy path's leading - * segment happens to look like a hostname. Dotted leading path segments are - * legal (`[domain]` → `:domain`, `[...slug]` → `(.+)`), so a hostless key for - * the page `/example.com/about` collides exactly with the host-scoped key for - * `(host=example.com, /about)`. The stored metadata is only `{s,c,t}` (no - * path/host), so a reader cannot tell them apart and could serve one page's - * body for the other. Encoding the shape in the version removes the ambiguity: - * the version alone determines whether a `` segment is present, so no - * runtime heuristic (e.g. "is the first segment an attached hostname?") is - * needed to classify a key. Package-internal, like `ISR_CACHE_KEY_VERSION`. + * Hostless key version. Host scoping needed a new version: otherwise a hostless + * key for `/example.com/about` would equal the host-scoped key for + * (`example.com`, `/about`). */ const ISR_LEGACY_CACHE_KEY_VERSION = 'v2'; -/** - * KV metadata stored alongside every ISR cache entry. This is the single - * canonical shape shared by the dispatch worker (which writes it on a live - * miss and reads it back on a hit — see `dispatch/src/isr.ts`) AND the API - * prerender-warm path (`api/src/deploy/publish-worker.ts`), which pre-populates the - * same KV keys during deploy. Both writers MUST use these exact field names: - * the reader requires `c` (createdAt) and `t` (ttl) and returns null without - * them, so a writer that drifts (e.g. writes `context` instead of `c`) produces - * a permanent cold miss that no typecheck would catch unless it is annotated - * `satisfies IsrCacheMetadata`. Keep the fields terse — this is stored on every - * KV entry. - */ export type { IsrCacheMetadata } from './core.ts'; /** @@ -54,9 +26,7 @@ export function normalizeIsrPath(pathname: string): string { 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); } @@ -93,61 +63,33 @@ function versionedIsrCachePrefix( } /** - * Prefix for listing/purging ISR keys. - * - * `deploymentId === ''` must still produce the versioned prefix with an - * empty segment (`/isr/v3//`) because dispatch uses - * `routing.version ?? 'legacy'`, which allows empty strings and writes - * keys at `/isr/v3//`. Only `undefined` selects the - * legacy scan-all prefix (`/isr/`). + * Prefix for listing/purging ISR keys. An empty `deploymentId` still yields the + * versioned prefix (`/isr/v3//`), because writers may use an empty + * id. Only `undefined` selects every key (`/isr/`). */ export function isrCachePrefix(projectId: string, deploymentId?: string): string { return versionedIsrCachePrefix(projectId, ISR_CACHE_KEY_VERSION, deploymentId); } /** - * Prefix for listing/purging LEGACY (pre-host-scoping) ISR keys — the `v2` - * namespace `/isr/v2//`. Mirrors `isrCachePrefix`'s - * optional-`deploymentId` behavior exactly (empty string yields the versioned - * prefix with an empty segment; only `undefined` selects the scan-all - * `/isr/` prefix). - * - * Exists SOLELY for the host-scoping rollout: the purger sweeps this namespace - * to reap orphaned `v2` KV keys and purge their edge URLs. Current dispatch - * neither reads nor writes `v2` keys. Transition-only — remove once `v2` - * entries have aged out. + * Like `isrCachePrefix`, for hostless `v2` keys. The `isrLegacy*` helpers exist + * only to purge `v2` entries; remove them once those entries have aged out. */ export function isrLegacyCachePrefix(projectId: string, deploymentId?: string): string { return versionedIsrCachePrefix(projectId, ISR_LEGACY_CACHE_KEY_VERSION, deploymentId); } -/** - * Separator that marks the rewrite-variant suffix on an ISR cache key or - * the corresponding edge-cache URL parameter. `#` cannot appear in - * `url.pathname` (fragments are stripped before the request reaches the - * server) and does not survive `normalizeIsrPath`, so it unambiguously - * identifies the variant suffix. - */ +/** `#` never appears in a request pathname, so it unambiguously marks the variant suffix. */ const ISR_VARIANT_SEPARATOR = '#rw='; /** - * Build the cache key for an ISR-cached page. - * ISR is deployment-scoped so cached HTML cannot outlive the hashed assets - * referenced by the deployment that produced it, and host-scoped (`` - * segment after the deployment id) so the same path served under different - * hostnames — e.g. a project slug and a custom domain, or two custom - * domains — never share a cache slot. `host` is the request hostname the - * dispatch worker saw (`new URL(request.url).hostname`). + * KV key for an ISR-cached page: `/isr/v3///`. Deployment + * scoping keeps cached HTML from outliving its hashed assets; host scoping keeps + * different hostnames apart. `host` is the request hostname. * - * Host-scoping is a `v3` key format: `/isr/v3///`. The - * version bump is load-bearing, not cosmetic — see `ISR_LEGACY_CACHE_KEY_VERSION`. - * - * When `originalUrl` is provided, the request was served via a dispatch-side - * rewrite (`redirectRules`/`fallbackRules` with status 200). The original - * request URL is mixed into the key so a direct hit to `/en/docs` and a - * rewrite from `/docs` to `/en/docs` do not collide — user code can branch - * on `c.isRewritten()` / `c.originalUrl()`, and the cache must honor that. - * A missing `originalUrl` means "direct hit" and produces the unsuffixed key. + * Pass `originalUrl` for a rewritten request (or a query variant). It adds a + * `#rw=` suffix, so a rewrite from `/docs` to `/en/docs` does not share + * an entry with a direct hit on `/en/docs`. */ export function isrCacheKey( projectId: string, @@ -163,10 +105,7 @@ export function isrCacheKey( return `${base}${ISR_VARIANT_SEPARATOR}${hashOriginalUrl(originalUrl)}`; } -/** - * Prefix used to list/purge every variant of a given pathname under a - * deployment — both the direct-hit key and any rewrite-variant keys. - */ +/** Prefix for listing/purging the `#rw=` variant keys of a pathname (not its direct-hit key). */ export function isrCacheVariantPrefix( projectId: string, deploymentId: string, @@ -176,16 +115,7 @@ export function isrCacheVariantPrefix( return `${isrCachePrefix(projectId, deploymentId)}${host}/${normalizeIsrPath(pathname)}${ISR_VARIANT_SEPARATOR}`; } -/** - * Build the exact LEGACY (pre-host-scoping) direct-hit key for a pathname under - * a deployment: `/isr/v2//` — the - * hostless counterpart of `isrCacheKey` with no `/` segment and no - * rewrite-variant suffix. Used by the purger to reap the orphaned `v2` - * direct-hit KV key for a requested path. - * - * Exists SOLELY for the host-scoping rollout — current dispatch neither writes - * nor reads hostless keys. Transition-only. Do not use it on the hot path. - */ +/** Hostless `v2` direct-hit key for a pathname (purge only; see `isrLegacyCachePrefix`). */ export function isrLegacyCacheKey( projectId: string, deploymentId: string, @@ -194,23 +124,7 @@ export function isrLegacyCacheKey( return `${isrLegacyCachePrefix(projectId, deploymentId)}${normalizeIsrPath(pathname)}`; } -/** - * Prefix used to list/purge the pre-host-scoped ("legacy") rewrite variants of - * a given pathname under a deployment. Before ISR keys grew a `` segment, - * variant keys were written as - * `/isr/v2//#rw=` — identical to - * `isrCacheVariantPrefix` but with NO `/` segment. Those entries still - * persist during the host-scoping rollout (ISR puts set no `expirationTtl`), - * so the purger must scan this hostless `v2` prefix in addition to the - * host-scoped `v3` one to recover their variant tokens and purge the matching - * edge URLs. - * - * Built off `isrLegacyCachePrefix` (the `v2` namespace), NOT `isrCachePrefix` - * (now `v3`), so it targets the legacy hostless keys it is meant to sweep. - * - * Exists SOLELY for that rollout cleanup — current dispatch neither writes nor - * reads hostless keys. Transition-only. Do not use it on the hot path. - */ +/** Hostless `v2` rewrite-variant prefix for a pathname (purge only; see `isrLegacyCachePrefix`). */ export function isrLegacyCacheVariantPrefix( projectId: string, deploymentId: string, @@ -219,28 +133,16 @@ export function isrLegacyCacheVariantPrefix( return `${isrLegacyCachePrefix(projectId, deploymentId)}${normalizeIsrPath(pathname)}${ISR_VARIANT_SEPARATOR}`; } -/** - * Hash an original-URL value into a fixed-length, opaque variant token. - * Used as both the KV-key suffix and the `__void_orig` query value, so the - * writer and purger paths see byte-identical tokens for the same URL. - * - * Properties: - * - Sync (Workers `crypto.subtle.digest` is async; we don't want to push - * async through every read/write path for this). - * - 16 hex chars (64 bits) — bounds the KV key length regardless of input. - * - Non-recoverable: KV is operator-readable, so the token must not reveal - * original URLs (including sensitive query strings like `?token=…`). - * - Not cryptographic: collision resistance is ~2^32 (birthday). Fine for - * the per-deployment, per-pathname variant set we ever store in one slot. - * - * Implementation: FNV-1a 64-bit over UTF-8 bytes. BigInt arithmetic for the - * multiply (no native u64 in JS), but inputs are short (URLs, sub-1KB) and - * this only runs once per ISR-touched request. - */ const FNV_OFFSET_BASIS = 0xcbf29ce484222325n; const FNV_PRIME = 0x100000001b3n; const FNV_MASK = 0xffffffffffffffffn; +/** + * Hash an original URL into a 16-hex-char variant token (FNV-1a 64-bit), used + * as both the KV-key suffix and the `__void_orig` edge-cache parameter. It is + * sync so it fits every read/write path, and opaque so KV never exposes + * original URLs or their query strings. Not cryptographic. + */ export function hashOriginalUrl(originalUrl: string): string { const bytes = new TextEncoder().encode(originalUrl); let h = FNV_OFFSET_BASIS; @@ -252,13 +154,9 @@ export function hashOriginalUrl(originalUrl: string): string { } /** - * Split a fully-qualified ISR KV key into the normalized pathname segment - * and the optional rewrite-variant token. Returns `null` token when the - * key has no `#rw=` suffix (direct hit). - * - * The token is opaque (a hash); the original URL it was derived from is not - * recoverable. Callers that need to reproduce the matching edge-cache URL - * pass the token through `isrEdgeCacheUrlForToken`. + * Split an ISR KV key into its normalized path and its `#rw=` variant token + * (`null` for a direct hit). Pass the token to `isrEdgeCacheUrlForToken` to + * rebuild the matching edge-cache URL. */ export function splitIsrCacheKey(fullKey: string): { normalizedPath: string; @@ -275,15 +173,9 @@ export function splitIsrCacheKey(fullKey: string): { } /** - * Build the edge cache URL that dispatch uses as its `caches.default` key - * (wrapped in a GET `Request`). The public URL stays clean; the internal - * cache key carries the deployment id so a new deploy never reads HTML - * produced by an older deploy. The `json` variant separates pages-protocol - * JSON responses from HTML for the same path+deployment. - * - * This MUST match the URL dispatch writes. Dispatch builds the URL from - * `new URL(request.url).hostname`; for standard-port HTTPS traffic (all CF - * edge traffic) that yields the same origin as the one produced here. + * Edge-cache (`caches.default`) URL for an ISR page. It carries the deployment + * id so a new deploy never reads older HTML, and the `json` variant keeps + * Pages-protocol JSON apart from HTML for the same path. */ export function isrEdgeCacheUrl( hostname: string, @@ -301,11 +193,7 @@ export function isrEdgeCacheUrl( ); } -/** - * Like `isrEdgeCacheUrl`, but takes a pre-hashed variant token (from - * `splitIsrCacheKey`). Used by the purger, which only knows the hashed - * token: the original URL is not recoverable from a KV key. - */ +/** Like `isrEdgeCacheUrl`, but takes a variant token from `splitIsrCacheKey`. */ export function isrEdgeCacheUrlForToken( hostname: string, pathname: string, diff --git a/packages/isr/src/runtime.ts b/packages/isr/src/runtime.ts index 0c6e81787..734c4ea69 100644 --- a/packages/isr/src/runtime.ts +++ b/packages/isr/src/runtime.ts @@ -49,41 +49,14 @@ export { matchSegmentPattern as matchLiteralPattern }; export { canonicalIsrPathname, denormalizeIsrPath, normalizeIsrPath } from './index.ts'; /** - * Single-tenant ISR (incremental static regeneration) cache ladder for the - * direct Cloudflare Worker (Void apps and framework deploy wrappers). - * - * 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`. 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 the hosted dispatch runtime and the shared `@void/isr` contract. + * Single-tenant ISR cache for a direct Cloudflare Worker (Void apps and + * framework deploy wrappers). Every `ISR_CACHE` (KV) and `caches.default` + * operation fails open: read errors render live, write errors are ignored. + * KV helpers read the deployment id from `env.__VOID_ISR_DEPLOYMENT_ID`; + * edge-cache keys use the `deploymentId` option. Both hold the same value. */ -// --------------------------------------------------------------------------- -// 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. - */ +/** Env fields ISR reads. Both are optional: without them ISR fails open. */ export interface IsrCacheEnv { ISR_CACHE?: KVNamespace; __VOID_ISR_DEPLOYMENT_ID?: string; @@ -100,14 +73,12 @@ export type HandleIsrOptions = Readonly<{ queryAllowlist?: QueryAllowlist; }>; +// Caps rewrite-variant entries per path, so random values of an allowlisted +// query param cannot grow KV without bound. Existing variants still update. export const MAX_ISR_VARIANTS_PER_PATH = 200; // Match managed ISR: tolerate removal responses for 24 hours after expiry. const ISR_STALE_EVICT_GRACE_MS = 86_400_000; -// --------------------------------------------------------------------------- -// Collapsed single-tenant cache keys -// --------------------------------------------------------------------------- - const DIRECT_ISR_PROJECT_ID = '__void_direct__'; /** Prefix for the current direct deployment's host-scoped cache entries. */ @@ -116,11 +87,8 @@ export function isrPrefix(deploymentId: string): string { } /** - * 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.). + * KV key for an ISR-cached page, scoped by deployment (a new deploy starts with + * a cold cache) and by host (custom domain, workers.dev, apex, and www stay apart). */ export function isrKey( deploymentId: string, @@ -136,10 +104,9 @@ export function isrVariantPrefix(deploymentId: string, host: string, pathname: s } /** - * 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. + * Host-registry key. Every host that writes cached content registers here, so a + * purge without a request host (cron or queue `revalidate({ paths })`) can find + * the hosts to purge. */ export function hostRegistryKey(deploymentId: string, host: string): string { return `void-isr-hosts/${deploymentId}/${host}`; @@ -172,16 +139,7 @@ export function jsonEdgeKey( return isrEdgeCacheUrl(host, pathname, id, 'json', variantUrl); } -// --------------------------------------------------------------------------- -// 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. - */ +/** Read an ISR entry from KV. Returns null on a miss, a malformed entry, or any KV error. */ export async function readIsrCache( env: IsrCacheEnv, host: string, @@ -200,17 +158,14 @@ export async function readIsrCache( value = result.value; metadata = result.metadata; } catch { - // Fail open — a broken ISR_CACHE must degrade to a live render, never 500. return null; } return decodeIsrCacheEntry(value, metadata); } /** - * 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). + * Write an ISR entry to KV. The response is cloned before any `await`, so the + * caller can stream the original body to the client. KV errors are ignored. */ export async function writeIsrCache( env: IsrCacheEnv, @@ -246,11 +201,8 @@ export async function writeIsrCache( } satisfies IsrCacheMetadata, }); } catch { - // Fail open — swallow write errors so a broken ISR_CACHE never 500s. + // Fail open. } - // 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); } @@ -271,11 +223,7 @@ async function isVariantWriteAllowed( ); } -/** - * 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. - */ +/** `${id}/${host}` pairs this isolate has registered: one registry write per pair, not per page. */ const registeredHosts = new Set(); async function registerIsrHost(kv: KVNamespace, deploymentId: string, host: string): Promise { @@ -283,26 +231,19 @@ async function registerIsrHost(kv: KVNamespace, deploymentId: string, host: stri 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. + // Mark before the await so concurrent writes skip the put; clear on failure so + // a later write retries instead of leaving the host out of `listIsrHosts()`. 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. + * List every host registered for a deployment. A registry read error returns + * the hosts collected so far instead of throwing. */ export async function listIsrHosts(kv: KVNamespace, deploymentId: string): Promise> { const prefix = `void-isr-hosts/${deploymentId}/`; @@ -317,15 +258,12 @@ export async function listIsrHosts(kv: KVNamespace, deploymentId: string): Promi cursor = list.list_complete ? undefined : list.cursor; } while (cursor); } catch { - // Fail open — a broken registry degrades purge coverage, never throws. + // Fail open. } 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). - */ +/** Delete an ISR entry from KV (for example after `x-revalidate: 0`). KV errors are ignored. */ export async function deleteIsrCache( env: IsrCacheEnv, host: string, @@ -343,10 +281,6 @@ export async function deleteIsrCache( } } -// --------------------------------------------------------------------------- -// Response builders -// --------------------------------------------------------------------------- - const CLIENT_REVALIDATE_CACHE_CONTROL = 'public, max-age=0, must-revalidate'; export function clientRevalidateResponse(response: Response): Response { @@ -396,11 +330,8 @@ function jsonCacheResponse(pageData: string, ttl: number, state: 'HIT' | 'MISS') const response = 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. + // Match the live Pages JSON response: the client parses JSON only when + // `X-VoidPages` is present, and otherwise reloads the whole page. 'X-VoidPages': 'true', Vary: 'X-VoidPages', 'Cache-Control': `public, s-maxage=${ttl}, max-age=0, must-revalidate`, @@ -412,10 +343,9 @@ function jsonCacheResponse(pageData: string, ttl: number, state: 'HIT' | 'MISS') } /** - * 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`. + * Fill the JSON edge cache from a response's page data (the `x-void-page-data` + * header or the `__VOID_PAGE_DATA__` script). Does nothing without page data; + * cache errors are ignored. */ export async function populateJsonEdgeCache( cache: Cache | undefined, @@ -437,30 +367,14 @@ export async function populateJsonEdgeCache( } await cache.put(cacheKey, jsonCacheResponse(pageData, ttl, state)); } catch { - // Fail open — a broken edge cache must never 500. + // Fail open. } } -// --------------------------------------------------------------------------- -// 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?.`). - */ +/** `caches.default`, or `undefined` when reading it throws (for example, with no Cache API). */ function edgeCache(): Cache | undefined { try { - return (caches as unknown as { default: Cache }).default; + return caches.default; } catch { return undefined; } @@ -505,31 +419,18 @@ async function deleteSharedIsrVariants( await safeCacheDelete(cache, keys.jsonKey); } -// --------------------------------------------------------------------------- -// Background revalidation (single-tenant: re-renders through `render`) -// --------------------------------------------------------------------------- - -/** - * 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. - */ +/** Copy a request without its `X-VoidPages` header. */ 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`. + // Construct from the Request, not its URL string, so workerd keeps `request.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. An explicit cache opt-out (including `ttl === 0`) - * deletes the KV entry and both edge variants. + * Re-render a stale page in the background and refresh KV and the edge cache. + * Errors are logged and the stale entry keeps serving. An explicit cache + * opt-out (including `ttl === 0`) deletes the KV entry and both edge variants. */ async function revalidateInBackground( env: IsrCacheEnv, @@ -544,8 +445,7 @@ async function revalidateInBackground( staleSinceMs: number, ): Promise { try { - const cleanRequest = cloneWithoutVoidPages(request); - const response = await render(cleanRequest); + const response = await render(request); const ttl = resolveRevalidateTtl(response, defaultTtl); const cache = edgeCache(); if (response.status === 200 && isEventStreamResponse(response)) { @@ -564,7 +464,9 @@ async function revalidateInBackground( headers: sharedHtmlCacheHeaders(response, ttl, 'HIT'), }), ); - await populateJsonEdgeCache(cache, keys.jsonKey, response, ttl, 'HIT'); + if (!htmlOnly) { + await populateJsonEdgeCache(cache, keys.jsonKey, response, ttl, 'HIT'); + } } else if ( isUncacheableRender(response, ttl, htmlOnly) || ((response.status === 404 || response.status === 410) && @@ -579,10 +481,6 @@ async function revalidateInBackground( } } -// --------------------------------------------------------------------------- -// Orchestrator -// --------------------------------------------------------------------------- - /** Cache only framework document HTML; leave client data requests and other response types live. */ export async function handleFrameworkIsr( request: Request, @@ -598,24 +496,16 @@ export async function handleFrameworkIsr( } /** - * Run the ISR caching ladder for a request. + * Run the ISR cache ladder for a request. Returns the response to send, or + * `null` when the caller should render live: the request is not eligible + * (not GET, `/api`, Range, `shouldBypassIsr`, or no TTL), a cache read failed, + * or a miss carries query params outside the allowlist. * - * Returns a cached/rendered `Response` when ISR applies, or `null` when the - * caller should render live itself (request not ISR-eligible, a read/hit - * cache op failed open, or a cache miss for a URL carrying query params - * outside the allowlist). - * - * Ladder: - * - Bail (`null`) if not GET, an API path, 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. + * Pages-protocol (`X-VoidPages`) requests read only the JSON edge cache, and a + * miss renders live without caching; HTML renders fill the JSON variant. HTML + * requests read the edge cache, then KV. A stale hit is served while it + * revalidates in the background, and a cacheable miss fills KV and the edge + * cache in the background. */ export async function handleIsr( request: Request, @@ -640,12 +530,7 @@ async function handleIsrInternal( 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. + // A partial 206 must never read or replace the shared full-response entry. if (request.headers.has('range')) { return null; } @@ -663,10 +548,8 @@ async function handleIsrInternal( 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. The worker - // entry usually sets this already; the `??=` covers callers that only pass - // the option. + // KV helpers and `revalidate()` read the id from env; fill it for callers + // that pass only the option. env.__VOID_ISR_DEPLOYMENT_ID ??= deploymentId; const host = url.hostname; @@ -683,8 +566,6 @@ async function handleIsrInternal( if (request.headers.has('X-VoidPages')) { 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 @@ -695,10 +576,6 @@ async function handleIsrInternal( if (jsonCached) { await safeCacheDelete(cache, jsonKey); } - // 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. const response = await render(request); const ttl = resolveRevalidateTtl(response, isrTtl); if (isUncacheableRender(response, ttl, false)) { @@ -714,9 +591,6 @@ async function handleIsrInternal( // 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 @@ -731,10 +605,6 @@ async function handleIsrInternal( // L2: KV (global, authoritative). `readIsrCache` fails open to null. const cached = await readIsrCache(env, host, pathname, variantUrl); 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) { @@ -787,7 +657,9 @@ async function handleIsrInternal( // 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, variantUrl)); - ctx.waitUntil(populateJsonEdgeCache(cache, jsonKey, response, ttl, 'HIT')); + if (!htmlOnly) { + ctx.waitUntil(populateJsonEdgeCache(cache, jsonKey, response, ttl, 'HIT')); + } const cacheResponse = new Response(response.clone().body, { status: response.status, diff --git a/packages/isr/test/unit/variant-hash.test.ts b/packages/isr/test/unit/variant-hash.test.ts index ce8da9d1d..57813a853 100644 --- a/packages/isr/test/unit/variant-hash.test.ts +++ b/packages/isr/test/unit/variant-hash.test.ts @@ -9,20 +9,8 @@ import { isrLegacyCacheKey, isrLegacyCachePrefix, isrLegacyCacheVariantPrefix, - splitIsrCacheKey, } from '../../src/index.ts'; -/** - * Hashing the variant token (instead of base64url-encoding the original URL) - * meets two operational concerns: - * - * 1. KV is operator-readable. A reversible token (`btoa(originalUrl)`) lets - * anyone with KV list access recover the URL — including any sensitive - * query strings (`?token=…`, `?email=…`) that produced the variant. - * 2. Variant tokens are used as KV-key suffixes. KV's 512-byte name limit - * can be approached if a deployment serves long URLs; a fixed-length - * hash bounds key length irrespective of input. - */ describe('ISR variant token hashing', () => { function variantSuffix(key: string): string { const idx = key.indexOf('#rw='); @@ -47,17 +35,6 @@ describe('ISR variant token hashing', () => { expect(a.length).toBeLessThanOrEqual(64); // bounded, not URL-length-dependent }); - it('does not embed any portion of the original URL in the token', () => { - // Sentinel chosen so any base64url/identity scheme would surface it. - const sentinel = 'leaky-secret-fragment'; - const key = isrCacheKey('p', 'd', 'h', '/x', `https://h/page?token=${sentinel}`); - const token = variantSuffix(key); - expect(token.toLowerCase()).not.toContain(sentinel.toLowerCase()); - // base64url of the URL would also contain the sentinel encoded — guard - // against that too by checking a few likely encodings. - expect(token).not.toContain('bGVha3k='.replace('=', '')); // partial b64 - }); - it('is deterministic — same URL produces the same token', () => { const a = isrCacheKey('p', 'd', 'h', '/x', 'https://h/foo'); const b = isrCacheKey('p', 'd', 'h', '/x', 'https://h/foo'); @@ -70,16 +47,7 @@ describe('ISR variant token hashing', () => { expect(a).not.toBe(b); }); - it('splitIsrCacheKey returns the (opaque) variant token, not a URL', () => { - const fullKey = isrCacheKey('p', 'd', 'h', '/x', 'https://h/foo'); - // splitIsrCacheKey consumes the suffix after the deployment+host prefix. - const split = splitIsrCacheKey(fullKey.slice(`p/isr/v3/d/h/`.length)); - expect(split.variantToken).toMatch(/^[0-9a-f]+$/); - expect(split.variantToken).not.toContain('https://'); - }); - it('current (v3) key strings are host-scoped', () => { - // Direct hit and prefixes all live under the `v3` (host-scoped) namespace. expect(isrCacheKey('p', 'd', 'h', '/about')).toBe('p/isr/v3/d/h/about'); expect(isrCacheKey('p', 'd', 'h', '/')).toBe('p/isr/v3/d/h/__index__'); expect(isrCachePrefix('p', 'd')).toBe('p/isr/v3/d/'); @@ -87,65 +55,19 @@ describe('ISR variant token hashing', () => { }); it('legacy (v2) helpers produce hostless isr/v2 strings', () => { - // Legacy (pre-host-scoping) keys have no `` segment and live under the - // `v2` namespace: `/isr/v2//[#rw=]`. expect(isrLegacyCachePrefix('p', 'd')).toBe('p/isr/v2/d/'); expect(isrLegacyCacheKey('p', 'd', '/en/docs')).toBe('p/isr/v2/d/en/docs'); expect(isrLegacyCacheKey('p', 'd', '/')).toBe('p/isr/v2/d/__index__'); expect(isrLegacyCacheKey('p', 'd', '/about/')).toBe('p/isr/v2/d/about'); expect(isrLegacyCacheVariantPrefix('p', 'd', '/en/docs')).toBe('p/isr/v2/d/en/docs#rw='); - // Root and trailing slashes normalize identically to the host-scoped form. expect(isrLegacyCacheVariantPrefix('p', 'd', '/')).toBe('p/isr/v2/d/__index__#rw='); expect(isrLegacyCacheVariantPrefix('p', 'd', '/about/')).toBe('p/isr/v2/d/about#rw='); - // The legacy variant prefix is the host-scoped one with BOTH the version - // bumped down (v3→v2) and the `/` segment removed. - const hostScoped = isrCacheVariantPrefix('p', 'd', 'h', '/en/docs'); - expect(isrLegacyCacheVariantPrefix('p', 'd', '/en/docs')).toBe( - hostScoped.replace('/v3/', '/v2/').replace('/h/', '/'), - ); - }); - - it('legacy (v2) prefix matches a v2 hostless key but never a v3 host-scoped one', () => { - const legacyPrefix = isrLegacyCacheVariantPrefix('p', 'd', '/en/docs'); - const legacyKey = 'p/isr/v2/d/en/docs#rw=deadbeefdeadbeef'; - expect(legacyKey.startsWith(legacyPrefix)).toBe(true); - // A v3 host-scoped variant key is never under the v2 legacy prefix — the - // version segment (`v3` vs `v2`) alone keeps the two namespaces disjoint. - const hostScopedKey = isrCacheKey( - 'p', - 'd', - 'app.void.app', - '/en/docs', - 'https://app.void.app/docs', - ); - expect(hostScopedKey.startsWith(legacyPrefix)).toBe(false); - }); - - it('v3 host-scoped key does NOT collide with the v2 legacy key of a dotted path', () => { - // The bug the v3 bump fixes: `normalizeIsrPath` does not escape `/` or `.`, - // so a host-scoped key for `(host=example.com, /about)` and a legacy hostless - // key for the page `/example.com/about` would be byte-identical under a - // single version. The version segment now keeps them apart. - const hostScoped = isrCacheKey('p', 'D', 'example.com', '/about'); - const legacy = isrLegacyCacheKey('p', 'D', '/example.com/about'); - expect(hostScoped).toBe('p/isr/v3/D/example.com/about'); - expect(legacy).toBe('p/isr/v2/D/example.com/about'); - // Byte-inequality is the whole point — a reader keying on either must not - // read the other's slot. - expect(hostScoped).not.toBe(legacy); - // Concretely: they differ ONLY in the version segment. - expect(hostScoped.replace('/isr/v3/', '/isr/v2/')).toBe(legacy); - expect(isrCachePrefix('p', 'D')).toContain('/isr/v3/'); - expect(isrLegacyCachePrefix('p', 'D')).toContain('/isr/v2/'); }); it('isrEdgeCacheUrl and isrEdgeCacheUrlForToken agree for the same URL', () => { - // The purger only knows the hashed token (URL is gone after hashing). - // It must still reproduce the exact edge-cache URL the writer wrote. + // The purger knows only the hashed token, not the original URL. const original = 'https://app.void.app/docs?utm=x'; const fromUrl = isrEdgeCacheUrl('app.void.app', '/en/docs', 'dpl_1', 'html', original); - // Pull the token out of the writer's URL and feed it to the by-token - // builder — they must produce byte-identical strings. const m = /__void_orig=([^&]+)/.exec(fromUrl); expect(m).not.toBeNull(); const token = m![1]!; diff --git a/packages/md/package.json b/packages/md/package.json index 0ab736f58..742545173 100644 --- a/packages/md/package.json +++ b/packages/md/package.json @@ -58,8 +58,7 @@ "@types/node": "catalog:", "vite": "catalog:", "vite-plus": "catalog:", - "void": "workspace:*", - "vue": "^3.5.43" + "void": "workspace:*" }, "peerDependencies": { "vite": "^8.0.0", diff --git a/packages/md/src/codegen/md-types.ts b/packages/md/src/codegen/md-types.ts deleted file mode 100644 index 473f974b3..000000000 --- a/packages/md/src/codegen/md-types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Generate .void/md.d.ts content. - * - * Declares the `@void/md/pages` virtual module so TypeScript can resolve it - * from anywhere in the project (pages/, routes/, etc.) without @ts-ignore. - */ -export function generateMdTypes(): string { - return [ - '// Auto-generated by @void/md — do not edit', - '', - 'export type VoidMdPage = Readonly<{', - ' path: string;', - ' title: string;', - ' frontmatter: Record;', - ' headings: ReadonlyArray<{ depth: number; slug: string; text: string }>;', - '}>;', - '', - 'declare const pages: ReadonlyArray;', - 'export default pages;', - '', - ].join('\n'); -} diff --git a/packages/md/src/compile.ts b/packages/md/src/compile.ts index 303155203..84b7c135c 100644 --- a/packages/md/src/compile.ts +++ b/packages/md/src/compile.ts @@ -100,26 +100,14 @@ async function createCompiler(options?: CompileOptions): Promise { // Code block wrapping (must come after shiki, before line-numbers) markdown.use(preWrapperPlugin); - // Container blocks: :::tip, :::warning, etc. markdown.use(containerPlugin); - - // Image path normalization + lazy loading markdown.use(imagePlugin, { lazyLoading: true }); - - // Link handling: external attrs, .md normalization markdown.use(linkPlugin); - - // Line numbers (opt-in per code block by default) - markdown.use(lineNumberPlugin, false); - - // GitHub alerts: > [!NOTE], > [!TIP], etc. + markdown.use(lineNumberPlugin); markdown.use(gitHubAlertsPlugin); - // Preserve the raw fence info string (e.g. `ts {1,3-5}`) on the token before - // `markdown-it-attrs` consumes the `{…}` as an attribute block. Anchored - // after `block` so fence tokens exist, and before `curly_attributes` (which - // runs after `inline`), so the fence renderer can still feed the ranges to - // Shiki's `transformerMetaHighlight` via `meta.__raw`. + // Save the raw fence info (e.g. `ts {1,3-5}`) before `markdown-it-attrs` strips the + // `{…}`, so `transformerMetaHighlight` still gets the line ranges via `meta.__raw`. const stashFenceInfo: RuleCore = (state) => { for (const token of state.tokens) { if (token.type === 'fence') { @@ -129,24 +117,18 @@ async function createCompiler(options?: CompileOptions): Promise { }; markdown.core.ruler.after('block', 'stash_fence_info', stashFenceInfo); - // Attribute syntax: {.class #id} markdown.use(attrs); - - // Emoji shortcodes markdown.use(emoji); - // Footnotes: `text[^1]` refs with matching `[^1]: definition` blocks. - // Registered after emoji and before anchor so footnote back-reference - // markers are not themselves treated as heading-anchor targets. + // After emoji and before anchor, so footnote markers are not treated as + // heading-anchor targets. markdown.use(footnote); - // Heading anchors // @ts-expect-error markdown-it-anchor types reference @types/markdown-it, structurally incompatible with markdown-exit markdown.use(anchor, { permalink: anchor.permalink.ariaHidden({ placement: 'before' }), }); - // [[toc]] directive markdown.use(tocPlugin); return markdown; @@ -156,16 +138,10 @@ export async function compile(source: string, options?: CompileOptions): Promise const { data: frontmatter, content } = matter(source); const compiler = await getCompiler(options); - // Each markdown-it call gets its OWN fresh env. `renderAsync` re-parses - // internally, so sharing one env across `parse` and `renderAsync` makes - // stateful plugins double-accumulate — markdown-it-footnote would append its - // footnote refs/defs to the same `env.footnotes` twice, producing duplicated - // footnote lists and wrong fn/fnref ids. A fresh env per call keeps the two - // parses independent. Heading/TOC extraction is unaffected: anchor ids are - // derived per-parse from the (identical) content, not from shared env state. - const tokens = compiler.parse(content, {}); + const env = {}; + const tokens = compiler.parse(content, env); const headings = extractHeadings(tokens); - const html = await compiler.renderAsync(content, {}); + const html = await compiler.renderer.renderAsync(tokens, compiler.options, env); const title = (frontmatter.title as string) ?? headings.find((heading) => heading.depth === 1)?.text ?? ''; diff --git a/packages/md/src/emit-react.ts b/packages/md/src/emit-react.ts index def2df45f..c7602a9ec 100644 --- a/packages/md/src/emit-react.ts +++ b/packages/md/src/emit-react.ts @@ -1,32 +1,9 @@ import type { CompileResult } from './compile.ts'; -import { buildRenderChunks, toIslandId } from './emit-shared.ts'; -import type { IslandImport } from './extract-script.ts'; +import { buildRenderChunks, type ResolvedIsland } from './emit-shared.ts'; -export function emitReactComponent(result: CompileResult): string { - return ` -import React from "react"; -import { FrontmatterContext } from "@void/md"; - -const __frontmatter = ${JSON.stringify(result.frontmatter)}; -const __headings = ${JSON.stringify(result.headings)}; -const __html = ${JSON.stringify(result.html)}; - -export const frontmatter = __frontmatter; -export const headings = __headings; - -export default function MarkdownPage() { - return React.createElement( - FrontmatterContext.Provider, - { value: __frontmatter }, - React.createElement("div", { dangerouslySetInnerHTML: { __html: __html } }) - ); -} -`; -} - -export function emitReactComponentWithIslands( +export function emitReactComponent( result: CompileResult, - islandImports: ReadonlyArray, + islandImports: ReadonlyArray, ): string { const chunks = buildRenderChunks(result.html, islandImports); @@ -44,7 +21,7 @@ export function emitReactComponentWithIslands( return React.createElement( "div", { - "data-island": ${JSON.stringify(toIslandId(islandImport))}, + "data-island": ${JSON.stringify(islandImport.islandId)}, "data-props": JSON.stringify(props), "data-hydrate": ${JSON.stringify(islandImport.strategy)} }, diff --git a/packages/md/src/emit-shared.ts b/packages/md/src/emit-shared.ts index d7fcb6915..eaa788939 100644 --- a/packages/md/src/emit-shared.ts +++ b/packages/md/src/emit-shared.ts @@ -1,5 +1,7 @@ import type { IslandImport } from './extract-script.ts'; +export type ResolvedIsland = IslandImport & Readonly<{ islandId: string }>; + // Match self-closing uppercase tags: , // and paired uppercase tags: ... const ISLAND_TAG_RE = @@ -19,7 +21,7 @@ type HtmlChunk = { type IslandChunk = { type: 'island'; - islandImport: IslandImport; + islandImport: ResolvedIsland; props: Record; }; @@ -53,14 +55,14 @@ function parseAttrs(attrStr: string): Record { return result; } -export function toIslandId(imp: IslandImport): string { - return imp.islandId ?? imp.specifier.replace(/\.[^.]+$/, ''); -} - export function buildRenderChunks( html: string, - islandImports: ReadonlyArray, + islandImports: ReadonlyArray, ): ReadonlyArray { + // Pages without islands render the whole body, even when it is empty. + if (islandImports.length === 0) { + return [{ type: 'html', html }]; + } const islandByName = new Map(islandImports.map((item) => [item.name, item])); const tags = findIslandTags(html, new Set(islandByName.keys())); diff --git a/packages/md/src/emit-solid.ts b/packages/md/src/emit-solid.ts index 9e8ab43dc..d2dac3de1 100644 --- a/packages/md/src/emit-solid.ts +++ b/packages/md/src/emit-solid.ts @@ -1,31 +1,9 @@ import type { CompileResult } from './compile.ts'; -import { buildRenderChunks, toIslandId } from './emit-shared.ts'; -import type { IslandImport } from './extract-script.ts'; +import { buildRenderChunks, type ResolvedIsland } from './emit-shared.ts'; -export function emitSolidComponent(result: CompileResult): string { - return ` -import { FrontmatterContext } from "@void/md"; - -const __frontmatter = ${JSON.stringify(result.frontmatter)}; -const __headings = ${JSON.stringify(result.headings)}; -const __html = ${JSON.stringify(result.html)}; - -export const frontmatter = __frontmatter; -export const headings = __headings; - -export default function MarkdownPage() { - return ( - -
- - ); -} -`; -} - -export function emitSolidComponentWithIslands( +export function emitSolidComponent( result: CompileResult, - islandImports: ReadonlyArray, + islandImports: ReadonlyArray, ): string { const chunks = buildRenderChunks(result.html, islandImports); const importAliasByName = new Map(); @@ -63,7 +41,7 @@ export const headings = __headings; const propsLiteral = Object.keys(chunk.props).length > 0 ? JSON.stringify(chunk.props) : '{}'; code += `\nconst ${propsName} = ${propsLiteral};`; bodyLines.push( - `
<${importAlias} {...${propsName}} />
`, + `
<${importAlias} {...${propsName}} />
`, ); } diff --git a/packages/md/src/emit-svelte.ts b/packages/md/src/emit-svelte.ts index 461124b93..07b4ae1c6 100644 --- a/packages/md/src/emit-svelte.ts +++ b/packages/md/src/emit-svelte.ts @@ -1,35 +1,13 @@ import type { CompileResult } from './compile.ts'; -import { buildRenderChunks, toIslandId } from './emit-shared.ts'; -import type { IslandImport } from './extract-script.ts'; +import { buildRenderChunks, type ResolvedIsland } from './emit-shared.ts'; function toSvelteLiteral(value: unknown): string { return JSON.stringify(value).replace(/<\//g, '<\\/'); } -export function emitSvelteComponent(result: CompileResult): string { - const frontmatter = toSvelteLiteral(result.frontmatter); - const headings = toSvelteLiteral(result.headings); - const html = toSvelteLiteral(result.html); - - return ` - - - -{@html ${html}} -`; -} - -export function emitSvelteComponentWithIslands( +export function emitSvelteComponent( result: CompileResult, - islandImports: ReadonlyArray, + islandImports: ReadonlyArray, ): string { const chunks = buildRenderChunks(result.html, islandImports); @@ -74,7 +52,7 @@ setFrontmatter(__frontmatter); source += `const ${propsName} = ${propsLiteral}; `; body.push( - `
<${alias} {...${propsName}} />
`, + `
<${alias} {...${propsName}} />
`, ); } diff --git a/packages/md/src/emit-vue.ts b/packages/md/src/emit-vue.ts index f85e0bfe6..6dff22617 100644 --- a/packages/md/src/emit-vue.ts +++ b/packages/md/src/emit-vue.ts @@ -1,30 +1,9 @@ import type { CompileResult } from './compile.ts'; -import { buildRenderChunks, toIslandId } from './emit-shared.ts'; -import type { IslandImport } from './extract-script.ts'; +import { buildRenderChunks, type ResolvedIsland } from './emit-shared.ts'; -export function emitVueComponent(result: CompileResult): string { - return ` -import { defineComponent, createStaticVNode, provide } from "vue"; - -const __frontmatter = ${JSON.stringify(result.frontmatter)}; -const __headings = ${JSON.stringify(result.headings)}; -const __html = ${JSON.stringify(result.html)}; - -export const frontmatter = __frontmatter; -export const headings = __headings; - -export default defineComponent({ - setup() { - provide("__void_frontmatter", __frontmatter); - return () => createStaticVNode(__html, 1); - } -}); -`; -} - -export function emitVueComponentWithIslands( +export function emitVueComponent( result: CompileResult, - islandImports: ReadonlyArray, + islandImports: ReadonlyArray, ): string { const chunks = buildRenderChunks(result.html, islandImports); @@ -40,7 +19,7 @@ export function emitVueComponentWithIslands( code += `const ${islandImport.name} = { inheritAttrs: false, render() { const props = this.$attrs; return h("div", { - "data-island": ${JSON.stringify(toIslandId(islandImport))}, + "data-island": ${JSON.stringify(islandImport.islandId)}, "data-props": JSON.stringify(props), "data-hydrate": ${JSON.stringify(islandImport.strategy)} }, h(_raw_island_${islandIndex}, props)); @@ -58,6 +37,9 @@ export function emitVueComponentWithIslands( } } + // A single root keeps SSR output free of fragment markers on pages without islands. + const root = islandImports.length > 0 ? `[\n ${vnodes.join(',\n ')}\n ]` : vnodes[0]; + code += ` const __frontmatter = ${JSON.stringify(result.frontmatter)}; const __headings = ${JSON.stringify(result.headings)}; @@ -68,9 +50,7 @@ export const headings = __headings; export default defineComponent({ setup() { provide("__void_frontmatter", __frontmatter); - return () => [ - ${vnodes.join(',\n ')} - ]; + return () => ${root}; } }); `; diff --git a/packages/md/src/env.d.ts b/packages/md/src/env.d.ts index cbad8236a..5139b70e6 100644 --- a/packages/md/src/env.d.ts +++ b/packages/md/src/env.d.ts @@ -16,9 +16,3 @@ declare module 'markdown-it-footnote' { const plugin: (md: unknown, ...args: ReadonlyArray) => void; export default plugin; } - -declare module '*.vue' { - import type { DefineComponent } from 'vue'; - const component: DefineComponent<{}, {}, unknown>; - export default component; -} diff --git a/packages/md/src/plugin.ts b/packages/md/src/plugin.ts index 6586f340e..2c7c6fc04 100644 --- a/packages/md/src/plugin.ts +++ b/packages/md/src/plugin.ts @@ -1,15 +1,14 @@ import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'pathslash'; import { glob } from 'tinyglobby'; import { normalizePath, type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'; import { resolvePagesDir } from 'void/pages'; -import { generateMdTypes } from './codegen/md-types.ts'; import type { CompileOptions } from './compile.ts'; -import { emitReactComponent, emitReactComponentWithIslands } from './emit-react.ts'; -import { emitSolidComponent, emitSolidComponentWithIslands } from './emit-solid.ts'; -import { emitSvelteComponent, emitSvelteComponentWithIslands } from './emit-svelte.ts'; -import { emitVueComponent, emitVueComponentWithIslands } from './emit-vue.ts'; +import { emitReactComponent } from './emit-react.ts'; +import { emitSolidComponent } from './emit-solid.ts'; +import { emitSvelteComponent } from './emit-svelte.ts'; +import { emitVueComponent } from './emit-vue.ts'; import { extractScript } from './extract-script.ts'; import { extractMetadata } from './metadata.ts'; @@ -125,12 +124,6 @@ export function voidMarkdown(options?: MarkdownOptions): Array { return pages.sort((leftPage, rightPage) => leftPage.path.localeCompare(rightPage.path)); } - function writeMdTypes() { - const outDir = join(root, '.void'); - mkdirSync(outDir, { recursive: true }); - writeFileSync(join(outDir, 'md.d.ts'), generateMdTypes()); - } - return [ { name: 'void-md', @@ -189,7 +182,6 @@ export function voidMarkdown(options?: MarkdownOptions): Array { solidModules.clear(); clientScripts.clear(); mdPages = await scanMdPages(); - writeMdTypes(); }, configureServer(server: ViteDevServer) { @@ -326,42 +318,26 @@ export function useFrontmatter() { return useContext(FrontmatterContext) ?? {}; const componentId = stripPageSuffix(_id.slice(pagesPrefix.length)) || 'index'; clientScripts.set(componentId, clientCode); } - for (const imp of islandImports) { + const islands = islandImports.map((imp) => { const absPath = resolve(dirname(_id), imp.specifier); - imp.islandId = normalizePath(relative(root, absPath)).replace(/\.[^.]+$/, ''); - } + const islandId = normalizePath(relative(root, absPath)).replace(/\.[^.]+$/, ''); + return { ...imp, islandId }; + }); const { compile } = await import('./compile.ts'); const result = await compile(body, { shiki: options?.shiki }); if (adapter === 'vue') { - if (islandImports.length > 0) { - return { - code: emitVueComponentWithIslands(result, islandImports), - map: null, - }; - } - return { code: emitVueComponent(result), map: null }; + return { code: emitVueComponent(result, islands), map: null }; } if (adapter === 'react') { - if (islandImports.length > 0) { - return { - code: emitReactComponentWithIslands(result, islandImports), - map: null, - }; - } - return { code: emitReactComponent(result), map: null }; + return { code: emitReactComponent(result, islands), map: null }; } if (adapter === 'svelte') { const virtualId = createVirtualModuleId(SVELTE_VIRTUAL_PREFIX, _id, code, 'svelte'); - svelteModules.set( - virtualId, - islandImports.length > 0 - ? emitSvelteComponentWithIslands(result, islandImports) - : emitSvelteComponent(result), - ); + svelteModules.set(virtualId, emitSvelteComponent(result, islands)); return { code: `export { default, frontmatter, headings } from ${JSON.stringify(virtualId)};`, map: null, @@ -370,12 +346,7 @@ export function useFrontmatter() { return useContext(FrontmatterContext) ?? {}; if (adapter === 'solid') { const virtualId = createVirtualModuleId(SOLID_VIRTUAL_PREFIX, _id, code, 'tsx'); - solidModules.set( - virtualId, - islandImports.length > 0 - ? emitSolidComponentWithIslands(result, islandImports) - : emitSolidComponent(result), - ); + solidModules.set(virtualId, emitSolidComponent(result, islands)); return { code: `export { default, frontmatter, headings } from ${JSON.stringify(virtualId)};`, map: null, diff --git a/packages/md/src/plugins/containers.ts b/packages/md/src/plugins/containers.ts index 514851d2f..3fb8d2d5d 100644 --- a/packages/md/src/plugins/containers.ts +++ b/packages/md/src/plugins/containers.ts @@ -2,14 +2,6 @@ import type MarkdownExit from 'markdown-exit'; import type { Token } from 'markdown-exit'; import container from 'markdown-it-container'; -export type ContainerOptions = Readonly<{ - tipLabel?: string; - infoLabel?: string; - warningLabel?: string; - dangerLabel?: string; - detailsLabel?: string; -}>; - type MarkdownEnv = { references?: Record; [key: string]: unknown; @@ -49,10 +41,10 @@ function createContainer( * Custom container blocks: :::tip, :::info, :::warning, :::danger, :::details * Ported from VitePress. */ -export function containerPlugin(md: MarkdownExit, options?: ContainerOptions): void { - md.use(...createContainer(md, 'tip', options?.tipLabel ?? 'TIP')) - .use(...createContainer(md, 'info', options?.infoLabel ?? 'INFO')) - .use(...createContainer(md, 'warning', options?.warningLabel ?? 'WARNING')) - .use(...createContainer(md, 'danger', options?.dangerLabel ?? 'DANGER')) - .use(...createContainer(md, 'details', options?.detailsLabel ?? 'Details')); +export function containerPlugin(md: MarkdownExit): void { + md.use(...createContainer(md, 'tip', 'TIP')) + .use(...createContainer(md, 'info', 'INFO')) + .use(...createContainer(md, 'warning', 'WARNING')) + .use(...createContainer(md, 'danger', 'DANGER')) + .use(...createContainer(md, 'details', 'Details')); } diff --git a/packages/md/src/plugins/github-alerts.ts b/packages/md/src/plugins/github-alerts.ts index 10c22d558..18039048f 100644 --- a/packages/md/src/plugins/github-alerts.ts +++ b/packages/md/src/plugins/github-alerts.ts @@ -3,39 +3,25 @@ import type { RuleCore, Token } from 'markdown-exit'; const markerRE = /^\[!(TIP|NOTE|INFO|IMPORTANT|WARNING|CAUTION|DANGER)\]([^\n\r]*)/i; -export type GitHubAlertsOptions = Readonly<{ - tipLabel?: string; - noteLabel?: string; - infoLabel?: string; - importantLabel?: string; - warningLabel?: string; - cautionLabel?: string; - dangerLabel?: string; -}>; +const titleMark: Record = { + tip: 'TIP', + note: 'NOTE', + info: 'INFO', + important: 'IMPORTANT', + warning: 'WARNING', + caution: 'CAUTION', + danger: 'DANGER', +}; type AlertToken = Token & { meta?: { title: string; type: string }; }; -function capitalize(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1); -} - /** * GitHub-flavored alerts: > [!NOTE], > [!TIP], > [!WARNING], etc. * Ported from VitePress. */ -export function gitHubAlertsPlugin(md: MarkdownExit, options?: GitHubAlertsOptions): void { - const titleMark: Record = { - tip: options?.tipLabel ?? 'TIP', - note: options?.noteLabel ?? 'NOTE', - info: options?.infoLabel ?? 'INFO', - important: options?.importantLabel ?? 'IMPORTANT', - warning: options?.warningLabel ?? 'WARNING', - caution: options?.cautionLabel ?? 'CAUTION', - danger: options?.dangerLabel ?? 'DANGER', - }; - +export function gitHubAlertsPlugin(md: MarkdownExit): void { const rule: RuleCore = (state) => { const tokens = state.tokens as ReadonlyArray; for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) { @@ -64,7 +50,7 @@ export function gitHubAlertsPlugin(md: MarkdownExit, options?: GitHubAlertsOptio continue; } const type = match[1].toLowerCase(); - const title = match[2].trim() || titleMark[type] || capitalize(type); + const title = match[2].trim() || titleMark[type]; firstContent.content = firstContent.content.slice(match[0].length).trimStart(); open.type = 'github_alert_open'; open.tag = 'div'; diff --git a/packages/md/src/plugins/line-numbers.ts b/packages/md/src/plugins/line-numbers.ts index 194570b21..b55fe5324 100644 --- a/packages/md/src/plugins/line-numbers.ts +++ b/packages/md/src/plugins/line-numbers.ts @@ -11,18 +11,18 @@ type FenceRender = ( ) => string | Promise; /** - * Adds line number gutter to code blocks. - * Supports `:line-numbers` / `:no-line-numbers` per-block control and `{=N}` start line. + * Adds a line number gutter to code blocks that opt in with `:line-numbers`. + * `{=N}` sets the start line. * Ported from VitePress. */ -export function lineNumberPlugin(md: MarkdownExit, enable = false): void { +export function lineNumberPlugin(md: MarkdownExit): void { const fence = md.renderer.rules.fence as FenceRender; md.renderer.rules.fence = async (...args: Parameters) => { const rawCode = await fence(...args); const [tokens, idx] = args; const info = tokens[idx].info; - if ((!enable && !/:line-numbers\b/.test(info)) || (enable && /:no-line-numbers\b/.test(info))) { + if (!/:line-numbers\b/.test(info)) { return rawCode; } diff --git a/packages/md/src/plugins/link.ts b/packages/md/src/plugins/link.ts index 84ecd93ff..a181b3d6e 100644 --- a/packages/md/src/plugins/link.ts +++ b/packages/md/src/plugins/link.ts @@ -3,21 +3,17 @@ import type MarkdownExit from 'markdown-exit'; const EXTERNAL_URL_RE = /^[a-z]+:/i; -export type LinkOptions = Readonly<{ - externalAttrs?: Record; -}>; +const EXTERNAL_ATTRS: Record = { + target: '_blank', + rel: 'noreferrer', +}; /** * Adds target="_blank" and rel="noreferrer" to external links. * Normalizes internal .md links to route paths. * Ported from VitePress. */ -export function linkPlugin(md: MarkdownExit, options?: LinkOptions): void { - const externalAttrs = options?.externalAttrs ?? { - target: '_blank', - rel: 'noreferrer', - }; - +export function linkPlugin(md: MarkdownExit): void { md.renderer.rules.link_open = ( tokens: Array, idx: number, @@ -40,7 +36,7 @@ export function linkPlugin(md: MarkdownExit, options?: LinkOptions): void { if (EXTERNAL_URL_RE.test(url)) { // External link — add target/rel attributes - for (const [key, val] of Object.entries(externalAttrs)) { + for (const [key, val] of Object.entries(EXTERNAL_ATTRS)) { token.attrSet(key, val); } } else { diff --git a/packages/md/src/plugins/pre-wrapper.ts b/packages/md/src/plugins/pre-wrapper.ts index 2a8b8028d..09e90bbd7 100644 --- a/packages/md/src/plugins/pre-wrapper.ts +++ b/packages/md/src/plugins/pre-wrapper.ts @@ -20,16 +20,11 @@ function extractLang(info: string): string { ); } -export type PreWrapperOptions = Readonly<{ - codeCopyButtonTitle?: string; -}>; - /** * Wraps fenced code blocks with a language label and copy button. * Ported from VitePress. */ -export function preWrapperPlugin(md: MarkdownExit, options?: PreWrapperOptions): void { - const title = options?.codeCopyButtonTitle ?? 'Copy Code'; +export function preWrapperPlugin(md: MarkdownExit): void { const fence = md.renderer.rules.fence as FenceRender; md.renderer.rules.fence = async (...args: Parameters) => { const [tokens, idx] = args; @@ -43,7 +38,7 @@ export function preWrapperPlugin(md: MarkdownExit, options?: PreWrapperOptions): const highlighted = await fence(...args); return ( `
` + - `` + + '' + `${lang}` + highlighted + `
` diff --git a/packages/md/src/plugins/toc.ts b/packages/md/src/plugins/toc.ts index 84d18809e..cbba33fd6 100644 --- a/packages/md/src/plugins/toc.ts +++ b/packages/md/src/plugins/toc.ts @@ -3,24 +3,19 @@ import type MarkdownExit from 'markdown-exit'; import type { Heading } from '../compile.ts'; import { extractHeadings } from '../metadata.ts'; -export type TocOptions = Readonly<{ - pattern?: RegExp; - level?: ReadonlyArray; - containerTag?: string; - containerClass?: string; - listTag?: string; -}>; +const TOC_RE = /^\[\[toc\]\]$/i; +const TOC_LEVELS: ReadonlyArray = [2, 3]; -function renderHeaders(headers: ReadonlyArray, listTag: string): string { +function renderHeaders(headers: ReadonlyArray): string { if (headers.length === 0) { return ''; } - let result = `<${listTag}>`; + let result = '
    '; for (const h of headers) { result += `
  • ${h.text}
  • `; } - result += ``; + result += '
'; return result; } @@ -28,13 +23,7 @@ function renderHeaders(headers: ReadonlyArray, listTag: string): string * [[toc]] directive — renders inline table of contents. * Ported from VitePress. */ -export function tocPlugin(md: MarkdownExit, options?: TocOptions): void { - const pattern = options?.pattern ?? /^\[\[toc\]\]$/i; - const containerTag = options?.containerTag ?? 'nav'; - const containerClass = options?.containerClass ?? 'table-of-contents'; - const listTag = options?.listTag ?? 'ul'; - const level = options?.level ?? [2, 3]; - +export function tocPlugin(md: MarkdownExit): void { md.block.ruler.before( 'heading', 'toc', @@ -43,7 +32,7 @@ export function tocPlugin(md: MarkdownExit, options?: TocOptions): void { const max = state.eMarks[startLine]; const content = state.src.slice(pos, max).trim(); - if (!pattern.test(content)) { + if (!TOC_RE.test(content)) { return false; } if (silent) { @@ -51,14 +40,14 @@ export function tocPlugin(md: MarkdownExit, options?: TocOptions): void { } state.line = startLine + 1; - const openToken = state.push('toc_open', containerTag, 1); + const openToken = state.push('toc_open', 'nav', 1); openToken.markup = content; - openToken.attrSet('class', containerClass); + openToken.attrSet('class', 'table-of-contents'); const bodyToken = state.push('toc_body', '', 0); bodyToken.markup = content; - const closeToken = state.push('toc_close', containerTag, -1); + const closeToken = state.push('toc_close', 'nav', -1); closeToken.markup = content; return true; @@ -67,7 +56,7 @@ export function tocPlugin(md: MarkdownExit, options?: TocOptions): void { ); md.renderer.rules.toc_body = (tokens: Array) => { - const headers = extractHeadings(tokens).filter((heading) => level.includes(heading.depth)); - return renderHeaders(headers, listTag); + const headers = extractHeadings(tokens).filter((heading) => TOC_LEVELS.includes(heading.depth)); + return renderHeaders(headers); }; } diff --git a/packages/md/test/compile-result-fixture.ts b/packages/md/test/compile-result-fixture.ts new file mode 100644 index 000000000..fbe827220 --- /dev/null +++ b/packages/md/test/compile-result-fixture.ts @@ -0,0 +1,11 @@ +import type { CompileResult } from '../src/compile.ts'; + +export function makeResult(overrides?: Partial): CompileResult { + return { + html: '

Hello

\n

World

\n', + frontmatter: { title: 'Test' }, + headings: [{ depth: 1, slug: 'hello', text: 'Hello' }], + title: 'Test', + ...overrides, + }; +} diff --git a/packages/md/test/integration/markdown-blog.test.ts b/packages/md/test/integration/markdown-blog.test.ts index 11f3d91bd..dd37873ac 100644 --- a/packages/md/test/integration/markdown-blog.test.ts +++ b/packages/md/test/integration/markdown-blog.test.ts @@ -32,36 +32,13 @@ function defineMdClientEntryTests(getBase: () => string) { BASE = getBase(); }); - it('md page with client script has data-md-script attribute', async () => { - const res = await fetch(`${BASE}/posts/client-script-test`); - expect(res.status).toBe(200); - const html = await res.text(); - - // data-md-script should be present on the app container - expect(html).toMatch(/
string) { @@ -94,13 +71,11 @@ function defineClientScriptTests(getBase: () => string) { try { await page.goto(`${BASE}/posts/mixed-test`); - // Client script should execute await page.waitForFunction( () => document.getElementById('mixed-target')?.textContent?.startsWith('hydrated'), { timeout: 5000 }, ); - // Island component should be SSR'd (data-island marker present) const island = await page.$('[data-island]'); expect(island).not.toBeNull(); @@ -163,7 +138,6 @@ function defineSidebarTests(getBase: () => string) { await page.goto(`${BASE}/posts/getting-started`); await page.waitForSelector('aside'); - // Click the "Code Examples" link in the sidebar await page.click('aside a[href="/posts/code-examples"]'); await page.waitForSelector('h1'); @@ -195,7 +169,6 @@ function defineSidebarTests(getBase: () => string) { describe('markdown-blog playground', () => { describe('dev', () => { let child: ReturnType; - let stderr = ''; beforeAll(async () => { const server = await startServer((port) => { @@ -221,10 +194,6 @@ describe('markdown-blog playground', () => { }, }, ); - stderr = ''; - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); return child; }, 20_000); DEV_BASE = server.base; @@ -234,9 +203,6 @@ describe('markdown-blog playground', () => { if (child) { await killChild(child); } - if (stderr.includes('error when starting dev server')) { - throw new Error(stderr); - } }); it('injects style for @void/md/theme.css in dev mode', async () => { @@ -244,10 +210,8 @@ describe('markdown-blog playground', () => { expect(res.status).toBe(200); const html = await res.text(); - // Dev mode includes Vite client expect(html).toContain('/@vite/client'); - // Theme CSS is injected (either inline -`; +`; } - function __voidInjectDevErrorOverlay(html: string, overlay: string): string { + function injectDevErrorOverlay(html: string, overlay: string): string { return /<\/body\s*>/i.test(html) ? html.replace(/<\/body\s*>/i, overlay + '') : html + overlay; } - function __voidGetErrorAssetCss(error: unknown): string { + function getErrorAssetCss(error: unknown): string { const tags = error && (typeof error === 'object' || typeof error === 'function') && @@ -562,31 +560,29 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere : ''; } - function __voidInjectErrorAssetCss(html: string, css: string): string { + function injectErrorAssetCss(html: string, css: string): string { if (!css) { return html; } return /<\/head\s*>/i.test(html) ? html.replace(/<\/head\s*>/i, css + '') : css + html; } - function __voidRenderErrorPage(status: number, title: string, error?: unknown): string { + function renderErrorPage(status: number, title: string, error?: unknown): string { const heading = status + ': ' + title; const prefix = status + ': '; const overlay = - __VOID_DEV && status === 500 && error - ? __voidRenderDevErrorOverlay(status, title, error) - : ''; + dev && status === 500 && error ? renderDevErrorOverlay(status, title, error) : ''; return ` - ${__voidEscapeHtml(heading)} - ${__voidGetErrorAssetCss(error)} + ${escapeHtml(heading)} + ${getErrorAssetCss(error)}