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 += `${listTag}>`;
+ 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
\nWorld
\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 `