@@ -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 */
151166export 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 - Z a - z 0 - 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 */
231251export 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 */
306327export 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 {
641718async 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 ) ;
0 commit comments