@nifrajs/web 5.0.0 → 5.2.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 (171) hide show
  1. package/CHANGELOG.md +2970 -0
  2. package/README.md +8 -0
  3. package/dist/build-plan.d.ts +7 -0
  4. package/dist/build-plan.d.ts.map +1 -1
  5. package/dist/build-plan.js +19 -0
  6. package/dist/build-plan.js.map +1 -1
  7. package/dist/build-vite.d.ts +1 -0
  8. package/dist/build-vite.d.ts.map +1 -1
  9. package/dist/build-vite.js +51 -3
  10. package/dist/build-vite.js.map +1 -1
  11. package/dist/build.d.ts +12 -1
  12. package/dist/build.d.ts.map +1 -1
  13. package/dist/build.js +43 -6
  14. package/dist/build.js.map +1 -1
  15. package/dist/client.d.ts +7 -6
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +39 -22
  18. package/dist/client.js.map +1 -1
  19. package/dist/dev-port.d.ts +3 -2
  20. package/dist/dev-port.d.ts.map +1 -1
  21. package/dist/dev-port.js +2 -6
  22. package/dist/dev-port.js.map +1 -1
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/codegen.d.ts +6 -0
  28. package/dist/internal/codegen.d.ts.map +1 -1
  29. package/dist/internal/codegen.js +23 -5
  30. package/dist/internal/codegen.js.map +1 -1
  31. package/dist/internal/copy-split.d.ts +27 -0
  32. package/dist/internal/copy-split.d.ts.map +1 -0
  33. package/dist/internal/copy-split.js +173 -0
  34. package/dist/internal/copy-split.js.map +1 -0
  35. package/dist/internal/package-entries.d.ts +34 -0
  36. package/dist/internal/package-entries.d.ts.map +1 -0
  37. package/dist/internal/package-entries.js +324 -0
  38. package/dist/internal/package-entries.js.map +1 -0
  39. package/dist/internal/page-execution.d.ts +19 -1
  40. package/dist/internal/page-execution.d.ts.map +1 -1
  41. package/dist/internal/page-execution.js +123 -14
  42. package/dist/internal/page-execution.js.map +1 -1
  43. package/dist/internal/parity.d.ts.map +1 -1
  44. package/dist/internal/parity.js +15 -2
  45. package/dist/internal/parity.js.map +1 -1
  46. package/dist/internal/private-env.d.ts +8 -0
  47. package/dist/internal/private-env.d.ts.map +1 -1
  48. package/dist/internal/private-env.js +25 -10
  49. package/dist/internal/private-env.js.map +1 -1
  50. package/dist/internal/render-document.d.ts +15 -46
  51. package/dist/internal/render-document.d.ts.map +1 -1
  52. package/dist/internal/render-document.js +59 -62
  53. package/dist/internal/render-document.js.map +1 -1
  54. package/dist/internal/route-path.d.ts +6 -0
  55. package/dist/internal/route-path.d.ts.map +1 -1
  56. package/dist/internal/route-path.js +11 -0
  57. package/dist/internal/route-path.js.map +1 -1
  58. package/dist/internal/secret-scan.d.ts.map +1 -1
  59. package/dist/internal/secret-scan.js +103 -1
  60. package/dist/internal/secret-scan.js.map +1 -1
  61. package/dist/internal/typescript-api.d.ts +4 -0
  62. package/dist/internal/typescript-api.d.ts.map +1 -0
  63. package/dist/internal/typescript-api.js +8 -0
  64. package/dist/internal/typescript-api.js.map +1 -0
  65. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  66. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  67. package/dist/internal/vite-plugin-compat.js +82 -0
  68. package/dist/internal/vite-plugin-compat.js.map +1 -0
  69. package/dist/internal/web-app.d.ts +38 -2
  70. package/dist/internal/web-app.d.ts.map +1 -1
  71. package/dist/internal/web-app.js +30 -3
  72. package/dist/internal/web-app.js.map +1 -1
  73. package/dist/internal/without-query.d.ts +4 -0
  74. package/dist/internal/without-query.d.ts.map +1 -0
  75. package/dist/internal/without-query.js +9 -0
  76. package/dist/internal/without-query.js.map +1 -0
  77. package/dist/internal/zone-graph.d.ts.map +1 -1
  78. package/dist/internal/zone-graph.js +17 -6
  79. package/dist/internal/zone-graph.js.map +1 -1
  80. package/dist/isr.d.ts +1 -1
  81. package/dist/isr.d.ts.map +1 -1
  82. package/dist/isr.js +15 -2
  83. package/dist/isr.js.map +1 -1
  84. package/dist/manifest.d.ts +9 -0
  85. package/dist/manifest.d.ts.map +1 -1
  86. package/dist/manifest.js +1 -0
  87. package/dist/manifest.js.map +1 -1
  88. package/dist/plugins/import-glob.d.ts +20 -0
  89. package/dist/plugins/import-glob.d.ts.map +1 -0
  90. package/dist/plugins/import-glob.js +401 -0
  91. package/dist/plugins/import-glob.js.map +1 -0
  92. package/dist/plugins/kit.d.ts +11 -0
  93. package/dist/plugins/kit.d.ts.map +1 -1
  94. package/dist/plugins/kit.js +32 -0
  95. package/dist/plugins/kit.js.map +1 -1
  96. package/dist/plugins/stylex.d.ts.map +1 -1
  97. package/dist/plugins/stylex.js +2 -2
  98. package/dist/plugins/stylex.js.map +1 -1
  99. package/dist/plugins/vite-leak-guard.d.ts +6 -0
  100. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  101. package/dist/plugins/vite-leak-guard.js +80 -7
  102. package/dist/plugins/vite-leak-guard.js.map +1 -1
  103. package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
  104. package/dist/plugins/vite-zone-guard.js +17 -21
  105. package/dist/plugins/vite-zone-guard.js.map +1 -1
  106. package/dist/plugins/zone-guard.d.ts.map +1 -1
  107. package/dist/plugins/zone-guard.js +44 -3
  108. package/dist/plugins/zone-guard.js.map +1 -1
  109. package/dist/prerender.d.ts +5 -1
  110. package/dist/prerender.d.ts.map +1 -1
  111. package/dist/prerender.js +41 -19
  112. package/dist/prerender.js.map +1 -1
  113. package/dist/render-seam.d.ts +60 -2
  114. package/dist/render-seam.d.ts.map +1 -1
  115. package/dist/render-seam.js +33 -0
  116. package/dist/render-seam.js.map +1 -1
  117. package/dist/router.d.ts +3 -2
  118. package/dist/router.d.ts.map +1 -1
  119. package/dist/router.js +13 -9
  120. package/dist/router.js.map +1 -1
  121. package/dist/seo.d.ts +49 -0
  122. package/dist/seo.d.ts.map +1 -0
  123. package/dist/seo.js +48 -0
  124. package/dist/seo.js.map +1 -0
  125. package/dist/server-components/typescript.d.ts.map +1 -1
  126. package/dist/server-components/typescript.js +2 -7
  127. package/dist/server-components/typescript.js.map +1 -1
  128. package/dist/vite.d.ts +1 -28
  129. package/dist/vite.d.ts.map +1 -1
  130. package/dist/vite.js +3 -69
  131. package/dist/vite.js.map +1 -1
  132. package/dist/zones.d.ts +30 -8
  133. package/dist/zones.d.ts.map +1 -1
  134. package/dist/zones.js +185 -46
  135. package/dist/zones.js.map +1 -1
  136. package/package.json +10 -4
  137. package/src/build-plan.ts +21 -0
  138. package/src/build-vite.ts +60 -3
  139. package/src/build.ts +57 -4
  140. package/src/client.ts +42 -22
  141. package/src/dev-port.ts +4 -10
  142. package/src/index.ts +8 -4
  143. package/src/internal/codegen.ts +32 -3
  144. package/src/internal/copy-split.ts +220 -0
  145. package/src/internal/package-entries.ts +331 -0
  146. package/src/internal/page-execution.ts +150 -13
  147. package/src/internal/parity.ts +15 -2
  148. package/src/internal/private-env.ts +27 -13
  149. package/src/internal/render-document.ts +100 -81
  150. package/src/internal/route-path.ts +16 -0
  151. package/src/internal/secret-scan.ts +101 -1
  152. package/src/internal/typescript-api.ts +11 -0
  153. package/src/internal/vite-plugin-compat.ts +81 -0
  154. package/src/internal/web-app.ts +71 -6
  155. package/src/internal/without-query.ts +8 -0
  156. package/src/internal/zone-graph.ts +19 -6
  157. package/src/isr.ts +13 -2
  158. package/src/manifest.ts +10 -0
  159. package/src/plugins/import-glob.ts +392 -0
  160. package/src/plugins/kit.ts +36 -0
  161. package/src/plugins/stylex.ts +2 -1
  162. package/src/plugins/vite-leak-guard.ts +99 -7
  163. package/src/plugins/vite-zone-guard.ts +21 -19
  164. package/src/plugins/zone-guard.ts +41 -2
  165. package/src/prerender.ts +39 -18
  166. package/src/render-seam.ts +88 -2
  167. package/src/router.ts +16 -11
  168. package/src/seo.ts +68 -0
  169. package/src/server-components/typescript.ts +2 -10
  170. package/src/vite.ts +3 -81
  171. package/src/zones.ts +237 -52
