diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 3e6a72793..390853345 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -33,11 +33,12 @@ import { import { scanJobsSync } from '../crons/scan.ts'; import { writeDrizzleConfig } from '../drizzle/config.ts'; import { discoverSchema } from '../drizzle/discover.ts'; -import { collectRevalidate } from '../isr-config.ts'; +import { collectRevalidate, collectPages } from '../isr-config.ts'; import { voidWarn } from '../log.ts'; import { collectMigrations } from '../migrations/collect.ts'; import { assertJournalCoherence, validateMigrations } from '../migrations/validate.ts'; import { scanPages } from '../pages/scan.ts'; +import type { PageScanResult } from '../pages/types.ts'; import { inferProjectBindings, FRAMEWORK_SCAN_DIRS, @@ -1034,6 +1035,40 @@ function formatKnownAssetSummary(result: DeployDoneEvent, skipped: number): stri : `${result.assets} static asset(s)`; } +/** + * Assemble the Void deploy manifest, deriving the route-aware ISR `pages` table + * from the page scan. This is the single production seam that forwards + * `pageScan` → `manifest.pages`; `runFullDeploy` calls it so the wiring is + * exercised (and regression-guarded) by a unit test. + * + * `pages` is a Void pages-mode concept. In framework mode (Class-A frameworks + * routed through `runFullDeploy`) we must NOT emit it — the framework owns its + * own routing/ISR — so `isFrameworkMode` suppresses the table. (plan 0077 + * Global Constraint: "`pages` is Void-pages-mode only; leave frameworks alone".) + */ +export function assembleVoidManifest( + pageScan: PageScanResult | null, + isFrameworkMode: boolean, + opts: Parameters[0], + revalidateConfig?: number | Record | undefined, +): DeployManifest { + return buildVoidManifest({ + ...opts, + // Bake each page's BUILD-time ISR TTL (3-state) from its own `export const + // revalidate` first, then a global-NUMBER `routing.revalidate`. A page under + // an OBJECT config bakes NO TTL (inherit-eligible) — see `collectPages`. + pages: isFrameworkMode ? undefined : collectPages(pageScan, revalidateConfig), + // Ship the RAW per-path OBJECT config alongside so dispatch can resolve those + // inherit-eligible pages against the ACTUAL request path (honoring per-path + // `0` opt-outs a dynamic pattern straddles). Object form only: a number + // config is already baked into every `pages` entry, so it is never consulted. + pathRevalidate: + isFrameworkMode || revalidateConfig == null || typeof revalidateConfig !== 'object' + ? undefined + : revalidateConfig, + }); +} + export async function runStaticDeploy( root: string, config: ProjectConfig, @@ -1874,25 +1909,30 @@ async function runFullDeploy( console.warn(`[void] ${fallbackWarning}`); } - const manifest = buildVoidManifest({ - bindings: liveConfig ? { ...effectiveBindings, needsLive: true } : effectiveBindings, - migrations: validatedMigrations, - schedules, - ssr: isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, - framework: isFrameworkMode ? detected!.name : undefined, - revalidate, - vars: envVars, - queues, - prerender: uniquePrerenderPaths, - assetConfig, - options: voidPackageOptions, - hashedAssetsPrefix: assetsPrefix, - headerRules, - redirectRules, - fallbackRules, - dialect, - webSockets, - }); + const manifest = assembleVoidManifest( + pageScan, + isFrameworkMode, + { + bindings: liveConfig ? { ...effectiveBindings, needsLive: true } : effectiveBindings, + migrations: validatedMigrations, + schedules, + ssr: isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, + framework: isFrameworkMode ? detected!.name : undefined, + revalidate, + vars: envVars, + queues, + prerender: uniquePrerenderPaths, + assetConfig, + options: voidPackageOptions, + hashedAssetsPrefix: assetsPrefix, + headerRules, + redirectRules, + fallbackRules, + dialect, + webSockets, + }, + deployConfig.routing?.revalidate, + ); const workerDir = join(distDir, workerDirName); if (isFrameworkMode) { const wrapperFeatures: WrapperFeatures = { diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index 22b3a0caa..497f82989 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -492,6 +492,8 @@ export function buildVoidManifest(opts: { ssr?: boolean; framework?: string; revalidate?: number | Record; + pages?: Array<{ pattern: string; revalidate?: number }>; + pathRevalidate?: Record; vars?: Record; queues?: Array; prerender?: Array; @@ -511,6 +513,8 @@ export function buildVoidManifest(opts: { ssr, framework, revalidate, + pages, + pathRevalidate, vars, queues, prerender, @@ -604,6 +608,14 @@ export function buildVoidManifest(opts: { manifest.revalidateQueryAllowlist = options.revalidateQueryAllowlist; } + if (pages && pages.length > 0) { + manifest.pages = pages; + } + + if (pathRevalidate && Object.keys(pathRevalidate).length > 0) { + manifest.pathRevalidate = pathRevalidate; + } + if (prerender && prerender.length > 0) { manifest.prerender = prerender; } diff --git a/packages/void/src/cli/types.ts b/packages/void/src/cli/types.ts index 603f7268c..ff12b094f 100644 --- a/packages/void/src/cli/types.ts +++ b/packages/void/src/cli/types.ts @@ -182,6 +182,18 @@ export type DeployManifest = { type?: 'void' | 'spa' | 'static' | 'framework'; revalidate?: number | Record; revalidateQueryAllowlist?: Record>; + /** Ordered page-route table for route-aware ISR resolution (pages mode). */ + pages?: Array<{ pattern: string; revalidate?: number }>; + /** + * RAW per-path `void.json` `routing.revalidate` OBJECT config (pages mode). + * Shipped ALONGSIDE the `pages` table so dispatch can resolve a matched + * inherit-eligible page (a `pages` entry with NO baked `revalidate`) against + * the ACTUAL request path — honoring per-path rules and `0` opt-outs that a + * dynamic page pattern straddles, which build-time baking cannot. Distinct + * from (and uncontaminated by) the folded `revalidate` map. Object form only: + * a global-number config is already baked into every `pages` entry. + */ + pathRevalidate?: Record; prerender?: Array; assetConfig?: AssetConfig; vars?: Record; diff --git a/packages/void/src/isr-config.ts b/packages/void/src/isr-config.ts index 70e0b24bc..5b501c850 100644 --- a/packages/void/src/isr-config.ts +++ b/packages/void/src/isr-config.ts @@ -1,5 +1,5 @@ import type { VoidConfig } from './config.ts'; -import type { PageScanResult } from './pages/types.ts'; +import type { PageDefinition, PageScanResult } from './pages/types.ts'; /** * Whether a resolved `revalidate` value represents an active ISR configuration. @@ -83,3 +83,94 @@ export function collectRevalidate( } return revalidate; } + +/** + * Resolve a single page's BUILD-time ISR TTL for the 3-STATE `pages` table. + * Returns: + * - a number `> 0` — ISR ON with that baked TTL; + * - `0` — an EXPLICIT opt-out (a per-page `export const revalidate = 0`, or a + * global-number config of 0). Dispatch honors it verbatim and does NOT fall + * back to the per-path config; + * - `undefined` — INHERIT-eligible: the page bakes no TTL, and dispatch + * resolves the RAW per-path `routing.revalidate` OBJECT config (shipped as + * `manifest.pathRevalidate`) against the ACTUAL request path at the edge. + * + * Sources, in precedence order — SOURCE-SEPARATED, which preserves the plan-0077 + * catch-all shadowing fix (a no-export page must NOT inherit a SIBLING page's + * per-page TTL — sibling exports never reach this function): + * 1. an explicit per-page `export const revalidate` is authoritative — a + * positive value turns ISR on; an explicit `0` opts the page OUT and is NOT + * overridden by the app-wide config; + * 2. else a global-NUMBER `void.json` `routing.revalidate` (applies to every + * page: `> 0` on, `0` off); + * 3. else (an OBJECT config, or none) → `undefined` (inherit-eligible). + * + * Why an OBJECT config is resolved at the EDGE, not baked here: it is a per-PATH + * rule set, and a single dynamic page pattern can straddle several of its entries + * — INCLUDING a per-path `0` opt-out (e.g. page `/blog/:slug` under + * `{ '/blog/private': 0, '/blog/*': 3600 }`). Collapsing that to one build-time + * TTL cannot honor the `0` and would OVER-cache an opted-out path — a wrong-serve. + * Dispatch has the concrete request path, so it resolves each rule per-path + * correctly. Baking is only safe for the whole-site number config and per-page + * exports (each maps to exactly one TTL). See plan 0077. + */ +function resolvePageBuildTtl( + page: PageDefinition, + revalidateConfig: number | Record | undefined, +): number | undefined { + // 1. Explicit per-page export wins (0 = explicit opt-out, preserved verbatim). + if (page.revalidate != null) { + return page.revalidate > 0 ? page.revalidate : 0; + } + // 2. App-wide void.json `routing.revalidate` — global NUMBER only. + if (typeof revalidateConfig === 'number') { + return revalidateConfig > 0 ? revalidateConfig : 0; + } + // 3. Object config (or none) → inherit-eligible; dispatch resolves per-path. + return undefined; +} + +/** + * Build the ordered page-route table shipped in the deploy manifest as `pages`. + * Emits EVERY page route in `pageScan.pages` order — already specificity-sorted + * (static → dynamic → catch-all), the exact order the framework's own + * `matchRoute` (pages/manifest.ts) resolves in. Patterns are emitted RAW + * (`:slug`, `:path{.+}`, `*`); the edge matcher (`matchSegmentPattern`) handles + * `:name{.+}` natively, so — unlike the flat `revalidate` map — catch-alls are + * NOT collapsed to `*`. + * + * Each entry carries a 3-STATE `revalidate` (see `resolvePageBuildTtl`): + * - `revalidate: N` (N > 0) — ISR on with baked TTL N; + * - `revalidate: 0` — EXPLICIT opt-out (per-page `= 0`, or global-0 config); + * - `revalidate` ABSENT — inherit-eligible; dispatch resolves the RAW per-path + * OBJECT config (`manifest.pathRevalidate`) against the real request path. + * + * The explicit-0 vs absent distinction is load-bearing: it lets dispatch tell a + * deliberate opt-out (never cache) apart from "no baked TTL, ask the per-path + * config" (which may cache), so a per-page `export const revalidate = 0` still + * wins over any `routing.revalidate` rule for the same path. + */ +// Route-unaware by design: this table is built from `pageScan.pages` only, so a +// broad catch-all page with a TTL can shadow a non-`/api` `routes/` handler at +// dispatch and ISR-cache its response (the worker serves the route, but dispatch +// resolves the page catch-all's TTL). PRE-EXISTING (a global-number +// `routing.revalidate` already did this), bounded by dispatch `shouldBypassIsr` +// (no cross-user auth leak) and `isApiPath` (`/api/*` excluded). Documented in +// plan 0077 as a bounded known limitation; the eventual fix prepends `routes/` +// patterns here as `revalidate: 0` opt-outs (with method/overlap caveats). +export function collectPages( + pageScan: PageScanResult | null, + revalidateConfig?: number | Record | undefined, +): Array<{ pattern: string; revalidate?: number }> | undefined { + if (!pageScan || pageScan.pages.length === 0) { + return undefined; + } + return pageScan.pages.map((page) => { + const ttl = resolvePageBuildTtl(page, revalidateConfig); + // `undefined` → inherit-eligible (omit `revalidate`); a number (0 or N) is + // baked verbatim so dispatch can distinguish an explicit opt-out from inherit. + return ttl === undefined + ? { pattern: page.pattern } + : { pattern: page.pattern, revalidate: ttl }; + }); +} diff --git a/packages/void/test/integration/full-deploy-manifest.test.ts b/packages/void/test/integration/full-deploy-manifest.test.ts new file mode 100644 index 000000000..5ad82df3d --- /dev/null +++ b/packages/void/test/integration/full-deploy-manifest.test.ts @@ -0,0 +1,294 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import spawn from 'cross-spawn'; +import { join } from 'pathe'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import type { DetectedFramework } from '../../src/plugin-inference.ts'; + +// @clack/prompts + the output-spinner wrapper are TTY-bound; noop them (same as +// direct-r2-deploy.test.ts) so runDeploy's interactive UI doesn't render here. +const noopSpinner = { + start: vi.fn(), + stop: vi.fn(), + message: vi.fn(), +}; +vi.mock('@clack/prompts', async () => { + const actual = await vi.importActual('@clack/prompts'); + return { + ...actual, + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + step: vi.fn(), + success: vi.fn(), + }, + spinner: () => noopSpinner, + }; +}); +vi.mock('../../src/cli/output.ts', async () => { + const actual = + await vi.importActual('../../src/cli/output.ts'); + return { ...actual, createSpinner: () => noopSpinner }; +}); + +// Prerender path discovery spins Miniflare/workerd in-process. It feeds +// `manifest.prerender`, NOT `manifest.pages`, so it is orthogonal to the seam +// under test. Stub it to [] to keep this test offline, fast and deterministic — +// everything else in runFullDeploy (including assembleVoidManifest) runs for +// real. (For a non-`output:static` Void app this is the only in-process CF +// runtime the deploy path would otherwise start; all network I/O is mocked +// below.) +vi.mock('../../src/prerender.ts', async () => { + const actual = + await vi.importActual('../../src/prerender.ts'); + return { + ...actual, + collectPrerenderPaths: vi.fn(async () => [] as Array), + }; +}); + +// detectFramework is mocked so the SAME pre-built fixture can be driven down +// both deploy routes: the default (undefined → Void pages path, exercised by +// the first test) and, per-test, a forced Class-A framework (→ runFullDeploy +// with isFrameworkMode = true, which must suppress manifest.pages). The return +// value is set in beforeEach (undefined) and overridden inside the framework +// test — so the Void test always sees the real-world `undefined`. +const { mockDetectFramework } = vi.hoisted(() => ({ + mockDetectFramework: vi.fn<(root: string) => DetectedFramework | undefined>(), +})); +vi.mock('../../src/plugin-inference.ts', async () => { + const actual = await vi.importActual( + '../../src/plugin-inference.ts', + ); + return { ...actual, detectFramework: mockDetectFramework }; +}); + +const { runDeploy } = await import('../../src/cli/deploy.ts'); + +const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const PLAYGROUND = 'playground/named-layouts'; +const FIXTURE = join(ROOT, PLAYGROUND); +const PROJECT_JSON = join(FIXTURE, '.void', 'project.json'); + +const fetchMock = vi.fn(); +let originalFetch: typeof globalThis.fetch | undefined; +let savedToken: string | undefined; +let savedProject: string | undefined; +let projectJsonSnapshot: string | undefined; +let projectJsonSnapshotTaken = false; + +type Captured = { finalizeBody: { manifest?: { pages?: unknown } } | null }; + +// Minimal platform mock: report every asset as already-present so the +// upload-urls + R2 PUT phase is skipped and runFullDeploy goes straight to the +// finalize POST, whose JSON body we capture. +function installPlatformMocks(): Captured { + const captured: Captured = { finalizeBody: null }; + + fetchMock.mockImplementation(async (input: RequestInfo, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as Request).url; + const reqInit = init ?? {}; + const method = (reqInit.method ?? 'GET').toUpperCase(); + + if (url.endsWith('/deploy/preflight') && method === 'POST') { + const body = JSON.parse(reqInit.body as string) as { + assetManifest?: Record; + }; + const total = Object.keys(body.assetManifest ?? {}).length; + return json({ needed: [], total, skipped: total }); + } + + if (url.endsWith('/deploy') && method === 'POST') { + captured.finalizeBody = JSON.parse(reqInit.body as string); + return ndjson([ + { event: 'start', deploymentId: 'dep_test' }, + { + event: 'done', + deployment: 'dep_test', + url: 'https://named-layouts.test.void.app', + urls: ['https://named-layouts.test.void.app'], + assets: 0, + workers: 1, + }, + ]); + } + + throw new Error(`unexpected fetch in test: ${method} ${url}`); + }); + + return captured; +} + +function json(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function ndjson(events: Array): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const e of events) { + controller.enqueue(encoder.encode(`${JSON.stringify(e)}\n`)); + } + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { 'content-type': 'application/x-ndjson' }, + }); +} + +// End-to-end guard for the `assembleVoidManifest` seam: drives the REAL exported +// runDeploy → runDeployInner → runFullDeploy (Void/SSR path) against a +// pre-built minimal Void pages fixture, with only network + prompts + prerender +// stubbed, and asserts the finalize manifest carries `pages`. Reverting +// runFullDeploy's `assembleVoidManifest(pageScan, …)` back to +// `buildVoidManifest({ … })` drops `pages` and turns this RED. +describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () => { + beforeAll(() => { + // Real build of the minimal React pages fixture so runDeploy(skipBuild:true) + // finds dist/client/.vite/manifest.json + dist/ssr/{index.js,wrangler.json}. + const result = spawn.sync('vp', ['run', '--filter', `./${PLAYGROUND}`, 'build'], { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + env: { ...process.env, VOID_TOKEN: 'test-token-do-not-use' }, + }); + expect(result.status, `${result.stdout ?? ''}${result.stderr ?? ''}`).toBe(0); + + // Link the fixture to a fake project (snapshot any real link first so we + // restore developer-local state exactly on teardown). + projectJsonSnapshot = existsSync(PROJECT_JSON) + ? readFileSync(PROJECT_JSON, 'utf-8') + : undefined; + projectJsonSnapshotTaken = true; + mkdirSync(join(FIXTURE, '.void'), { recursive: true }); + writeFileSync( + PROJECT_JSON, + JSON.stringify({ projectId: 'proj_named_layouts_test', slug: 'named-layouts-test' }), + ); + }, 180_000); + + afterAll(() => { + if (!projectJsonSnapshotTaken) { + return; + } + if (projectJsonSnapshot === undefined) { + rmSync(PROJECT_JSON, { force: true }); + } else { + writeFileSync(PROJECT_JSON, projectJsonSnapshot); + } + }); + + beforeEach(() => { + // Default: no framework detected → the Void pages path. The framework test + // overrides this to a Class-A framework before driving runDeploy. + mockDetectFramework.mockReturnValue(undefined); + + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + fetchMock.mockReset(); + + savedToken = process.env.VOID_TOKEN; + savedProject = process.env.VOID_PROJECT; + // Explicit token bypasses getToken() + the GitHub-Actions OIDC branch. + process.env.VOID_TOKEN = 'test-token-do-not-use'; + // Force resolution via .void/project.json (avoid the listProjects() path). + delete process.env.VOID_PROJECT; + }); + + afterEach(() => { + if (originalFetch) { + globalThis.fetch = originalFetch; + } + if (savedToken === undefined) { + delete process.env.VOID_TOKEN; + } else { + process.env.VOID_TOKEN = savedToken; + } + if (savedProject === undefined) { + delete process.env.VOID_PROJECT; + } else { + process.env.VOID_PROJECT = savedProject; + } + }); + + it('forwards the page scan into the finalize manifest.pages (incl. the ISR page)', async () => { + const captured = installPlatformMocks(); + + await runDeploy(FIXTURE, { skipBuild: true }); + + expect(captured.finalizeBody).not.toBeNull(); + const pages = captured.finalizeBody!.manifest?.pages as + | Array<{ pattern: string; revalidate?: number }> + | undefined; + + // manifest.pages must be a non-empty array — this is exactly what dropping + // the assembleVoidManifest seam (reverting to buildVoidManifest) removes. + expect(Array.isArray(pages)).toBe(true); + expect(pages!.length).toBeGreaterThan(0); + // Every entry carries a string pattern (raw, route-shaped). + for (const p of pages!) { + expect(typeof p.pattern).toBe('string'); + } + // The /isr page (pages/isr.server.ts → `export const revalidate = 60`) must + // survive into the real deploy manifest with its exact TTL. + expect(pages).toContainEqual({ pattern: '/isr', revalidate: 60 }); + + // The object `routing.revalidate` in the fixture void.json is NOT baked into + // per-page TTLs — a REAL build must emit the 3-state table + the RAW object + // config as `manifest.pathRevalidate` (this is the producer half of the + // cross-repo contract dispatch consumes; a manually-constructed manifest + // would not prove the actual CLI emits it). `/docs` and `/docs/ref` have no + // per-page export, so they ship INHERIT-ELIGIBLE (no `revalidate` key) and + // dispatch resolves the per-path config against the real request path. + const manifest = captured.finalizeBody!.manifest as unknown as { + pathRevalidate?: Record; + }; + expect(manifest.pathRevalidate).toEqual({ '/docs': 120, '/docs/ref': 0 }); + expect(pages).toContainEqual({ pattern: '/docs' }); + expect(pages).toContainEqual({ pattern: '/docs/ref' }); + // Neither inherit-eligible page carries a baked `revalidate` (that would be + // the unsafe build-time collapse of a per-path config — incl. the `0` opt-out). + const docsEntry = pages!.find((p) => p.pattern === '/docs'); + const docsRefEntry = pages!.find((p) => p.pattern === '/docs/ref'); + expect(docsEntry).not.toHaveProperty('revalidate'); + expect(docsRefEntry).not.toHaveProperty('revalidate'); + }); + + it('suppresses manifest.pages in framework mode (Class-A framework forced via detectFramework)', async () => { + // Force the SAME pre-built fixture down the Class-A framework route. The + // vinext-pages preset (workerDir dist/ssr, assetsDir dist/client, + // workerMain index.js) matches the Void React build layout exactly, so + // runDeployInner routes to runFullDeploy with isFrameworkMode = true. + mockDetectFramework.mockReturnValue({ name: 'vinext-pages', class: 'a' }); + const captured = installPlatformMocks(); + + await runDeploy(FIXTURE, { skipBuild: true }); + + // The deploy still finalizes (manifest present)... + expect(captured.finalizeBody).not.toBeNull(); + expect(captured.finalizeBody!.manifest).toBeDefined(); + // ...but `pages` (the Void pages-mode ISR table) is suppressed in framework + // mode even though the fixture has a pages/ dir with an ISR page. Removing + // the `isFrameworkMode ? undefined :` gate in assembleVoidManifest turns + // this RED (pages becomes the populated array instead of undefined). + expect(captured.finalizeBody!.manifest?.pages).toBeUndefined(); + }); +}); diff --git a/packages/void/test/integration/kitchen-sink-build.test.ts b/packages/void/test/integration/kitchen-sink-build.test.ts index 97026d8ee..9d9f07d8f 100644 --- a/packages/void/test/integration/kitchen-sink-build.test.ts +++ b/packages/void/test/integration/kitchen-sink-build.test.ts @@ -2,6 +2,10 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync import spawn from 'cross-spawn'; import { join } from 'pathe'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { assembleVoidManifest } from '../../src/cli/deploy.ts'; +import { readConfig } from '../../src/config.ts'; +import { scanPages } from '../../src/pages/scan.ts'; +import { resolveProjectPaths } from '../../src/project-paths.ts'; import { toSlash } from '../../src/shared/utils.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); @@ -122,6 +126,40 @@ describe('playground/kitchen-sink build', () => { expect(vars.LEGIT_USER_VAR).toBe('kept'); }); + it('emits manifest.pages for the Void pages app (scan order, RAW patterns)', async () => { + // The deploy manifest (carrying `pages`) is a `void deploy` artifact, not a + // `vite build` output — the `vp build` above never writes it to `dist`. Drive + // the SAME production seam `runFullDeploy` uses (`assembleVoidManifest`, which + // owns `scanPages` result → collectPages → manifest.pages) against the real + // kitchen-sink pages to prove the A3 wiring emits `manifest.pages` for a Void + // pages app (which includes an ISR page). + const pg = join(ROOT, PLAYGROUND); + const deployConfig = readConfig(pg); + const paths = resolveProjectPaths(pg, deployConfig); + const pageScan = await scanPages(pg, { output: deployConfig.output, paths }); + const manifest = assembleVoidManifest(pageScan, false, { + bindings: { + needsD1: false, + needsKV: false, + needsR2: false, + needsAuth: false, + needsQueues: false, + needsAI: false, + needsSandbox: false, + }, + }); + + // kitchen-sink is a Void pages app → manifest.pages is emitted, in scan order. + expect(Array.isArray(manifest.pages)).toBe(true); + expect(manifest.pages!.length).toBeGreaterThan(0); + // every entry has a string pattern; catch-alls are RAW (`{.+}`/`*`), not normalized away + for (const p of manifest.pages!) { + expect(typeof p.pattern).toBe('string'); + } + // the /revalidate page (export const revalidate = 60) surfaces as an ISR entry + expect(manifest.pages!.some((p) => p.revalidate != null)).toBe(true); + }); + it('includes sandbox bindings and container metadata', () => { const wranglerConfig = JSON.parse(readFileSync(join(DIST, 'ssr', 'wrangler.json'), 'utf-8')); expect(wranglerConfig.compatibility_flags).toContain('nodejs_compat'); diff --git a/packages/void/test/unit/assemble-manifest.test.ts b/packages/void/test/unit/assemble-manifest.test.ts new file mode 100644 index 000000000..535514b29 --- /dev/null +++ b/packages/void/test/unit/assemble-manifest.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { assembleVoidManifest } from '../../src/cli/deploy.ts'; +import type { InferredBindings } from '../../src/inference/walk.ts'; +import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; + +// Minimal all-false bindings — `manifest.pages` emission is independent of +// bindings, so this is the smallest valid `buildVoidManifest` input. +const MINIMAL_BINDINGS: InferredBindings = { + needsD1: false, + needsKV: false, + needsR2: false, + needsAuth: false, + needsQueues: false, + needsAI: false, + needsSandbox: false, +}; + +function makePage(pattern: string, revalidate?: number): PageDefinition { + return { + pattern, + params: [], + catchAll: false, + componentPath: `${pattern}.vue`, + serverPath: `${pattern}.server.ts`, + methods: ['GET'], + componentId: pattern.replace(/^\//, ''), + revalidate, + ssrMode: 'auto', + prerenderMode: 'auto', + renderMode: 'server', + shouldPrerender: false, + }; +} + +function makeScan(pages: Array): PageScanResult { + return { pages, layouts: [], namedLayouts: [] }; +} + +// This is the production seam `runFullDeploy` uses to forward `pageScan` into +// `manifest.pages`. If the `pages: collectPages(pageScan)` injection is dropped +// from `assembleVoidManifest`, the first assertion below fails (`.toEqual` +// mismatch) and the second fails (`pages` stays undefined) — so this test goes +// RED the moment the wiring regresses. +describe('assembleVoidManifest', () => { + it('forwards the page scan into manifest.pages (ordered, raw patterns, ISR TTL preserved)', () => { + const pageScan = makeScan([ + makePage('/docs/setup/install'), // static, no revalidate + makePage('/blog/:slug', 120), // dynamic segment, ISR + makePage('/docs/:path{.+}', 3600), // catch-all, ISR, RAW pattern + ]); + const manifest = assembleVoidManifest(pageScan, false, { bindings: MINIMAL_BINDINGS }); + expect(manifest.pages).toEqual([ + { pattern: '/docs/setup/install' }, + { pattern: '/blog/:slug', revalidate: 120 }, + { pattern: '/docs/:path{.+}', revalidate: 3600 }, + ]); + }); + + it('omits manifest.pages when the scan has no pages', () => { + expect( + assembleVoidManifest(makeScan([]), false, { bindings: MINIMAL_BINDINGS }).pages, + ).toBeUndefined(); + expect(assembleVoidManifest(null, false, { bindings: MINIMAL_BINDINGS }).pages).toBeUndefined(); + }); + + it('emits manifest.pages for a void app (isFrameworkMode = false)', () => { + const scan = makeScan([makePage('/isr', 60)]); + expect(assembleVoidManifest(scan, false, { bindings: MINIMAL_BINDINGS }).pages).toEqual([ + { pattern: '/isr', revalidate: 60 }, + ]); + }); + + it('omits manifest.pages in framework mode even with a populated scan (isFrameworkMode = true)', () => { + // `pages` is a Void pages-mode concept: a Class-A framework routed through + // runFullDeploy must NOT emit it, even when it happens to have a pages/ dir. + const scan = makeScan([makePage('/isr', 60)]); + expect(assembleVoidManifest(scan, true, { bindings: MINIMAL_BINDINGS }).pages).toBeUndefined(); + }); + + it('ships the RAW object config as manifest.pathRevalidate; those pages stay inherit-eligible', () => { + // Object `routing.revalidate` is not baked — pages stay inherit-eligible + // (absent revalidate) and the RAW config ships so dispatch resolves it + // per-path against the real request path. + const scan = makeScan([makePage('/blog/:slug'), makePage('/about')]); + const manifest = assembleVoidManifest( + scan, + false, + { bindings: MINIMAL_BINDINGS }, + { + '/blog/private': 0, + '/blog/*': 3600, + }, + ); + expect(manifest.pages).toEqual([{ pattern: '/blog/:slug' }, { pattern: '/about' }]); + expect(manifest.pathRevalidate).toEqual({ '/blog/private': 0, '/blog/*': 3600 }); + }); + + it('omits manifest.pathRevalidate for a global-number config (baked into every page)', () => { + // A number config is fully baked into `pages`, so no inherit-eligible page + // exists and pathRevalidate would never be consulted — it is not shipped. + const scan = makeScan([makePage('/a'), makePage('/b')]); + const manifest = assembleVoidManifest(scan, false, { bindings: MINIMAL_BINDINGS }, 3600); + expect(manifest.pages).toEqual([ + { pattern: '/a', revalidate: 3600 }, + { pattern: '/b', revalidate: 3600 }, + ]); + expect(manifest.pathRevalidate).toBeUndefined(); + }); + + it('omits manifest.pathRevalidate in framework mode even with an object config', () => { + const scan = makeScan([makePage('/blog/:slug')]); + const manifest = assembleVoidManifest( + scan, + true, + { bindings: MINIMAL_BINDINGS }, + { + '/blog/*': 3600, + }, + ); + expect(manifest.pages).toBeUndefined(); + expect(manifest.pathRevalidate).toBeUndefined(); + }); +}); diff --git a/packages/void/test/unit/isr-config.test.ts b/packages/void/test/unit/isr-config.test.ts index df2474f47..d71f35286 100644 --- a/packages/void/test/unit/isr-config.test.ts +++ b/packages/void/test/unit/isr-config.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vite-plus/test'; import type { VoidConfig } from '../../src/config.ts'; -import { collectRevalidate, hasRevalidate } from '../../src/isr-config.ts'; +import { collectPages, collectRevalidate, hasRevalidate } from '../../src/isr-config.ts'; import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; import { resolvePathTtl } from '../../src/runtime/isr-cache.ts'; @@ -148,3 +148,110 @@ describe('collectRevalidate', () => { expect(resolvePathTtl(revalidate, '/about')).toBe(0); }); }); + +describe('collectPages', () => { + it('returns undefined when there is no page scan', () => { + expect(collectPages(null)).toBeUndefined(); + }); + + it('returns undefined when the scan has no pages', () => { + expect(collectPages(makeScan([]))).toBeUndefined(); + }); + + it('emits every page in scan order with raw patterns; revalidate only when set', () => { + // makeScan preserves array order; a real scan is pre-sorted static→dynamic→catch-all. + const scan = makeScan([ + makePage('/docs/setup/install'), // specific, no revalidate (opt-out) + makePage('/blog/:slug', 120), // single-segment dynamic + makePage('/docs/:path{.+}', 3600), // named catch-all — RAW pattern, not `*` + ]); + expect(collectPages(scan)).toEqual([ + { pattern: '/docs/setup/install' }, + { pattern: '/blog/:slug', revalidate: 120 }, + { pattern: '/docs/:path{.+}', revalidate: 3600 }, + ]); + }); + + // Test 1 — a per-page `export const revalidate` is authoritative and wins over + // ANY void.json routing.revalidate config (number or object). + it('per-page export wins over config', () => { + const scan = makeScan([makePage('/p', 120)]); + expect(collectPages(scan, 3600)).toEqual([{ pattern: '/p', revalidate: 120 }]); + expect(collectPages(scan, { '/p': 9999 })).toEqual([{ pattern: '/p', revalidate: 120 }]); + }); + + // An explicit `export const revalidate = 0` is an opt-OUT: it bakes the + // DISTINGUISHED marker `revalidate: 0` (NOT absent) so dispatch treats it as a + // deliberate opt-out — never overridden by an app-wide config, never a per-path + // inherit. The `0`-vs-absent distinction is load-bearing (see the 3-state test). + it('explicit per-page revalidate=0 bakes the distinguished opt-out marker (revalidate:0)', () => { + const scan = makeScan([makePage('/p', 0)]); + expect(collectPages(scan, 3600)).toEqual([{ pattern: '/p', revalidate: 0 }]); + // Even with an object rule for the same path, the explicit opt-out marker wins. + expect(collectPages(scan, { '/p': 9999 })).toEqual([{ pattern: '/p', revalidate: 0 }]); + }); + + // The 3-STATE encoding in one table: a baked number `N` (ISR on), an explicit + // `0` (opt-out marker), and an ABSENT revalidate (inherit — dispatch resolves + // the per-path config against the real request path). Dispatch relies on the + // `0`-vs-absent distinction to tell a deliberate opt-out from "ask the config". + it('emits all three states: baked N, explicit 0 opt-out, and absent (inherit)', () => { + const scan = makeScan([ + makePage('/on', 120), // per-page export > 0 → baked N + makePage('/off', 0), // per-page export 0 → explicit opt-out marker + makePage('/inherit'), // no export, object config → inherit (absent) + ]); + expect(collectPages(scan, { '/inherit': 60 })).toEqual([ + { pattern: '/on', revalidate: 120 }, + { pattern: '/off', revalidate: 0 }, + { pattern: '/inherit' }, + ]); + }); + + // Test 2 — REGRESSION GUARD (the must-fix): a global-number config must turn + // ISR ON for EVERY no-export page. Before this fix, such pages baked no TTL and + // dispatch's resolvePageTtl returned 0 → page ISR silently OFF for the whole site. + it('a global-number config applies to every no-export page', () => { + const scan = makeScan([makePage('/blog/:slug'), makePage('/about')]); + expect(collectPages(scan, 3600)).toEqual([ + { pattern: '/blog/:slug', revalidate: 3600 }, + { pattern: '/about', revalidate: 3600 }, + ]); + }); + + // Test 3 — an OBJECT (per-path) config bakes NO per-page TTL: those pages are + // INHERIT-eligible (absent revalidate). A page can straddle several object + // entries (incl. a per-path `0`), so it can't collapse to one build-time TTL — + // the raw config ships as `manifest.pathRevalidate` and dispatch resolves it + // against the ACTUAL request path. Absent revalidate = "ask the edge per-path". + it('an object config bakes no per-page TTL (pages are inherit-eligible)', () => { + const scan = makeScan([makePage('/blog/:slug'), makePage('/about')]); + expect(collectPages(scan, { '/blog/*': 3600 })).toEqual([ + { pattern: '/blog/:slug' }, // inherit-eligible → dispatch resolves per-path + { pattern: '/about' }, + ]); + }); + + // A per-path `0` opt-out inside an object config is exactly why baking is unsafe: + // a build-time collapse would miss `/blog/private: 0` and over-cache it. Instead + // the page bakes no TTL (inherit) and dispatch resolves the per-path config + // against the real path, so `/blog/private` correctly resolves 0 at the edge and + // `/blog/other` resolves 3600 — a per-path fidelity build-time baking can't give. + it('leaves an object-config page inherit-eligible so a per-path 0 opt-out is honored at the edge', () => { + const scan = makeScan([makePage('/blog/:slug')]); + expect(collectPages(scan, { '/blog/private': 0, '/blog/*': 3600 })).toEqual([ + { pattern: '/blog/:slug' }, // no baked TTL; edge honors /blog/private:0 per-path + ]); + }); + + // Test 4 — SHADOWING PRESERVED (plan 0077): a specific no-export page under a + // SIBLING catch-all that HAS an export must NOT inherit the sibling's per-page + // TTL. It stays 0 — the table never folds a sibling's per-page export in. + it('a no-export page does not inherit a sibling catch-all export', () => { + const scan = makeScan([makePage('/docs/:path{.+}', 300), makePage('/docs/setup/install')]); + expect(collectPages(scan)).toEqual([ + { pattern: '/docs/:path{.+}', revalidate: 300 }, + { pattern: '/docs/setup/install' }, // stays 0 — no sibling inheritance + ]); + }); +}); diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index 6db1caf1f..b3351c1be 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -151,6 +151,20 @@ describe('buildVoidManifest — self-host non-regression', () => { expect(revalidate).toBeUndefined(); expect(manifest.revalidate).toBeUndefined(); }); + + it('carries a pages array verbatim into manifest.pages', () => { + const pages = [ + { pattern: '/docs/setup/install' }, + { pattern: '/docs/:path{.+}', revalidate: 3600 }, + ]; + const manifest = buildVoidManifest({ bindings: makeBindings(), pages }); + expect(manifest.pages).toEqual(pages); + }); + + it('omits manifest.pages when the pages array is empty or missing', () => { + expect(buildVoidManifest({ bindings: makeBindings(), pages: [] }).pages).toBeUndefined(); + expect(buildVoidManifest({ bindings: makeBindings() }).pages).toBeUndefined(); + }); }); describe('resolveSpaFallbackChain', () => { diff --git a/playground/named-layouts/pages/isr.server.ts b/playground/named-layouts/pages/isr.server.ts new file mode 100644 index 000000000..4c37f2c68 --- /dev/null +++ b/playground/named-layouts/pages/isr.server.ts @@ -0,0 +1,10 @@ +import { defineHandler } from 'void'; + +// ISR fixture: `export const revalidate` makes this page surface in the deploy +// manifest's `pages` table with its TTL. Exercised by the full-deploy manifest +// integration test (test/integration/full-deploy-manifest.test.ts). +export const revalidate = 60; + +export const loader = defineHandler(() => { + return {}; +}); diff --git a/playground/named-layouts/pages/isr.tsx b/playground/named-layouts/pages/isr.tsx new file mode 100644 index 000000000..483bfbcb0 --- /dev/null +++ b/playground/named-layouts/pages/isr.tsx @@ -0,0 +1,5 @@ +export const layout = false; + +export default function IsrPage() { + return
ISR
; +} diff --git a/playground/named-layouts/void.json b/playground/named-layouts/void.json index 33a12dfec..777b9fcec 100644 --- a/playground/named-layouts/void.json +++ b/playground/named-layouts/void.json @@ -2,5 +2,11 @@ "$schema": "./node_modules/void/schema.json", "worker": { "compatibility_date": "2026-02-24" + }, + "routing": { + "revalidate": { + "/docs": 120, + "/docs/ref": 0 + } } }