Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 60 additions & 20 deletions packages/void/src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof buildVoidManifest>[0],
revalidateConfig?: number | Record<string, number> | 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,
Expand Down Expand Up @@ -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 = {
Expand Down
12 changes: 12 additions & 0 deletions packages/void/src/cli/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,8 @@ export function buildVoidManifest(opts: {
ssr?: boolean;
framework?: string;
revalidate?: number | Record<string, number>;
pages?: Array<{ pattern: string; revalidate?: number }>;
pathRevalidate?: Record<string, number>;
vars?: Record<string, string>;
queues?: Array<QueueMeta>;
prerender?: Array<string>;
Expand All @@ -511,6 +513,8 @@ export function buildVoidManifest(opts: {
ssr,
framework,
revalidate,
pages,
pathRevalidate,
vars,
queues,
prerender,
Expand Down Expand Up @@ -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;
}
Expand Down
12 changes: 12 additions & 0 deletions packages/void/src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,18 @@ export type DeployManifest = {
type?: 'void' | 'spa' | 'static' | 'framework';
revalidate?: number | Record<string, number>;
revalidateQueryAllowlist?: Record<string, Array<string>>;
/** 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<string, number>;
prerender?: Array<string>;
assetConfig?: AssetConfig;
vars?: Record<string, string>;
Expand Down
93 changes: 92 additions & 1 deletion packages/void/src/isr-config.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<string, number> | 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<string, number> | 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 };
Comment thread
Brooooooklyn marked this conversation as resolved.
});
}
Loading