@nifrajs/web 5.2.0 → 5.4.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/build.d.ts +11 -2
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +265 -15
  5. package/dist/build.js.map +1 -1
  6. package/dist/csp.d.ts +2 -0
  7. package/dist/csp.d.ts.map +1 -1
  8. package/dist/csp.js +2 -0
  9. package/dist/csp.js.map +1 -1
  10. package/dist/deferred.d.ts.map +1 -1
  11. package/dist/deferred.js +23 -3
  12. package/dist/deferred.js.map +1 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/deferred-free.d.ts +3 -0
  18. package/dist/internal/deferred-free.d.ts.map +1 -0
  19. package/dist/internal/deferred-free.js +15 -0
  20. package/dist/internal/deferred-free.js.map +1 -0
  21. package/dist/internal/module-preparation.d.ts +7 -0
  22. package/dist/internal/module-preparation.d.ts.map +1 -0
  23. package/dist/internal/module-preparation.js +24 -0
  24. package/dist/internal/module-preparation.js.map +1 -0
  25. package/dist/internal/node-adapter.d.ts +19 -0
  26. package/dist/internal/node-adapter.d.ts.map +1 -0
  27. package/dist/internal/node-adapter.js +48 -0
  28. package/dist/internal/node-adapter.js.map +1 -0
  29. package/dist/internal/output-guard.d.ts +2 -0
  30. package/dist/internal/output-guard.d.ts.map +1 -1
  31. package/dist/internal/output-guard.js +51 -9
  32. package/dist/internal/output-guard.js.map +1 -1
  33. package/dist/internal/page-execution.d.ts +1 -0
  34. package/dist/internal/page-execution.d.ts.map +1 -1
  35. package/dist/internal/page-execution.js +53 -43
  36. package/dist/internal/page-execution.js.map +1 -1
  37. package/dist/internal/parity.d.ts.map +1 -1
  38. package/dist/internal/parity.js +3 -0
  39. package/dist/internal/parity.js.map +1 -1
  40. package/dist/internal/prerendered-pages.d.ts +42 -0
  41. package/dist/internal/prerendered-pages.d.ts.map +1 -0
  42. package/dist/internal/prerendered-pages.js +92 -0
  43. package/dist/internal/prerendered-pages.js.map +1 -0
  44. package/dist/internal/render-document.d.ts.map +1 -1
  45. package/dist/internal/render-document.js +93 -35
  46. package/dist/internal/render-document.js.map +1 -1
  47. package/dist/internal/request-url.d.ts +4 -0
  48. package/dist/internal/request-url.d.ts.map +1 -1
  49. package/dist/internal/request-url.js +24 -0
  50. package/dist/internal/request-url.js.map +1 -1
  51. package/dist/internal/secret-scan.d.ts +4 -0
  52. package/dist/internal/secret-scan.d.ts.map +1 -1
  53. package/dist/internal/secret-scan.js +8 -1
  54. package/dist/internal/secret-scan.js.map +1 -1
  55. package/dist/internal/web-app.d.ts +11 -0
  56. package/dist/internal/web-app.d.ts.map +1 -1
  57. package/dist/internal/web-app.js +10 -1
  58. package/dist/internal/web-app.js.map +1 -1
  59. package/dist/isr.d.ts +1 -0
  60. package/dist/isr.d.ts.map +1 -1
  61. package/dist/isr.js +7 -4
  62. package/dist/isr.js.map +1 -1
  63. package/dist/prerender.d.ts +4 -0
  64. package/dist/prerender.d.ts.map +1 -1
  65. package/dist/prerender.js +9 -1
  66. package/dist/prerender.js.map +1 -1
  67. package/dist/public-dir.d.ts +19 -7
  68. package/dist/public-dir.d.ts.map +1 -1
  69. package/dist/public-dir.js +130 -99
  70. package/dist/public-dir.js.map +1 -1
  71. package/dist/render-seam.d.ts +5 -2
  72. package/dist/render-seam.d.ts.map +1 -1
  73. package/dist/render-seam.js.map +1 -1
  74. package/dist/search.d.ts.map +1 -1
  75. package/dist/search.js +2 -0
  76. package/dist/search.js.map +1 -1
  77. package/dist/vite.d.ts +6 -0
  78. package/dist/vite.d.ts.map +1 -1
  79. package/dist/vite.js +127 -54
  80. package/dist/vite.js.map +1 -1
  81. package/package.json +3 -3
  82. package/src/build.ts +330 -49
  83. package/src/csp.ts +4 -0
  84. package/src/deferred.ts +20 -3
  85. package/src/index.ts +3 -0
  86. package/src/internal/deferred-free.ts +15 -0
  87. package/src/internal/module-preparation.ts +30 -0
  88. package/src/internal/node-adapter.ts +57 -0
  89. package/src/internal/output-guard.ts +51 -9
  90. package/src/internal/page-execution.ts +55 -47
  91. package/src/internal/parity.ts +2 -0
  92. package/src/internal/prerendered-pages.ts +124 -0
  93. package/src/internal/render-document.ts +116 -52
  94. package/src/internal/request-url.ts +24 -0
  95. package/src/internal/secret-scan.ts +9 -1
  96. package/src/internal/web-app.ts +21 -1
  97. package/src/isr.ts +7 -4
  98. package/src/prerender.ts +13 -1
  99. package/src/public-dir.ts +158 -97
  100. package/src/render-seam.ts +5 -2
  101. package/src/search.ts +1 -0
  102. package/src/vite.ts +130 -54
