@nifrajs/core 2.14.1 → 3.1.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 (138) hide show
  1. package/README.md +4 -0
  2. package/dist/assurance.d.ts +16 -8
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +33 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/edge-kit.d.ts +27 -0
  7. package/dist/edge-kit.d.ts.map +1 -0
  8. package/dist/edge-kit.js +24 -0
  9. package/dist/edge-kit.js.map +1 -0
  10. package/dist/errors.d.ts +1 -1
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/route-execution.d.ts +11 -5
  18. package/dist/internal/route-execution.d.ts.map +1 -1
  19. package/dist/internal/route-execution.js +2 -1
  20. package/dist/internal/route-execution.js.map +1 -1
  21. package/dist/response-observer.d.ts +25 -0
  22. package/dist/response-observer.d.ts.map +1 -0
  23. package/dist/response-observer.js +40 -0
  24. package/dist/response-observer.js.map +1 -0
  25. package/dist/security/baseline.d.ts +68 -0
  26. package/dist/security/baseline.d.ts.map +1 -0
  27. package/dist/security/baseline.js +118 -0
  28. package/dist/security/baseline.js.map +1 -0
  29. package/dist/server/bare-error-lane.d.ts +26 -0
  30. package/dist/server/bare-error-lane.d.ts.map +1 -0
  31. package/dist/server/bare-error-lane.js +60 -0
  32. package/dist/server/bare-error-lane.js.map +1 -0
  33. package/dist/server/body.d.ts +7 -0
  34. package/dist/server/body.d.ts.map +1 -1
  35. package/dist/server/body.js +7 -0
  36. package/dist/server/body.js.map +1 -1
  37. package/dist/server/bridge-markers.d.ts +25 -0
  38. package/dist/server/bridge-markers.d.ts.map +1 -0
  39. package/dist/server/bridge-markers.js +25 -0
  40. package/dist/server/bridge-markers.js.map +1 -0
  41. package/dist/server/context.d.ts +6 -1
  42. package/dist/server/context.d.ts.map +1 -1
  43. package/dist/server/header-case.d.ts.map +1 -1
  44. package/dist/server/header-case.js +2 -1
  45. package/dist/server/header-case.js.map +1 -1
  46. package/dist/server/http.d.ts +36 -3
  47. package/dist/server/http.d.ts.map +1 -1
  48. package/dist/server/http.js +49 -3
  49. package/dist/server/http.js.map +1 -1
  50. package/dist/server/install.d.ts +6 -8
  51. package/dist/server/install.d.ts.map +1 -1
  52. package/dist/server/install.js +7 -0
  53. package/dist/server/install.js.map +1 -1
  54. package/dist/server/node-outcome-hook.d.ts +2 -1
  55. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  56. package/dist/server/node-outcome-hook.js.map +1 -1
  57. package/dist/server/node-outcome.d.ts +17 -2
  58. package/dist/server/node-outcome.d.ts.map +1 -1
  59. package/dist/server/node-outcome.js +37 -3
  60. package/dist/server/node-outcome.js.map +1 -1
  61. package/dist/server/query.d.ts +7 -1
  62. package/dist/server/query.d.ts.map +1 -1
  63. package/dist/server/query.js +8 -3
  64. package/dist/server/query.js.map +1 -1
  65. package/dist/server/request-context.d.ts +17 -4
  66. package/dist/server/request-context.d.ts.map +1 -1
  67. package/dist/server/request-context.js +63 -29
  68. package/dist/server/request-context.js.map +1 -1
  69. package/dist/server/respond.d.ts.map +1 -1
  70. package/dist/server/respond.js +19 -2
  71. package/dist/server/respond.js.map +1 -1
  72. package/dist/server/response-contract-lane.d.ts.map +1 -1
  73. package/dist/server/response-contract-lane.js +5 -2
  74. package/dist/server/response-contract-lane.js.map +1 -1
  75. package/dist/server/response-observer-runtime.d.ts +20 -0
  76. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  77. package/dist/server/response-observer-runtime.js +95 -0
  78. package/dist/server/response-observer-runtime.js.map +1 -0
  79. package/dist/server/runtime-core.d.ts +60 -0
  80. package/dist/server/runtime-core.d.ts.map +1 -1
  81. package/dist/server/runtime-core.js +65 -1
  82. package/dist/server/runtime-core.js.map +1 -1
  83. package/dist/server/server-types.d.ts +3 -1
  84. package/dist/server/server-types.d.ts.map +1 -1
  85. package/dist/server/server.d.ts +52 -39
  86. package/dist/server/server.d.ts.map +1 -1
  87. package/dist/server/server.js +267 -271
  88. package/dist/server/server.js.map +1 -1
  89. package/dist/server/websocket.d.ts +20 -4
  90. package/dist/server/websocket.d.ts.map +1 -1
  91. package/dist/server/websocket.js +81 -11
  92. package/dist/server/websocket.js.map +1 -1
  93. package/dist/server/ws-bun.d.ts +1 -1
  94. package/dist/server/ws-bun.d.ts.map +1 -1
  95. package/dist/server/ws-bun.js +12 -8
  96. package/dist/server/ws-bun.js.map +1 -1
  97. package/dist/server.d.ts +2 -1
  98. package/dist/server.d.ts.map +1 -1
  99. package/dist/server.js +2 -1
  100. package/dist/server.js.map +1 -1
  101. package/dist/single-copy-register.d.ts +2 -0
  102. package/dist/single-copy-register.d.ts.map +1 -0
  103. package/dist/single-copy-register.js +24 -0
  104. package/dist/single-copy-register.js.map +1 -0
  105. package/dist/single-copy.d.ts +156 -0
  106. package/dist/single-copy.d.ts.map +1 -0
  107. package/dist/single-copy.js +470 -0
  108. package/dist/single-copy.js.map +1 -0
  109. package/package.json +24 -2
  110. package/src/assurance.ts +58 -1
  111. package/src/edge-kit.ts +27 -0
  112. package/src/errors.ts +1 -0
  113. package/src/index.ts +1 -1
  114. package/src/internal/route-execution.ts +23 -16
  115. package/src/response-observer.ts +64 -0
  116. package/src/security/baseline.ts +172 -0
  117. package/src/server/bare-error-lane.ts +76 -0
  118. package/src/server/body.ts +8 -0
  119. package/src/server/bridge-markers.ts +24 -0
  120. package/src/server/context.ts +6 -1
  121. package/src/server/header-case.ts +3 -1
  122. package/src/server/http.ts +53 -3
  123. package/src/server/install.ts +14 -0
  124. package/src/server/node-outcome-hook.ts +2 -1
  125. package/src/server/node-outcome.ts +44 -3
  126. package/src/server/query.ts +10 -4
  127. package/src/server/request-context.ts +85 -33
  128. package/src/server/respond.ts +26 -1
  129. package/src/server/response-contract-lane.ts +6 -2
  130. package/src/server/response-observer-runtime.ts +135 -0
  131. package/src/server/runtime-core.ts +98 -1
  132. package/src/server/server-types.ts +3 -1
  133. package/src/server/server.ts +436 -367
  134. package/src/server/websocket.ts +127 -17
  135. package/src/server/ws-bun.ts +26 -7
  136. package/src/server.ts +2 -1
  137. package/src/single-copy-register.ts +24 -0
  138. package/src/single-copy.ts +524 -0
