Skip to content

Commit 9df3936

Browse files
Brooooooklynclaude
andcommitted
fix(isr): segment-anchored revalidate matching + host-scoped self-host KV
FIX #3 — segment-aware revalidate (parity with void-platform dispatch): - collectRevalidate (isr-config.ts): only named/anonymous catch-alls (:name{.+}, *) collapse to '*'; single-segment params (:slug) stay as :slug so the matcher can anchor them to ONE path segment. - matchLiteralPattern (isr-cache.ts): whole-segment :name -> [^/]+ via a U+E000 private-use sentinel (survives regex-special escaping, expanded after). Mid-segment literal ':' (e.g. /api/v1:health) left alone. '*' still crosses '/'. Strict SUPERSET of the old '*'-only matcher, so old '*'-only manifests match identically (rollout safety). Byte-identical to the shared cross-repo matcher spec. - Manifest emission changes again (single-seg pages now :slug, not '*'); updated the pinned keys in isr-config/package tests. FIX #4 — host-scoped self-host ISR KV keys + host registry: - isrKey -> void-isr/<id>/<host>/<path>; readIsrCache/writeIsrCache/ deleteIsrCache/handleIsr/revalidateInBackground thread `host` (url.hostname). One host never reads/serves another host's cached bytes. - writeIsrCache registers the host under void-isr-hosts/<id>/<host> (guarded by an in-isolate Set, fail-open). New listIsrHosts(kv, id) enumerates them. - revalidate() self-host purge (isr.ts): {paths} enumerates registered hosts (union __VOID_ISR_HOST) and purges each host's KV + edge; {all} splits the host off the scanned key tail before denormalizing. KV purge never depends on caches.default (edge purge guarded, fail-open). Empty host set no-ops. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0faa462 commit 9df3936

7 files changed

Lines changed: 446 additions & 139 deletions

File tree

‎packages/void/src/isr-config.ts‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,13 @@ export function collectRevalidate(
5656
const perPageRules: Record<string, number> = {};
5757
for (const page of pageScan.pages) {
5858
if (page.revalidate != null) {
59-
// The TTL matcher (`matchLiteralPattern`/`resolvePathTtl`) treats `:` as
60-
// a literal and only `*` as a wildcard, so a raw route pattern like
61-
// `/blog/:slug` would never match `/blog/hello`. Convert route params
62-
// (`:slug`, `:slug{.+}`) to `*` so generated dynamic-page rules match.
63-
// This applies ONLY to scanned page patterns — the user's own
64-
// `routing.revalidate` keys below are authored literals and pass through
65-
// untouched.
66-
const key = page.pattern.replace(/:[A-Za-z0-9_]+(?:\{[^}]*\})?/g, '*');
59+
// Single-segment params (:slug) stay as :slug so the matcher can anchor
60+
// them to ONE path segment; named catch-alls (:slug{.+}) and anonymous
61+
// catch-alls (*) stay/become '*' (a splat that crosses '/'). See
62+
// matchLiteralPattern. This applies ONLY to scanned page patterns — the
63+
// user's own `routing.revalidate` keys below are authored literals and
64+
// pass through untouched.
65+
const key = page.pattern.replace(/:[A-Za-z0-9_]+\{[^}]*\}/g, '*');
6766
perPageRules[key] = page.revalidate;
6867
}
6968
}

‎packages/void/src/runtime/isr-cache.ts‎