@@ -1,6 +1,12 @@
1
1
  import { isSameOriginPath, type ResponseResult, status as statusResult } from "@nifrajs/core/server"
2
2
  import type { BoundaryRegistration, BoundaryStates } from "../boundary.ts"
3
- import { type CspPolicy, cspDocumentNonce, cspHeaderValue, prepareCspPolicy } from "../csp.ts"
3
+ import {
4
+ type CspPolicy,
5
+ cspCandidateNonce,
6
+ cspDocumentNonce,
7
+ cspHeaderValue,
8
+ prepareCspPolicy,
9
+ } from "../csp.ts"
4
10
  import { type CssLoadingMode, DEFAULT_CSS_LOADING, normalizeCssLoading } from "../css-contract.ts"
5
11
  import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, prepareDeferred } from "../deferred.ts"
6
12
  import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "../isr.ts"
@@ -54,6 +60,13 @@ const SCRIPT_ESCAPE_MAP: Readonly<Record<string, string>> = {
54
60
  [LINE_SEP]: "\\u2028",
55
61
  [PARA_SEP]: "\\u2029",
56
62
  }
63
+ // Pages (the chain's last entry) whose string render suspended, with how many more requests go
64
+ // straight to the stream instead of rendering the whole tree twice. The count runs out so a page
65
+ // that suspended only once (a cold `lazy`) gets its buffered render back.
66
+ const SUSPENDING_PAGES = new WeakMap<object, number>()
67
+ const SUSPENDED_STREAMS = 32
68
+ const isWeakKey = (value: unknown): value is object =>
69
+ (typeof value === "object" && value !== null) || typeof value === "function"
57
70
  const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
58
71
  const RESPONSE_RESULT = Symbol.for("nifra.response.result")
59
72
 
@@ -544,43 +557,77 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
544
557
  // streaming renderer are pure overhead vs a single sync render + concat. A buffered string body also
545
558
  // gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
546
559
  // shell-readiness `await` does, so the `_error` boundary still maps it to a status.
