@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
package/src/csp.ts CHANGED
@@ -175,6 +175,10 @@ export function cspDocumentNonce(
175
175
  return requested ?? policy[CSP_POLICY].generate()
176
176
  }
177
177
 
178
+ /** A nonce for a render that may write a script of its own; the document carries it only if used. */
179
+ export const cspCandidateNonce = (policy: CspPolicy, requested: string | undefined): string =>
180
+ requested ?? policy[CSP_POLICY].generate()
181
+
178
182
  /** The Content-Security-Policy value for one document, or `undefined` when the policy sends none. */
179
183
  export function cspHeaderValue(
180
184
  policy: CspPolicy,
package/src/deferred.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * resolution scripts settle. The per-adapter `<Await>` lives in `@nifrajs/web-{solid,react}/client`.
7
7
  */
8
8
  import { plainJsonCodec, type TransportCodec } from "@nifrajs/core/transport-codec"
9
+ import { isDeferredFree } from "./internal/deferred-free.ts"
9
10
  import { parsePageData } from "./internal/revive-dates.ts"
10
11
 
11
12
  /**
@@ -65,6 +66,7 @@ export function prepareDeferred(
65
66
  readonly deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>
66
67
  } {
67
68
  const deferred: Array<{ id: number; promise: Promise<unknown> }> = []
69
+ if (isDeferredFree(data)) return { forComponent: data, forClient: data, deferred }
68
70
  // Structural sharing: a subtree with no deferred value returns the ORIGINAL node for
69
71
  // both sides (`fc`/`cl` identical to the input), so a deferred-free result - the common case - is
70
72
  // returned by reference with no persisted clone, no downstream copy, and no throwaway clone
@@ -321,6 +323,14 @@ export async function parseNdjsonData(
321
323
  // accumulate listeners across navigations (a per-nav signal would be GC'd, but don't rely on it).
322
324
  const onAbort = (): void => void reader.cancel()
323
325
  if (signal) signal.addEventListener("abort", onAbort, { once: true })
326
+ let ended = false
327
+ // Every way out, a malformed line included, detaches from the signal and the body: a source left
328
+ // streaming into a reader nobody reads, or still locked to it, would hold its response open.
329
+ const release = (): void => {
330
+ signal?.removeEventListener("abort", onAbort) // no-op if it already fired ({ once: true })
331
+ if (!ended) reader.cancel().catch(() => {})
332
+ reader.releaseLock()
333
+ }
324
334
 
325
335
  const readLine = async (): Promise<string | null> => {
326
336
  for (;;) {
@@ -332,6 +342,7 @@ export async function parseNdjsonData(
332
342
  }
333
343
  const { done, value } = await reader.read()
334
344
  if (done) {
345
+ ended = true
335
346
  const rest = buffer
336
347
  buffer = ""
337
348
  return rest === "" ? null : rest
@@ -344,8 +355,14 @@ export async function parseNdjsonData(
344
355
  // Only plain JSON carries t.date() tags; a richer codec decodes its own dates.
345
356
  const decode = (line: string): unknown =>
346
357
  codec === plainJsonCodec ? parsePageData(line) : codec.decode(line)
347
- const first = await readLine()
348
- const data = markersFromPlaceholders(first === null ? null : decode(first), pending)
358
+ let data: unknown
359
+ try {
360
+ const first = await readLine()
361
+ data = markersFromPlaceholders(first === null ? null : decode(first), pending)
362
+ } catch (error) {
363
+ release()
364
+ throw error
365
+ }
349
366
 
350
367
  void (async () => {
351
368
  try {
@@ -363,7 +380,7 @@ export async function parseNdjsonData(
363
380
  } catch {
364
381
  // stream errored - leftovers are rejected below (unless this was an abort)
365
382
  } finally {
366
- signal?.removeEventListener("abort", onAbort) // no-op if it already fired ({ once: true })
383
+ release()
367
384
  // A natural stream end with markers unsettled = truncation → reject (so `<Await>` shows its
368
385
  // error). An `abort` = a superseded navigation → leave them pending: the markers are detached
369
386
  // (their data was never applied), so rejecting would only risk an error flash mid-transition.
package/src/index.ts CHANGED
@@ -87,6 +87,7 @@ export {
87
87
  // Generated client entries intentionally import the browser router from the client subpath:
88
88
  // `import { createClientRouter, createMatcher, mergeHeads, resolveChainMeta } from "@nifrajs/web/client"`.
89
89
  export { mergeHeads, resolveChainMeta, resolveMeta } from "./internal/head-merge.ts"
90
+ export type { PrerenderedPages } from "./internal/prerendered-pages.ts"
90
91
  export {
91
92
  gone,
92
93
  notFound,
@@ -121,6 +122,7 @@ export {
121
122
  type CreateWebAppOptions,
122
123
  createWebApp,
123
124
  type NonceResolver,
125
+ type WebAppI18n,
124
126
  webProjectEvidence,
125
127
  } from "./internal/web-app.ts"
126
128
  // ISR (incremental static regeneration): a pluggable cache store + the `withISR` stale-while-revalidate
@@ -223,7 +225,9 @@ export {
223
225
  } from "./nonce.ts"
224
226
  // public/ - user-authored static files, served identically in dev and production by one handler.
225
227
  export {
228
+ type PrerenderedPageReaderOptions,
226
229
  type PublicDirCache,
230
+ prerenderedPageReader,
227
231
  resolvePublicPath,
228
232
  type ServePublicDirOptions,
229
233
  servePublicDir,
@@ -256,7 +260,12 @@ export {
256
260
  type DocumentParts,
257
261
  HANDOVER_ID,
258
262
  LAYOUT_DATA_GLOBAL,
263
+ LOCALES_GLOBAL,
264
+ localeOfProps,
259
265
  type MatchChain,
266
+ type PageLocale,
267
+ type PageLocales,
268
+ pageLocaleIn,
260
269
  type RenderAdapter,
261
270
  type RenderProps,
262
271
  type RenderStreamOptions,
@@ -5,9 +5,12 @@ import {
5
5
  ACTION_GLOBAL,
6
6
  BOUNDARY_GLOBAL,
7
7
  COMPONENT_GLOBAL,
8
+ COPY_CHUNKS_GLOBAL,
9
+ COPY_REALM,
8
10
  DATA_GLOBAL,
9
11
  HANDOVER_ID,
10
12
  LAYOUT_DATA_GLOBAL,
13
+ LOCALES_GLOBAL,
11
14
  ROOT_ATTRIBUTE,
12
15
  ROUTE_GLOBAL,
13
16
  } from "../render-seam.ts"
@@ -23,6 +26,7 @@ const HANDOVER_GLOBALS = [
23
26
  BOUNDARY_GLOBAL,
24
27
  COMPONENT_GLOBAL,
25
28
  PRERENDERED_GLOBAL,
29
+ LOCALES_GLOBAL,
26
30
  ]
27
31
  export interface GenerateClientEntryOptions {
28
32
  /**
@@ -39,6 +43,9 @@ export interface GenerateClientEntryOptions {
39
43
  /** Turn a route/layout source file (relative to the routes dir) into an import specifier. */
40
44
  readonly resolve: (file: string) => string
41
45
  readonly serverComponents?: boolean
46
+ /** The build moved copy tables into per-locale chunks: the handover carries the chunk URLs, and a
47
+ * navigation loads the copy of the URL it goes to beside the route's code. */
48
+ readonly copyChunks?: boolean
42
49
  }