@@ -0,0 +1,524 @@
1
+ /**
2
+ * One physical copy of an identity-sensitive package, enforced at LOAD time.
3
+ *
4
+ * ## The gap this closes
5
+ *
6
+ * `@nifrajs/web`'s build already pins react/preact/svelte to the app's copy through `onResolve`
7
+ * (`reactDedupePlugin` and friends), and that covers every bundled phase. It cannot cover an
8
+ * UNBUNDLED one: Bun's runtime plugin API delivers only the entry point and RELATIVE specifiers to
9
+ * `onResolve`, so a bare `import "react"` inside a linked package never reaches a resolver hook. The
10
+ * surfaces that run app sources unbundled - `bun test`, a preloaded script, a route imported natively
11
+ * - therefore keep loading a second copy no matter what the build does. `onLoad` DOES fire on the
12
+ * resolved file, which is the one hook that can still intervene, and it is what this module uses.
13
+ *
14
+ * It also covers a package class the build plugins never did: `@nifrajs/*` itself. Two copies of
15
+ * `@nifrajs/core` are two `Server` classes, and `Server` carries private members, so `.merge()` stops
16
+ * accepting the other's app with a type error that names neither copy.
17
+ *
18
+ * ## Why a topology needs it at all
19
+ *
20
+ * A package consumed with `link:` (or `file:`, or an `npm link`) resolves ITS OWN imports from where
21
+ * it physically lives. A shared component library in a sibling repository therefore loads that
22
+ * repository's react, while the app loads its own - same version, two paths, two module registries.
23
+ * React reads hooks off a dispatcher the other copy never set, and SSR dies with `Invalid hook call
24
+ * … resolveDispatcher() is null`, naming neither react nor the package that shadowed it.
25
+ *
26
+ * The conventional fixes both cost something real: a cross-repo `workspaces` entry makes the
27
+ * consumer's install the owner of the other repository's tree and writes into it, and vendoring or
28
+ * packing copies files that exist precisely so they are not copied. This module is the third option -
29
+ * leave the topology alone and make the resolution answer correctly - and it is not a workaround:
30
+ * these packages declare react as a `peerDependency`, which means "the consumer supplies the copy".
31
+ * That sentence has simply never been enforceable at runtime. Now it is.
32
+ *
33
+ * ## What it will not do
34
+ *
35
+ * Redirect across a VERSION difference. Two versions is a different defect (someone's range is wrong)
36
+ * and silently serving 19.2.8 to a package that asked for 19.2.7 turns a loud install problem into a
37
+ * quiet behavioural one. A version skew is left untouched, so `nifra check` still fails it.
38
+ *
39
+ * @example Declare it in package.json, then preload the registrar in bunfig.toml.
40
+ * ```json
41
+ * { "nifra": { "singleCopy": ["react", "react-dom", "@nifrajs/*"] } }
42
+ * ```
43
+ * ```toml
44
+ * preload = ["@nifrajs/core/single-copy/register"]
45
+ * [test]
46
+ * preload = ["@nifrajs/core/single-copy/register"]
47
+ * ```
48
+ */
49
+
50
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
51
+ import { dirname, join, relative, resolve, sep } from "node:path"
52
+ import type { BunPlugin } from "bun"
53
+
54
+ /**
55
+ * Packages whose duplication is a defect rather than a waste. Each keeps module-scoped state that
56
+ * every importer must share - a hook dispatcher, a renderer's options global, a class identity - so a
57
+ * second physical copy breaks behaviour instead of merely costing bytes. Anything scoped `@nifrajs/`
58
+ * qualifies for the same reason and is matched by pattern.
59
+ */
60
+ export const IDENTITY_SENSITIVE_PACKAGES: readonly string[] = [
61
+ "@nifrajs/*",
62
+ "react",
63
+ "react-dom",
64
+ "preact",
65
+ "solid-js",
66
+ "svelte",
67
+ "vue",
68
+ ]
69
+
70
+ /** Files a redirect may rewrite. Everything else in a package (json, css, wasm) loads unchanged. */
71
+ const REDIRECTABLE = /\.(?:m|c)?[jt]sx?$/
72
+ /** Runaway guards. A real `node_modules` top level is hundreds of entries, a repo walk is a few. */
73
+ const MAX_SCANNED_ENTRIES = 4_096
74
+ const MAX_ANCESTOR_DIRS = 16
75
+ const MAX_REPO_DEPTH = 8
76
+
77
+ export type SingleCopySkipReason = "version-skew" | "no-counterpart"
78
+
79
+ /** One foreign copy that will be redirected into the app's copy. */
80
+ export interface SingleCopyRedirect {
81
+ readonly package: string
82
+ /** Absolute realpath of the copy that loses - the one a linked package would otherwise load. */
83
+ readonly from: string
84
+ /** Absolute realpath of the copy that wins - the one resolvable from the app root. */
85
+ readonly to: string
86
+ readonly version: string
87
+ }
88
+
89
+ /** A foreign copy deliberately left alone, and why - never silently dropped. */
90
+ export interface SingleCopySkip {
91
+ readonly package: string
92
+ readonly from: string
93
+ readonly reason: SingleCopySkipReason
94
+ readonly detail: string
95
+ }
96
+
97
+ export interface SingleCopyPlan {
98
+ /** The app root whose installed copy wins. */
99
+ readonly root: string
100
+ /** Declared package names and patterns, as written. */
101
+ readonly declared: readonly string[]
102
+ readonly redirects: readonly SingleCopyRedirect[]
103
+ readonly skipped: readonly SingleCopySkip[]
104
+ }
105
+
106
+ export interface SingleCopyOptions {
107
+ /** The app root whose copy wins. Defaults to `process.cwd()`. */
108
+ readonly cwd?: string
109
+ /** Package names or `@scope/*` patterns. Defaults to the `nifra.singleCopy` declaration. */
110
+ readonly packages?: readonly string[]
111
+ }
112
+
113
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
114
+ typeof value === "object" && value !== null && !Array.isArray(value)
115
+
116
+ const readJsonSync = (path: string): Record<string, unknown> | undefined => {
117
+ try {
118
+ const value: unknown = JSON.parse(readFileSync(path, "utf8"))
119
+ return isRecord(value) ? value : undefined
120
+ } catch {
121
+ return undefined
122
+ }
123
+ }
124
+
125
+ const realpathOrSelf = (path: string): string => {
126
+ try {
127
+ return realpathSync(path)
128
+ } catch {
129
+ return path
130
+ }
131
+ }
132
+
133
+ const inside = (root: string, path: string): boolean =>
134
+ path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)
135
+
136
+ /**
137
+ * The declaration, read from `package.json` - deliberately NOT from `nifra.config.ts`.
138
+ *
139
+ * `nifra check` holds a pre-load invariant: it never imports the app's config, because importing is
140
+ * executing. A dedupe claim has to be verifiable by a checker that refuses to run the app, so it lives
141
+ * in the one file every tool already parses. `true` means the built-in identity-sensitive set.
142
+ */
143
+ export function readSingleCopyDeclaration(cwd: string): readonly string[] | undefined {
144
+ const pkg = readJsonSync(join(resolve(cwd), "package.json"))
145
+ const nifra = pkg?.nifra
146
+ if (!isRecord(nifra)) return undefined
147
+ const declared = nifra.singleCopy
148
+ if (declared === true) return IDENTITY_SENSITIVE_PACKAGES
149
+ if (declared === false || declared === undefined) return undefined
150
+ if (!Array.isArray(declared)) return undefined
151
+ const names = declared.filter(
152
+ (name): name is string => typeof name === "string" && name.length > 0,
153
+ )
154
+ return names.length > 0 ? names : undefined
155
+ }
156
+
157
+ /**
158
+ * Match a package name against a declaration entry: an exact name, or a `@scope/*` prefix.
159
+ *
160
+ * Exported because the enforcement and the CHECK must agree on it exactly. If `nifra check` decided
161
+ * coverage by its own rule, a package could be reported as deduplicated while the plugin walked past
162
+ * it - which is worse than no check at all, because it is a green light for a broken graph.
163
+ */
164
+ export const matchesSingleCopyDeclaration = (declared: readonly string[], name: string): boolean =>
165
+ declared.some((entry) =>
166
+ entry.endsWith("/*") ? name.startsWith(entry.slice(0, -1)) : entry === name,
167
+ )
168
+
169
+ /** The public specifier a `bunfig.toml` preload must name to arm the runtime. */
170
+ export const SINGLE_COPY_REGISTER_SPECIFIER = "@nifrajs/core/single-copy/register"
171
+
172
+ /** Which unbundled phases have the resolver preloaded. Bundled phases never need it - nifra's build
173
+ * injects the plugin itself. */
174
+ export interface SingleCopyRegistration {
175
+ /** `preload` at the top level: covers `bun run` and anything that loads app sources directly. */
176
+ readonly run: boolean
177
+ /** `[test] preload`: covers `bun test`, the surface that runs app sources unbundled. */
178
+ readonly test: boolean
179
+ /** The `bunfig.toml` that was read, if one exists. */
180
+ readonly config?: string
181
+ }
182
+
183
+ /** Every `preload = [...]` assignment, tagged with the section it appears under. */
184
+ const PRELOAD_ASSIGNMENT =
185
+ /^\s*(?:\[(?<section>[^\]]+)\]|preload\s*=\s*(?<value>\[[^\]]*\]|.+))\s*$/
186
+
187
+ /**
188
+ * The exact entries of a `preload` assignment: a single-line array, or one quoted string.
189
+ *
190
+ * A substring test on the raw assignment is not proof. `preload =
191
+ * ["@nifrajs/core/single-copy/register-extra"]` contains the specifier and satisfies an
192
+ * `includes()`, while Bun loads that other module and the registrar never runs - the check then
193
+ * reports single-copy enforcement as armed on a process that still loads both copies. An entry only
194
+ * counts when it IS the specifier. Anything this cannot read as a quoted entry (a multi-line array,
195
+ * an interpolated value) yields nothing, so the caller reports "not registered" rather than proof.
196
+ */
197
+ function preloadEntries(value: string): readonly string[] {
198
+ const closing = value.lastIndexOf("]")
199
+ const inner = value.startsWith("[") && closing !== -1 ? value.slice(1, closing) : value
200
+ const entries: string[] = []
201
+ for (const raw of inner.split(",")) {
202
+ const entry = raw.trim()
203
+ // A TOML string is quoted at both ends with the same quote; two characters is the shortest one.
204
+ if (entry.length < 2) continue
205
+ const quote = entry.charCodeAt(0)
206
+ if (quote !== 34 && quote !== 39) continue
207
+ if (entry.charCodeAt(entry.length - 1) !== quote) continue
208
+ entries.push(entry.slice(1, -1))
209
+ }
210
+ return entries
211
+ }
212
+
213
+ /**
214
+ * Read the runtime proof out of `bunfig.toml`.
215
+ *
216
+ * Line-oriented on purpose: this runs inside `nifra check`, which must not execute or import anything
217
+ * from the project, and the shape being read is a literal array of strings under a known key. A full
218
+ * TOML parse would buy nothing here and would turn an unrelated syntax error elsewhere in the file
219
+ * into a failure to answer this question.
220
+ */
221
+ export function readSingleCopyRegistration(cwd: string): SingleCopyRegistration {
222
+ const config = join(resolve(cwd), "bunfig.toml")
223
+ let text: string
224
+ try {
225
+ text = readFileSync(config, "utf8")
226
+ } catch {
227
+ return { run: false, test: false }
228
+ }
229
+ let section = ""
230
+ let run = false
231
+ let test = false
232
+ for (const line of text.split(/\r?\n/)) {
233
+ const match = PRELOAD_ASSIGNMENT.exec(line)
234
+ if (match?.groups === undefined) continue
235
+ const { section: heading, value } = match.groups
236
+ if (heading !== undefined) {
237
+ section = heading.trim()
238
+ continue
239
+ }
240
+ if (value === undefined || !preloadEntries(value).includes(SINGLE_COPY_REGISTER_SPECIFIER))
241
+ continue
242
+ if (section === "test") test = true
243
+ else if (section === "") run = true
244
+ }
245
+ return { run, test, config }
246
+ }
247
+
248
+ /** The repository a linked checkout resolves its own dependencies from: its nearest `.git` ancestor.
249
+ * A path that lands inside a `node_modules` is a package-manager store copy rather than a checkout,
250
+ * and everything above it belongs to whoever owns that store - clamp to the copy itself. */
251
+ const repoBoundary = (packageRoot: string): string => {
252
+ if (packageRoot.split(sep).includes("node_modules")) return packageRoot
253
+ let dir = packageRoot
254
+ for (let depth = 0; depth < MAX_REPO_DEPTH; depth++) {
255
+ if (existsSync(join(dir, ".git"))) return dir
256
+ const parent = dirname(dir)
257
+ if (parent === dir) break
258
+ dir = parent
259
+ }
260
+ return packageRoot
261
+ }
262
+
263
+ /** Walk up from `start` for `node_modules/<name>/package.json`, stopping after `boundary`. */
264
+ const installedCopy = (
265
+ start: string,
266
+ name: string,
267
+ boundary: string,
268
+ ): { readonly root: string; readonly version: string } | undefined => {
269
+ const parts = name.split("/")
270
+ for (let dir = start; ; dir = dirname(dir)) {
271
+ const packageRoot = join(dir, "node_modules", ...parts)
272
+ const meta = readJsonSync(join(packageRoot, "package.json"))
273
+ if (meta !== undefined) {
274
+ return {
275
+ root: realpathOrSelf(packageRoot),
276
+ version: typeof meta.version === "string" ? meta.version : "unknown",
277
+ }
278
+ }
279
+ if (dir === boundary) return undefined
280
+ const parent = dirname(dir)
281
+ if (parent === dir) return undefined
282
+ }
283
+ }
284
+
285
+ /** Every `node_modules` from the app root up to (and including) its repository root. */
286
+ const nodeModulesDirs = (root: string): readonly string[] => {
287
+ const boundary = repoBoundary(root)
288
+ const dirs: string[] = []
289
+ let dir = root
290
+ for (let depth = 0; depth < MAX_ANCESTOR_DIRS; depth++) {
291
+ const candidate = join(dir, "node_modules")
292
+ if (existsSync(candidate)) dirs.push(candidate)
293
+ if (dir === boundary) break
294
+ const parent = dirname(dir)
295
+ if (parent === dir) break
296
+ dir = parent
297
+ }
298
+ return dirs
299
+ }
300
+
301
+ /**
302
+ * Package directories that are symlinked OUT of this repository - the `link:` / `file:` / `npm link`
303
+ * consumers whose own imports resolve somewhere this install does not own. Those are the only
304
+ * importers that can introduce a second copy; a normally-installed dependency resolves inside the
305
+ * tree that just installed it.
306
+ */
307
+ const linkedPackageDirs = (root: string): readonly string[] => {
308
+ const repo = realpathOrSelf(repoBoundary(root))
309
+ const found = new Set<string>()
310
+ let scanned = 0
311
+ for (const nodeModules of nodeModulesDirs(root)) {
312
+ let entries: readonly string[]
313
+ try {
314
+ entries = readdirSync(nodeModules)
315
+ } catch {
316
+ continue
317
+ }
318
+ for (const entry of entries) {
319
+ if (scanned++ >= MAX_SCANNED_ENTRIES) return [...found]
320
+ if (entry === ".bin" || entry === ".cache") continue
321
+ const path = join(nodeModules, entry)
322
+ // A scope directory is not a package; its children are.
323
+ const children = entry.startsWith("@")
324
+ ? readdirSync(path, { withFileTypes: true }).map((child) => join(path, child.name))
325
+ : [path]
326
+ for (const candidate of children) {
327
+ if (scanned++ >= MAX_SCANNED_ENTRIES) return [...found]
328
+ let link: ReturnType<typeof lstatSync>
329
+ try {
330
+ link = lstatSync(candidate)
331
+ } catch {
332
+ continue
333
+ }
334
+ if (!link.isSymbolicLink()) continue
335
+ const target = realpathOrSelf(candidate)
336
+ if (inside(repo, target)) continue
337
+ found.add(target)
338
+ }
339
+ }
340
+ }
341
+ return [...found]
342
+ }
343
+
344
+ /** Concrete package names a declaration covers, taken from what is actually installed for the app. */
345
+ const declaredNames = (root: string, declared: readonly string[]): readonly string[] => {
346
+ const names = new Set<string>()
347
+ for (const entry of declared) {
348
+ if (!entry.endsWith("/*")) {
349
+ names.add(entry)
350
+ continue
351
+ }
352
+ const scope = entry.slice(0, -2)
353
+ for (const nodeModules of nodeModulesDirs(root)) {
354
+ const scopeDir = join(nodeModules, ...scope.split("/"))
355
+ try {
356
+ for (const child of readdirSync(scopeDir)) names.add(`${scope}/${child}`)
357
+ } catch {
358
+ // No such scope installed here - the pattern simply matches nothing in this tree.
359
+ }
360
+ }
361
+ }
362
+ return [...names].sort()
363
+ }
364
+
365
+ /**
366
+ * Work out which foreign copies exist and which of them may be redirected.
367
+ *
368
+ * Pure discovery: it reads `package.json` files and symlink targets, never application source, and
369
+ * never mutates anything. Both the plugin and `nifra check`'s verification are built on it, so the
370
+ * enforcement and the report cannot drift apart.
371
+ */
372
+ export function planSingleCopy(options: SingleCopyOptions = {}): SingleCopyPlan {
373
+ const root = realpathOrSelf(resolve(options.cwd ?? process.cwd()))
374
+ const declared = options.packages ?? readSingleCopyDeclaration(root) ?? []
375
+ if (declared.length === 0) return { root, declared, redirects: [], skipped: [] }
376
+
377
+ const names = declaredNames(root, declared)
378
+ const redirects: SingleCopyRedirect[] = []
379
+ const skipped: SingleCopySkip[] = []
380
+ const rootBoundary = repoBoundary(root)
381
+
382
+ for (const linked of linkedPackageDirs(root)) {
383
+ const boundary = repoBoundary(linked)
384
+ for (const name of names) {
385
+ if (!matchesSingleCopyDeclaration(declared, name)) continue
386
+ const theirs = installedCopy(linked, name, boundary)
387
+ if (theirs === undefined) continue
388
+ const ours = installedCopy(root, name, rootBoundary)
389
+ if (ours === undefined || ours.root === theirs.root) continue
390
+ if (ours.version !== theirs.version) {
391
+ skipped.push({
392
+ package: name,
393
+ from: theirs.root,
394
+ reason: "version-skew",
395
+ detail: `${theirs.version} there, ${ours.version} here - redirecting would serve a version that copy did not ask for`,
396
+ })
397
+ continue
398
+ }
399
+ if (redirects.some((redirect) => redirect.from === theirs.root)) continue
400
+ redirects.push({ package: name, from: theirs.root, to: ours.root, version: ours.version })
401
+ }
402
+ }
403
+ redirects.sort((a, b) => a.from.localeCompare(b.from))
404
+ skipped.sort((a, b) => a.from.localeCompare(b.from))
405
+ return { root, declared, redirects, skipped }
406
+ }
407
+
408
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
409
+
410
+ /**
411
+ * The module a foreign file should become: the same relative file inside the winning copy.
412
+ *
413
+ * Mapping FILE to FILE rather than file to export subpath is what makes this safe on a package with a
414
+ * rich exports map. Guessing that `dist/server.js` is the `./server` subpath is guesswork that fails
415
+ * on the first package whose map is not one-to-one; the counterpart path is a fact, and the two trees
416
+ * hold the same version, so the layout matches by construction. When it does not, the file loads
417
+ * untouched.
418
+ */
419
+ const counterpart = (redirect: SingleCopyRedirect, path: string): string | undefined => {
420
+ const rel = relative(redirect.from, path)
421
+ if (rel === "" || rel.startsWith(`..${sep}`)) return undefined
422
+ const target = join(redirect.to, rel)
423
+ try {
424
+ return statSync(target).isFile() ? target : undefined
425
+ } catch {
426
+ return undefined
427
+ }
428
+ }
429
+
430
+ /**
431
+ * A re-export of the winning file, written so both module systems keep working.
432
+ *
433
+ * `export *` forwards the named bindings, including the ones Bun synthesizes for a CommonJS target,
434
+ * and the explicit default covers `module.exports` (react is CommonJS, and its consumers `require` it
435
+ * as often as they import it). Static imports keep the module SYNCHRONOUS, which matters more than it
436
+ * looks: an `await import()` here would make the module async, and a CommonJS `require` of an async
437
+ * module fails outright with `require() async module … is unsupported`.
438
+ */
439
+ const reexport = (target: string): string => {
440
+ const from = JSON.stringify(target)
441
+ return (
442
+ `export * from ${from};\n` +
443
+ `import * as __singleCopy from ${from};\n` +
444
+ `export default __singleCopy.default ?? __singleCopy;\n`
445
+ )
446
+ }
447
+
448
+ /** A Bun plugin - `Bun.plugin(...)` for the runtime, or a `plugins:` entry for `Bun.build`. */
449
+ export interface SingleCopyPlugin extends BunPlugin {
450
+ /** What it will do, computed once at construction. Exposed so a caller can report it. */
451
+ readonly plan: SingleCopyPlan
452
+ }
453
+
454
+ /**
455
+ * Pin every declared package to the app's copy.
456
+ *
457
+ * Two hooks, because the two phases resolve differently. `onResolve` is the direct statement of the
458
+ * rule and it is what a BUNDLER honours - it sees every bare specifier. The runtime does not deliver
459
+ * bare specifiers to it at all, so the `onLoad` arm intercepts the foreign file itself and hands back
460
+ * a re-export of the counterpart in the winning copy. The importer gets the same function objects and
461
+ * therefore the same module state, which is the entire requirement.
462
+ */
463
+ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlugin {
464
+ const plan = planSingleCopy(options)
465
+ const declared = plan.declared
466
+ return {
467
+ name: "nifra-single-copy",
468
+ plan,
469
+ setup(build) {
470
+ if (declared.length > 0) {
471
+ const exact = declared.filter((entry) => !entry.endsWith("/*")).map(escapeRegExp)
472
+ const scopes = declared
473
+ .filter((entry) => entry.endsWith("/*"))
474
+ .map((entry) => `${escapeRegExp(entry.slice(0, -1))}[^/]+`)
475
+ const alternatives = [...exact.map((name) => `${name}(?:/.*)?`), ...scopes]
476
+ if (alternatives.length > 0) {
477
+ const filter = new RegExp(`^(?:${alternatives.join("|")})$`)
478
+ build.onResolve({ filter }, (args) => {
479
+ try {
480
+ return { path: Bun.resolveSync(args.path, plan.root) }
481
+ } catch {
482
+ // Not installed for the app, so there is nothing to dedupe TO. Forcing a resolution here
483
+ // would turn a working import into a build failure.
484
+ return undefined
485
+ }
486
+ })
487
+ }
488
+ }
489
+ if (plan.redirects.length === 0) return
490
+
491
+ const roots = plan.redirects.map((redirect) => escapeRegExp(redirect.from)).join("|")
492
+ // Anchored at the foreign roots so the winning copy can never match itself. It must not: this
493
+ // hook has to return contents for everything it matches, and re-emitting a CommonJS entry as an
494
+ // ES module is how `Export named 'useEffect' not found` happens.
495
+ const filter = new RegExp(`^(?:${roots})[/\\\\].*${REDIRECTABLE.source}`)
496
+ build.onLoad({ filter }, (args) => {
497
+ for (const redirect of plan.redirects) {
498
+ const target = counterpart(redirect, args.path)
499
+ if (target === undefined) continue
500
+ return { contents: reexport(target), loader: "js" }
501
+ }
502
+ // A runtime `onLoad` must always return an object - returning nothing fails the load rather
503
+ // than falling through to the default. Hand back the file as it was, at its own path, so its
504
+ // relative imports keep resolving exactly as they would have.
505
+ return { contents: readFileSync(args.path, "utf8"), loader: "js" }
506
+ })
507
+ },
508
+ }
509
+ }
510
+
511
+ /** Set once the runtime plugin is installed, so a checker can tell "one copy" from "deduplicated". */
512
+ export const SINGLE_COPY_ACTIVE = Symbol.for("nifra.single-copy.active")
513
+
514
+ /**
515
+ * Install the plugin into the Bun RUNTIME. Import `@nifrajs/core/single-copy/register` from a
516
+ * `bunfig.toml` preload rather than calling this from application code: a resolver installed from
517
+ * inside a module cannot affect the imports that module already resolved.
518
+ */
519
+ export function registerSingleCopy(options: SingleCopyOptions = {}): SingleCopyPlan {
520
+ const plugin = singleCopyPlugin(options)
521
+ if (plugin.plan.declared.length > 0) Bun.plugin(plugin)
522
+ ;(globalThis as Record<symbol, unknown>)[SINGLE_COPY_ACTIVE] = plugin.plan
523
+ return plugin.plan
524
+ }