@nifrajs/web 5.1.0 → 5.3.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 (162) hide show
  1. package/CHANGELOG.md +3042 -0
  2. package/dist/build-plan.d.ts +7 -0
  3. package/dist/build-plan.d.ts.map +1 -1
  4. package/dist/build-plan.js +19 -0
  5. package/dist/build-plan.js.map +1 -1
  6. package/dist/build-vite.d.ts +1 -0
  7. package/dist/build-vite.d.ts.map +1 -1
  8. package/dist/build-vite.js +51 -3
  9. package/dist/build-vite.js.map +1 -1
  10. package/dist/build.d.ts +23 -3
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +224 -16
  13. package/dist/build.js.map +1 -1
  14. package/dist/client.d.ts +2 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +14 -7
  17. package/dist/client.js.map +1 -1
  18. package/dist/csp.d.ts +2 -0
  19. package/dist/csp.d.ts.map +1 -1
  20. package/dist/csp.js +2 -0
  21. package/dist/csp.js.map +1 -1
  22. package/dist/deferred.d.ts.map +1 -1
  23. package/dist/deferred.js +23 -3
  24. package/dist/deferred.js.map +1 -1
  25. package/dist/index.d.ts +4 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/codegen.d.ts +6 -0
  30. package/dist/internal/codegen.d.ts.map +1 -1
  31. package/dist/internal/codegen.js +23 -5
  32. package/dist/internal/codegen.js.map +1 -1
  33. package/dist/internal/copy-split.d.ts +27 -0
  34. package/dist/internal/copy-split.d.ts.map +1 -0
  35. package/dist/internal/copy-split.js +173 -0
  36. package/dist/internal/copy-split.js.map +1 -0
  37. package/dist/internal/deferred-free.d.ts +3 -0
  38. package/dist/internal/deferred-free.d.ts.map +1 -0
  39. package/dist/internal/deferred-free.js +15 -0
  40. package/dist/internal/deferred-free.js.map +1 -0
  41. package/dist/internal/module-preparation.d.ts +7 -0
  42. package/dist/internal/module-preparation.d.ts.map +1 -0
  43. package/dist/internal/module-preparation.js +24 -0
  44. package/dist/internal/module-preparation.js.map +1 -0
  45. package/dist/internal/output-guard.d.ts +2 -0
  46. package/dist/internal/output-guard.d.ts.map +1 -1
  47. package/dist/internal/output-guard.js +51 -9
  48. package/dist/internal/output-guard.js.map +1 -1
  49. package/dist/internal/page-execution.d.ts +20 -1
  50. package/dist/internal/page-execution.d.ts.map +1 -1
  51. package/dist/internal/page-execution.js +115 -50
  52. package/dist/internal/page-execution.js.map +1 -1
  53. package/dist/internal/parity.d.ts.map +1 -1
  54. package/dist/internal/parity.js +3 -1
  55. package/dist/internal/parity.js.map +1 -1
  56. package/dist/internal/prerendered-pages.d.ts +42 -0
  57. package/dist/internal/prerendered-pages.d.ts.map +1 -0
  58. package/dist/internal/prerendered-pages.js +92 -0
  59. package/dist/internal/prerendered-pages.js.map +1 -0
  60. package/dist/internal/public-url.d.ts +7 -0
  61. package/dist/internal/public-url.d.ts.map +1 -1
  62. package/dist/internal/public-url.js +13 -0
  63. package/dist/internal/public-url.js.map +1 -1
  64. package/dist/internal/render-document.d.ts +11 -2
  65. package/dist/internal/render-document.d.ts.map +1 -1
  66. package/dist/internal/render-document.js +131 -41
  67. package/dist/internal/render-document.js.map +1 -1
  68. package/dist/internal/request-url.d.ts +4 -0
  69. package/dist/internal/request-url.d.ts.map +1 -1
  70. package/dist/internal/request-url.js +24 -0
  71. package/dist/internal/request-url.js.map +1 -1
  72. package/dist/internal/secret-scan.d.ts +4 -0
  73. package/dist/internal/secret-scan.d.ts.map +1 -1
  74. package/dist/internal/secret-scan.js +8 -1
  75. package/dist/internal/secret-scan.js.map +1 -1
  76. package/dist/internal/typescript-api.d.ts +4 -0
  77. package/dist/internal/typescript-api.d.ts.map +1 -0
  78. package/dist/internal/typescript-api.js +8 -0
  79. package/dist/internal/typescript-api.js.map +1 -0
  80. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  81. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  82. package/dist/internal/vite-plugin-compat.js +82 -0
  83. package/dist/internal/vite-plugin-compat.js.map +1 -0
  84. package/dist/internal/web-app.d.ts +45 -0
  85. package/dist/internal/web-app.d.ts.map +1 -1
  86. package/dist/internal/web-app.js +32 -1
  87. package/dist/internal/web-app.js.map +1 -1
  88. package/dist/isr.d.ts +1 -0
  89. package/dist/isr.d.ts.map +1 -1
  90. package/dist/isr.js +7 -4
  91. package/dist/isr.js.map +1 -1
  92. package/dist/plugins/import-glob.d.ts +20 -0
  93. package/dist/plugins/import-glob.d.ts.map +1 -0
  94. package/dist/plugins/import-glob.js +401 -0
  95. package/dist/plugins/import-glob.js.map +1 -0
  96. package/dist/plugins/kit.d.ts +11 -0
  97. package/dist/plugins/kit.d.ts.map +1 -1
  98. package/dist/plugins/kit.js +32 -0
  99. package/dist/plugins/kit.js.map +1 -1
  100. package/dist/plugins/stylex.d.ts.map +1 -1
  101. package/dist/plugins/stylex.js +2 -2
  102. package/dist/plugins/stylex.js.map +1 -1
  103. package/dist/prerender.d.ts +4 -0
  104. package/dist/prerender.d.ts.map +1 -1
  105. package/dist/prerender.js +9 -1
  106. package/dist/prerender.js.map +1 -1
  107. package/dist/public-dir.d.ts +15 -0
  108. package/dist/public-dir.d.ts.map +1 -1
  109. package/dist/public-dir.js +91 -12
  110. package/dist/public-dir.js.map +1 -1
  111. package/dist/render-seam.d.ts +42 -2
  112. package/dist/render-seam.d.ts.map +1 -1
  113. package/dist/render-seam.js +33 -0
  114. package/dist/render-seam.js.map +1 -1
  115. package/dist/router.d.ts +3 -2
  116. package/dist/router.d.ts.map +1 -1
  117. package/dist/router.js +4 -4
  118. package/dist/router.js.map +1 -1
  119. package/dist/search.d.ts.map +1 -1
  120. package/dist/search.js +2 -0
  121. package/dist/search.js.map +1 -1
  122. package/dist/server-components/typescript.d.ts.map +1 -1
  123. package/dist/server-components/typescript.js +2 -7
  124. package/dist/server-components/typescript.js.map +1 -1
  125. package/dist/vite.d.ts +7 -28
  126. package/dist/vite.d.ts.map +1 -1
  127. package/dist/vite.js +130 -123
  128. package/dist/vite.js.map +1 -1
  129. package/package.json +5 -4
  130. package/src/build-plan.ts +21 -0
  131. package/src/build-vite.ts +60 -3
  132. package/src/build.ts +283 -39
  133. package/src/client.ts +15 -7
  134. package/src/csp.ts +4 -0
  135. package/src/deferred.ts +20 -3
  136. package/src/index.ts +9 -0
  137. package/src/internal/codegen.ts +32 -3
  138. package/src/internal/copy-split.ts +220 -0
  139. package/src/internal/deferred-free.ts +15 -0
  140. package/src/internal/module-preparation.ts +30 -0
  141. package/src/internal/output-guard.ts +51 -9
  142. package/src/internal/page-execution.ts +137 -53
  143. package/src/internal/parity.ts +2 -1
  144. package/src/internal/prerendered-pages.ts +124 -0
  145. package/src/internal/public-url.ts +13 -0
  146. package/src/internal/render-document.ts +179 -58
  147. package/src/internal/request-url.ts +24 -0
  148. package/src/internal/secret-scan.ts +9 -1
  149. package/src/internal/typescript-api.ts +11 -0
  150. package/src/internal/vite-plugin-compat.ts +81 -0
  151. package/src/internal/web-app.ts +82 -2
  152. package/src/isr.ts +7 -4
  153. package/src/plugins/import-glob.ts +392 -0
  154. package/src/plugins/kit.ts +36 -0
  155. package/src/plugins/stylex.ts +2 -1
  156. package/src/prerender.ts +13 -1
  157. package/src/public-dir.ts +107 -12
  158. package/src/render-seam.ts +65 -2
  159. package/src/router.ts +7 -6
  160. package/src/search.ts +1 -0
  161. package/src/server-components/typescript.ts +2 -10
  162. package/src/vite.ts +133 -135