43
50
 
44
51
  /**
@@ -222,7 +229,7 @@ export function generateClientEntry(
222
229
  // routeId → the ids and `handle` exports `useMatches` reports (layouts, then the page).
223
230
  `const layoutIdsOf = ${jsValueLiteral(layoutIdsOf)}`,
224
231
  "const matchChains = {}",
225
- "const loadModule = async (id) => {",
232
+ `const ${options.copyChunks === true ? "loadRoute" : "loadModule"} = async (id) => {`,
226
233
  " if (chains[id]) return",
227
234
  " const mods = await loaders[id]()",
228
235
  // For an error route the `_error` module is appended LAST: wrap the page (now second-to-last) in
@@ -260,6 +267,17 @@ export function generateClientEntry(
260
267
  " routeHooks[id] = { clientLoader: page.clientLoader, clientAction: page.clientAction, boundaries }",
261
268
  " if (page.ssr === false) holds[id] = [...chains[id].slice(0, -1), page.HydrateFallback ?? (() => null)]",
262
269
  "}",
270
+ ...(options.copyChunks === true
271
+ ? [
272
+ `const copyRealm = () => globalThis[Symbol.for(${jsStringLiteral(COPY_REALM)})]`,
273
+ // A second prepare covers a copy definition the route's own code brought in.
274
+ "const loadModule = async (id, path) => {",
275
+ " if (path === undefined) return loadRoute(id)",
276
+ " await Promise.all([loadRoute(id), copyRealm()?.prepare(path)])",
277
+ " await copyRealm()?.prepare(path)",
278
+ "}",
279
+ ]
280
+ : []),
263
281
  // The server hands page state over as one inert JSON script; lift it onto the globals the
264
282
  // router, the deferred mapper and the adapters read, before any of them runs. Only the LAST
265
283
  // matching <script> counts, and only known keys: page HTML (sanitized user content) can carry an
@@ -268,7 +286,7 @@ export function generateClientEntry(
268
286
  "const handover = handovers[handovers.length - 1]",
269
287
  "if (handover !== undefined) {",
270
288
  ' const state = JSON.parse(handover.textContent || "{}")',
271
- ` for (const key of ${jsValueLiteral(HANDOVER_GLOBALS)}) {`,
289
+ ` for (const key of ${jsValueLiteral(options.copyChunks === true ? [...HANDOVER_GLOBALS, COPY_CHUNKS_GLOBAL] : HANDOVER_GLOBALS)}) {`,
272
290
  " if (Object.prototype.hasOwnProperty.call(state, key)) Reflect.set(window, key, state[key])",
273
291
  " }",
274
292
  "}",
@@ -383,7 +401,7 @@ export function generateClientEntry(
383
401
  // page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url - no head drift.
384
402
  " const nonce = currentDocumentNonce()",
385
403
  " const args = { data: s.data, pageData: s.data, params: s.params, origin: location.origin, ...(nonce === undefined ? {} : { nonce }) }",
386
- " applyHead(mergeHeads(resolveChainMeta(metas[s.routeId] ?? [undefined], s.layoutData, args)))",
404
+ " applyHead(mergeHeads(resolveChainMeta(metas[s.routeId] ?? [undefined], s.layoutData, args)), s.path)",
387
405
  " }",
388
406
  " })",
389
407
  "}).catch((error) => console.error('[nifra/web] hydration failed:', error))",
@@ -453,6 +471,9 @@ export interface GenerateServerManifestOptions {
453
471
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
454
472
  /** How framework-owned stylesheet links should activate before the generated client mounts. */