Lines changed: 105 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,24 @@ export function canonicalIsrPathname(pathname: string): string {
110110
// ---------------------------------------------------------------------------
111111

112112
/**
113-
* KV cache key for an ISR-cached page. Deployment-scoped so cached HTML cannot
114-
* outlive the hashed assets referenced by the deployment that produced it
115-
* (a fresh `wrangler deploy` mints a new deployment id → cold cache).
113+
* KV cache key for an ISR-cached page. Deployment- AND host-scoped: cached HTML
114+
* cannot outlive the hashed assets of the deployment that produced it (a fresh
115+
* `wrangler deploy` mints a new deployment id → cold cache), and one host's
116+
* cached content never bleeds into another host served by the same worker
117+
* (custom domain + workers.dev, apex + www, etc.).
116118
*/
117-
export function isrKey(deploymentId: string, pathname: string): string {
118-
return `void-isr/${deploymentId}/${normalizeIsrPath(pathname)}`;
119+
export function isrKey(deploymentId: string, host: string, pathname: string): string {
120+
return `void-isr/${deploymentId}/${host}/${normalizeIsrPath(pathname)}`;
121+
}
122+
123+
/**
124+
* Host-registry key. Every host that has written cached content registers itself
125+
* under `void-isr-hosts/<id>/<host>` (value `''`) so a purge with no request host
126+
* (cron / queue `revalidate({ paths })`) can enumerate the hosts to purge.
127+
* Scoped by deployment id so registrations from an old deploy do not leak.
128+
*/
129+
export function hostRegistryKey(deploymentId: string, host: string): string {
130+
return `void-isr-hosts/${deploymentId}/${host}`;
119131
}
120132

121133
/**
@@ -144,9 +156,12 @@ const literalRegexCache = new Map<string, RegExp>();
144156

145157
/**
146158
* Match a pattern against a pathname using LITERAL semantics — no named
147-
* captures. Only `*` is special (matches any characters including `/`); every
148-
* other character (including `:`) is treated as a literal. Malformed patterns
149-
* yield `false` rather than throwing.
159+
* captures. Two wildcards are special: `*` matches any characters INCLUDING `/`
160+
* (a splat), and a WHOLE-segment `:name` (preceded by `/` or start, followed by
161+
* `/` or end) matches exactly ONE path segment (`[^/]+`). A mid-segment literal
162+
* `:` (e.g. `/api/v1:health`) is left alone. This is a strict SUPERSET of the
163+
* old `*`-only matcher (old `*`-only manifests match identically). Malformed
164+
* patterns yield `false` rather than throwing.
150165
*/
151166
export function matchLiteralPattern(pattern: string, pathname: string): boolean {
152167
let regex = literalRegexCache.get(pattern);
@@ -156,7 +171,11 @@ export function matchLiteralPattern(pattern: string, pathname: string): boolean
156171
literalRegexCache.set(pattern, regex);
157172
} else {
158173
try {
159-
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*');
174+
const escaped = pattern
175+
.replace(/(^|\/):[A-Za-z0-9_]+(?=\/|$)/g, '$1') // whole-segment :name -> sentinel
176+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex specials
177+
.replaceAll('*', '.*') // catch-all crosses '/'
178+
.replaceAll('', '[^/]+'); // sentinel -> single segment
160179
regex = new RegExp(`^${escaped}$`);
161180
} catch {
162181
return false;
@@ -226,17 +245,19 @@ export function unpackIsrValue(
226245
/**
227246
* Read an ISR cache entry from KV. Returns null on cache miss, malformed
228247
* metadata, or ANY KV error (fail-open → caller renders live). The KV key is
229-
* deployment-scoped via `env.__VOID_ISR_DEPLOYMENT_ID` (see module header).
248+
* deployment-scoped via `env.__VOID_ISR_DEPLOYMENT_ID` (see module header) and
249+
* host-scoped via `host` so one host never reads another host's cached bytes.
230250
*/
231251
export async function readIsrCache(
232252
env: IsrCacheEnv,
253+
host: string,
233254
pathname: string,
234255
): Promise<IsrCacheEntry | null> {
235256
const kv = env.ISR_CACHE;
236257
if (!kv) {
237258
return null;
238259
}
239-
const key = isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', pathname);
260+
const key = isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', host, pathname);
240261
let value: ArrayBuffer | null;
241262
let metadata: IsrCacheMetadata | null;
242263
try {
@@ -305,6 +326,7 @@ export async function readIsrCache(
305326
*/
306327
export async function writeIsrCache(
307328
env: IsrCacheEnv,
329+
host: string,
308330
pathname: string,
309331
response: Response,
310332
ttl: number,
@@ -319,10 +341,11 @@ export async function writeIsrCache(
319341
const headers = serializeHeaders(responseClone);
320342
const status = responseClone.status;
321343

344+
const id = env.__VOID_ISR_DEPLOYMENT_ID ?? '';
322345
try {
323346
const body = await responseClone.arrayBuffer();
324347
const packed = packIsrValue(headers, body);
325-
await kv.put(isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', pathname), packed, {
348+
await kv.put(isrKey(id, host, pathname), packed, {
326349
metadata: {
327350
s: status,
328351
c: Date.now(),
@@ -332,19 +355,73 @@ export async function writeIsrCache(
332355
} catch {
333356
// Fail open — swallow write errors so a broken ISR_CACHE never 500s.
334357
}
358+
// Register this host so a host-less purge (cron / queue) can find it. Guarded
359+
// by an in-isolate Set so it costs one KV write per (isolate, id, host), not
360+
// one per page write. Fail-open — a registry write error never 500s a render.
361+
await registerIsrHost(kv, id, host);
362+
}
363+
364+
/**
365+
* In-isolate guard: `${id}/${host}` values whose host-registry key this isolate
366+
* has already written (or attempted). Bounds host registration to ~one KV write
367+
* per (isolate, deployment, host) instead of one per page write.
368+
*/
369+
const registeredHosts = new Set<string>();
370+
371+
async function registerIsrHost(kv: KVNamespace, deploymentId: string, host: string): Promise<void> {
372+
const guardKey = `${deploymentId}/${host}`;
373+
if (registeredHosts.has(guardKey)) {
374+
return;
375+
}
376+
// Mark BEFORE the await so concurrent page writes in this isolate don't all
377+
// fire the registry put; a rare transient failure simply retries next isolate.
378+
registeredHosts.add(guardKey);
379+
try {
380+
await kv.put(hostRegistryKey(deploymentId, host), '');
381+
} catch {
382+
// Fail open — a registry write failure must never 500 a page render.
383+
}
384+
}
385+
386+
/**
387+
* List every host that has registered cached content for a deployment. Used by
388+
* the on-demand `revalidate({ paths })` purge when there is no request host
389+
* (cron / queue). Fail-open: a registry read error yields the hosts collected so
390+
* far (or none) rather than throwing — degraded purge coverage, never a 500.
391+
*/
392+
export async function listIsrHosts(kv: KVNamespace, deploymentId: string): Promise<Array<string>> {
393+
const prefix = `void-isr-hosts/${deploymentId}/`;
394+
const hosts: Array<string> = [];
395+
try {
396+
let cursor: string | undefined;
397+
do {
398+
const list = await kv.list({ prefix, cursor });
399+
for (const key of list.keys) {
400+
hosts.push(key.name.slice(prefix.length));
401+
}
402+
cursor = list.list_complete ? undefined : list.cursor;
403+
} while (cursor);
404+
} catch {
405+
// Fail open — a broken registry degrades purge coverage, never throws.
406+
}
407+
return hosts;
335408
}
336409

337410
/**
338411
* Delete an ISR cache entry from KV. Used when a worker permanently opts out
339412
* via `x-revalidate: 0`. Any KV error is swallowed (fail-open).
340413
*/
341-
export async function deleteIsrCache(env: IsrCacheEnv, pathname: string): Promise<void> {
414+
export async function deleteIsrCache(
415+
env: IsrCacheEnv,
416+
host: string,
417+
pathname: string,
418+
): Promise<void> {
342419
const kv = env.ISR_CACHE;
343420
if (!kv) {
344421
return;
345422
}
346423
try {
347-
await kv.delete(isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', pathname));
424+
await kv.delete(isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', host, pathname));
348425
} catch {
349426
// Fail open.
350427
}
@@ -641,6 +718,7 @@ export function cloneWithoutVoidPages(request: Request): Request {
641718
async function revalidateInBackground(
642719
env: IsrCacheEnv,
643720
request: Request,
721+
host: string,
644722
pathname: string,
645723
defaultTtl: number,
646724
keys: { htmlKey: string; jsonKey: string },
@@ -655,7 +733,7 @@ async function revalidateInBackground(
655733
const ttl = resolveRevalidateTtl(response, defaultTtl);
656734
const cache = edgeCache();
657735
if (ttl > 0 && response.ok) {
658-
await writeIsrCache(env, pathname, response, ttl);
736+
await writeIsrCache(env, host, pathname, response, ttl);
659737
// Update HTML edge cache with the fresh response.
660738
const headers = new Headers(response.headers);
661739
headers.set('x-isr-cache', 'HIT');
@@ -673,7 +751,7 @@ async function revalidateInBackground(
673751
await populateJsonEdgeCache(cache, keys.jsonKey, response, ttl, 'HIT');
674752
} else if (ttl === 0) {
675753
// Worker permanently opted out — remove KV + both edge variants.
676-
await deleteIsrCache(env, pathname);
754+
await deleteIsrCache(env, host, pathname);
677755
await safeCacheDelete(cache, keys.htmlKey);
678756
await safeCacheDelete(cache, keys.jsonKey);
679757
}
@@ -775,7 +853,7 @@ export async function handleIsr(
775853
}
776854

777855
// L2: KV (global, authoritative). `readIsrCache` fails open to null.
778-
const cached = await readIsrCache(env, pathname);
856+
const cached = await readIsrCache(env, host, pathname);
779857
if (cached) {
780858
// Belt-and-suspenders: `readIsrCache` already validates the entry so the
781859
// Response constructors below can't throw, but the never-500 fail-open
@@ -785,7 +863,15 @@ export async function handleIsr(
785863
const stale = isStale(cached);
786864
if (stale) {
787865
ctx.waitUntil(
788-
revalidateInBackground(env, request, pathname, isrTtl, { htmlKey, jsonKey }, render),
866+
revalidateInBackground(
867+
env,
868+
request,
869+
host,
870+
pathname,
871+
isrTtl,
872+
{ htmlKey, jsonKey },
873+
render,
874+
),
789875
);
790876
}
791877
const cacheResponse = isrCacheResponse(cached, stale);
@@ -806,7 +892,7 @@ export async function handleIsr(
806892
if (ttl > 0 && response.ok) {
807893
// Every clone below is taken synchronously (before `response.body` is read
808894
// for the client) so no consumer races the body away.
809-
ctx.waitUntil(writeIsrCache(env, pathname, response, ttl));
895+
ctx.waitUntil(writeIsrCache(env, host, pathname, response, ttl));
810896
ctx.waitUntil(populateJsonEdgeCache(cache, jsonKey, response, ttl, 'MISS'));
811897

812898
const cacheHeaders = new Headers(response.headers);

‎packages/void/src/runtime/isr.ts‎

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getRawRuntimeEnv } from './env-raw.ts';
2-
import { denormalizeIsrPath, htmlEdgeKey, isrKey, jsonEdgeKey } from './isr-cache.ts';
2+
import { denormalizeIsrPath, htmlEdgeKey, isrKey, jsonEdgeKey, listIsrHosts } from './isr-cache.ts';
33

44
export type RevalidateOptions = {
55
paths?: Array<string>;
@@ -38,38 +38,61 @@ export async function revalidate(options: RevalidateOptions): Promise<void> {
3838
if (!kv || !id) {
3939
return;
4040
}
41-
const host = rawEnv.__VOID_ISR_HOST as string | undefined;
42-
const purge = async (path: string): Promise<void> => {
43-
// KV first, and unconditionally — the authoritative purge must never
44-
// depend on the Cache API. In scheduled/queue handlers the deployment id
45-
// is stamped without a host (the F1 fix), so edge purges are skipped and
46-
// this KV delete must work even if `caches.default` is degraded.
47-
await kv.delete(isrKey(id, path));
48-
if (host) {
49-
// Acquire the edge cache lazily — only when there is a host to purge —
50-
// so the KV path above never touches (and never throws on) the Cache
51-
// API. Cast because this package has both @types/node and
52-
// @cloudflare/workers-types in scope, and Node's ambient CacheStorage
53-
// (which lacks `.default`) otherwise shadows the Workers one on the
54-
// `caches` global — same pattern as isr-cache.ts.
41+
const requestHost = rawEnv.__VOID_ISR_HOST as string | undefined;
42+
// Cache keys are host-scoped (`void-isr/<id>/<host>/<path>`), so a purge must
43+
// target a concrete (host, path). Delete KV first and unconditionally — the
44+
// authoritative purge must never depend on the Cache API — then best-effort
45+
// purge the per-host edge variants, guarded so a degraded `caches.default`
46+
// (scheduled/queue handlers, or no Cache API) cannot fail the KV delete.
47+
const purge = async (host: string, path: string): Promise<void> => {
48+
await kv.delete(isrKey(id, host, path));
49+
try {
50+
// Cast because this package has both @types/node and @cloudflare/workers-types
51+
// in scope, and Node's ambient CacheStorage (which lacks `.default`)
52+
// otherwise shadows the Workers one — same pattern as isr-cache.ts.
5553
const cache = (caches as unknown as { default: Cache }).default;
5654
await cache.delete(htmlEdgeKey(host, path, id));
5755
await cache.delete(jsonEdgeKey(host, path, id));
56+
} catch {
57+
// Fail open — a degraded Cache API must not fail the authoritative KV purge.
5858
}
5959
};
6060
if (options.all) {
61+
// Enumerate the actual host-scoped cache keys (registry-independent, so it
62+
// is complete even for a host that was never registered). Split the host
63+
// off the key tail BEFORE denormalizing: `void-isr/<id>/<host>/<path>`.
6164
const prefix = `void-isr/${id}/`;
6265
let cursor: string | undefined;
6366
do {
6467
const list = await kv.list({ prefix, cursor });
6568
for (const key of list.keys) {
66-
await purge(denormalizeIsrPath(key.name.slice(prefix.length)));
69+
const tail = key.name.slice(prefix.length);
70+
const slash = tail.indexOf('/');
71+
if (slash < 0) {
72+
continue;
73+
}
74+
const host = tail.slice(0, slash);
75+
const path = denormalizeIsrPath(tail.slice(slash + 1));
76+
await purge(host, path);
6777
}
6878
cursor = list.list_complete ? undefined : list.cursor;
6979
} while (cursor);
7080
} else {
71-
for (const path of options.paths ?? []) {
72-
await purge(path);
81+
const paths = options.paths ?? [];
82+
if (paths.length === 0) {
83+
return;
84+
}
85+
// No request host is guaranteed here (cron / queue handlers stamp the id
86+
// WITHOUT a host — the F1 fix), so enumerate every registered host, union
87+
// the request host when present. An empty host set is a silent no-op.
88+
const hosts = new Set(await listIsrHosts(kv, id));
89+
if (requestHost) {
90+
hosts.add(requestHost);
91+
}
92+
for (const host of hosts) {
93+
for (const path of paths) {
94+
await purge(host, path);
95+
}
7396
}
7497
}
7598
return;

0 commit comments

Comments
 (0)