@nifrajs/web 2.9.1 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"route-manifest.d.ts","sourceRoot":"","sources":["../src/route-manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAE1D;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,KAAK,GAAG,KAAK,CAAA;AAEjD,uEAAuE;AACvE,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,cAAc,CAAA;AAEvD,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,iGAAiG;IACjG,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,8DAA8D;IAC9D,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7C,iEAAiE;IACjE,QAAQ,CAAC,QAAQ,EAAE,SAAS,eAAe,EAAE,CAAA;CAC9C;AAED,wGAAwG;AACxG,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAA;IACpC,sFAAsF;IACtF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED,2FAA2F;AAC3F,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,SAAS,kBAAkB,EAAE,CAAA;IAC9C,QAAQ,CAAC,SAAS,EAAE,SAAS,qBAAqB,EAAE,CAAA;IACpD,8DAA8D;IAC9D,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAA;CACtD;AAKD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,IAAI,CAAC,WAAW,EAAE,WAAW,GAAG,gBAAgB,GAAG,YAAY,GAAG,SAAS,CAAC,EACpF,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,GACnC,kBAAkB,CAyBpB;AA0BD;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,QAAQ,EAClB,OAAO,GAAE;IACP,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAA;IAClE,mGAAmG;IACnG,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAA;CAC9C,GACL,OAAO,CAAC,aAAa,CAAC,CAuCxB;AAED,oFAAoF;AACpF,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,CA2CnE"}
1
+ {"version":3,"file":"route-manifest.d.ts","sourceRoot":"","sources":["../src/route-manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAE1D;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,KAAK,GAAG,KAAK,CAAA;AAEjD,uEAAuE;AACvE,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,cAAc,CAAA;AAEvD,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,iGAAiG;IACjG,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,8DAA8D;IAC9D,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,sEAAsE;IACtE,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7C,iEAAiE;IACjE,QAAQ,CAAC,QAAQ,EAAE,SAAS,eAAe,EAAE,CAAA;CAC9C;AAED,wGAAwG;AACxG,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAA;IACpC,sFAAsF;IACtF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED,2FAA2F;AAC3F,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,SAAS,kBAAkB,EAAE,CAAA;IAC9C,QAAQ,CAAC,SAAS,EAAE,SAAS,qBAAqB,EAAE,CAAA;IACpD,8DAA8D;IAC9D,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAA;CACtD;AAKD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,IAAI,CACV,WAAW,EACX,WAAW,GAAG,gBAAgB,GAAG,YAAY,GAAG,gBAAgB,GAAG,SAAS,CAC7E,EACD,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,GACnC,kBAAkB,CA6BpB;AA0BD;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,QAAQ,EAClB,OAAO,GAAE;IACP,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAA;IAClE,mGAAmG;IACnG,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAA;CAC9C,GACL,OAAO,CAAC,aAAa,CAAC,CAuCxB;AAED,oFAAoF;AACpF,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,CA2CnE"}
@@ -24,6 +24,9 @@ export function deriveRouteEntry(id, pattern, module, prerenderedPaths) {
24
24
  const entry = { id, pattern, mode, hydrate: module.hydrate !== false, requires };
25
25
  if (mode === "isr" && module.revalidate !== undefined)
26
26
  entry.revalidate = module.revalidate;
27
+ if (mode === "isr" && module.revalidateTags !== undefined) {
28
+ entry.revalidateTags = module.revalidateTags;
29
+ }
27
30
  if (prerenderedPaths !== undefined)
28
31
  entry.prerenderedPaths = prerenderedPaths;
29
32
  return entry;
@@ -1 +1 @@
1
- {"version":3,"file":"route-manifest.js","sourceRoot":"","sources":["../src/route-manifest.ts"],"names":[],"mappings":"AAwEA,+EAA+E;AAC/E,MAAM,SAAS,GAAG,CAAC,OAAe,EAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;AAEpE;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAC9B,EAAU,EACV,OAAe,EACf,MAAoF,EACpF,gBAAoC;IAEpC,MAAM,cAAc,GAAG,MAAM,CAAC,SAAS,KAAK,IAAI,IAAI,MAAM,CAAC,cAAc,KAAK,SAAS,CAAA;IACvF,mGAAmG;IACnG,oGAAoG;IACpG,uFAAuF;IACvF,MAAM,UAAU,GAAG,gBAAgB,KAAK,SAAS,IAAI,gBAAgB,CAAC,MAAM,GAAG,CAAC,CAAA;IAChF,MAAM,QAAQ,GAAG,cAAc,IAAI,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,UAAU,CAAC,CAAA;IACtE,MAAM,IAAI,GAAe,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAA;IAE9F,MAAM,QAAQ,GAAsB,EAAE,CAAA;IACtC,IAAI,IAAI,KAAK,QAAQ;QAAE,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IAC9C,IAAI,IAAI,KAAK,KAAK;QAAE,QAAQ,CAAC,IAAI,CAAC,cAAc,CAAC,CAAA;IAEjD,MAAM,KAAK,GAQP,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,KAAK,KAAK,EAAE,QAAQ,EAAE,CAAA;IACtE,IAAI,IAAI,KAAK,KAAK,IAAI,MAAM,CAAC,UAAU,KAAK,SAAS;QAAE,KAAK,CAAC,UAAU,GAAG,MAAM,CAAC,UAAU,CAAA;IAC3F,IAAI,gBAAgB,KAAK,SAAS;QAAE,KAAK,CAAC,gBAAgB,GAAG,gBAAgB,CAAA;IAC7E,OAAO,KAA2B,CAAA;AACpC,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,mBAAmB,GAAyD;IAChF,GAAG,EAAE,CAAC,QAAQ,CAAC;IACf,IAAI,EAAE,CAAC,QAAQ,CAAC;IAChB,IAAI,EAAE,CAAC,QAAQ,CAAC;IAChB,UAAU,EAAE,CAAC,QAAQ,CAAC;IACtB,MAAM,EAAE,CAAC,QAAQ,CAAC;IAClB,MAAM,EAAE,EAAE;CACX,CAAA;AAED,MAAM,WAAW,GAA8C;IAC7D,MAAM,EACJ,yOAAyO;IAC3O,YAAY,EACV,oHAAoH;CACvH,CAAA;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,QAAkB,EAClB,UAKI,EAAE;IAEN,MAAM,OAAO,GAAyB,EAAE,CAAA;IACxC,KAAK,MAAM,KAAK,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;QACpC,iGAAiG;QACjG,8DAA8D;QAC9D,MAAM,MAAM,GAAG,CAAC,MAAM,KAAK,CAAC,IAAI,EAAE,CAAgB,CAAA;QAClD,OAAO,CAAC,IAAI,CAAC,gBAAgB,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAClG,CAAC;IACD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAA;IAE1D,MAAM,SAAS,GAA4B,EAAE,CAAA;IAC7C,MAAM,YAAY,GAChB,OAAO,CAAC,YAAY;QACpB,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,mBAAmB,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAA;IAClF,IAAI,YAAY,KAAK,SAAS,EAAE,CAAC;QAC/B,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;YAC5B,KAAK,MAAM,UAAU,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;gBACxC,IAAI,YAAY,CAAC,QAAQ,CAAC,UAAU,CAAC;oBAAE,SAAQ;gBAC/C,SAAS,CAAC,IAAI,CAAC;oBACb,EAAE,EAAE,KAAK,CAAC,EAAE;oBACZ,OAAO,EAAE,KAAK,CAAC,OAAO;oBACtB,UAAU;oBACV,WAAW,EAAE,WAAW,CAAC,UAAU,CAAC;iBACrC,CAAC,CAAA;YACJ,CAAC;QACH,CAAC;IACH,CAAC;IAED,MAAM,MAAM,GAA+B,EAAE,MAAM,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,CAAA;IACxE,KAAK,MAAM,KAAK,IAAI,OAAO;QAAE,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAEpD,MAAM,MAAM,GAKR,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,CAAA;IAC1C,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS;QAAE,MAAM,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAA;IAChE,OAAO,MAAuB,CAAA;AAChC,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,mBAAmB,CAAC,QAAuB;IACzD,MAAM,KAAK,GAAa,EAAE,CAAA;IAC1B,KAAK,CAAC,IAAI,CACR,QAAQ,CAAC,MAAM,KAAK,SAAS;QAC3B,CAAC,CAAC,sBAAsB;QACxB,CAAC,CAAC,kCAAkC,QAAQ,CAAC,MAAM,EAAE,CACxD,CAAA;IACD,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IACd,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACjC,KAAK,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAA;QAC9B,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACzB,CAAC;IAED,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,GAAG,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,CAAA;IAC/E,KAAK,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,CAAA;IACpF,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC,CAAA;IACvC,KAAK,MAAM,KAAK,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;QACpC,MAAM,KAAK,GACT,KAAK,CAAC,IAAI,KAAK,KAAK;YAClB,CAAC,CAAC,cAAc,KAAK,CAAC,UAAU,GAAG;YACnC,CAAC,CAAC,KAAK,CAAC,IAAI,KAAK,QAAQ;gBACvB,CAAC,CAAC,cAAc,KAAK,CAAC,gBAAgB,CAAC,CAAC,CAAC,KAAK,KAAK,CAAC,gBAAgB,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;gBACrF,CAAC,CAAC,aAAa,CAAA;QACrB,KAAK,CAAC,IAAI,CACR,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC;YACzB,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;YACpB,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;YACxC,KAAK,CACR,CAAA;IACH,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IACd,KAAK,CAAC,IAAI,CACR,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,aAAa,QAAQ,CAAC,MAAM,CAAC,GAAG,UAAU,QAAQ,CAAC,MAAM,CAAC,GAAG,MAAM,CAC7F,CAAA;IAED,IAAI,QAAQ,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAClC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACd,KAAK,CAAC,IAAI,CAAC,KAAK,QAAQ,CAAC,SAAS,CAAC,MAAM,qCAAqC,CAAC,CAAA;QAC/E,KAAK,MAAM,QAAQ,IAAI,QAAQ,CAAC,SAAS,EAAE,CAAC;YAC1C,KAAK,CAAC,IAAI,CAAC,OAAO,QAAQ,CAAC,OAAO,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAA;QACjE,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AACzB,CAAC"}
1
+ {"version":3,"file":"route-manifest.js","sourceRoot":"","sources":["../src/route-manifest.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/web",
3
- "version": "2.9.1",
3
+ "version": "2.10.0",
4
4
  "description": "Framework-agnostic SSR core for nifra - the render seam + HTML document orchestration.",
5
5
  "keywords": [
6
6
  "bun",
@@ -171,7 +171,7 @@
171
171
  "access": "public"
172
172
  },
173
173
  "dependencies": {
174
- "@nifrajs/core": "2.9.1"
174
+ "@nifrajs/core": "2.10.0"
175
175
  },
176
176
  "peerDependencies": {
177
177
  "sass": "^1.0.0",
package/src/dev.ts CHANGED
@@ -367,7 +367,10 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
367
367
  return new Response(body, { headers })
368
368
  }
369
369
  // Static probe before routing; a miss returns undefined and falls through, so no route is
370
- // shadowed and a page render never pays a filesystem stat (extension-less paths skip it).
370
+ // shadowed. Every GET/HEAD pays the probe, including page renders and API routes: the handler
371
+ // serves extension-less files too (an ACME challenge token is the reason), so there is no
372
+ // cheap prefilter to skip. Dev only - production serves `public/` from the CDN, and a caller
373
+ // mounting this handler itself can pass `files` to answer a miss without touching the disk.
371
374
  const staticFile = await servePublic(req)
372
375
  if (staticFile !== undefined) return staticFile
373
376
  try {
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ import { isDraftEnabled } from "./draft.ts"
27
27
  import { trustedHeadAttributes } from "./internal/head-attributes.ts"
28
28
  import { jsStringLiteral } from "./internal/js-string.ts"
29
29
  import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
30
- import { ISR_REVALIDATE_HEADER } from "./isr.ts"
30
+ import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "./isr.ts"
31
31
  import { generateLlmsTxt } from "./llms-txt.ts"
32
32
  import type {
33
33
  LayoutEntry,
@@ -83,6 +83,7 @@ export {
83
83
  type CachedResponse,
84
84
  type CacheStore,
85
85
  ISR_REVALIDATE_HEADER,
86
+ ISR_REVALIDATE_TAGS_HEADER,
86
87
  ISR_STATUS_HEADER,
87
88
  type ISRApp,
88
89
  type ISROptions,
@@ -427,6 +428,8 @@ export interface RenderPageOptions {
427
428
  /** ISR: route freshness in seconds, emitted as the `x-nifra-isr-revalidate` header for a `withISR`
428
429
  * wrapper to read. Omit ⇒ no header (the wrapper's default TTL applies). */
429
430
  readonly revalidate?: number
431
+ /** ISR invalidation tags emitted as a bounded `x-nifra-isr-tags` header. */
432
+ readonly revalidateTags?: readonly string[]
430
433
  /** Matched route id; written to `window.__NIFRA_ROUTE__` so the client hydrates this chain. */
431
434
  readonly routeId?: string
432
435
  /** The matched route's decoded path params - surfaced to the page as `params` (via {@link RenderProps})
@@ -541,6 +544,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
541
544
  styles = [],
542
545
  prerenderedPaths = [],
543
546
  revalidate,
547
+ revalidateTags = [],
544
548
  routeId,
545
549
  status = 200,
546
550
  title = "nifra",
@@ -695,6 +699,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
695
699
  // dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
696
700
  // the client's path-list channel.
697
701
  if (revalidate !== undefined) headers[ISR_REVALIDATE_HEADER] = String(revalidate)
702
+ const serializedTags = serializeISRTags(revalidateTags)
703
+ if (serializedTags !== undefined) headers[ISR_REVALIDATE_TAGS_HEADER] = serializedTags
698
704
  const renderProps: RenderProps = {
699
705
  data: forComponent,
700
706
  actionData: actionSplit?.forComponent,
@@ -2225,6 +2231,7 @@ export function createWebApp<Env = unknown>(
2225
2231
  ...stylesOf(route.id),
2226
2232
  prerenderedPaths: options.prerenderedPaths ?? [],
2227
2233
  ...(mod.revalidate !== undefined ? { revalidate: mod.revalidate } : {}),
2234
+ ...(mod.revalidateTags !== undefined ? { revalidateTags: mod.revalidateTags } : {}),
2228
2235
  ...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
2229
2236
  ...titleOption,
2230
2237
  ...assemblyCacheFor(mod, chainLayoutModules),
package/src/isr.ts CHANGED
@@ -20,6 +20,8 @@ export interface CachedResponse {
20
20
  readonly storedAt: number
21
21
  /** Freshness window (ms): `now - storedAt >= revalidate` ⇒ stale (serve it, regenerate behind it). */
22
22
  readonly revalidate: number
23
+ /** Public, bounded invalidation labels. A tag purge makes matching entries miss immediately. */
24
+ readonly tags?: readonly string[]
23
25
  }
24
26
 
25
27
  /**
@@ -35,6 +37,45 @@ export interface CacheStore {
35
37
  set(key: string, value: CachedResponse): Promise<void>
36
38
  /** Drop `key` (on-demand revalidation / purge). A no-op if absent. */
37
39
  delete(key: string): Promise<void>
40
+ /** Drop every cached response carrying `tag`, when the backend supports tag invalidation. */
41
+ readonly invalidateTag?: (tag: string) => Promise<void>
42
+ }
43
+
44
+ const ISR_TAG_PATTERN = /^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/
45
+ const MAX_ISR_TAGS = 32
46
+
47
+ function normalizeTag(tag: string): string {
48
+ if (typeof tag !== "string" || !ISR_TAG_PATTERN.test(tag)) {
49
+ throw new TypeError("[nifra/web] ISR tag must be a bounded token")
50
+ }
51
+ return tag
52
+ }
53
+
54
+ function normalizeTags(tags: readonly string[] | undefined): readonly string[] {
55
+ if (tags === undefined) return []
56
+ if (!Array.isArray(tags) || tags.length > MAX_ISR_TAGS) {
57
+ throw new TypeError("[nifra/web] ISR tags must contain at most 32 bounded tokens")
58
+ }
59
+ const unique = new Set<string>()
60
+ for (const tag of tags) unique.add(normalizeTag(tag))
61
+ return Object.freeze([...unique])
62
+ }
63
+
64
+ function tagsFromHeader(value: string | null): readonly string[] {
65
+ if (value === null || value.trim() === "") return []
66
+ try {
67
+ return normalizeTags(value.split(",").map((tag) => tag.trim()))
68
+ } catch {
69
+ // A malformed response-controlled header must never accidentally create an unpurgeable cache
70
+ // entry. Treat it as untagged; route declarations are validated before they reach this path.
71
+ return []
72
+ }
73
+ }
74
+
75
+ /** Validate and serialize route-declared ISR tags for the internal response header. */
76
+ export function serializeISRTags(tags: readonly string[]): string | undefined {
77
+ const normalized = normalizeTags(tags)
78
+ return normalized.length === 0 ? undefined : normalized.join(",")
38
79
  }
39
80
 
40
81
  export interface MemoryCacheStoreOptions {
@@ -52,8 +93,12 @@ export interface MemoryCacheStoreOptions {
52
93
  * page survives a burst of new pages).
53
94
  */
54
95
  export class MemoryCacheStore implements CacheStore {
55
- private readonly cache = new Map<string, CachedResponse>()
96
+ private readonly cache = new Map<
97
+ string,
98
+ { readonly value: CachedResponse; readonly tagEpochs: Readonly<Record<string, string>> }
99
+ >()
56
100
  private readonly max: number
101
+ private readonly tagEpoch = new Map<string, string>()
57
102
 
58
103
  constructor(options: MemoryCacheStoreOptions = {}) {
59
104
  const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production"
@@ -71,31 +116,75 @@ export class MemoryCacheStore implements CacheStore {
71
116
  }
72
117
 
73
118
  get(key: string): Promise<CachedResponse | undefined> {
74
- const value = this.cache.get(key)
119
+ const row = this.cache.get(key)
120
+ if (row !== undefined) {
121
+ for (const [tag, epoch] of Object.entries(row.tagEpochs)) {
122
+ if ((this.tagEpoch.get(tag) ?? "0") !== epoch) {
123
+ this.cache.delete(key)
124
+ this.pruneTagEpochs()
125
+ return Promise.resolve(undefined)
126
+ }
127
+ }
128
+ }
129
+ const value = row?.value
75
130
  // LRU: a read bumps the entry to the tail so the bounded evict drops the LEAST-RECENTLY-USED,
76
131
  // not the oldest-written - otherwise a burst of new pages would evict a hot, frequently-read one.
77
132
  if (value !== undefined) {
78
133
  this.cache.delete(key)
79
- this.cache.set(key, value)
134
+ this.cache.set(
135
+ key,
136
+ row as {
137
+ readonly value: CachedResponse
138
+ readonly tagEpochs: Readonly<Record<string, string>>
139
+ },
140
+ )
80
141
  }
81
142
  return Promise.resolve(value)
82
143
  }
83
144
 
84
145
  set(key: string, value: CachedResponse): Promise<void> {
146
+ const tags = normalizeTags(value.tags)
147
+ const tagEpochs: Record<string, string> = {}
148
+ for (const tag of tags) tagEpochs[tag] = this.tagEpoch.get(tag) ?? "0"
85
149
  this.cache.delete(key) // re-insert at the tail so Map order tracks recency (with get) for the LRU evict
86
- this.cache.set(key, value)
150
+ this.cache.set(key, { value: tags.length === 0 ? value : { ...value, tags }, tagEpochs })
87
151
  while (this.cache.size > this.max) {
88
152
  const oldest = this.cache.keys().next().value
89
153
  if (oldest === undefined) break
90
154
  this.cache.delete(oldest)
91
155
  }
156
+ this.pruneTagEpochs()
92
157
  return Promise.resolve()
93
158
  }
94
159
 
95
160
  delete(key: string): Promise<void> {
96
161
  this.cache.delete(key)
162
+ this.pruneTagEpochs()
163
+ return Promise.resolve()
164
+ }
165
+
166
+ invalidateTag(tag: string): Promise<void> {
167
+ normalizeTag(tag)
168
+ this.tagEpoch.set(tag, crypto.randomUUID())
169
+ // This store is bounded and local, so eagerly remove matching rows. It keeps invalidation
170
+ // immediate and lets us discard epoch keys that no retained entry references anymore.
171
+ for (const [key, row] of this.cache) {
172
+ if (Object.hasOwn(row.tagEpochs, tag)) this.cache.delete(key)
173
+ }
174
+ this.pruneTagEpochs()
97
175
  return Promise.resolve()
98
176
  }
177
+
178
+ private pruneTagEpochs(): void {
179
+ if (this.tagEpoch.size === 0) return
180
+ const used = new Set<string>()
181
+ for (const row of this.cache.values()) {
182
+ for (const tag of Object.keys(row.tagEpochs)) used.add(tag)
183
+ }
184
+ for (const tag of this.tagEpoch.keys()) {
185
+ if (!used.has(tag)) this.tagEpoch.delete(tag)
186
+ }
187
+ }
99
188
  }
100
189
 
101
190
  /**
@@ -121,6 +210,16 @@ export interface KVCacheStoreOptions {
121
210
  * overwritten on regeneration or purged via `revalidateEndpoint`.
122
211
  */
123
212
  readonly expirationTtl?: number
213
+ /**
214
+ * Smallest `expirationTtl` this binding accepts (**seconds**). Defaults to 60, which is Cloudflare
215
+ * KV's floor - the common case, and worth rejecting at construction because Cloudflare fails the
216
+ * `put` at runtime instead, one deploy later.
217
+ *
218
+ * The floor belongs to the *binding*, not to this class. {@link KVNamespaceLike} is structural, so
219
+ * a Redis, Deno KV, Upstash, or in-memory binding satisfies it too, and those accept far shorter
220
+ * TTLs. Pass their real minimum, or `0` for a backend with none.
221
+ */
222
+ readonly minExpirationTtl?: number
124
223
  }
125
224
 
126
225
  /**
@@ -141,31 +240,64 @@ const isCachedResponse = (value: unknown): value is CachedResponse => {
141
240
  ) {
142
241
  return false
143
242
  }
243
+ if (v.tags !== undefined) {
244
+ if (!Array.isArray(v.tags)) return false
245
+ try {
246
+ normalizeTags(v.tags as string[])
247
+ } catch {
248
+ return false
249
+ }
250
+ }
144
251
  for (const headerValue of Object.values(v.headers as Record<string, unknown>)) {
145
252
  if (typeof headerValue !== "string") return false
146
253
  }
147
254
  return true
148
255
  }
149
256
 
257
+ const KV_TAG_EPOCH_PREFIX = "__nifra_isr_tag_v1:"
258
+
259
+ type StoredTaggedResponse = CachedResponse & {
260
+ readonly __nifraTagEpochs?: Readonly<Record<string, string>>
261
+ }
262
+
150
263
  /**
151
264
  * A {@link CacheStore} backed by a **Cloudflare Workers KV** namespace (or any {@link KVNamespaceLike}
152
265
  * binding) - the production-grade shared/durable store ISR wants: cached pages and on-demand purges
153
266
  * hold *across* worker instances (unlike the per-instance {@link MemoryCacheStore}). Entries serialize
154
267
  * to JSON; every read is validated before it's trusted (a malformed/version-skewed entry is treated as
155
268
  * a miss). Construct it in your Workers `fetch` from the binding: `new KVCacheStore(env.ISR_CACHE)`.
269
+ *
270
+ * Cloudflare is the default, not a requirement: {@link KVNamespaceLike} is three structural methods,
271
+ * so a Redis, Deno KV, or Upstash binding satisfies it (pass `minExpirationTtl` for their TTL floor).
272
+ * A backend that *can* enumerate keys deserves its own {@link CacheStore} rather than this one - the
273
+ * epoch indirection in {@link KVCacheStore.invalidateTag} exists only because KV cannot list by tag.
156
274
  */
157
275
  export class KVCacheStore implements CacheStore {
158
276
  private readonly kv: KVNamespaceLike
159
277
  private readonly putOptions: { readonly expirationTtl?: number } | undefined
160
278
 
161
279
  constructor(kv: KVNamespaceLike, options: KVCacheStoreOptions = {}) {
162
- if (options.expirationTtl !== undefined && options.expirationTtl < 60) {
163
- throw new Error(
164
- "[nifra/web] KVCacheStore expirationTtl must be >= 60 (Cloudflare KV's minimum), and should " +
165
- "exceed your longest `revalidate` window so stale-while-revalidate isn't turned into a " +
166
- "blocking miss by KV expiry.",
280
+ const floor = options.minExpirationTtl ?? 60
281
+ if (!Number.isSafeInteger(floor) || floor < 0) {
282
+ throw new RangeError(
283
+ "[nifra/web] KVCacheStore minExpirationTtl must be a non-negative integer",
167
284
  )
168
285
  }
286
+ if (options.expirationTtl !== undefined) {
287
+ if (!Number.isSafeInteger(options.expirationTtl) || options.expirationTtl < 0) {
288
+ throw new RangeError(
289
+ "[nifra/web] KVCacheStore expirationTtl must be a non-negative integer (seconds)",
290
+ )
291
+ }
292
+ if (options.expirationTtl < floor) {
293
+ throw new Error(
294
+ `[nifra/web] KVCacheStore expirationTtl must be >= ${floor}, this binding's minimum. 60 is ` +
295
+ "Cloudflare KV's floor and the default; pass `minExpirationTtl` for a backend with a " +
296
+ "lower one. Whatever the floor, the TTL should exceed your longest `revalidate` window, " +
297
+ "or expiry turns a stale-while-revalidate into a blocking miss.",
298
+ )
299
+ }
300
+ }
169
301
  this.kv = kv
170
302
  // Precompute the put options object once: omit it entirely when no TTL (KV then never expires).
171
303
  this.putOptions =
@@ -182,16 +314,53 @@ export class KVCacheStore implements CacheStore {
182
314
  // A corrupt (non-JSON) entry: treat as a miss so the page re-renders; the next set overwrites it.
183
315
  return undefined
184
316
  }
185
- return isCachedResponse(parsed) ? parsed : undefined
317
+ if (!isCachedResponse(parsed)) return undefined
318
+ const tags = normalizeTags(parsed.tags)
319
+ if (tags.length === 0) return parsed
320
+ const epochs = (parsed as StoredTaggedResponse).__nifraTagEpochs
321
+ if (epochs === undefined) return undefined
322
+ const current = await Promise.all(
323
+ tags.map(async (tag) => [tag, (await this.kv.get(this.tagKey(tag))) ?? "0"] as const),
324
+ )
325
+ if (current.some(([tag, epoch]) => epochs[tag] !== epoch)) return undefined
326
+ const { __nifraTagEpochs: _ignored, ...value } = parsed as StoredTaggedResponse
327
+ return value
186
328
  }
187
329
 
188
330
  async set(key: string, value: CachedResponse): Promise<void> {
189
- await this.kv.put(key, JSON.stringify(value), this.putOptions)
331
+ const tags = normalizeTags(value.tags)
332
+ if (tags.length === 0) {
333
+ await this.kv.put(key, JSON.stringify(value), this.putOptions)
334
+ return
335
+ }
336
+ const epochs = Object.fromEntries(
337
+ await Promise.all(
338
+ tags.map(async (tag) => [tag, (await this.kv.get(this.tagKey(tag))) ?? "0"] as const),
339
+ ),
340
+ )
341
+ const stored: StoredTaggedResponse = {
342
+ ...value,
343
+ tags,
344
+ __nifraTagEpochs: epochs,
345
+ }
346
+ await this.kv.put(key, JSON.stringify(stored), this.putOptions)
190
347
  }
191
348
 
192
349
  async delete(key: string): Promise<void> {
193
350
  await this.kv.delete(key)
194
351
  }
352
+
353
+ async invalidateTag(tag: string): Promise<void> {
354
+ normalizeTag(tag)
355
+ // Epoch invalidation avoids KV key enumeration and remains bounded for a tag with millions of
356
+ // pages. Epoch keys intentionally have no expiration: an expired epoch would resurrect entries
357
+ // written before the invalidation when their page key still exists.
358
+ await this.kv.put(this.tagKey(tag), crypto.randomUUID())
359
+ }
360
+
361
+ private tagKey(tag: string): string {
362
+ return `${KV_TAG_EPOCH_PREFIX}${encodeURIComponent(tag)}`
363
+ }
195
364
  }
196
365
 
197
366
  /** Minimal platform shape `withISR` needs - just `waitUntil` (edge runtimes extend the response
@@ -209,6 +378,9 @@ export interface ISRApp {
209
378
  * regenerating behind it), or `miss` (rendered now + stored). Useful for debugging + tests. */
210
379
  export const ISR_STATUS_HEADER = "x-nifra-isr"
211
380
 
381
+ /** Response header carrying bounded route tags into {@link withISR}. */
382
+ export const ISR_REVALIDATE_TAGS_HEADER = "x-nifra-isr-tags"
383
+
212
384
  /**
213
385
  * Response header a route uses to advertise its ISR freshness (**seconds**) to a {@link withISR}
214
386
  * wrapper - `createWebApp` emits it from a route's `export const revalidate`. Deliberately distinct
@@ -289,6 +461,9 @@ const CACHEABLE_RESPONSE_HEADERS = new Set([
289
461
  "link",
290
462
  ])
291
463
 
464
+ const tagsOf = (res: Response): readonly string[] =>
465
+ tagsFromHeader(res.headers.get(ISR_REVALIDATE_TAGS_HEADER))
466
+
292
467
  const headersOf = (res: Response): Record<string, string> => {
293
468
  const out: Record<string, string> = {}
294
469
  res.headers.forEach((value, key) => {
@@ -349,12 +524,14 @@ export function withISR(
349
524
  const res = await app.fetch(req, platform)
350
525
  if (!isCacheablePage(req, res)) return res
351
526
  const body = await res.text()
527
+ const tags = tagsOf(res)
352
528
  const entry: CachedResponse = {
353
529
  body,
354
530
  status: res.status,
355
531
  headers: headersOf(res),
356
532
  storedAt: now(),
357
533
  revalidate: ttlMs(res),
534
+ ...(tags.length === 0 ? {} : { tags }),
358
535
  }
359
536
  await store.set(key, entry)
360
537
  return new Response(body, {
@@ -375,12 +552,14 @@ export function withISR(
375
552
  try {
376
553
  const res = await app.fetch(req, platform)
377
554
  if (isCacheablePage(req, res)) {
555
+ const tags = tagsOf(res)
378
556
  await store.set(key, {
379
557
  body: await res.text(),
380
558
  status: res.status,
381
559
  headers: headersOf(res),
382
560
  storedAt: now(),
383
561
  revalidate: ttlMs(res),
562
+ ...(tags.length === 0 ? {} : { tags }),
384
563
  })
385
564
  } else {
386
565
  await res.body?.cancel()
@@ -431,9 +610,10 @@ export interface RevalidateEndpointOptions {
431
610
 
432
611
  /**
433
612
  * An **on-demand revalidation** (purge) endpoint - a `fetch` handler that drops a path's cached entry
434
- * so the next request re-renders. `POST` with the secret in the token header and the path as `?path=`
435
- * or a JSON `{ "path": "/blog/x" }` body. The token is checked in **constant time** (wrong/missing →
436
- * `401`); a missing/relative path → `400`; non-POST → `405`. Mount it on a nifra route, e.g.
613
+ * or invalidates every entry carrying a tag. `POST` with the secret in the token header and either
614
+ * `?path=/blog/x`, `?tag=products`, or a JSON `{ "path": "/blog/x" }` / `{ "tag": "products" }` body.
615
+ * The token is checked in **constant time** (wrong/missing → `401`); malformed targets → `400`;
616
+ * non-POST → `405`. A store without tag support returns `501` for tag requests. Mount it on a nifra route, e.g.
437
617
  * `app.post("/__nifra/revalidate", (c) => handler(c.req))`.
438
618
  */
439
619
  export function revalidateEndpoint(
@@ -446,12 +626,29 @@ export function revalidateEndpoint(
446
626
  if (!timingSafeEqual(req.headers.get(tokenHeader) ?? "", options.secret)) {
447
627
  return jsonError(401, "unauthorized")
448
628
  }
449
- let path = new URL(req.url).searchParams.get("path")
450
- if (path === null) {
629
+ const url = new URL(req.url)
630
+ let path = url.searchParams.get("path")
631
+ let tag = url.searchParams.get("tag")
632
+ if (path === null && tag === null) {
451
633
  const body: unknown = await req.json().catch(() => null)
452
- const candidate =
453
- typeof body === "object" && body !== null ? (body as { path?: unknown }).path : undefined
454
- path = typeof candidate === "string" ? candidate : null
634
+ if (typeof body === "object" && body !== null) {
635
+ const candidate = body as { path?: unknown; tag?: unknown }
636
+ path = typeof candidate.path === "string" ? candidate.path : null
637
+ tag = typeof candidate.tag === "string" ? candidate.tag : null
638
+ }
639
+ }
640
+ if (path !== null && tag !== null) return jsonError(400, "choose_path_or_tag")
641
+ if (tag !== null) {
642
+ try {
643
+ normalizeTag(tag)
644
+ } catch {
645
+ return jsonError(400, "invalid_tag")
646
+ }
647
+ if (options.store.invalidateTag === undefined) {
648
+ return jsonError(501, "tag_invalidation_not_supported")
649
+ }
650
+ await options.store.invalidateTag(tag)
651
+ return Response.json({ revalidatedTag: tag })
455
652
  }
456
653
  if (path === null || !path.startsWith("/")) return jsonError(400, "invalid_path")
457
654
  await options.store.delete(keyOf(path, req))
package/src/manifest.ts CHANGED
@@ -227,6 +227,8 @@ export interface RouteModule {
227
227
  * default). Older-than-`revalidate` cached pages are served stale while regenerating.
228
228
  */
229
229
  readonly revalidate?: number
230
+ /** Optional bounded tags used by ISR on-demand invalidation (`?tag=...`). */
231
+ readonly revalidateTags?: readonly string[]
230
232
  /**
231
233
  * No-framework island bundles (`@nifrajs/web/islands`) to load on this route, as `<script
232
234
  * type="module">` in the document tail. Loaded **regardless of `hydrate`** - pair with
package/src/public-dir.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { realpath } from "node:fs/promises"
16
16
  import { normalize, resolve, sep } from "node:path"
17
+ import { parseByteRange } from "@nifrajs/core/range"
17
18
  import { pathnameOf } from "@nifrajs/core/server"
18
19
 
19
20
  /** How long each subtree may be cached. Content-hashed bundle output can be immutable; a
@@ -38,6 +39,35 @@ export interface ServePublicDirOptions {
38
39
  const IMMUTABLE = "public, max-age=31536000, immutable"
39
40
  const ONE_DAY = "public, max-age=86400"
40
41
 
42
+ /** HTTP dates carry second precision; comparing raw milliseconds makes every file look stale. */
43
+ function seconds(time: number): number {
44
+ return Math.floor(time / 1000)
45
+ }
46
+
47
+ /**
48
+ * `If-Range` decides whether a range request is still safe to answer partially. The only validator
49
+ * this handler publishes is `last-modified`, so an entity-tag form can never match and the whole
50
+ * representation is sent instead - which is the conformant outcome, not a fallback. Dates use strong
51
+ * comparison (RFC 9110 13.1.5): a file modified since the client's copy invalidates its byte offsets.
52
+ */
53
+ function ifRangeMatches(value: string | null, lastModified: number | undefined): boolean {
54
+ if (value === null) return true
55
+ if (lastModified === undefined) return false
56
+ const item = value.trim()
57
+ if (item.startsWith('"') || item.startsWith("W/")) return false
58
+ const parsed = Date.parse(item)
59
+ return Number.isFinite(parsed) && seconds(parsed) === seconds(lastModified)
60
+ }
61
+
62
+ /** `If-Modified-Since` freshness. No ETag is published, so there is no `If-None-Match` precedence. */
63
+ function isNotModified(request: Request, lastModified: number | undefined): boolean {
64
+ if (lastModified === undefined) return false
65
+ const since = request.headers.get("if-modified-since")
66
+ if (since === null) return false
67
+ const parsed = Date.parse(since)
68
+ return Number.isFinite(parsed) && seconds(parsed) >= seconds(lastModified)
69
+ }
70
+
41
71
  /**
42
72
  * Resolve a URL pathname to an absolute path **confined** to `root`, or `undefined` if it escapes.
43
73
  *
@@ -101,12 +131,52 @@ export function servePublicDir(
101
131
  }
102
132
  const file = Bun.file(resolvedFile)
103
133
  if (!(await file.exists())) return undefined
134
+ const size = file.size
104
135
  const headers = new Headers({
105
136
  "cache-control": pathname.startsWith(hashedPrefix) ? hashed : assets,
137
+ // Advertised unconditionally: a client that never sees `accept-ranges` will not attempt a seek,
138
+ // so a video or audio file under `public/` is scrubbable only once this header is present.
139
+ "accept-ranges": "bytes",
106
140
  })
107
- // HEAD must not carry a body, but must otherwise match GET's headers.
108
- return request.method === "HEAD"
109
- ? new Response(null, { headers })
110
- : new Response(file, { headers })
141
+ // `new Response(file)` used to infer the media type, which meant HEAD - built from a null body -
142
+ // silently lost it. Setting it here is what makes the two methods agree.
143
+ if (file.type !== "") headers.set("content-type", file.type)
144
+ const lastModified =
145
+ Number.isFinite(file.lastModified) && file.lastModified > 0 ? file.lastModified : undefined
146
+ if (lastModified !== undefined) {
147
+ headers.set("last-modified", new Date(lastModified).toUTCString())
148
+ }
149
+ const head = request.method === "HEAD"
150
+
151
+ if (isNotModified(request, lastModified)) {
152
+ headers.delete("content-type")
153
+ return new Response(null, { status: 304, headers })
154
+ }
155
+
156
+ const rangeHeader = request.headers.get("range")
157
+ const range =
158
+ rangeHeader !== null && ifRangeMatches(request.headers.get("if-range"), lastModified)
159
+ ? parseByteRange(rangeHeader, size)
160
+ : ({ kind: "none" } as const)
161
+
162
+ if (range.kind === "unsatisfiable") {
163
+ headers.delete("content-type")
164
+ headers.set("content-range", `bytes */${size}`)
165
+ return new Response(null, { status: 416, headers })
166
+ }
167
+
168
+ if (range.kind === "satisfiable" && range.ranges.length === 1) {
169
+ const { start, end } = range.ranges[0]!
170
+ headers.set("content-range", `bytes ${start}-${end}/${size}`)
171
+ headers.set("content-length", String(end - start + 1))
172
+ // `slice` keeps the read lazy: only the selected window is ever pulled off disk.
173
+ return new Response(head ? null : file.slice(start, end + 1), { status: 206, headers })
174
+ }
175
+
176
+ // Multiple ranges would require assembling `multipart/byteranges`, and doing that for a file on
177
+ // disk means buffering the whole representation to serve a request that asked for less of it.
178
+ // RFC 9110 lets a server ignore Range entirely, so the full body is the conformant answer here.
179
+ if (head) headers.set("content-length", String(size))
180
+ return new Response(head ? null : file, { headers })
111
181
  }
112
182
  }
@@ -41,6 +41,8 @@ export interface RouteManifestEntry {
41
41
  readonly hydrate: boolean
42
42
  /** Revalidation window in seconds. Present only for `isr`. */
43
43
  readonly revalidate?: number
44
+ /** Bounded tags that can purge this route from a shared ISR store. */
45
+ readonly revalidateTags?: readonly string[]
44
46
  /**
45
47
  * Concrete paths prerendered at build. Present for `static` routes: a single path for a static route,
46
48
  * one per `getStaticPaths` entry for a dynamic one. Absent means "no path was enumerated", which for a
@@ -84,7 +86,10 @@ const isDynamic = (pattern: string): boolean => /[:*]/.test(pattern)
84
86
  export function deriveRouteEntry(
85
87
  id: string,
86
88
  pattern: string,
87
- module: Pick<RouteModule, "prerender" | "getStaticPaths" | "revalidate" | "hydrate">,
89
+ module: Pick<
90
+ RouteModule,
91
+ "prerender" | "getStaticPaths" | "revalidate" | "revalidateTags" | "hydrate"
92
+ >,
88
93
  prerenderedPaths?: readonly string[],
89
94
  ): RouteManifestEntry {
90
95
  const wantsPrerender = module.prerender === true || module.getStaticPaths !== undefined
@@ -105,10 +110,14 @@ export function deriveRouteEntry(
105
110
  mode: RenderMode
106
111
  hydrate: boolean
107
112
  revalidate?: number
113
+ revalidateTags?: readonly string[]
108
114
  prerenderedPaths?: readonly string[]
109
115
  requires: readonly RouteCapability[]
110
116
  } = { id, pattern, mode, hydrate: module.hydrate !== false, requires }
111
117
  if (mode === "isr" && module.revalidate !== undefined) entry.revalidate = module.revalidate
118
+ if (mode === "isr" && module.revalidateTags !== undefined) {
119
+ entry.revalidateTags = module.revalidateTags
120
+ }
112
121
  if (prerenderedPaths !== undefined) entry.prerenderedPaths = prerenderedPaths
113
122
  return entry as RouteManifestEntry
114
123
  }