@@ -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"
@@ -11,13 +17,23 @@ import type {
11
17
  RouteModule,
12
18
  UnsafeScriptDescriptor,
13
19
  } from "../manifest.ts"
14
- import type { DocumentParts, MatchChain, RenderAdapter, RenderProps } from "../render-seam.ts"
20
+ import type {
21
+ DocumentParts,
22
+ MatchChain,
23
+ PageLocale,
24
+ PageLocales,
25
+ RenderAdapter,
26
+ RenderProps,
27
+ } from "../render-seam.ts"
15
28
  import {
16
29
  ACTION_GLOBAL,
17
30
  BOUNDARY_GLOBAL,
31
+ COPY_CHUNKS_GLOBAL,
32
+ COPY_REALM,
18
33
  DATA_GLOBAL,
19
34
  HANDOVER_ID,
20
35
  LAYOUT_DATA_GLOBAL,
36
+ LOCALES_GLOBAL,
21
37
  ROOT_ATTRIBUTE,
22
38
  ROUTE_GLOBAL,
23
39
  } from "../render-seam.ts"
@@ -44,6 +60,13 @@ const SCRIPT_ESCAPE_MAP: Readonly<Record<string, string>> = {
44
60
  [LINE_SEP]: "\\u2028",
45
61
  [PARA_SEP]: "\\u2029",
46
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"
47
70
  const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
48
71
  const RESPONSE_RESULT = Symbol.for("nifra.response.result")
49
72
 
@@ -111,6 +134,15 @@ export interface RenderPageOptions {
111
134
  /** SSG: the URL of a JSON array of the prerendered paths, handed over in place of
112
135
  * {@link prerenderedPaths} so a large set is fetched once instead of riding in every page. */
113
136
  readonly prerenderedListUrl?: string
137
+ /** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`), handed over to the
138
+ * client; the chunks this page reads its copy from are preloaded. Omit ⇒ none. */
139
+ readonly copyChunks?: Readonly<Record<string, string>>
140
+ /** The page's locale from the app's locale routing: `<html lang dir>` default to it unless `head`
141
+ * sets `lang`, the response carries `Content-Language`, and the adapter reads it as
142
+ * `RenderProps.locale`. Omit ⇒ none of these. */
143
+ readonly locale?: PageLocale
144
+ /** The served locales, handed to the client so it names each page's locale on navigation. */
145
+ readonly locales?: PageLocales
114
146
  /** ISR: route freshness in seconds, emitted as the `x-nifra-isr-revalidate` header for a `withISR`
115
147
  * wrapper to read. Omit ⇒ no header (the wrapper's default TTL applies). */
116
148
  readonly revalidate?: number
@@ -184,7 +216,7 @@ export interface RenderPageOptions {
184
216
  * prefix/suffix, tail statics) on the first render and reuses afterwards, skipping their re-assembly.
185
217
  *
186
218
  * Only pass a cache when every shell-shaping input is IDENTICAL across the requests sharing it:
187
- * same `head` content (a static meta chain - never a `meta(data)` function), `title`, `styles`,
219
+ * same `head` content (a static meta chain - never a `meta(data)` function), `locale`, `title`, `styles`,
188
220
  * `cssLoading`, `preload`, `islandScripts`, `clientEntry`, `rootId`, `hydrate`, and `prerenderedPaths`.
189
221
  * Per-request
190
222
  * values (`data`, `params`, `search`, `actionData`, `layoutData`, deferred state) are always assembled
@@ -248,6 +280,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
248
280
  cssLoading: requestedCssLoading,
249
281
  prerenderedPaths = [],
250
282
  prerenderedListUrl,
283
+ copyChunks,
284
+ locale,
251
285
  revalidate,
252
286
  revalidateTags = [],
253
287
  routeId,
@@ -276,6 +310,13 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
276
310
  : prerenderedPaths.length === 0
277
311
  ? ""
278
312
  : `,"${PRERENDERED_GLOBAL}":${serializeData(prerenderedPaths)}`
313
+ const copyHandover =
314
+ copyChunks === undefined ? "" : `,"${COPY_CHUNKS_GLOBAL}":${serializeData(copyChunks)}`
315
+ const localesHandover =
316
+ options.locales === undefined ? "" : `,"${LOCALES_GLOBAL}":${serializeData(options.locales)}`
317
+ // A route's `lang` wins with its own `dir`; otherwise the page's registered locale names both.
318
+ const lang = head?.lang ?? locale?.lang ?? "en"
319
+ const dir = head?.dir ?? (head?.lang === undefined ? locale?.dir : undefined)
279
320
  // Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
280
321
  // data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
281
322
  // also `defer()` - split it too, continuing the id space so a single registry settles both. The
@@ -405,7 +446,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
405
446
  // `<html>` attributes. `lang` defaults to `"en"`; `dir` is omitted entirely when unset, which IS
406
447
  // HTML's `ltr` default. Both attribute-escaped. `client.ts`'s `applyHead` mirrors this exact
407
448
  // defaulting on soft-nav, so a hard load and a client navigation produce the same `<html>`.
408
- const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`
449
+ const htmlAttrs = ` lang="${escapeAttr(lang)}"${dir === undefined ? "" : ` dir="${escapeAttr(dir)}"`}`
409
450
  // Marks the container for the client entry when the id is not the one the entry falls back to.
410
451
  const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`
411
452
  // A non-hydrated page omits the adapter's hydration bootstrap entirely (Solid's `_$HY` registry
@@ -432,7 +473,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
432
473
  if (hydrate) {
433
474
  // Inert JSON, never executed: the page state needs no nonce and no hash under any CSP.
434
475
  slot.tailPre = `<script type="application/json" id="${HANDOVER_ID}">{"${DATA_GLOBAL}":`
435
- slot.tailPost = `${route}${prerendered}}</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>${islandTags}</body></html>`
476
+ slot.tailPost = `${route}${prerendered}${copyHandover}${localesHandover}}</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>${islandTags}</body></html>`
436
477
  } else {
437
478
  slot.tailPre = ""
438
479
  slot.tailPost = `${islandTags}</body></html>`
@@ -441,13 +482,18 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
441
482
  // The deferred runtime rides only on a hydrating page (a static page has no client takeover), and
442
483
  // only when something actually deferred - matching the pre-cache emission exactly.
443
484
  const runtimeSeam = hydrate && allDeferred.length > 0 ? deferredRuntime : ""
444
- const shellHtml = `${slot.shellPre}${runtimeSeam}${slot.shellPost}${slot.rootOpen}`
485
+ // Per request, so outside the cached shell: one route serves every locale.
486
+ const copyPreloads =
487
+ hydrate && copyChunks !== undefined
488
+ ? copyPreloadLinks(copyChunks, options.path ?? "", lang)
489
+ : ""
490
+ const shellHtml = `${slot.shellPre}${copyPreloads}${runtimeSeam}${slot.shellPost}${slot.rootOpen}`
445
491
  const parts: DocumentParts | undefined =
446
492
  adapter.documentParts === true ? { head: "", body: "" } : undefined
447
493
  const shellOf = (): string =>
448
494
  parts === undefined || (parts.head === "" && parts.body === "")
449
495
  ? shellHtml
450
- : `${slot.shellPre}${runtimeSeam}${parts.head}${slot.shellPost}${parts.body}${slot.rootOpen}`
496
+ : `${slot.shellPre}${copyPreloads}${runtimeSeam}${parts.head}${slot.shellPost}${parts.body}${slot.rootOpen}`
451
497
  // Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
452
498
  // aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
453
499
  const closeRootHtml = "</div>"
@@ -457,6 +503,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
457
503
  ? `${slot.tailPre}${serializeData(forClient)}${action}${layoutTail}${boundaryTail}${componentTail}${slot.tailPost}`
458
504
  : (slot.tailPost as string)
459
505
  const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
506
+ if (locale !== undefined) headers["content-language"] = lang
460
507
  // Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
461
508
  // framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
462
509
  // not be able to mislabel the document or forge the freshness header by passing them here.
@@ -501,6 +548,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
501
548
  ? { boundaries: boundarySplit.forComponent as BoundaryStates }
502
549
  : {}),
503
550
  ...(options.matchChain !== undefined ? { matchChain: options.matchChain } : {}),
551
+ ...(locale === undefined ? {} : { locale }),
504
552
  }
505
553
 
506
554
  // Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
@@ -509,43 +557,77 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
509
557
  // streaming renderer are pure overhead vs a single sync render + concat. A buffered string body also
510
558
  // gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
511
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
512
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
+ }
513
598
  const out =