560
+ const streamed = (streamNonce: string | undefined): Promise<RenderedPage> =>
561
+ renderStreamedPage(
562
+ adapter,
563
+ chain,
564
+ renderProps,
565
+ shellOf,
566
+ parts,
567
+ closeRootHtml,
568
+ allDeferred,
569
+ tailHtml,
570
+ status,
571
+ headers,
572
+ streamNonce,
573
+ streamNonce === nonce ? nonceAttr : ` nonce="${escapeAttr(streamNonce ?? "")}"`,
574
+ ).then((response) => new ResponseRenderedPage(response))
575
+ const last = chain[chain.length - 1]
576
+ const leaf = isWeakKey(last) ? last : undefined
547
577
  if (allDeferred.length === 0 && adapter.renderToString !== undefined) {
578
+ // Under a CSP a render can still write a script for this request alone (Solid's serialized
579
+ // error boundary, React's streamed Suspense reveal): it is handed a nonce, which the document
580
+ // keeps only when the render used it, so every other document stays nonce-free and cacheable.
581
+ const renderNonce =
582
+ nonce ?? (csp === undefined ? undefined : cspCandidateNonce(csp, requestedNonce))
583
+ const adoptNonce = (): void => {
584
+ if (renderNonce === nonce || csp === undefined || renderNonce === undefined) return
585
+ headers["cache-control"] = "private, no-store"
586
+ const policy = cspHeaderValue(csp, adapter, renderNonce)
587
+ if (policy !== undefined) headers["content-security-policy"] = policy
588
+ }
589
+ // A page whose string render suspended before goes straight to the stream: the string pass
590
+ // would render the whole tree only to be thrown away again.
591
+ const skips = leaf === undefined ? undefined : SUSPENDING_PAGES.get(leaf)
592
+ if (leaf !== undefined && skips !== undefined) {
593
+ if (skips > 1) SUSPENDING_PAGES.set(leaf, skips - 1)
594
+ else SUSPENDING_PAGES.delete(leaf)
595
+ adoptNonce()
596
+ return streamed(renderNonce)
597
+ }
548
598
  const out =
549
599
  parts !== undefined
550
600
  ? adapter.renderToString(
551
601
  chain,
552
602
  renderProps,
553
- nonce === undefined ? { parts } : { nonce, parts },
603
+ renderNonce === undefined ? { parts } : { nonce: renderNonce, parts },
554
604
  )
555
- : nonce === undefined
605
+ : renderNonce === undefined
556
606
  ? adapter.renderToString(chain, renderProps)
557
- : adapter.renderToString(chain, renderProps, { nonce })
558
- return typeof out === "string"
559
- ? new BufferedRenderedPage(shellOf() + out + closeRootHtml + tailHtml, status, headers)
560
- : out.then(
561
- (bodyHtml) =>
562
- new BufferedRenderedPage(
563
- shellOf() + bodyHtml + closeRootHtml + tailHtml,
564
- status,
565
- headers,
566
- ),
567
- )
607
+ : adapter.renderToString(chain, renderProps, { nonce: renderNonce })
608
+ const page = (bodyHtml: string | undefined): MaybePromise<RenderedPage> => {
609
+ if (bodyHtml === undefined) {
610
+ // The render suspended on something only the streaming renderer waits for (React's `use`
611
+ // in a `<Suspense>`): stream the page, so the boundary resolves on the server.
612
+ if (leaf !== undefined) SUSPENDING_PAGES.set(leaf, SUSPENDED_STREAMS)
613
+ if (parts !== undefined) {
614
+ parts.head = ""
615
+ parts.body = ""
616
+ }
617
+ adoptNonce()
618
+ return streamed(renderNonce)
619
+ }
620
+ if (renderNonce !== undefined && bodyHtml.includes(`nonce="${renderNonce}"`)) adoptNonce()
621
+ return new BufferedRenderedPage(
622
+ shellOf() + bodyHtml + closeRootHtml + tailHtml,
623
+ status,
624
+ headers,
625
+ )
626
+ }
627
+ return out instanceof Promise ? out.then(page) : page(out)
568
628
  }
569
629
 
570
- return renderStreamedPage(
571
- adapter,
572
- chain,
573
- renderProps,
574
- shellOf,
575
- parts,
576
- closeRootHtml,
577
- allDeferred,
578
- tailHtml,
579
- status,
580
- headers,
581
- nonce,
582
- nonceAttr,
583
- ).then((response) => new ResponseRenderedPage(response))
630
+ return streamed(nonce)
584
631
  }
585
632
 