455
473
  readonly cssLoading?: CssLoadingMode
474
+ /** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`) - baked as
475
+ * `export const copyChunks` when the client build split copy tables, for `createWebApp`. */
476
+ readonly copyChunks?: Readonly<Record<string, string>> | undefined
456
477
  /** Emit **lazy** per-route loaders (`() => import("./routes/x")`, a static specifier) instead of
457
478
  * eager `import * as`, so a bundler with code-splitting emits one chunk per route - loaded on the
458
479
  * first request to it, not all at boot (smaller cold-start parse). Default `false` (eager). Both
@@ -500,6 +521,7 @@ export function generateServerManifest(
500
521
  styles,
501
522
  routeStyles,
502
523
  cssLoading: requestedCssLoading,
524
+ copyChunks,
503
525
  lazy = false,
504
526
  } = options
505
527
  // A missing entry would bake `clientEntry = undefined` and the build would still succeed, leaving
@@ -541,6 +563,11 @@ export function generateServerManifest(
541
563
  const stylesLine = `export const styles = ${jsValueLiteral(styles ?? [])}`
542
564
  const routeStylesLine = `export const routeStyles = ${jsValueLiteral(routeStyles ?? {})}`
543
565
  const cssLoadingLine = `export const cssLoading = ${jsStringLiteral(cssLoading)}`
566
+ // Only a build that split copy tables has the line; its server entry is the one that imports it.
567
+ const copyChunksLines =
568
+ copyChunks === undefined || Object.keys(copyChunks).length === 0
569
+ ? []
570
+ : [`export const copyChunks = ${jsValueLiteral(copyChunks)}`]
544
571
  if (lazy) {
545
572
  // Lazy: `() => import("./routes/x")` per route (static specifier → one chunk per route under a
546
573
  // code-splitting bundler, loaded on first request). The map keys are the route-relative paths
@@ -558,6 +585,7 @@ export function generateServerManifest(
558
585
  stylesLine,
559
586
  routeStylesLine,
560
587
  cssLoadingLine,
588
+ ...copyChunksLines,
561
589
  `export const manifest = buildManifest(Object.keys(loaders), ${LAZY_IMPORTER})`,
562
590
  ].join("\n")}\n`
563
591
  }
@@ -577,6 +605,7 @@ export function generateServerManifest(
577
605
  stylesLine,
578
606
  routeStylesLine,
579
607
  cssLoadingLine,
608
+ ...copyChunksLines,
580
609
  `export const manifest = buildManifest(Object.keys(modules), ${EAGER_IMPORTER})`,
581
610
  ].join("\n")}\n`
582
611
  }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Copy tables (`@nifrajs/i18n/copy`) in a production client build. Each table a module declares is
3
+ * replaced by its chunk id, and its values move into one chunk per locale, so a page's JavaScript carries
4
+ * the copy of the locale it renders in and none of the others. The server bundle keeps every table whole.
5
+ *
6
+ * The tables are found by `@nifrajs/i18n/extract` with the app's own TypeScript, both resolved from the
7
+ * app root: `@nifrajs/web` depends on neither. An app with no `defineCopy` pays one source scan.
8
+ */
9
+ import { createHash } from "node:crypto"
10
+ import { type Dirent, readdirSync, readFileSync } from "node:fs"
11
+ import { createRequire } from "node:module"
12
+ import { join, resolve as resolvePath } from "node:path"
13
+ import { pathToFileURL } from "node:url"
14
+ import type { BunPlugin } from "bun"
15
+ import type * as TS from "typescript"
16
+ import { addSourceTransform, normalizeFilePath } from "../plugins/kit.ts"
17
+ import { isTypeScriptCompiler } from "./typescript-api.ts"
18
+
19
+ const COPY_MODULE = "@nifrajs/i18n/copy"
20
+ const EXTRACT_MODULE = "@nifrajs/i18n/extract"
21
+ const SCRIPT = /\.[cm]?[jt]sx?$/
22
+ const REWRITABLE = /\.m?[jt]sx?(\?|$)/
23
+ const DEPENDENCY = /[\\/]node_modules[\\/]/
24
+ const MAX_DEPTH = 32
25
+
26
+ /** The part of `@nifrajs/i18n/extract` the build reads. */
27
+ interface TableSite {
28
+ readonly id: string
29
+ readonly values?: Readonly<Record<string, unknown>>
30
+ readonly definition: { readonly file: string; readonly name: string }
31
+ }
32
+ interface Scanner {
33
+ tables(file: string): readonly TableSite[]
34
+ definition(ref: TableSite["definition"]): { readonly locales?: unknown } | undefined
35
+ rewrite(file: string): string | undefined
36
+ }
37
+ interface SplitTable {
38
+ readonly id: string
39
+ readonly values: Readonly<Record<string, unknown>>
40
+ readonly locales: unknown
41
+ }
42
+ interface Extract {
43
+ createCopyScanner(
44
+ ts: typeof TS,
45
+ host: {
46
+ readonly root: string
47
+ read(file: string): string | undefined
48
+ resolve(specifier: string, from: string): string | undefined
49
+ },
50
+ ): Scanner
51
+ copyChunks(tables: Iterable<SplitTable>): ReadonlyMap<string, unknown>
52
+ }
53
+
54
+ const isExtract = (value: unknown): value is Extract =>
55
+ typeof value === "object" &&
56
+ value !== null &&
57
+ "createCopyScanner" in value &&
58
+ typeof value.createCopyScanner === "function" &&
59
+ "copyChunks" in value &&
60
+ typeof value.copyChunks === "function"
61
+
62
+ /** A chunk file the build wrote. */
63
+ export interface CopyChunkFile {
64
+ /** The file name, served under the build's public path. */
65
+ readonly name: string
66
+ readonly text: string
67
+ }
68
+
69
+ export interface CopySplit {
70
+ /** The client build's plugin, registered after every other one. */
71
+ readonly plugin: BunPlugin
72
+ /** A module with its tables replaced by chunk ids, or `undefined` when it has none to split. */
73
+ rewrite(file: string, source: string): string | undefined
74
+ /** One chunk per locale for the tables this build split, and each one's URL by locale key. */
75
+ chunks(publicPath: string): {
76
+ readonly urls: Readonly<Record<string, string>>
77
+ readonly files: readonly CopyChunkFile[]
78
+ }
79
+ }
80
+
81
+ const readText = (file: string): string | undefined => {
82
+ try {
83
+ return readFileSync(file, "utf8")
84
+ } catch {
85
+ return undefined
86
+ }
87
+ }
88
+
89
+ /** Whether a module under `dir` imports the copy module: the only way an app defines copy tables. */
90
+ function definesCopy(dir: string, skip: ReadonlySet<string>, depth = 0): boolean {
91
+ let entries: Dirent[]
92
+ try {
93
+ entries = readdirSync(dir, { withFileTypes: true })
94
+ } catch {
95
+ return false
96
+ }
97
+ for (const entry of entries) {
98
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue
99
+ const path = join(dir, entry.name)
100
+ if (entry.isDirectory()) {
101
+ if (depth < MAX_DEPTH && !skip.has(path) && definesCopy(path, skip, depth + 1)) return true
102
+ } else if (entry.isFile() && SCRIPT.test(entry.name)) {
103
+ if (readText(path)?.includes(COPY_MODULE) === true) return true
104
+ }
105
+ }
106
+ return false
107
+ }
108
+
109
+ const loaderOf = (path: string): "js" | "jsx" | "ts" | "tsx" => {
110
+ if (path.endsWith("x")) return path.endsWith(".tsx") ? "tsx" : "jsx"
111
+ return /\.m?ts$/.test(path) ? "ts" : "js"
112
+ }
113
+
114
+ /**
115
+ * The copy split for an app, or `undefined` when it defines no copy tables. An app that does, but whose
116
+ * TypeScript has no compiler API, builds with its tables whole, with a warning.
117
+ */
118
+ export async function loadCopySplit(
119
+ root: string,
120
+ options: { readonly skip?: readonly string[]; readonly warn?: (message: string) => void } = {},
121
+ ): Promise<CopySplit | undefined> {
122
+ const appRoot = resolvePath(root)
123
+ const require = createRequire(join(appRoot, "package.json"))
124
+ let extractPath: string
125
+ try {
126
+ extractPath = require.resolve(EXTRACT_MODULE)
127
+ } catch {
128
+ return undefined
129
+ }
130
+ if (!definesCopy(appRoot, new Set((options.skip ?? []).map((dir) => resolvePath(dir))))) {
131
+ return undefined
132
+ }
133
+ const warn = options.warn ?? console.warn
134
+ let ts: unknown
135
+ try {
136
+ ts = require("typescript")
137
+ } catch {
138
+ ts = undefined
139
+ }
140
+ if (!isTypeScriptCompiler(ts)) {
141
+ warn(
142
+ "[nifra/web] copy tables ship every locale in the client bundle: splitting them by locale reads the source with the TypeScript compiler API, and the app's TypeScript has none (TypeScript 7 ships none; install typescript@^6 as a dev dependency).",
143
+ )
144
+ return undefined
145
+ }
146
+ const extract: unknown = await import(pathToFileURL(extractPath).href)
147
+ if (!isExtract(extract)) {
148
+ warn(
149
+ `[nifra/web] copy tables ship every locale in the client bundle: ${EXTRACT_MODULE} at ${extractPath} predates the client split; update @nifrajs/i18n.`,
150
+ )
151
+ return undefined
152
+ }
153
+ const scanner = extract.createCopyScanner(ts, {
154
+ root: appRoot.replaceAll("\\", "/"),
155
+ read: readText,
156
+ resolve: (specifier, from) => {
157
+ try {
158
+ return createRequire(from).resolve(specifier)
159
+ } catch {
160
+ return undefined
161
+ }
162
+ },
163
+ })
164
+
165
+ const split = new Map<string, readonly SplitTable[]>()
166
+ const rewrite = (file: string, source: string): string | undefined => {
167
+ const path = normalizeFilePath(file)
168
+ // The scanner reads the file off disk: text another transform changed first is left alone, since
169
+ // its tables' positions are no longer the file's.
170
+ if (!REWRITABLE.test(path) || DEPENDENCY.test(path) || readText(path) !== source) {
171
+ return undefined
172
+ }
173
+ const out = scanner.rewrite(path)
174
+ if (out === undefined) return undefined
175
+ split.set(
176
+ path,
177
+ scanner.tables(path).flatMap((site) => {
178
+ const locales =
179
+ site.values === undefined ? undefined : scanner.definition(site.definition)?.locales
180
+ return site.values === undefined || locales === undefined
181
+ ? []
182
+ : [{ id: site.id, values: site.values, locales }]
183
+ }),
184
+ )
185
+ return out
186
+ }
187
+
188
+ return {
189
+ plugin: {
190
+ name: "nifra-copy-split",
191
+ setup(build) {
192
+ addSourceTransform(build, rewrite)
193
+ build.onLoad({ filter: REWRITABLE }, async (args) => {
194
+ if (args.namespace !== "file") return undefined
195
+ const path = normalizeFilePath(args.path)
196
+ const contents = rewrite(path, await Bun.file(path).text())
197
+ return contents === undefined ? undefined : { contents, loader: loaderOf(path) }
198
+ })
199
+ },
200
+ },
201
+ rewrite,
202
+ chunks(publicPath) {
203
+ // Sorted, so a chunk's bytes, and so its name, do not depend on the order modules loaded in.
204
+ const tables = [...split.values()].flat().sort((a, b) => (a.id < b.id ? -1 : 1))
205
+ const urls: Record<string, string> = {}
206
+ const files: CopyChunkFile[] = []
207
+ if (tables.length === 0) return { urls, files }
208
+ const chunks = [...extract.copyChunks(tables)].sort(([a], [b]) => (a < b ? -1 : 1))
209
+ for (const [locale, chunk] of chunks) {
210
+ // A JSON string parses faster than the same object as a literal.
211
+ const text = `export default JSON.parse(${JSON.stringify(JSON.stringify(chunk))})\n`
212
+ const hash = createHash("sha256").update(text).digest("hex").slice(0, 12)
213
+ const name = `copy-${locale.replace(/[^A-Za-z0-9-]/g, "_")}-${hash}.js`
214
+ urls[locale] = `${publicPath}${name}`
215
+ files.push({ name, text })
216
+ }
217
+ return { urls, files }
218
+ },
219
+ }
220
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Values an output guard settled under a schema that cannot let a `defer()` marker through: no
3
+ * `t.deferred`, and no open or untyped node. `prepareDeferred` returns them without walking them.
4
+ * A marker the schema does declare is projected into a new object, so the value that holds it is
5
+ * a fresh copy, never one marked here.
6
+ */
7
+ const deferredFree = new WeakSet<object>()
8
+
9
+ export function markDeferredFree(value: unknown): void {
10
+ if (typeof value === "object" && value !== null) deferredFree.add(value)
11
+ }
12
+
13
+ export function isDeferredFree(value: unknown): boolean {
14
+ return typeof value === "object" && value !== null && deferredFree.has(value)
15
+ }
@@ -0,0 +1,30 @@
1
+ interface ModuleEntry<T extends object> {
2
+ readonly load: () => Promise<T>
3
+ }
4
+
5
+ /** Production imports are immutable; failed imports remain retryable. No loader data is cached. */
6
+ export function prepareModules(
7
+ enabled: boolean,
8
+ ): <T extends object>(entry: ModuleEntry<T>) => T | Promise<T> {
9
+ const modules = new WeakMap<object, object | Promise<object>>()
10
+ return <T extends object>(entry: ModuleEntry<T>): T | Promise<T> => {
11
+ if (!enabled) return entry.load()
12
+ const known = modules.get(entry)
13
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Only this entry.load populates its slot, so the cached value retains that entry's module type.
14
+ if (known !== undefined) return known as T | Promise<T>
15
+ const pending = Promise.resolve()
16
+ .then(() => entry.load())
17
+ .then(
18
+ (module) => {
19
+ modules.set(entry, module)
20
+ return module
21
+ },
22
+ (error) => {
23
+ modules.delete(entry)
24
+ throw error
25
+ },
26
+ )
27
+ modules.set(entry, pending)
28
+ return pending
29
+ }
30
+ }
@@ -14,6 +14,7 @@
14
14
  * its own `validate` output, as zod and valibot objects do by default.
15
15
  */
16
16
  import type { StandardResult, StandardSchemaV1 } from "@nifrajs/core/server"
17
+ import { markDeferredFree } from "./deferred-free.ts"
17
18
  import { wireDate } from "./wire-date.ts"
18
19
 
19
20
  // Registry symbols shared with core and `@nifrajs/schema`, so two copies of either still agree.
@@ -84,6 +85,8 @@ interface CompiledGuard {
84
85
  readonly validate: StandardSchemaV1["~standard"]["validate"]
85
86
  /** Dev only: name the keys the guard dropped, so a schema missing a field is not a silent blank. */
86
87
  readonly warnDropped: boolean
88
+ /** No value this guard passes can hold a `defer()` marker (see internal/deferred-free.ts). */
89
+ readonly deferredFree: boolean
87
90
  }
88
91
 
89
92
  interface Deferred {
@@ -200,7 +203,10 @@ function shapeOf(
200
203
  }
201
204
 
202
205
  class Compiler {
206
+ /** Some node may pass a `defer()` marker on: a deferred node, or one that lets any value through. */
207
+ mayPassMarker = false
203
208
  private readonly memo = new Map<Node, Project>()
209
+ private readonly issueFree = new WeakSet<Project>([LEAF, LEAF_ARRAY, DATE])
204
210
  private readonly ids = new Map<string, Node>()
205
211
  readonly sensitive: string[] = []
206
212
 
@@ -263,25 +269,33 @@ class Compiler {
263
269
  case "array":
264
270
  return this.array(node, where)
265
271
  case "leaf":
272
+ // `not` (or an object `const`) without a `type` can accept an object.
273
+ if (node.type === undefined) this.mayPassMarker = true
266
274
  return LEAF
267
275
  default:
268
- return UNKNOWN
276
+ return this.unknown()
269
277
  }
270
278
  }
271
279
 
280
+ private unknown(): Project {
281
+ this.mayPassMarker = true
282
+ return UNKNOWN
283
+ }
284
+
272
285
  private extra(schema: unknown, where: string): Project | undefined {
273
- if (schema === true || (isNode(schema) && Object.keys(schema).length === 0)) return UNKNOWN
286
+ if (schema === true || (isNode(schema) && Object.keys(schema).length === 0))
287
+ return this.unknown()
274
288
  return isNode(schema) ? this.compile(schema, `${where}/*`) : undefined
275
289
  }
276
290
 
277
291
  private object(node: Node, where: string): Project {
278
- const declared = new Map<string, Project>()
292
+ const declared: Record<string, Project> = Object.create(null)
279
293
  for (const [key, sub] of Object.entries(isNode(node.properties) ? node.properties : {})) {
280
294
  if (!isNode(sub)) continue
281
295
  if (isSensitiveFieldName(key) && typeof sub[DECLASSIFIED_KEY] !== "string") {
282
296
  this.sensitive.push(`${where}/${key}`)
283
297
  }
284
- declared.set(key, this.compile(sub, `${where}/${key}`))
298
+ declared[key] = this.compile(sub, `${where}/${key}`)
285
299
  }
286
300
  const patterns: Array<readonly [RegExp, Project]> = []
287
301
  for (const [pattern, sub] of Object.entries(
@@ -302,7 +316,7 @@ class Compiler {
302
316
  }
303
317
  // A closed object drops a deferred marker's keys unless it declares them, so only an open one, or
304
318
  // one declaring the marker key, could pass a marker on.
305
- const checksMarker = open || declared.has("__nifra_deferred")
319
+ const checksMarker = open || declared.__nifra_deferred !== undefined
306
320
  const project: Project = (value, state) => {
307
321
  if (typeof value !== "object" || value === null || Array.isArray(value)) return value
308
322
  if (checksMarker && isDeferred(value)) {
@@ -325,7 +339,7 @@ class Compiler {
325
339
  for (const key in input) {
326
340
  // An inherited enumerable key (a polluted prototype) is never data.
327
341
  if (!Object.hasOwn(input, key)) continue
328
- const projector = declared.get(key) ?? (open ? undeclared(key) : undefined)
342
+ const projector = declared[key] ?? (open ? undeclared(key) : undefined)
329
343
  if (projector === undefined) {
330
344
  out ??= copyBefore(input, key)
331
345
  continue
@@ -346,6 +360,8 @@ class Compiler {
346
360
  }
347
361
  return out ?? value
348
362
  }
363
+ if (!checksMarker && Object.values(declared).every((child) => this.issueFree.has(child)))
364
+ this.issueFree.add(project)
349
365
  return project
350
366
  }
351
367
 
@@ -356,13 +372,32 @@ class Compiler {
356
372
  ? node.items
357
373
  : []
358
374
  const head = prefixNodes.map((sub, i) =>
359
- isNode(sub) ? this.compile(sub, `${where}/${i}`) : UNKNOWN,
375
+ isNode(sub) ? this.compile(sub, `${where}/${i}`) : this.unknown(),
360
376
  )
361
377
  const restSchema = Array.isArray(node.items) ? node.additionalItems : node.items
362
- const rest = this.extra(restSchema, where) ?? UNKNOWN
378
+ const rest = this.extra(restSchema, where) ?? this.unknown()
363
379
  // An array of scalars is the validator's alone: no key to drop, and walking it here would cost
364
380
  // every `string[]` a loop. A `Date` inside one reaches the validator as it is.
365
381
  if (rest === LEAF && head.every((projector) => projector === LEAF)) return LEAF_ARRAY
382
+ // A uniform subtree that cannot emit deferred-marker issues needs no per-item path bookkeeping.
383
+ if (head.length === 0 && this.issueFree.has(rest)) {
384
+ const project: Project = (value, state) => {
385
+ if (!Array.isArray(value)) return value
386
+ let out: unknown[] | undefined
387
+ for (let i = 0; i < value.length; i++) {
388
+ const before = value[i]
389
+ const after = rest(before, state)
390
+ if (out !== undefined) out[i] = after
391
+ else if (after !== before) {
392
+ out = value.slice(0, i)
393
+ out[i] = after
394
+ }
395
+ }
396
+ return out ?? value
397
+ }
398
+ this.issueFree.add(project)
399
+ return project
400
+ }
366
401
  return (value, state) => {
367
402
  if (!Array.isArray(value)) return value
368
403
  let out: unknown[] | undefined
@@ -424,7 +459,7 @@ class Compiler {
424
459
  const branches: Node[] = []
425
460
  this.branches(node, where, branches, new Set())
426
461
  const kinds = branches.map(shapeOf)
427
- if (kinds.includes("unknown")) return UNKNOWN
462
+ if (kinds.includes("unknown")) return this.unknown()
428
463
  const deferreds = branches.filter((_, i) => kinds[i] === "deferred")
429
464
  if (deferreds.length > 1) {
430
465
  throw new OutputGuardError(
@@ -463,6 +498,7 @@ class Compiler {
463
498
  }
464
499
 
465
500
  private deferred(node: Node, where: string): Project {
501
+ this.mayPassMarker = true
466
502
  const validate = (node as { readonly [DEFERRED_VALIDATE]?: unknown })[DEFERRED_VALIDATE]
467
503
  if (!isNode(node.inner) || typeof validate !== "function") {
468
504
  throw new OutputGuardError(
@@ -473,6 +509,7 @@ class Compiler {
473
509
  project: this.compile(node.inner, where),
474
510
  validate: validate as CompiledGuard["validate"],
475
511
  warnDropped: inDevelopment(),
512
+ deferredFree: false,
476
513
  }
477
514
  return (value, state) => {
478
515
  if (!isDeferred(value)) return value
@@ -591,9 +628,11 @@ export function outputGuard(schema: unknown, owner: string, exportName: string):
591
628
  const standard = schema as StandardSchemaV1
592
629
  const json = jsonSchemaOf(standard)
593
630
  let project: Project | undefined
631
+ let deferredFree = false
594
632
  if (json !== undefined) {
595
633
  const compiler = new Compiler(json)
596
634
  project = compiler.compile(json, "")
635
+ deferredFree = !compiler.mayPassMarker
597
636
  if (compiler.sensitive.length > 0) {
598
637
  throw new OutputGuardError(
599
638
  `${owner} declares sensitive field(s) ${compiler.sensitive.join(", ")} in ${exportName}. Remove them, or mark a field that must reach the browser with t.declassified("why it may", schema)`,
@@ -605,6 +644,7 @@ export function outputGuard(schema: unknown, owner: string, exportName: string):
605
644
  project,
606
645
  validate: standard["~standard"].validate,
607
646
  warnDropped: inDevelopment(),
647
+ deferredFree,
608
648
  }
609
649
  compiled.set(schema, guard)
610
650
  return guard
@@ -675,6 +715,8 @@ function settle(
675
715
  // No JSON Schema to project by: the schema's own validate stripped what it does not declare.
676
716
  if (guard.warnDropped) warnDropped(label, input, result.value)
677
717
  }
718
+ // Only the projected value itself: a validator that returns a new value (a transform) may add a marker.
719
+ if (guard.deferredFree && result.value === input) markDeferredFree(result.value)
678
720
  return result.value
679
721
  }
680
722