@@ -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,331 @@
1
+ /**
2
+ * Per-entry package sides: `"nifra": { "environment": { ".": "backend", "./react": "frontend" } }`.
3
+ *
4
+ * An import names an entry, and that entry's side decides it. A path names no entry, so a file is owned
5
+ * by every mapped entry that reaches it inside the package: the entry's targets under any export
6
+ * condition, followed through relative imports. A stage that sees only paths decides by those owners;
7
+ * which entry an import may name is decided where the import is written, from its specifier.
8
+ */
9
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
10
+ import { dirname, extname, join, relative } from "node:path"
11
+ import type { PackageEnvironment } from "../zones.ts"
12
+ import { type Token, tokenize } from "./private-env.ts"
13
+ import { withoutQuery } from "./without-query.ts"
14
+
15
+ export type EnvironmentDeclaration =
16
+ | { readonly kind: "none" }
17
+ | { readonly kind: "package"; readonly side: PackageEnvironment }
18
+ | { readonly kind: "entries"; readonly entries: Readonly<Record<string, PackageEnvironment>> }
19
+ | { readonly kind: "invalid"; readonly problem: string }
20
+
21
+ const SIDES: ReadonlySet<string> = new Set(["frontend", "backend", "shared", "library"])
22
+ const isSide = (value: unknown): value is PackageEnvironment =>
23
+ typeof value === "string" && SIDES.has(value)
24
+
25
+ /** A manifest's `nifra.environment`. A string naming no side declares nothing, as it always has. */
26
+ export function parseEnvironment(value: unknown): EnvironmentDeclaration {
27
+ if (isSide(value)) return { kind: "package", side: value }
28
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { kind: "none" }
29
+ const entries: Record<string, PackageEnvironment> = {}
30
+ for (const [key, side] of Object.entries(value)) {
31
+ if (key !== "." && !key.startsWith("./"))
32
+ return {
33
+ kind: "invalid",
34
+ problem: `key "${key}" must be "." or a subpath starting with "./"`,
35
+ }
36
+ if (key.indexOf("*") !== key.lastIndexOf("*"))
37
+ return { kind: "invalid", problem: `key "${key}" may hold one "*"` }
38
+ if (!isSide(side))
39
+ return {
40
+ kind: "invalid",
41
+ problem: `"${key}" must be one of frontend, backend, shared, library`,
42
+ }
43
+ entries[key] = side
44
+ }
45
+ return Object.keys(entries).length === 0
46
+ ? {
47
+ kind: "invalid",
48
+ problem: 'maps no entry; give each export subpath (".", "./react") a side',
49
+ }
50
+ : { kind: "entries", entries }
51
+ }
52
+
53
+ /**
54
+ * The mapped key that decides `subpath`: the exact key, else the `*` pattern with the longest prefix,
55
+ * then the longest key, where `*` matches at least one character - as Node matches `exports`.
56
+ */
57
+ export function entryKey(
58
+ entries: Readonly<Record<string, PackageEnvironment>>,
59
+ subpath: string,
60
+ ): string | undefined {
61
+ if (Object.hasOwn(entries, subpath)) return subpath
62
+ let best: string | undefined
63
+ for (const key of Object.keys(entries)) {
64
+ const star = key.indexOf("*")
65
+ if (star === -1 || subpath.length < key.length) continue
66
+ if (!subpath.startsWith(key.slice(0, star)) || !subpath.endsWith(key.slice(star + 1))) continue
67
+ const bestStar = best?.indexOf("*") ?? -1
68
+ if (best === undefined || star > bestStar || (star === bestStar && key.length > best.length))
69
+ best = key
70
+ }
71
+ return best
72
+ }
73
+
74
+ export interface EntryOwnership {
75
+ /** Each file a mapped entry reaches, by real path, with the sides of every entry that reaches it. */
76
+ readonly owners: ReadonlyMap<string, ReadonlySet<PackageEnvironment>>
77
+ /** Export subpaths the map gives no side. */
78
+ readonly unmapped: readonly string[]
79
+ }
80
+
81
+ const isFile = (path: string): boolean =>
82
+ statSync(path, { throwIfNoEntry: false })?.isFile() ?? false
83
+
84
+ const real = (path: string): string => {
85
+ try {
86
+ return realpathSync.native(path).replaceAll("\\", "/")
87
+ } catch {
88
+ return path.replaceAll("\\", "/")
89
+ }
90
+ }
91
+
92
+ const SCRIPT = /\.(?:[cm]?[jt]sx?|svelte|vue)$/
93
+ const STYLESHEET = /\.(?:css|scss|sass|less|styl)$/
94
+ const DECLARATION = /\.d\.[cm]?ts$/
95
+ const PROBE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"]
96
+ /** A TypeScript source imports its sibling by the extension it compiles to. */
97
+ const SOURCE_TWINS: Readonly<Record<string, readonly string[]>> = {
98
+ ".js": [".ts", ".tsx"],
99
+ ".jsx": [".tsx"],
100
+ ".mjs": [".mts"],
101
+ ".cjs": [".cts"],
102
+ }
103
+
104
+ function probe(base: string): string | undefined {
105
+ if (isFile(base)) return base
106
+ const extension = extname(base)
107
+ for (const twin of SOURCE_TWINS[extension] ?? []) {
108
+ const candidate = `${base.slice(0, -extension.length)}${twin}`
109
+ if (isFile(candidate)) return candidate
110
+ }
111
+ for (const candidate of PROBE_EXTENSIONS.map((ext) => `${base}${ext}`)) {
112
+ if (isFile(candidate)) return candidate
113
+ }
114
+ for (const candidate of PROBE_EXTENSIONS.map((ext) => join(base, `index${ext}`))) {
115
+ if (isFile(candidate)) return candidate
116
+ }
117
+ return undefined
118
+ }
119
+
120
+ const CLAUSE_PUNCT: ReadonlySet<string> = new Set(["{", "}", ",", "*"])
121
+ const isPunct = (token: Token | undefined, value: string): boolean =>
122
+ token?.kind === "punct" && token.value === value
123
+ const isId = (token: Token | undefined, value: string): boolean =>
124
+ token?.kind === "id" && token.value === value
125
+
126
+ /**
127
+ * Whether the `import`/`export` clause ending at the `from` token `at` binds only types: `import type`,
128
+ * `export type`, or a `{ }` list whose every specifier is `type`. Such an edge grants no ownership - a
129
+ * backend file a frontend entry merely types against stays backend. `import { type A }` survives as
130
+ * `import {}` under Vite with `verbatimModuleSyntax`; dropping it then fails closed, since the guard
131
+ * judges that load by the file's remaining owners.
132
+ */
133
+ function typeOnlyClause(tokens: readonly Token[], at: number): boolean {
134
+ let start = at - 1
135
+ for (; start >= 0; start--) {
136
+ const token = tokens[start]
137
+ if (isId(token, "import") || isId(token, "export")) break
138
+ if (token?.kind !== "id" && !(token?.kind === "punct" && CLAUSE_PUNCT.has(token.value)))
139
+ return false
140
+ }
141
+ if (start < 0) return false
142
+ const clause = tokens.slice(start + 1, at)
143
+ // `import type from "./x"` and `import type, { a } from` bind a default import named `type`.
144
+ if (isId(clause[0], "type")) return clause.length > 1 && !isPunct(clause[1], ",")
145
+ if (!isPunct(clause[0], "{") || !isPunct(clause.at(-1), "}")) return false
146
+ const specifiers: Token[][] = [[]]
147
+ for (const token of clause.slice(1, -1)) {
148
+ if (isPunct(token, ",")) specifiers.push([])
149
+ else specifiers.at(-1)?.push(token)
150
+ }
151
+ const named = specifiers.filter((specifier) => specifier.length > 0)
152
+ // `{ type as x }` imports a value named `type`; `{ type A }` and `{ type A as B }` import types.
153
+ return (
154
+ named.length > 0 &&
155
+ named.every(
156
+ (specifier) =>
157
+ isId(specifier[0], "type") &&
158
+ (specifier.length === 2 || (specifier.length === 4 && isId(specifier[2], "as"))),
159
+ )
160
+ )
161
+ }
162
+
163
+ /** The relative specifiers a module loads: imports, re-exports, `import()`s, `require`s and
164
+ * `new URL()`s (a worker or an asset beside it). A clause binding only types loads nothing. */
165
+ function relativeImports(code: string): string[] {
166
+ const tokens = tokenize(code)
167
+ const found: string[] = []
168
+ for (let i = 0; i < tokens.length; i++) {
169
+ const token = tokens[i]
170
+ if (token?.kind !== "id") continue
171
+ const previous = tokens[i - 1]
172
+ if (previous?.kind === "punct" && previous.value === ".") continue
173
+ let next = tokens[i + 1]
174
+ const call = next?.kind === "punct" && next.value === "("
175
+ const url = token.value === "URL" && previous?.kind === "id" && previous.value === "new"
176
+ if (token.value === "require" || url || (token.value === "import" && call)) {
177
+ if (!call) continue
178
+ next = tokens[i + 2]
179
+ } else if (token.value !== "from" && token.value !== "import") continue
180
+ if (next?.kind !== "string" || !/^\.\.?\//.test(next.value)) continue
181
+ if (token.value === "from" && typeOnlyClause(tokens, i)) continue
182
+ found.push(next.value)
183
+ }
184
+ return found
185
+ }
186
+
187
+ /** What a stylesheet pulls in beside it: `@import`s and `url()`s that name no scheme or root. */
188
+ function stylesheetImports(code: string): string[] {
189
+ const text = code.replace(/\/\*[\s\S]*?\*\//g, "")
190
+ const found: string[] = []
191
+ for (const match of text.matchAll(
192
+ /@import\s+(?:url\(\s*)?["']?([^"'()\s;]+)|url\(\s*["']?([^"'()\s]+)/g,
193
+ )) {
194
+ const reference = match[1] ?? match[2] ?? ""
195
+ if (reference !== "" && !/^(?:[a-z][\w+.-]*:|\/|#|~)/i.test(reference)) found.push(reference)
196
+ }
197
+ return found
198
+ }
199
+
200
+ /** Every string target of an export value, under every condition. */
201
+ function targetsOf(value: unknown): string[] {
202
+ if (typeof value === "string") return value.startsWith("./") ? [value] : []
203
+ if (Array.isArray(value)) return value.flatMap(targetsOf)
204
+ if (typeof value === "object" && value !== null) return Object.values(value).flatMap(targetsOf)
205
+ return []
206
+ }
207
+
208
+ /** `exports` as subpath entries; without it, the root is `main`/`module`/`browser` and any file a subpath. */
209
+ function exportEntries(
210
+ manifest: Readonly<Record<string, unknown>>,
211
+ entries: Readonly<Record<string, PackageEnvironment>>,
212
+ ): Array<readonly [string, readonly string[]]> {
213
+ const exports = manifest.exports
214
+ if (exports === undefined || exports === null) {
215
+ const roots = [manifest.main, manifest.module, manifest.browser]
216
+ .filter((root): root is string => typeof root === "string")
217
+ .map((root) => (root.startsWith("./") ? root : `./${root}`))
218
+ return [
219
+ [".", roots.length > 0 ? roots : ["./index.js"]],
220
+ ...Object.keys(entries)
221
+ .filter((key) => key !== ".")
222
+ .map((key) => [key, [key]] as const),
223
+ ]
224
+ }
225
+ if (typeof exports !== "object" || Array.isArray(exports)) return [[".", targetsOf(exports)]]
226
+ const keys = Object.keys(exports)
227
+ if (keys.length === 0 || !keys.every((key) => key.startsWith(".")))
228
+ return [[".", targetsOf(exports)]]
229
+ return Object.entries(exports).map(([key, value]) => [key, targetsOf(value)] as const)
230
+ }
231
+
232
+ /** Every file under `dir`, outside `node_modules`. */
233
+ function filesUnder(dir: string): string[] {
234
+ let names: string[]
235
+ try {
236
+ names = readdirSync(dir, { recursive: true, encoding: "utf8" })
237
+ } catch {
238
+ return []
239
+ }
240
+ return names
241
+ .filter((name) => !/(?:^|[\\/])node_modules(?:[\\/]|$)/.test(name))
242
+ .map((name) => join(dir, name))
243
+ .filter(isFile)
244
+ }
245
+
246
+ /** Which mapped entries reach each file of the package at `dir` (a real path). */
247
+ export function entryOwnership(
248
+ dir: string,
249
+ manifest: Readonly<Record<string, unknown>>,
250
+ entries: Readonly<Record<string, PackageEnvironment>>,
251
+ ): EntryOwnership {
252
+ const owners = new Map<string, Set<PackageEnvironment>>()
253
+ const unmapped = new Set<string>()
254
+ const queue: Array<readonly [string, PackageEnvironment]> = []
255
+ const reach = (path: string, side: PackageEnvironment): void => {
256
+ const file = real(path)
257
+ if (!file.startsWith(`${dir}/`) || file.slice(dir.length).includes("/node_modules/")) return
258
+ const sides = owners.get(file) ?? new Set<PackageEnvironment>()
259
+ if (sides.has(side)) return
260
+ sides.add(side)
261
+ owners.set(file, sides)
262
+ queue.push([file, side])
263
+ }
264
+ const sideOf = (subpath: string, exportKey = subpath): PackageEnvironment | undefined => {
265
+ const key = entryKey(entries, subpath)
266
+ if (key === undefined) unmapped.add(exportKey)
267
+ return key === undefined ? undefined : entries[key]
268
+ }
269
+ for (const [key, targets] of exportEntries(manifest, entries)) {
270
+ const star = key.indexOf("*")
271
+ if (star === -1) {
272
+ const side = sideOf(key)
273
+ if (side === undefined) continue
274
+ for (const target of targets) {
275
+ const file = target.includes("*") ? undefined : probe(join(dir, target))
276
+ if (file !== undefined) reach(file, side)
277
+ }
278
+ continue
279
+ }
280
+ // A pattern entry: each file a target pattern matches is the subpath its `*` spells.
281
+ for (const target of targets) {
282
+ const at = target.indexOf("*")
283
+ if (at === -1) continue
284
+ const prefix = target.slice(0, at)
285
+ const suffix = target.slice(at + 1)
286
+ for (const file of filesUnder(join(dir, prefix.slice(0, prefix.lastIndexOf("/") + 1)))) {
287
+ const path = `./${relative(dir, file).replaceAll("\\", "/")}`
288
+ if (path.length <= prefix.length + suffix.length) continue
289
+ if (!path.startsWith(prefix) || !path.endsWith(suffix)) continue
290
+ const matched = path.slice(prefix.length, path.length - suffix.length)
291
+ const side = sideOf(`${key.slice(0, star)}${matched}${key.slice(star + 1)}`, key)
292
+ if (side !== undefined) reach(file, side)
293
+ }
294
+ }
295
+ }
296
+ const imports = new Map<string, readonly string[]>()
297
+ for (const [file, side] of queue) {
298
+ const script = SCRIPT.test(file) && !DECLARATION.test(file)
299
+ if (!script && !STYLESHEET.test(file)) continue
300
+ let specifiers = imports.get(file)
301
+ if (specifiers === undefined) {
302
+ try {
303
+ const code = readFileSync(file, "utf8")
304
+ specifiers = script ? relativeImports(code) : stylesheetImports(code)
305
+ } catch {
306
+ specifiers = []
307
+ }
308
+ imports.set(file, specifiers)
309
+ }
310
+ for (const specifier of specifiers) {
311
+ const found = probe(join(dirname(file), withoutQuery(specifier)))
312
+ if (found !== undefined) reach(found, side)
313
+ }
314
+ }
315
+ return { owners, unmapped: [...unmapped].sort() }
316
+ }
317
+
318
+ /**
319
+ * The `package.json` of the installed package `name`, as Node finds it: the nearest
320
+ * `node_modules/<name>` at or above `fromDir`.
321
+ */
322
+ export function installedManifest(fromDir: string, name: string): string | undefined {
323
+ let dir = fromDir
324
+ for (;;) {
325
+ const manifest = join(dir, "node_modules", name, "package.json")
326
+ if (isFile(manifest)) return manifest
327
+ const parent = dirname(dir)
328
+ if (parent === dir) return undefined
329
+ dir = parent
330
+ }
331
+ }