@nifrajs/core 2.13.0 → 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.
- package/README.md +4 -0
- package/dist/assurance.d.ts +16 -8
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +33 -0
- package/dist/assurance.js.map +1 -1
- package/dist/binary.d.ts +32 -0
- package/dist/binary.d.ts.map +1 -1
- package/dist/binary.js +17 -0
- package/dist/binary.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +21 -50
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-execution.d.ts.map +1 -1
- package/dist/durable-execution.js +49 -69
- package/dist/durable-execution.js.map +1 -1
- package/dist/edge-kit.d.ts +27 -0
- package/dist/edge-kit.d.ts.map +1 -0
- package/dist/edge-kit.js +24 -0
- package/dist/edge-kit.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/durable-approval-protocol.d.ts +13 -0
- package/dist/internal/durable-approval-protocol.d.ts.map +1 -0
- package/dist/internal/durable-approval-protocol.js +66 -0
- package/dist/internal/durable-approval-protocol.js.map +1 -0
- package/dist/internal/durable-effect-protocol.d.ts +14 -0
- package/dist/internal/durable-effect-protocol.d.ts.map +1 -0
- package/dist/internal/durable-effect-protocol.js +16 -0
- package/dist/internal/durable-effect-protocol.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts +8 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -0
- package/dist/internal/durable-saga-protocol.js +21 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -0
- package/dist/internal/route-catalog.d.ts +3 -0
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +5 -0
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/internal/route-execution.d.ts +11 -5
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +2 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/response-observer.d.ts +25 -0
- package/dist/response-observer.d.ts.map +1 -0
- package/dist/response-observer.js +40 -0
- package/dist/response-observer.js.map +1 -0
- package/dist/security/baseline.d.ts +68 -0
- package/dist/security/baseline.d.ts.map +1 -0
- package/dist/security/baseline.js +118 -0
- package/dist/security/baseline.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +26 -0
- package/dist/server/bare-error-lane.d.ts.map +1 -0
- package/dist/server/bare-error-lane.js +60 -0
- package/dist/server/bare-error-lane.js.map +1 -0
- package/dist/server/body.d.ts +7 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +7 -0
- package/dist/server/body.js.map +1 -1
- package/dist/server/bridge-markers.d.ts +25 -0
- package/dist/server/bridge-markers.d.ts.map +1 -0
- package/dist/server/bridge-markers.js +25 -0
- package/dist/server/bridge-markers.js.map +1 -0
- package/dist/server/context.d.ts +6 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/header-case.d.ts.map +1 -1
- package/dist/server/header-case.js +2 -1
- package/dist/server/header-case.js.map +1 -1
- package/dist/server/http.d.ts +36 -3
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +49 -3
- package/dist/server/http.js.map +1 -1
- package/dist/server/install.d.ts +6 -8
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +7 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/node-outcome-hook.d.ts +2 -1
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +17 -2
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +37 -3
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/plugin.d.ts +1 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/query.d.ts +7 -1
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +8 -3
- package/dist/server/query.js.map +1 -1
- package/dist/server/registry.d.ts +11 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +17 -4
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +82 -45
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +19 -2
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +5 -2
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +20 -0
- package/dist/server/response-observer-runtime.d.ts.map +1 -0
- package/dist/server/response-observer-runtime.js +95 -0
- package/dist/server/response-observer-runtime.js.map +1 -0
- package/dist/server/runtime-core.d.ts +60 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +65 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +18 -1
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +89 -48
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +397 -276
- package/dist/server/server.js.map +1 -1
- package/dist/server/websocket.d.ts +20 -4
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +81 -11
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +1 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +12 -8
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.d.ts +2 -0
- package/dist/single-copy-register.d.ts.map +1 -0
- package/dist/single-copy-register.js +24 -0
- package/dist/single-copy-register.js.map +1 -0
- package/dist/single-copy.d.ts +156 -0
- package/dist/single-copy.d.ts.map +1 -0
- package/dist/single-copy.js +470 -0
- package/dist/single-copy.js.map +1 -0
- package/package.json +24 -2
- package/src/assurance.ts +58 -1
- package/src/binary.ts +35 -0
- package/src/durable-adapters.ts +26 -51
- package/src/durable-execution.ts +46 -88
- package/src/edge-kit.ts +27 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +1 -1
- package/src/internal/durable-approval-protocol.ts +92 -0
- package/src/internal/durable-effect-protocol.ts +37 -0
- package/src/internal/durable-saga-protocol.ts +55 -0
- package/src/internal/route-catalog.ts +6 -0
- package/src/internal/route-execution.ts +23 -16
- package/src/response-observer.ts +64 -0
- package/src/security/baseline.ts +172 -0
- package/src/server/bare-error-lane.ts +76 -0
- package/src/server/body.ts +8 -0
- package/src/server/bridge-markers.ts +24 -0
- package/src/server/context.ts +6 -1
- package/src/server/header-case.ts +3 -1
- package/src/server/http.ts +53 -3
- package/src/server/install.ts +14 -0
- package/src/server/node-outcome-hook.ts +2 -1
- package/src/server/node-outcome.ts +44 -3
- package/src/server/plugin.ts +1 -1
- package/src/server/query.ts +10 -4
- package/src/server/registry.ts +19 -4
- package/src/server/request-context.ts +106 -51
- package/src/server/respond.ts +26 -1
- package/src/server/response-contract-lane.ts +6 -2
- package/src/server/response-observer-runtime.ts +135 -0
- package/src/server/runtime-core.ts +98 -1
- package/src/server/server-types.ts +18 -1
- package/src/server/server.ts +607 -376
- package/src/server/websocket.ts +127 -17
- package/src/server/ws-bun.ts +26 -7
- package/src/server.ts +2 -1
- package/src/single-copy-register.ts +24 -0
- 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
|
+
}
|