586
633
  async function renderStreamedPage(
@@ -673,6 +720,9 @@ function htmlResponse(body: string, init: ResponseInit): Response {
673
720
  * script per deferred value (emitted once its promise settles - by now the app stream has awaited
674
721
  * the same Suspense boundaries - and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
675
722
  * the result (the body breaks) rather than silently truncating a 200.
723
+ *
724
+ * The app stream is read one chunk per pull, so a client that stops reading stops the renderer
725
+ * too: a stalled connection holds a chunk, not the whole rendered body.
676
726
  */
677
727
  function streamDocument(
678
728
  shell: Uint8Array,
@@ -687,45 +737,59 @@ function streamDocument(
687
737
  // A client that goes away cancels the body: the renderer stops, and nothing more is written.
688
738
  let cancelled = false
689
739
  let released = false
740
+ const release = (): void => {
741
+ if (released) return
742
+ released = true
743
+ reader.releaseLock()
744
+ }
745
+ // Settled into scripts now, not when the app stream ends: a rejection a stalled client never
746
+ // reads up to is still handled (and logged) rather than left unhandled.
747
+ const scripts = deferred.map((d) =>
748
+ d.promise
749
+ .then(
750
+ (value) =>
751
+ // `serializeData` JSON-serializes the value and escapes `<`, `>`, and Unicode line
752
+ // separators before this framework-owned script is emitted.
753
+ `<script${nonceAttr}>window.__nifraResolve(${d.id},${serializeData(value)})</script>`, // nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag
754
+ )
755
+ .catch((err: unknown) => {
756
+ // A rejected (or unserializable) deferred streams __nifraReject (the client `<Await>`
757
+ // surfaces it) - it must not break the whole body. Redact: stream a stable opaque code,
758
+ // never the raw error text; log the real reason server-side.
759
+ console.error("[nifra/web] deferred value rejected:", err)
760
+ return `<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`
761
+ }),
762
+ )
690
763
  return new ReadableStream({
691
- async start(controller) {
764
+ start(controller) {
765
+ controller.enqueue(shell)
766
+ },
767
+ async pull(controller) {
692
768
  try {
693
- controller.enqueue(shell)
694
- for (;;) {
769
+ if (!released) {
695
770
  const { done, value } = await reader.read()
696
771
  if (cancelled) return
697
- if (done) break
698
- controller.enqueue(value)
772
+ if (!done) {
773
+ controller.enqueue(value)
774
+ return
775
+ }
776
+ release()
777
+ controller.enqueue(closeRoot)
699
778
  }
700
- controller.enqueue(closeRoot)
701
779
  // Stream each resolution as ITS OWN promise settles - NOT in array order. A slow
702
780
  // defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
703
781
  await Promise.all(
704
- deferred.map(async (d) => {
705
- let script: string
706
- try {
707
- const value = serializeData(await d.promise)
708
- // `serializeData` JSON-serializes the value and escapes `<`, `>`, and Unicode line
709
- // separators before this framework-owned script is emitted.
710
- script = `<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>` // nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag
711
- } catch (err) {
712
- // A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
713
- // not break the whole body. Redact: stream a stable opaque code, never the raw error
714
- // text; log the real reason server-side.
715
- console.error("[nifra/web] deferred value rejected:", err)
716
- script = `<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`
717
- }
718
- if (!cancelled) controller.enqueue(enc.encode(script))
782
+ scripts.map(async (script) => {
783
+ const html = await script
784
+ if (!cancelled) controller.enqueue(enc.encode(html))
719
785
  }),
720
786
  )
721
787
  if (cancelled) return
722
788
  controller.enqueue(tail)
723
789
  controller.close()
724
790
  } catch (err) {
791
+ release()
725
792
  if (!cancelled) controller.error(err)
726
- } finally {
727
- released = true
728
- reader.releaseLock()
729
793
  }
730
794
  },
731
795
  cancel(reason) {
@@ -12,3 +12,27 @@ export function urlPartsFor(request: Request): UrlParts {
12
12
  REQUEST_URL_PARTS.set(request, parts)
13
13
  return parts
14
14
  }
15
+
16
+ // The origin depends only on the text before the path, which repeats across requests, so each
17
+ // distinct prefix is parsed once instead of every request's whole URL. Cleared when full: a client
18
+ // can vary the Host header without bound.
19
+ const ORIGINS = new Map<string, string>()
20
+
21
+ /** `new URL(request.url).origin`, or `""` for a URL that does not parse. */
22
+ export function originFor(request: { readonly url: string }): string {
23
+ const url = request.url
24
+ const schemeEnd = url.indexOf("://")
25
+ const pathStart = schemeEnd === -1 ? -1 : url.indexOf("/", schemeEnd + 3)
26
+ const prefix = pathStart === -1 ? url : url.slice(0, pathStart)
27
+ let origin = ORIGINS.get(prefix)
28
+ if (origin === undefined) {
29
+ try {
30
+ origin = new URL(prefix).origin
31
+ } catch {
32
+ origin = ""
33
+ }
34
+ if (ORIGINS.size >= 64) ORIGINS.clear()
35
+ ORIGINS.set(prefix, origin)
36
+ }
37
+ return origin
38
+ }
@@ -694,10 +694,18 @@ export function createRedactor(
694
694
  export const buildEnvironment = (): Readonly<Record<string, string | undefined>> =>
695
695
  (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env
696
696
 
697
+ /** A build artifact holds a secret. Its own class so a step that tolerates other failures still stops. */
698
+ export class SecretScanError extends Error {
699
+ constructor(message: string) {
700
+ super(message)
701
+ this.name = "SecretScanError"
702
+ }
703
+ }
704
+
697
705
  /** Scan and throw the build-failing message on any finding. */
698
706
  export function assertNoSecrets(input: SecretScanInput): void {
699
707
  const message = formatSecretFindings(scanForSecrets(input))
700
- if (message !== undefined) throw new Error(message)
708
+ if (message !== undefined) throw new SecretScanError(message)
701
709
  }
702
710
 
703
711
  /** The build-failing message for secret findings, or `undefined` when there are none. */
@@ -22,6 +22,7 @@ import { createMatcher, PRERENDERED_LIST_PATH } from "../router.ts"
22
22
  import type { EvidenceAncestry } from "./evidence-traversal.ts"
23
23
  import { formatShadowedPages, shadowedPages } from "./mount-shadow.ts"
24
24
  import { createPageRequestExecutor, type NonceResolver, type PageI18n } from "./page-execution.ts"
25
+ import { type PrerenderedPages, servePrerenderedPages } from "./prerendered-pages.ts"
25
26
  import { requestPattern } from "./route-path.ts"
26
27
 
27
28
  export type { NonceResolver } from "./page-execution.ts"
@@ -73,6 +74,8 @@ export interface CreateWebAppOptions<Env = unknown> {
73
74
  readonly serverComponents?: import("../server-components/types.ts").ServerComponentRuntime
74
75
  readonly adapter: RenderAdapter
75
76
  readonly manifest: Manifest
77
+ /** Cache production module imports and layout preparation, never request data. Defaults to NODE_ENV === "production"; disable for a mutable manifest. */
78
+ readonly cacheRouteModules?: boolean
76
79
  /** URL of the built client entry (module script) injected into every page. */
77
80
  readonly clientEntry: string
78
81
  /** Default document title for all pages. */
@@ -252,6 +255,14 @@ export interface CreateWebAppOptions<Env = unknown> {
252
255
  * 4 KB of JSON is served once from `/__nifra/prerendered.json?v=<version>`, cacheable for good, and
253
256
  * pages hand over only that URL; `prerenderRoutes` writes the file into a static output. */
254
257
  readonly prerenderedPaths?: readonly string[]
258
+ /**
259
+ * Pages a self-hosted build prerendered, answered from their file instead of rendered. `nifra build
260
+ * --target bun|node` wires this from its prerender pass. The page is served from the page route,
261
+ * so `use` middleware and every request hook run first; a request with a query string, a navigation
262
+ * data or component request, or a draft-mode visitor renders on demand as before. Ignored with
263
+ * {@link nonce}, whose documents need a nonce minted for their request.
264
+ */
265
+ readonly prerenderedPages?: PrerenderedPages
255
266
  /** Publish the project's `AGENTS.md` inside `/llms.txt` and `/llms-full.txt`. **Off by default**:
256
267
  * those endpoints are public and unauthenticated, while `AGENTS.md` is a repo file written for the
257
268
  * team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
@@ -475,6 +486,9 @@ export function createWebApp<Env = unknown>(
475
486
  ? undefined
476
487
  : servedPrerenderedList(options.prerenderedPaths)
477
488
  const pageExecutor = createPageRequestExecutor<Env>({
489
+ ...(options.cacheRouteModules === undefined
490
+ ? {}
491
+ : { cacheRouteModules: options.cacheRouteModules }),
478
492
  ...(options.serverComponents === undefined
479
493
  ? {}
480
494
  : { serverComponents: options.serverComponents }),
@@ -520,17 +534,23 @@ export function createWebApp<Env = unknown>(
520
534
  cacheChannel,
521
535
  })
522
536
 
537
+ // A nonce app's documents authorize their scripts per request, which a file rendered once cannot.
538
+ const prerendered =
539
+ options.prerenderedPages === undefined || options.nonce !== undefined
540
+ ? undefined
541
+ : servePrerenderedPages(options.prerenderedPages, options.draftSecret)
523
542
  let ownsRootCatchAll = false
524
543
  for (const route of manifest.routes) {
525
544
  const pattern = requestPattern(route.pattern)
526
545
  if (pattern.startsWith("/*")) ownsRootCatchAll = true
546
+ const render = pageExecutor.get(route)
527
547
  app.register(
528
548
  "GET",
529
549
  pattern,
530
550
  options.serverComponents?.owns(route.id)
531
551
  ? { capabilities: options.serverComponents.capabilities(route.id) }
532
552
  : undefined,
533
- pageExecutor.get(route),
553
+ prerendered === undefined ? render : prerendered(render),
534
554
  )
535
555
  app.register("POST", pattern, undefined, pageExecutor.post(route))
536
556
  }
package/src/isr.ts CHANGED
@@ -161,6 +161,8 @@ export class MemoryCacheStore implements CacheStore {
161
161
  private order = 0
162
162
  private readonly purgedKeys = new Map<string, number>()
163
163
  private readonly purgedTags = new Map<string, number>()
164
+ // The newest purge order whose mark was evicted. A render fenced before it may predate that purge.
165
+ private purgeFloor = 0
164
166
 
165
167
  constructor(options: MemoryCacheStoreOptions = {}) {
166
168
  const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production"
@@ -246,8 +248,8 @@ export class MemoryCacheStore implements CacheStore {
246
248
  }
247
249
 
248
250
  private purgedAfter(key: string, tags: readonly string[], fence: number): boolean {
249
- if ((this.purgedKeys.get(key) ?? 0) > fence) return true
250
- return tags.some((tag) => (this.purgedTags.get(tag) ?? 0) > fence)
251
+ if ((this.purgedKeys.get(key) ?? this.purgeFloor) > fence) return true
252
+ return tags.some((tag) => (this.purgedTags.get(tag) ?? this.purgeFloor) > fence)
251
253
  }
252
254
 
253
255
  private markPurged(marks: Map<string, number>, name: string): void {
@@ -255,9 +257,10 @@ export class MemoryCacheStore implements CacheStore {
255
257
  marks.set(name, ++this.order)
256
258
  // Bounded like the entries: a mark matters only to a render still in flight.
257
259
  while (marks.size > this.max) {
258
- const oldest = marks.keys().next().value
260
+ const oldest = marks.entries().next().value
259
261
  if (oldest === undefined) break
260
- marks.delete(oldest)
262
+ marks.delete(oldest[0])
263
+ this.purgeFloor = Math.max(this.purgeFloor, oldest[1])
261
264
  }
262
265
  }
263
266
 
package/src/prerender.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { mkdirSync, writeFileSync } from "node:fs"
12
12
  import { dirname, join, resolve, sep } from "node:path"
13
+ import { prerenderedDocumentHeaders } from "./internal/prerendered-pages.ts"
13
14
  import { assertNoSecrets, buildEnvironment, type SecretExemption } from "./internal/secret-scan.ts"
14
15
  import { fillRoutePattern, type RouteEntry } from "./manifest.ts"
15
16
  import { DATA_HEADER, PRERENDERED_GLOBAL, PRERENDERED_LIST_PATH } from "./router.ts"
@@ -52,6 +53,10 @@ export interface PrerenderEntry {
52
53
  /** The loader-data file written alongside (`<path>/_data.json`) so a client soft-nav INTO this
53
54
  * route skips the worker. Absent when the data-mode response is NDJSON (a deferred loader). */
54
55
  readonly dataFile?: string
56
+ /** The document headers a self-hosted server sends with this file (its `content-language` and CSP).
57
+ * Absent when the render was specific to its request - it set a cookie, was private or no-store, or
58
+ * varied on a request header - so a server keeps rendering this path on demand. */
59
+ readonly serveHeaders?: Readonly<Record<string, string>>
55
60
  }
56
61
 
57
62
  export interface PrerenderResult {
@@ -184,7 +189,14 @@ export async function prerenderRoutes(options: PrerenderOptions): Promise<Preren
184
189
  await dataRes.body?.cancel() // not emitting (non-OK or NDJSON) - release the stream
185
190
  }
186
191
 
187
- prerendered.push({ path, file, bytes: html.length, ...(dataFile ? { dataFile } : {}) })
192
+ const serveHeaders = prerenderedDocumentHeaders(res.status, res.headers)
193
+ prerendered.push({
194
+ path,
195
+ file,
196
+ bytes: html.length,
197
+ ...(dataFile ? { dataFile } : {}),
198
+ ...(serveHeaders ? { serveHeaders } : {}),
199
+ })
188
200
  }
189
201
 
190
202
  for (const route of options.routes) {