@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
@@ -34,7 +34,7 @@ import type {
34
34
  ShouldRevalidate,
35
35
  } from "../manifest.ts"
36
36
  import type { NonceResolver } from "../nonce.ts"
37
- import type { MatchChain, RenderAdapter } from "../render-seam.ts"
37
+ import type { MatchChain, PageLocale, PageLocales, RenderAdapter } from "../render-seam.ts"
38
38
  import {
39
39
  createMatcher,
40
40
  DATA_HEADER,
@@ -46,6 +46,7 @@ import {
46
46
  } from "../router.ts"
47
47
  import { searchOf, searchOfChain } from "../search.ts"
48
48
  import { mergeHeads, resolveChainMeta } from "./head-merge.ts"
49
+ import { prepareModules } from "./module-preparation.ts"
49
50
  import {
50
51
  EMPTY_RETAIN,
51
52
  type HeadersLike,
@@ -54,9 +55,12 @@ import {
54
55
  isStatusSignal,
55
56
  type LoadedLayoutModules,
56
57
  layoutErrorId,
58
+ notFound,
57
59
  type RenderAssemblyCache,
58
60
  type RenderedPage,
61
+ type RenderPageOptions,
59
62
  type RevalidateResult,
63
+ redirect,
60
64
  renderPageResult,
61
65
  STATUS_SIGNAL,
62
66
  STATUS_TEXT,
@@ -65,7 +69,7 @@ import {
65
69
  tagLayoutError,
66
70
  withDuplicateInstanceHint,
67
71
  } from "./render-document.ts"
68
- import { urlPartsFor } from "./request-url.ts"
72
+ import { originFor, urlPartsFor } from "./request-url.ts"
69
73
  import {
70
74
  ACTION_SCOPE,
71
75
  type CoreResponseControls,
@@ -179,6 +183,24 @@ function appTarget(location: string, requestUrl: string): string {
179
183
  : target.pathname + target.search + target.hash
180
184
  }
181
185
 
186
+ /** The app's locale routing, as page execution reads it. */
187
+ export interface PageI18n {
188
+ readonly locales: PageLocales
189
+ /** The served locale key a `pathname + search` is in. */
190
+ localeOf(path: string): string
191
+ /** The answer for a `[lang]` / `[[lang]]` segment's value (`undefined` when an optional one is
192
+ * absent), as `@nifrajs/i18n/routing`'s `matchSegment` gives it. */
193
+ matchSegment(
194
+ value: string | undefined,
195
+ pathname: string,
196
+ ):
197
+ | { readonly kind: "ok" | "not-found" }
198
+ | { readonly kind: "redirect"; readonly location: string }
199
+ }
200
+
201
+ // The route directories whose first segment names the locale.
202
+ const LOCALE_SEGMENTS: ReadonlySet<string> = new Set(["[lang]", "[[lang]]"])
203
+
182
204
  export interface PageExecutionOptions<Env = unknown> {
183
205
  readonly serverComponents?: import("../server-components/types.ts").ServerComponentRuntime
184
206
  readonly rerenderOwnerGET?: (
@@ -187,6 +209,7 @@ export interface PageExecutionOptions<Env = unknown> {
187
209
  ) => Promise<Response>
188
210
  readonly adapter: RenderAdapter
189
211
  readonly manifest: Manifest
212
+ readonly cacheRouteModules?: boolean
190
213
  readonly clientEntry: string
191
214
  readonly title?: string
192
215
  readonly api?: unknown
@@ -198,6 +221,10 @@ export interface PageExecutionOptions<Env = unknown> {
198
221
  readonly prerenderedPaths?: readonly string[]
199
222
  /** Where pages fetch {@link prerenderedPaths} from when the list is too large to inline. */
200
223
  readonly prerenderedListUrl?: string
224
+ /** Locale key -> copy chunk URL, from a build that split copy tables by locale. */
225
+ readonly copyChunks?: Readonly<Record<string, string>>
226
+ /** The app's locale routing: each document's locale, and the check on a `[lang]` segment. */
227
+ readonly i18n?: PageI18n
201
228
  readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
202
229
  readonly staticBoundaryCache?: StaticBoundaryCache
203
230
  /** Per-request CSP nonce for framework-owned executable document scripts. */
@@ -272,6 +299,12 @@ type LayoutChain = {
272
299
  */
273
300
  type LayoutChainSource = (page: RouteModule, count: number) => Promise<LayoutChain | undefined>
274
301
 
302
+ /** A document's render input, filled in field by field. */
303
+ type DocumentInput = { -readonly [K in keyof RenderPageOptions]: RenderPageOptions[K] } & {
304
+ readonly clientEntry: string
305
+ readonly hydrate: boolean
306
+ }
307
+
275
308
  /**
276
309
  * Own the page request program behind one private seam.
277
310
  *
@@ -285,6 +318,11 @@ export function createPageRequestExecutor<Env = unknown>(
285
318
  options: PageExecutionOptions<Env>,
286
319
  ): PageRequestExecutor<Env> {
287
320
  const { adapter, manifest, clientEntry, api } = options
321
+ const cacheModules =
322
+ options.cacheRouteModules ??
323
+ (typeof process !== "undefined" && process.env?.NODE_ENV === "production")
324
+ const loadModule = prepareModules(cacheModules)
325
+ const preparedLayouts = new WeakMap<RouteEntry, LoadedLayoutModules>()
288
326
  // The action result is request-local; a browser cannot manufacture this handoff.
289
327
  const progressiveActionData = new WeakMap<Request, unknown>()
290
328
  // Bound once per render, so every loader, action and boundary in that render shares one view of the
@@ -301,6 +339,24 @@ export function createPageRequestExecutor<Env = unknown>(
301
339
  options.prerenderedListUrl === undefined
302
340
  ? { prerenderedPaths: options.prerenderedPaths ?? [] }
303
341
  : { prerenderedListUrl: options.prerenderedListUrl }
342
+ const copyOption = options.copyChunks === undefined ? {} : { copyChunks: options.copyChunks }
343
+ const appDocumentOptions = { ...prerenderedHandover, ...copyOption, ...cspOption, ...titleOption }
344
+ const { i18n } = options
345
+ const pageLocales = new Map(i18n?.locales.locales.map((locale) => [locale.key, locale]))
346
+ /** The request's locale for a document, with the served locales the client names locales from. */
347
+ const localeOption = (req: Request): { locale?: PageLocale; locales?: PageLocales } => {
348
+ if (i18n === undefined) return {}
349
+ const locale = pageLocales.get(i18n.localeOf(pathOf(req)))
350
+ return locale === undefined ? { locales: i18n.locales } : { locale, locales: i18n.locales }
351
+ }
352
+ /** Whether a route sits under a `[lang]` or `[[lang]]` directory, which the locale routing checks. */
353
+ const localeGuarded = (route: RouteEntry): boolean => {
354
+ if (i18n === undefined) return false
355
+ for (const segment of route.id.split("/")) {
356
+ if (!(segment.startsWith("(") && segment.endsWith(")"))) return LOCALE_SEGMENTS.has(segment)
357
+ }
358
+ return false
359
+ }
304
360
  const matchManifestRoute = createMatcher(
305
361
  manifest.routes.map((route) => ({ routeId: route.id, pattern: route.pattern })),
306
362
  )
@@ -379,8 +435,10 @@ export function createPageRequestExecutor<Env = unknown>(
379
435
  // Load a route's layout modules once for the current execution. The render path needs each whole
380
436
  // module for its component + meta, while the data path also needs loader/gate/boundary declarations.
381
437
  const loadLayoutModules = async (route: RouteEntry): Promise<LoadedLayoutModules> => {
438
+ const known = cacheModules ? preparedLayouts.get(route) : undefined
439
+ if (known !== undefined) return known
382
440
  const modules = await Promise.all(
383
- route.layoutIds.map((id) => (manifest.layouts[id] as LayoutEntry).load()),
441
+ route.layoutIds.map((id) => loadModule(manifest.layouts[id] as LayoutEntry)),
384
442
  )
385
443
  for (let i = 0; i < modules.length; i++) {
386
444
  if ((modules[i] as { action?: unknown }).action === undefined) continue
@@ -389,6 +447,7 @@ export function createPageRequestExecutor<Env = unknown>(
389
447
  `[nifra/web] "${(manifest.layouts[id] as LayoutEntry).file}" exports an \`action\`, but layouts do not run one - only route files do. Move the mutation into the route that submits to it.`,
390
448
  )
391
449
  }
450
+ if (cacheModules) preparedLayouts.set(route, modules)
392
451
  return modules
393
452
  }
394
453
 
@@ -553,6 +612,21 @@ export function createPageRequestExecutor<Env = unknown>(
553
612
  ctx: LoaderContext,
554
613
  controls: PageResponseControls,
555
614
  ): Promise<void> => {
615
+ if (i18n !== undefined && localeGuarded(route)) {
616
+ // Before any app middleware, as a root `_middleware` would run: an unserved locale, or a page
617
+ // missing from it, never reaches the app's code.
618
+ const match = i18n.matchSegment(ctx.params.lang, pathOf(ctx.request))
619
+ if (match.kind === "redirect") {
620
+ throw tagLayoutError(redirect(match.location, { status: 308 }), "_layout")
621
+ }
622
+ if (match.kind === "not-found") {
623
+ try {
624
+ notFound()
625
+ } catch (signal) {
626
+ throw tagLayoutError(signal, "_layout")
627
+ }
628
+ }
629
+ }
556
630
  const ids = route.middlewareIds ?? []
557
631
  for (let i = 0; i < ids.length; i++) {
558
632
  const id = ids[i] as string
@@ -560,7 +634,7 @@ export function createPageRequestExecutor<Env = unknown>(
560
634
  const dirTag = `${id.slice(0, id.length - "_middleware".length)}_layout`
561
635
  let outcome: unknown
562
636
  try {
563
- const middleware = (await entry.load()).default
637
+ const middleware = (await loadModule(entry)).default
564
638
  // A `_layout.backend.ts` that exports no middleware is a layout data module, nothing more.
565
639
  if (middleware === undefined) continue
566
640
  if (typeof middleware !== "function") {
@@ -718,14 +792,6 @@ export function createPageRequestExecutor<Env = unknown>(
718
792
  return undefined
719
793
  }
720
794
 
721
- const originOf = (req: Request): string => {
722
- try {
723
- return new URL(req.url).origin
724
- } catch {
725
- return ""
726
- }
727
- }
728
-
729
795
  const pathOf = (req: Request): string => {
730
796
  try {
731
797
  const parts = urlPartsFor(req)
@@ -738,8 +804,8 @@ export function createPageRequestExecutor<Env = unknown>(
738
804
  /** What a status or error page's `meta` receives: no route params, and its own data. */
739
805
  const statusMetaArgs = (req: Request, data: unknown, nonce: string | undefined): MetaArgs =>
740
806
  nonce === undefined
741
- ? { data, pageData: data, params: {}, origin: originOf(req) }
742
- : { data, pageData: data, params: {}, origin: originOf(req), nonce }
807
+ ? { data, pageData: data, params: {}, origin: originFor(req) }
808
+ : { data, pageData: data, params: {}, origin: originFor(req), nonce }
743
809
 
744
810
  const renderError = async (
745
811
  req: Request,
@@ -776,6 +842,7 @@ export function createPageRequestExecutor<Env = unknown>(
776
842
  chain: components,
777
843
  data,
778
844
  ...(wrapped === undefined ? {} : { head: wrapped.head }),
845
+ ...localeOption(req),
779
846
  clientEntry,
780
847
  routeId: errorId,
781
848
  status: 500,
@@ -939,6 +1006,8 @@ export function createPageRequestExecutor<Env = unknown>(
939
1006
  // The build keys stylesheets by route, so a page inside the root layout links the whole-app one.
940
1007
  ...(chain === undefined ? stylesOf(routeId) : stylesFrom(options.styles)),
941
1008
  ...prerenderedHandover,
1009
+ ...copyOption,
1010
+ ...localeOption(req),
942
1011
  ...titleOption,
943
1012
  }
944
1013
  if (chain === undefined) return renderPageResult(statusPage)
@@ -1062,13 +1131,15 @@ export function createPageRequestExecutor<Env = unknown>(
1062
1131
  data,
1063
1132
  pageData: data,
1064
1133
  params,
1065
- origin: originOf(req),
1134
+ origin: originFor(req),
1066
1135
  ...(nonce === undefined ? {} : { nonce }),
1067
1136
  },
1068
1137
  serverLeafOf(route, mod),
1069
1138
  )
1070
1139
  try {
1071
- return await renderPageResult({
1140
+ // Optional fields are assigned, not spread in: a conditional spread per field cost every
1141
+ // document request about 1us on Bun.
1142
+ const page: DocumentInput = {
1072
1143
  adapter,
1073
1144
  chain,
1074
1145
  data,
@@ -1079,36 +1150,32 @@ export function createPageRequestExecutor<Env = unknown>(
1079
1150
  path: pathOf(req),
1080
1151
  search: searchChainOf(layoutModules, mod, req),
1081
1152
  matchChain: matchChainOf(route.layoutIds, layoutModules, route.id, mod),
1082
- ...(serverComponents === undefined ? {} : { serverComponents }),
1083
- ...(progressiveActionData.has(req) ? { actionData: progressiveActionData.get(req) } : {}),
1084
1153
  hydrate: mod.hydrate !== false,
1085
- ...(layoutData !== undefined ? { layoutData } : {}),
1086
- ...(boundaryStates !== undefined ? { boundaries: boundaryStates } : {}),
1087
1154
  ...preloadOf(route.id),
1088
1155
  ...stylesOf(route.id),
1089
- ...prerenderedHandover,
1090
- ...(responseHeaders === undefined ? {} : { headers: responseHeaders }),
1091
- // A response that sets a cookie is one visitor's: it never advertises ISR freshness.
1092
- ...(mod.revalidate !== undefined && !personalized && options.cacheChannel?.enabled === true
1093
- ? { revalidate: mod.revalidate }
1094
- : {}),
1095
- ...(mod.revalidateTags !== undefined &&
1096
- !personalized &&
1097
- options.cacheChannel?.enabled === true
1098
- ? {
1099
- revalidateTags: routeTags(
1100
- mod.revalidateTags,
1101
- { params, url: new URL(req.url) },
1102
- route.id,
1103
- ),
1104
- }
1105
- : {}),
1106
- ...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
1107
- ...(nonce === undefined ? {} : { nonce }),
1108
- ...cspOption,
1109
- ...titleOption,
1110
- ...(assemblyCache === undefined ? {} : { assemblyCache }),
1111
- })
1156
+ ...appDocumentOptions,
1157
+ ...localeOption(req),
1158
+ }
1159
+ if (serverComponents !== undefined) page.serverComponents = serverComponents
1160
+ if (progressiveActionData.has(req)) page.actionData = progressiveActionData.get(req)
1161
+ if (layoutData !== undefined) page.layoutData = layoutData
1162
+ if (boundaryStates !== undefined) page.boundaries = boundaryStates
1163
+ if (responseHeaders !== undefined) page.headers = responseHeaders
1164
+ // A response that sets a cookie is one visitor's: it never advertises ISR freshness.
1165
+ if (!personalized && options.cacheChannel?.enabled === true) {
1166
+ if (mod.revalidate !== undefined) page.revalidate = mod.revalidate
1167
+ if (mod.revalidateTags !== undefined) {
1168
+ page.revalidateTags = routeTags(
1169
+ mod.revalidateTags,
1170
+ { params, url: new URL(req.url) },
1171
+ route.id,
1172
+ )
1173
+ }
1174
+ }
1175
+ if (mod.islandScripts !== undefined) page.islandScripts = mod.islandScripts
1176
+ if (nonce !== undefined) page.nonce = nonce
1177
+ if (assemblyCache !== undefined) page.assemblyCache = assemblyCache
1178
+ return await renderPageResult(page)
1112
1179
  } catch (err) {
1113
1180
  if (isStatusSignal(err)) return renderStatusSignal(req, env, err, personalized, settled)
1114
1181
  if (isControlFlow(err)) throw err
@@ -1191,7 +1258,7 @@ export function createPageRequestExecutor<Env = unknown>(
1191
1258
  data: null,
1192
1259
  pageData: null,
1193
1260
  params,
1194
- origin: originOf(req),
1261
+ origin: originFor(req),
1195
1262
  ...(nonce === undefined ? {} : { nonce }),
1196
1263
  })
1197
1264
  let headers: Headers | undefined
@@ -1213,6 +1280,7 @@ export function createPageRequestExecutor<Env = unknown>(
1213
1280
  matchChain: matchChainOf(page.layoutIds, layoutModules, id, mod),
1214
1281
  status: 404,
1215
1282
  hydrate: false,
1283
+ ...localeOption(req),
1216
1284
  ...(layoutData !== undefined ? { layoutData } : {}),
1217
1285
  ...stylesOf(id),
1218
1286
  ...(headers === undefined ? {} : { headers }),
@@ -1304,12 +1372,14 @@ export function createPageRequestExecutor<Env = unknown>(
1304
1372
  return {
1305
1373
  get: (route) => {
1306
1374
  const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
1307
- let assemblySlot: RenderAssemblyCache | undefined
1375
+ // One shell per locale: `<html lang>` is in it, and one route serves every locale.
1376
+ let assemblySlots = new Map<string, RenderAssemblyCache>()
1308
1377
  let assemblySlotMod: unknown
1309
1378
  let assemblySlotLayouts: LoadedLayoutModules | undefined
1310
1379
  const assemblyCacheFor = (
1311
1380
  mod: RouteModule,
1312
1381
  layouts: LoadedLayoutModules,
1382
+ req: Request,
1313
1383
  ): RenderAssemblyCache | undefined => {
1314
1384
  if (typeof mod.meta === "function") return undefined
1315
1385
  for (const layout of layouts) if (typeof layout.meta === "function") return undefined
@@ -1318,11 +1388,17 @@ export function createPageRequestExecutor<Env = unknown>(
1318
1388
  assemblySlotLayouts.length === layouts.length &&
1319
1389
  assemblySlotLayouts.every((layout, index) => layout === layouts[index])
1320
1390
  if (assemblySlotMod !== mod || !layoutsUnchanged) {
1321
- assemblySlot = {}
1391
+ assemblySlots = new Map()
1322
1392
  assemblySlotMod = mod
1323
1393
  assemblySlotLayouts = layouts
1324
1394
  }
1325
- return assemblySlot
1395
+ const key = i18n === undefined ? "" : i18n.localeOf(pathOf(req))
1396
+ let slot = assemblySlots.get(key)
1397
+ if (slot === undefined) {
1398
+ slot = {}
1399
+ assemblySlots.set(key, slot)
1400
+ }
1401
+ return slot
1326
1402
  }
1327
1403
 
1328
1404
  return withResponseControls(async (c, controls) => {
@@ -1340,7 +1416,8 @@ export function createPageRequestExecutor<Env = unknown>(
1340
1416
  if (!componentOwner && is404Fallback && !prerenderedSet.has(urlPartsFor(c.req).pathname)) {
1341
1417
  return renderNotFound(c, controls)
1342
1418
  }
1343
- const mod = await route.load()
1419
+ const loaded = loadModule(route)
1420
+ const mod = cacheModules && !(loaded instanceof Promise) ? loaded : await loaded
1344
1421
  const draft = await draftFlag(c.req)
1345
1422
  // Unpublished content is the editor's alone: whatever cache-control a loader sets, no cache keeps it.
1346
1423
  if (draft) controls.personalize()
@@ -1364,7 +1441,9 @@ export function createPageRequestExecutor<Env = unknown>(
1364
1441
  search: loaderSearch(mod.searchSchema, c.req),
1365
1442
  set: controls.scope(PAGE_SCOPE),
1366
1443
  }
1367
- if (route.middlewareIds !== undefined) await runMiddleware(route, ctx, controls)
1444
+ if (route.middlewareIds !== undefined || localeGuarded(route)) {
1445
+ await runMiddleware(route, ctx, controls)
1446
+ }
1368
1447
  run = await runLayoutChain(
1369
1448
  route,
1370
1449
  ctx,
@@ -1510,7 +1589,7 @@ export function createPageRequestExecutor<Env = unknown>(
1510
1589
  layoutModules ?? (await loadLayoutModules(route)),
1511
1590
  layoutRetained,
1512
1591
  boundaryStates,
1513
- layoutModules === undefined ? undefined : assemblyCacheFor(mod, layoutModules),
1592
+ layoutModules === undefined ? undefined : assemblyCacheFor(mod, layoutModules, c.req),
1514
1593
  responseHeaders,
1515
1594
  controls.personalized,
1516
1595
  componentState,
@@ -1520,7 +1599,8 @@ export function createPageRequestExecutor<Env = unknown>(
1520
1599
 
1521
1600
  post: (route) =>
1522
1601
  withResponseControls(async (c, controls) => {
1523
- const mod = await route.load()
1602
+ const loaded = loadModule(route)
1603
+ const mod = cacheModules && !(loaded instanceof Promise) ? loaded : await loaded
1524
1604
  const draft = await draftFlag(c.req)
1525
1605
  if (draft) controls.personalize()
1526
1606
  if (mod.action === undefined) {
@@ -1569,7 +1649,9 @@ export function createPageRequestExecutor<Env = unknown>(
1569
1649
  let gated: GatedLayouts
1570
1650
  let result: unknown
1571
1651
  try {
1572
- if (route.middlewareIds !== undefined) await runMiddleware(route, actionContext, controls)
1652
+ if (route.middlewareIds !== undefined || localeGuarded(route)) {
1653
+ await runMiddleware(route, actionContext, controls)
1654
+ }
1573
1655
  gated = await runLayoutGates(route, actionContext, controls)
1574
1656
  result = await mod.action(actionContext)
1575
1657
  } catch (err) {
@@ -1736,7 +1818,7 @@ export function createPageRequestExecutor<Env = unknown>(
1736
1818
  data,
1737
1819
  pageData: data,
1738
1820
  params: c.params,
1739
- origin: originOf(c.req),
1821
+ origin: originFor(c.req),
1740
1822
  ...(nonce === undefined ? {} : { nonce }),
1741
1823
  },
1742
1824
  serverLeafOf(route, mod),
@@ -1758,6 +1840,8 @@ export function createPageRequestExecutor<Env = unknown>(
1758
1840
  ...preloadOf(route.id),
1759
1841
  ...stylesOf(route.id),
1760
1842
  ...prerenderedHandover,
1843
+ ...copyOption,
1844
+ ...localeOption(c.req),
1761
1845
  ...(responseHeaders === undefined ? {} : { headers: responseHeaders }),
1762
1846
  ...(nonce === undefined ? {} : { nonce }),
1763
1847
  ...cspOption,
@@ -20,7 +20,7 @@ import { discoverRoutes } from "../fs.ts"
20
20
  import { codeUnitOrder } from "./code-unit-order.ts"
21
21
  import { withoutComments } from "./html-spans.ts"
22
22
  import { isIdentitySensitivePackage } from "./identity-policy.ts"
23
- import { publicUrlPath } from "./public-url.ts"
23
+ import { isHiddenPublicPath, publicUrlPath } from "./public-url.ts"
24
24
 
25
25
  const DEPENDENCY_FIELDS = [
26
26
  "dependencies",
@@ -1077,6 +1077,7 @@ const publicFilesUnder = (publicDir: string | false | undefined): readonly strin
1077
1077
  const files: string[] = []
1078
1078
  const walk = (current: string): void => {
1079
1079
  for (const entry of readdirSync(current, { withFileTypes: true })) {
1080
+ if (isHiddenPublicPath(entry.name)) continue // the build skips it too
1080
1081
  const path = join(current, entry.name)
1081
1082
  if (entry.isDirectory()) walk(path)
1082
1083
  else if (entry.isFile()) files.push(publicUrlPath(relative(root, path)))
@@ -0,0 +1,124 @@
1
+ import { pathnameOf } from "@nifrajs/core/server"
2
+ import { isDraftEnabled } from "../draft.ts"
3
+ import { DATA_HEADER } from "../router.ts"
4
+
5
+ /**
6
+ * Pages a self-hosted build prerendered, which `createWebApp` answers from their file instead of
7
+ * rendering them. The file is served from inside the app, after `use` middleware, so a gate in front of
8
+ * the pages still runs.
9
+ */
10
+ export interface PrerenderedPages {
11
+ /** Each servable path, spelled as a request URL spells it, mapped to the document headers its
12
+ * build-time render answered with (a prerender entry's `serveHeaders`). */
13
+ readonly pages: Readonly<Record<string, Readonly<Record<string, string>>>>
14
+ /** The page's HTML, or `undefined` to render it on demand instead. */
15
+ read(pathname: string): Promise<string | undefined>
16
+ }
17
+
18
+ // The document headers a render produces from the route alone, so the build-time value is the value
19
+ // every request would get. Loader headers are left out: a prerendered page is a static file.
20
+ const REPLAYED = [
21
+ "content-language",
22
+ "content-security-policy",
23
+ "content-security-policy-report-only",
24
+ ]
25
+ // Vary values a file still answers correctly: compression is negotiated by the host, and a data request
26
+ // never reaches the file.
27
+ const STATIC_VARY = new Set(["accept-encoding", DATA_HEADER])
28
+
29
+ /**
30
+ * The headers a self-hosted server replays with a prerendered page, or `undefined` when its build-time
31
+ * render was specific to that request and the page must keep rendering on demand: it set a cookie, was
32
+ * marked private or no-store (a nonce page always is), or varied on a request header.
33
+ */
34
+ export function prerenderedDocumentHeaders(
35
+ status: number,
36
+ headers: Headers,
37
+ ): Record<string, string> | undefined {
38
+ if (status !== 200 || headers.has("set-cookie")) return undefined
39
+ const cacheControl = (headers.get("cache-control") ?? "").toLowerCase()
40
+ if (/\b(?:private|no-store)\b/.test(cacheControl)) return undefined
41
+ for (const name of (headers.get("vary") ?? "").split(",")) {
42
+ const trimmed = name.trim().toLowerCase()
43
+ if (trimmed !== "" && !STATIC_VARY.has(trimmed)) return undefined
44
+ }
45
+ const replayed: Record<string, string> = {}
46
+ for (const name of REPLAYED) {
47
+ const value = headers.get(name)
48
+ if (value !== null) replayed[name] = value
49
+ }
50
+ return replayed
51
+ }
52
+
53
+ /** One page a self-hosting build prerendered: its file and the headers it is served with. */
54
+ export interface ServerPrerenderedPage {
55
+ readonly file: string
56
+ readonly headers: Readonly<Record<string, string>>
57
+ }
58
+
59
+ /**
60
+ * The prerendered pages a server may answer from their file, keyed by the pathname of the request that
61
+ * rendered them. Left out: a render specific to its request, and two paths written to one file (`/x`
62
+ * and `//x`, or `/A` and `/a` on a case-insensitive disk), where only the last render is on disk.
63
+ */
64
+ export function servablePrerenderedPages(
65
+ entries: readonly {
66
+ readonly path: string
67
+ readonly file: string
68
+ readonly serveHeaders?: Readonly<Record<string, string>>
69
+ }[],
70
+ ): Record<string, ServerPrerenderedPage> {
71
+ const writes = new Map<string, number>()
72
+ for (const { file } of entries) {
73
+ writes.set(file.toLowerCase(), (writes.get(file.toLowerCase()) ?? 0) + 1)
74
+ }
75
+ const pages: Record<string, ServerPrerenderedPage> = {}
76
+ for (const { path, file, serveHeaders } of entries) {
77
+ if (serveHeaders === undefined || writes.get(file.toLowerCase()) !== 1) continue
78
+ // Concatenated as prerenderRoutes requested it: `new URL("//x", base)` would read a host.
79
+ pages[new URL(`http://localhost${path}`).pathname] = { file, headers: serveHeaders }
80
+ }
81
+ return pages
82
+ }
83
+
84
+ /**
85
+ * Wrap a page route's GET handler so a plain document request for a prerendered path is answered from
86
+ * its file. Anything a file cannot answer the way a render would renders on demand: a query string, a
87
+ * navigation data or component request, a draft-mode visitor, or a file the reader cannot return.
88
+ */
89
+ export function servePrerenderedPages(
90
+ prerendered: PrerenderedPages,
91
+ draftSecret: string | undefined,
92
+ ): <C extends { readonly req: Request }, R>(
93
+ render: (c: C) => Promise<R>,
94
+ ) => (c: C) => Promise<R | Response> {
95
+ const pages = new Map(Object.entries(prerendered.pages))
96
+ return (render) => async (c) => {
97
+ const req = c.req
98
+ const pathname = pathnameOf(req.url)
99
+ const headers = pages.get(pathname)
100
+ if (
101
+ headers === undefined ||
102
+ req.url.includes("?") ||
103
+ req.headers.has(DATA_HEADER) ||
104
+ req.headers.has("x-nifra-component") ||
105
+ (draftSecret !== undefined && (await isDraftEnabled(req, draftSecret)))
106
+ ) {
107
+ return render(c)
108
+ }
109
+ const html = await prerendered.read(pathname)
110
+ if (html === undefined) return render(c)
111
+ if (req.method !== "HEAD") {
112
+ return new Response(html, {
113
+ headers: { ...headers, "content-type": "text/html; charset=utf-8" },
114
+ })
115
+ }
116
+ return new Response(null, {
117
+ headers: {
118
+ ...headers,
119
+ "content-type": "text/html; charset=utf-8",
120
+ "content-length": String(new TextEncoder().encode(html).byteLength),
121
+ },
122
+ })
123
+ }
124
+ }
@@ -20,3 +20,16 @@ function encodePathSegment(segment: string): string {
20
20
  export function publicUrlPath(rel: string): string {
21
21
  return `/${rel.split(sep).map(encodePathSegment).join("/")}`
22
22
  }
23
+
24
+ /**
25
+ * Whether a path under the public directory crosses a dot-segment other than `.well-known`. Serving,
26
+ * the build copy and the parity listing all apply it, so an editor or VCS artifact left in `public/`
27
+ * (`.env`, `.git/`, `.DS_Store`) is neither served in development nor shipped by a build. Both
28
+ * separators split: a request path uses `/`, a relative file path the OS separator.
29
+ */
30
+ export function isHiddenPublicPath(rel: string): boolean {
31
+ for (const segment of rel.split(/[\\/]/)) {
32
+ if (segment.startsWith(".") && segment !== ".well-known") return true
33
+ }
34
+ return false
35
+ }