514
599
  parts !== undefined
515
600
  ? adapter.renderToString(
516
601
  chain,
517
602
  renderProps,
518
- nonce === undefined ? { parts } : { nonce, parts },
603
+ renderNonce === undefined ? { parts } : { nonce: renderNonce, parts },
519
604
  )
520
- : nonce === undefined
605
+ : renderNonce === undefined
521
606
  ? adapter.renderToString(chain, renderProps)
522
- : adapter.renderToString(chain, renderProps, { nonce })
523
- return typeof out === "string"
524
- ? new BufferedRenderedPage(shellOf() + out + closeRootHtml + tailHtml, status, headers)
525
- : out.then(
526
- (bodyHtml) =>
527
- new BufferedRenderedPage(
528
- shellOf() + bodyHtml + closeRootHtml + tailHtml,
529
- status,
530
- headers,
531
- ),
532
- )
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)
533
628
  }
534
629
 
535
- return renderStreamedPage(
536
- adapter,
537
- chain,
538
- renderProps,
539
- shellOf,
540
- parts,
541
- closeRootHtml,
542
- allDeferred,
543
- tailHtml,
544
- status,
545
- headers,
546
- nonce,
547
- nonceAttr,
548
- ).then((response) => new ResponseRenderedPage(response))
630
+ return streamed(nonce)
549
631
  }
550
632
 
551
633
  async function renderStreamedPage(
@@ -638,6 +720,9 @@ function htmlResponse(body: string, init: ResponseInit): Response {
638
720
  * script per deferred value (emitted once its promise settles - by now the app stream has awaited
639
721
  * the same Suspense boundaries - and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
640
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.
641
726
  */
642
727
  function streamDocument(
643
728
  shell: Uint8Array,
@@ -652,45 +737,59 @@ function streamDocument(
652
737
  // A client that goes away cancels the body: the renderer stops, and nothing more is written.
653
738
  let cancelled = false
654
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
+ )
655
763
  return new ReadableStream({
656
- async start(controller) {
764
+ start(controller) {
765
+ controller.enqueue(shell)
766
+ },
767
+ async pull(controller) {
657
768
  try {
658
- controller.enqueue(shell)
659
- for (;;) {
769
+ if (!released) {
660
770
  const { done, value } = await reader.read()
661
771
  if (cancelled) return
662
- if (done) break
663
- controller.enqueue(value)
772
+ if (!done) {
773
+ controller.enqueue(value)
774
+ return
775
+ }
776
+ release()
777
+ controller.enqueue(closeRoot)
664
778
  }
665
- controller.enqueue(closeRoot)
666
779
  // Stream each resolution as ITS OWN promise settles - NOT in array order. A slow
667
780
  // defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
668
781
  await Promise.all(
669
- deferred.map(async (d) => {
670
- let script: string
671
- try {
672
- const value = serializeData(await d.promise)
673
- // `serializeData` JSON-serializes the value and escapes `<`, `>`, and Unicode line
674
- // separators before this framework-owned script is emitted.
675
- script = `<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>` // nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag
676
- } catch (err) {
677
- // A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
678
- // not break the whole body. Redact: stream a stable opaque code, never the raw error
679
- // text; log the real reason server-side.
680
- console.error("[nifra/web] deferred value rejected:", err)
681
- script = `<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`
682
- }
683
- 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))
684
785
  }),
685
786
  )
686
787
  if (cancelled) return
687
788
  controller.enqueue(tail)
688
789
  controller.close()
689
790
  } catch (err) {
791
+ release()
690
792
  if (!cancelled) controller.error(err)
691
- } finally {
692
- released = true
693
- reader.releaseLock()
694
793
  }
695
794
  },
696
795
  cancel(reason) {
@@ -1171,3 +1270,25 @@ export function unsafeInlineScript(
1171
1270
  content,
1172
1271
  }
1173
1272
  }
1273
+
1274
+ /** The copy chunks a page loads first, as `@nifrajs/i18n/copy` picks them in the browser, so they
1275
+ * download beside the route's code instead of after it. */
1276
+ function copyPreloadLinks(
1277
+ chunks: Readonly<Record<string, string>>,
1278
+ path: string,
1279
+ lang: string,
1280
+ ): string {
1281
+ const realm: unknown = Reflect.get(globalThis, Symbol.for(COPY_REALM))
1282
+ if (typeof realm !== "object" || realm === null) return ""
1283
+ const pageLocales: unknown = Reflect.get(realm, "pageLocales")
1284
+ if (typeof pageLocales !== "function") return ""
1285
+ const locales: unknown = Reflect.apply(pageLocales, realm, [path, lang])
1286
+ if (!Array.isArray(locales)) return ""
1287
+ let links = ""
1288
+ for (const locale of locales) {
1289
+ const url =
1290
+ typeof locale === "string" && Object.hasOwn(chunks, locale) ? chunks[locale] : undefined
1291
+ if (url !== undefined) links += `<link rel="modulepreload" href="${escapeAttr(url)}">`
1292
+ }
1293
+ return links
1294
+ }
@@ -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. */
@@ -0,0 +1,11 @@
1
+ import type * as TS from "typescript"
2
+
3
+ /** Whether a loaded `typescript` module carries the in-process compiler API (TypeScript 7's does not). */
4
+ export function isTypeScriptCompiler(value: unknown): value is typeof TS {
5
+ return (
6
+ typeof value === "object" &&
7
+ value !== null &&
8
+ "createSourceFile" in value &&
9
+ typeof value.createSourceFile === "function"
10
+ )
11
+ }
@@ -0,0 +1,81 @@
1
+ // A Vite `config` hook is a plain function or the object form `{ handler, order }` Vite accepts for hook
2
+ // ordering; plugins are typed structurally (no `vite` type dep) and every other field is kept by spread.
3
+ type ConfigFn = (config: unknown, env: unknown) => unknown
4
+
5
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
6
+ value !== null && typeof value === "object" && !Array.isArray(value)
7
+
8
+ /**
9
+ * Move a plugin's `optimizeDeps.rollupOptions` to `optimizeDeps.rolldownOptions` under rolldown-vite and
10
+ * drop its `jsx` key, so Vite 8 warns about neither on `nifra dev` or a Vite build.
11
+ *
12
+ * Both are version-skew artifacts of plugins such as `@vitejs/plugin-react@4.x`, which still emit
13
+ * `optimizeDeps.rollupOptions.jsx`:
14
+ *
15
+ * - Vite 8 renamed the dep optimizer's options to `rolldownOptions` and moved jsx under `transform.jsx`,
16
+ * so a stale `jsx` key prints `Invalid input options … "jsx" Invalid key`. The route JSX transform runs
17
+ * through the plugin's own `transform` hook and pre-bundled deps almost never hold raw JSX, so the key
18
+ * is dead and dropping it changes no behavior.
19
+ * - Vite 8 reads `rollupOptions` there only as a deprecated alias of `rolldownOptions` and warns on every
20
+ * resolve, even for `{}`. The remaining keys move to `rolldownOptions`; when the same object already
21
+ * sets `rolldownOptions`, Vite ignores the alias, so it is dropped. `ssr.optimizeDeps` gets the same.
22
+ *
23
+ * Only a plugin's `config` hook output is rewritten, never mutated, and only under rolldown-vite.
24
+ * Non-rolldown Vite is passed through verbatim.
25
+ *
26
+ * FLATTEN FIRST: a Vite plugin factory may return an ARRAY of plugins - `@vitejs/plugin-react`'s `react()`
27
+ * returns `[vite:react-babel, vite:react-refresh]`, and it's `react:react-babel`'s `config` hook that emits
28
+ * the offending `optimizeDeps.rollupOptions.jsx`. `nifra.config.ts` writes `vitePlugins = [react()]`, so the
29
+ * plugin list arrives NESTED (`[[babel, refresh]]`). Without flattening, `.map` sees the inner array (which
30
+ * has no `config`), leaves it untouched, and Vite - which flattens plugin arrays itself before running them
31
+ * - then executes the un-stripped babel hook, so the warning survives. Flattening here (Vite accepts a flat
32
+ * list identically) is what lets the strip reach every real plugin.
33
+ */
34
+ export function normalizeRolldownPlugins(
35
+ plugins: readonly unknown[],
36
+ isRolldown: boolean,
37
+ ): readonly unknown[] {
38
+ if (!isRolldown) return plugins
39
+ const migrateDepOptions = (deps: unknown): unknown => {
40
+ if (!isRecord(deps)) return deps
41
+ const { rollupOptions, ...rest } = deps
42
+ if (rollupOptions === undefined) return deps
43
+ // Vite skips the alias when the same object sets rolldownOptions (mergeConfigRecursively).
44
+ if (rest.rolldownOptions || rollupOptions === null) return rest
45
+ if (!isRecord(rollupOptions)) return { ...rest, rolldownOptions: rollupOptions }
46
+ const { jsx: _dropped, ...kept } = rollupOptions
47
+ return Object.keys(kept).length === 0 ? rest : { ...rest, rolldownOptions: kept }
48
+ }
49
+ const migrateConfig = (returned: unknown): unknown => {
50
+ if (!isRecord(returned)) return returned
51
+ const ssr = isRecord(returned.ssr) ? returned.ssr : undefined
52
+ const deps = migrateDepOptions(returned.optimizeDeps)
53
+ const ssrDeps = migrateDepOptions(ssr?.optimizeDeps)
54
+ if (deps === returned.optimizeDeps && ssrDeps === ssr?.optimizeDeps) return returned
55
+ const out = { ...returned }
56
+ if (deps !== returned.optimizeDeps) out.optimizeDeps = deps
57
+ if (ssr !== undefined && ssrDeps !== ssr.optimizeDeps)
58
+ out.ssr = { ...ssr, optimizeDeps: ssrDeps }
59
+ return out
60
+ }
61
+ return plugins.flat(Number.POSITIVE_INFINITY).map((plugin) => {
62
+ if (!isRecord(plugin)) return plugin
63
+ // `config` may be a plain function or the object form `{ handler, order }`. Wrap the handler either way.
64
+ const hook = plugin.config
65
+ const objectHook = isRecord(hook) ? hook : undefined
66
+ const handler = typeof hook === "function" ? hook : objectHook?.handler
67
+ if (typeof handler !== "function") return plugin
68
+ const wrappedHandler: ConfigFn = (config, env) => {
69
+ const returned: unknown = handler(config, env)
70
+ // The hook may return a promise - normalize both shapes.
71
+ return returned instanceof Promise ? returned.then(migrateConfig) : migrateConfig(returned)
72
+ }
73
+ // Preserve the ORIGINAL shape: a function stays a function; the object form keeps its `order`
74
+ // (collapsing `{ handler, order }` to a bare function would silently drop the hook ordering).
75
+ return {
76
+ ...plugin,
77
+ config:
78
+ objectHook === undefined ? wrappedHandler : { ...objectHook, handler: wrappedHandler },
79
+ }
80
+ })
81
+ }
@@ -21,14 +21,61 @@ import type { RenderAdapter } from "../render-seam.ts"
21
21
  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
- import { createPageRequestExecutor, type NonceResolver } from "./page-execution.ts"
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"
29
+
30
+ /** An app's locale routing as `createWebApp` reads it: what `defineI18nRouting()` from
31
+ * `@nifrajs/i18n/routing` returns. */
32
+ export interface WebAppI18n {
33
+ readonly locales: {
34
+ readonly default: string
35
+ /** Served locale keys, in registry order. */
36
+ readonly served: readonly string[]
37
+ documentMeta(key: string): { readonly lang: string; readonly dir: "ltr" | "rtl" }
38
+ }
39
+ localeOf(path: string): string
40
+ matchSegment(
41
+ value: string | undefined,
42
+ pathname: string,
43
+ ):
44
+ | { readonly kind: "ok" | "not-found" }
45
+ | { readonly kind: "redirect"; readonly location: string }
46
+ }
47
+
48
+ function pageI18n(routing: WebAppI18n): PageI18n {
49
+ const { locales } = routing
50
+ if (
51
+ typeof routing.localeOf !== "function" ||
52
+ typeof routing.matchSegment !== "function" ||
53
+ typeof locales?.documentMeta !== "function" ||
54
+ !Array.isArray(locales.served)
55
+ ) {
56
+ throw new TypeError(
57
+ "[nifra/web] createWebApp `i18n` must be the router defineI18nRouting() from @nifrajs/i18n/routing returns",
58
+ )
59
+ }
60
+ return {
61
+ locales: {
62
+ default: locales.default,
63
+ locales: locales.served.map((key) => {
64
+ const { lang, dir } = locales.documentMeta(key)
65
+ return { key, lang, dir }
66
+ }),
67
+ },
68
+ localeOf: (path) => routing.localeOf(path),
69
+ matchSegment: (value, pathname) => routing.matchSegment(value, pathname),
70
+ }
71
+ }
72
+
28
73
  export interface CreateWebAppOptions<Env = unknown> {
29
74
  readonly serverComponents?: import("../server-components/types.ts").ServerComponentRuntime
30
75
  readonly adapter: RenderAdapter
31
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
32
79
  /** URL of the built client entry (module script) injected into every page. */
33
80
  readonly clientEntry: string
34
81
  /** Default document title for all pages. */
@@ -181,6 +228,20 @@ export interface CreateWebAppOptions<Env = unknown> {
181
228
  * of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
182
229
  * page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
183
230
  readonly routeStyles?: Readonly<Record<string, readonly string[]>>
231
+ /** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`, the server manifest's
232
+ * `copyChunks`). A production client build moves copy tables (`@nifrajs/i18n/copy`) into one chunk per
233
+ * locale; each page lists them for the browser and preloads its own. Omit ⇒ tables keep their values. */
234
+ readonly copyChunks?: Readonly<Record<string, string>>
235
+ /**
236
+ * The app's locale routing, `defineI18nRouting()` from `@nifrajs/i18n/routing`, registered once:
237
+ * export it as `i18n` from `backend/framework.ts`. Every document then takes its locale from the
238
+ * URL: `<html lang dir>` from the registry unless the route's meta sets `lang`, a `Content-Language`
239
+ * header, and the locale each adapter's `useLocale()` and `<I18nProvider>` read. A route under a
240
+ * `[lang]` or `[[lang]]` directory is checked with its `matchSegment()` before any middleware: an
241
+ * unserved locale, or a page missing from it, is a 404, and a non-canonical prefix (`/en/x` when the
242
+ * default is unprefixed) redirects with a 308.
243
+ */
244
+ readonly i18n?: WebAppI18n
184
245
  /**
185
246
  * Activation policy for framework-owned stylesheet links (default `"blocking"`). `"deferred"`
186
247
  * emits `media="print"` links; the generated client waits for them and switches them to `all` before
@@ -194,6 +255,14 @@ export interface CreateWebAppOptions<Env = unknown> {
194
255
  * 4 KB of JSON is served once from `/__nifra/prerendered.json?v=<version>`, cacheable for good, and
195
256
  * pages hand over only that URL; `prerenderRoutes` writes the file into a static output. */
196
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
197
266
  /** Publish the project's `AGENTS.md` inside `/llms.txt` and `/llms-full.txt`. **Off by default**:
198
267
  * those endpoints are public and unauthenticated, while `AGENTS.md` is a repo file written for the
199
268
  * team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
@@ -417,6 +486,9 @@ export function createWebApp<Env = unknown>(
417
486
  ? undefined
418
487
  : servedPrerenderedList(options.prerenderedPaths)
419
488
  const pageExecutor = createPageRequestExecutor<Env>({
489
+ ...(options.cacheRouteModules === undefined
490
+ ? {}
491
+ : { cacheRouteModules: options.cacheRouteModules }),
420
492
  ...(options.serverComponents === undefined
421
493
  ? {}
422
494
  : { serverComponents: options.serverComponents }),
@@ -442,6 +514,8 @@ export function createWebApp<Env = unknown>(
442
514
  ...(options.routePreload === undefined ? {} : { routePreload: options.routePreload }),
443
515
  ...(options.styles === undefined ? {} : { styles: options.styles }),
444
516
  ...(options.routeStyles === undefined ? {} : { routeStyles: options.routeStyles }),
517
+ ...(options.copyChunks === undefined ? {} : { copyChunks: options.copyChunks }),
518
+ ...(options.i18n === undefined ? {} : { i18n: pageI18n(options.i18n) }),
445
519
  ...(options.cssLoading === undefined ? {} : { cssLoading: options.cssLoading }),
446
520
  ...(options.prerenderedPaths === undefined
447
521
  ? {}
@@ -460,17 +534,23 @@ export function createWebApp<Env = unknown>(
460
534
  cacheChannel,
461
535
  })
462
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)
463
542
  let ownsRootCatchAll = false
464
543
  for (const route of manifest.routes) {
465
544
  const pattern = requestPattern(route.pattern)
466
545
  if (pattern.startsWith("/*")) ownsRootCatchAll = true
546
+ const render = pageExecutor.get(route)
467
547
  app.register(
468
548
  "GET",
469
549
  pattern,
470
550
  options.serverComponents?.owns(route.id)
471
551
  ? { capabilities: options.serverComponents.capabilities(route.id) }
472
552
  : undefined,
473
- pageExecutor.get(route),
553
+ prerendered === undefined ? render : prerendered(render),
474
554
  )
475
555
  app.register("POST", pattern, undefined, pageExecutor.post(route))
476
556
  }