@nifrajs/core 3.3.0 → 4.0.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 +26 -0
- package/dist/assurance.d.ts +7 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +39 -3
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +20 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +47 -15
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +6 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +36 -2
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +16 -3
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-effect.d.ts.map +1 -1
- package/dist/durable-effect.js +6 -3
- package/dist/durable-effect.js.map +1 -1
- package/dist/edge-kit.d.ts +2 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +2 -0
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-lifecycle.d.ts.map +1 -1
- package/dist/effect-lifecycle.js +2 -1
- package/dist/effect-lifecycle.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +1 -1
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +36 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +219 -13
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts +31 -0
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +48 -9
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +7 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +9 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
- package/dist/internal/durable-saga-protocol.js +2 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -1
- package/dist/internal/route-catalog.d.ts +6 -6
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +20 -18
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +7 -6
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +36 -4
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +7 -3
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +1 -0
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +34 -29
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +8 -2
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +222 -20
- package/dist/internal/route-program.js.map +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +8 -5
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +52 -46
- package/dist/manifest.js.map +1 -1
- package/dist/mount.d.ts +36 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +32 -0
- package/dist/mount.js.map +1 -1
- package/dist/reflection.d.ts +26 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +65 -1
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -1
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js.map +1 -1
- package/dist/router/overlap.d.ts +23 -0
- package/dist/router/overlap.d.ts.map +1 -0
- package/dist/router/overlap.js +224 -0
- package/dist/router/overlap.js.map +1 -0
- package/dist/router/pattern.d.ts +117 -2
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +283 -23
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts +18 -4
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +51 -44
- package/dist/router/router.js.map +1 -1
- package/dist/server/auth.d.ts +53 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +9 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +12 -7
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +13 -11
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/body-lane.d.ts +20 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +26 -7
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts +68 -0
- package/dist/server/body-parser.d.ts.map +1 -0
- package/dist/server/body-parser.js +172 -0
- package/dist/server/body-parser.js.map +1 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +62 -7
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +8 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +8 -0
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +71 -6
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -5
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +25 -4
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts +4 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +40 -2
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.d.ts +2 -0
- package/dist/server/core-copies.d.ts.map +1 -0
- package/dist/server/core-copies.js +20 -0
- package/dist/server/core-copies.js.map +1 -0
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +36 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +130 -73
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +4 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +4 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/methods.d.ts +45 -0
- package/dist/server/methods.d.ts.map +1 -0
- package/dist/server/methods.js +37 -0
- package/dist/server/methods.js.map +1 -0
- package/dist/server/multipart.d.ts +63 -0
- package/dist/server/multipart.d.ts.map +1 -0
- package/dist/server/multipart.js +374 -0
- package/dist/server/multipart.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +20 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +15 -6
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/not-found-answer.d.ts +40 -0
- package/dist/server/not-found-answer.d.ts.map +1 -0
- package/dist/server/not-found-answer.js +56 -0
- package/dist/server/not-found-answer.js.map +1 -0
- package/dist/server/not-found.d.ts +23 -0
- package/dist/server/not-found.d.ts.map +1 -0
- package/dist/server/not-found.js +81 -0
- package/dist/server/not-found.js.map +1 -0
- package/dist/server/plugin.d.ts +26 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +18 -5
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +114 -9
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts +7 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +47 -0
- package/dist/server/query.js.map +1 -1
- package/dist/server/registry.d.ts +13 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +11 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +33 -19
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-lineage.d.ts +8 -0
- package/dist/server/request-lineage.d.ts.map +1 -0
- package/dist/server/request-lineage.js +14 -0
- package/dist/server/request-lineage.js.map +1 -0
- package/dist/server/request-target.d.ts +31 -0
- package/dist/server/request-target.d.ts.map +1 -0
- package/dist/server/request-target.js +44 -0
- package/dist/server/request-target.js.map +1 -0
- package/dist/server/respond.d.ts +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +16 -4
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +19 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +59 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +9 -5
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -6
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +55 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +194 -19
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1342 -274
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +10 -7
- package/dist/server/sse.js.map +1 -1
- package/dist/server/static-headers.d.ts +3 -0
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +19 -1
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +9 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/validation.d.ts.map +1 -1
- package/dist/server/validation.js +5 -1
- package/dist/server/validation.js.map +1 -1
- package/dist/server/websocket.d.ts +10 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +7 -4
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +2 -0
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +6 -3
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +2 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +8 -5
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +5 -2
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.js +5 -0
- package/dist/single-copy-register.js.map +1 -1
- package/dist/single-copy.d.ts +46 -3
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +125 -12
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts +23 -0
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +37 -3
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +27 -2
- package/src/assurance.ts +52 -2
- package/src/capabilities.ts +81 -22
- package/src/diff.ts +54 -7
- package/src/durable-adapters.ts +20 -3
- package/src/durable-effect.ts +10 -3
- package/src/edge-kit.ts +7 -0
- package/src/effect-lifecycle.ts +5 -1
- package/src/effect-scope.ts +2 -1
- package/src/errors.ts +3 -0
- package/src/evidence.ts +302 -12
- package/src/idempotency.ts +80 -9
- package/src/index.ts +41 -3
- package/src/internal/code-unit-order.ts +8 -0
- package/src/internal/durable-saga-protocol.ts +2 -0
- package/src/internal/route-catalog.ts +19 -18
- package/src/internal/route-compiler.ts +62 -13
- package/src/internal/route-execution.ts +13 -3
- package/src/internal/route-lanes.ts +37 -28
- package/src/internal/route-program.ts +434 -20
- package/src/ledger.ts +11 -5
- package/src/manifest.ts +52 -46
- package/src/mount.ts +62 -0
- package/src/reflection.ts +84 -1
- package/src/response-observer.ts +1 -0
- package/src/router/overlap.ts +258 -0
- package/src/router/pattern.ts +349 -25
- package/src/router/router.ts +76 -51
- package/src/server/auth.ts +75 -0
- package/src/server/bare-error-lane.ts +12 -11
- package/src/server/body-lane.ts +46 -6
- package/src/server/body-parser.ts +241 -0
- package/src/server/body.ts +82 -9
- package/src/server/client-ip.ts +9 -0
- package/src/server/context.ts +219 -12
- package/src/server/contract.ts +54 -9
- package/src/server/cookies.ts +34 -2
- package/src/server/core-copies.ts +21 -0
- package/src/server/edge.ts +2 -0
- package/src/server/http.ts +34 -0
- package/src/server/idempotency-lane.ts +147 -72
- package/src/server/install.ts +6 -0
- package/src/server/methods.ts +143 -0
- package/src/server/multipart.ts +424 -0
- package/src/server/node-outcome-hook.ts +40 -7
- package/src/server/not-found-answer.ts +109 -0
- package/src/server/not-found.ts +121 -0
- package/src/server/plugin.ts +45 -1
- package/src/server/proto-guard.ts +128 -9
- package/src/server/query.ts +49 -0
- package/src/server/registry.ts +17 -2
- package/src/server/request-context.ts +44 -16
- package/src/server/request-lineage.ts +15 -0
- package/src/server/request-target.ts +46 -0
- package/src/server/respond.ts +14 -3
- package/src/server/response-contract-lane.ts +97 -16
- package/src/server/response-observer-runtime.ts +37 -8
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +61 -2
- package/src/server/server.ts +1798 -355
- package/src/server/sse.ts +10 -8
- package/src/server/static-headers.ts +22 -1
- package/src/server/transport-lane.ts +17 -9
- package/src/server/validation.ts +7 -1
- package/src/server/websocket.ts +23 -9
- package/src/server/ws-bun.ts +8 -3
- package/src/server/ws-hook.ts +2 -1
- package/src/server.ts +39 -2
- package/src/single-copy-register.ts +5 -0
- package/src/single-copy.ts +160 -12
- package/src/tool-contract.ts +66 -3
- package/src/transport-codec.ts +2 -1
package/src/single-copy.ts
CHANGED
|
@@ -34,12 +34,19 @@
|
|
|
34
34
|
*
|
|
35
35
|
* Redirect across a VERSION difference. Two versions is a different defect (someone's range is wrong)
|
|
36
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
|
|
37
|
+
* quiet behavioural one. A version skew is left untouched, so `nifra check` still fails it, and the
|
|
38
|
+
* preloaded registrar prints one warning per package naming both copies - or, in strict mode, refuses
|
|
39
|
+
* to start.
|
|
38
40
|
*
|
|
39
41
|
* @example Declare it in package.json, then preload the registrar in bunfig.toml.
|
|
40
42
|
* ```json
|
|
41
43
|
* { "nifra": { "singleCopy": ["react", "react-dom", "@nifrajs/*"] } }
|
|
42
44
|
* ```
|
|
45
|
+
* The object form adds strict mode: a copy that cannot be collapsed fails the preload instead of
|
|
46
|
+
* warning.
|
|
47
|
+
* ```json
|
|
48
|
+
* { "nifra": { "singleCopy": { "packages": ["react", "@nifrajs/*"], "strict": true } } }
|
|
49
|
+
* ```
|
|
43
50
|
* ```toml
|
|
44
51
|
* preload = ["@nifrajs/core/single-copy/register"]
|
|
45
52
|
* [test]
|
|
@@ -50,6 +57,7 @@
|
|
|
50
57
|
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
|
|
51
58
|
import { dirname, join, relative, resolve, sep } from "node:path"
|
|
52
59
|
import type { BunPlugin } from "bun"
|
|
60
|
+
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
53
61
|
|
|
54
62
|
/**
|
|
55
63
|
* Packages whose duplication is a defect rather than a waste. Each keeps module-scoped state that
|
|
@@ -86,10 +94,23 @@ export interface SingleCopyRedirect {
|
|
|
86
94
|
readonly version: string
|
|
87
95
|
}
|
|
88
96
|
|
|
89
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* A foreign copy left alone, and why - never silently dropped.
|
|
99
|
+
*
|
|
100
|
+
* `version-skew`: the two copies advertise different versions, so no redirect is planned.
|
|
101
|
+
* `no-counterpart`: a redirect was planned, but a file the foreign copy loads has no file at the same
|
|
102
|
+
* relative path in the app's copy (the layouts differ), so that file loads from the foreign copy.
|
|
103
|
+
*/
|
|
90
104
|
export interface SingleCopySkip {
|
|
91
105
|
readonly package: string
|
|
106
|
+
/** Absolute realpath of the foreign copy (or, for `no-counterpart`, the foreign file). */
|
|
92
107
|
readonly from: string
|
|
108
|
+
/** Absolute realpath of the app's copy, when one is installed. */
|
|
109
|
+
readonly to?: string
|
|
110
|
+
/** Version the foreign copy advertises. */
|
|
111
|
+
readonly fromVersion?: string
|
|
112
|
+
/** Version the app's copy advertises. */
|
|
113
|
+
readonly toVersion?: string
|
|
93
114
|
readonly reason: SingleCopySkipReason
|
|
94
115
|
readonly detail: string
|
|
95
116
|
}
|
|
@@ -108,8 +129,24 @@ export interface SingleCopyOptions {
|
|
|
108
129
|
readonly cwd?: string
|
|
109
130
|
/** Package names or `@scope/*` patterns. Defaults to the `nifra.singleCopy` declaration. */
|
|
110
131
|
readonly packages?: readonly string[]
|
|
132
|
+
/**
|
|
133
|
+
* Throw instead of warning when a declared package keeps a second copy (a version skew, or a file
|
|
134
|
+
* with no counterpart in the app's copy). Read by {@link registerSingleCopy}; defaults to the
|
|
135
|
+
* declaration's `strict` flag, or {@link SINGLE_COPY_STRICT_ENV} set to `1`/`true`.
|
|
136
|
+
*/
|
|
137
|
+
readonly strict?: boolean
|
|
138
|
+
/**
|
|
139
|
+
* Called for every skip: by {@link registerSingleCopy} for each planned `version-skew`, and by the
|
|
140
|
+
* plugin for each `no-counterpart` file it lets through at load time. Unset, the plugin stays silent
|
|
141
|
+
* (a bundle's duplicates are the identity preflight's to report); the registrar always warns, or
|
|
142
|
+
* throws in strict mode, and calls this as well.
|
|
143
|
+
*/
|
|
144
|
+
readonly onSkip?: (skip: SingleCopySkip) => void
|
|
111
145
|
}
|
|
112
146
|
|
|
147
|
+
/** Environment switch for strict mode when the declaration does not set it: `1` or `true`. */
|
|
148
|
+
export const SINGLE_COPY_STRICT_ENV = "NIFRA_SINGLE_COPY_STRICT"
|
|
149
|
+
|
|
113
150
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
114
151
|
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
115
152
|
|
|
@@ -133,18 +170,24 @@ const realpathOrSelf = (path: string): string => {
|
|
|
133
170
|
const inside = (root: string, path: string): boolean =>
|
|
134
171
|
path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)
|
|
135
172
|
|
|
173
|
+
/** The raw `nifra.singleCopy` value from `package.json`, if any. */
|
|
174
|
+
const singleCopyField = (cwd: string): unknown => {
|
|
175
|
+
const pkg = readJsonSync(join(resolve(cwd), "package.json"))
|
|
176
|
+
const nifra = pkg?.nifra
|
|
177
|
+
return isRecord(nifra) ? nifra.singleCopy : undefined
|
|
178
|
+
}
|
|
179
|
+
|
|
136
180
|
/**
|
|
137
181
|
* The declaration, read from `package.json` - deliberately NOT from `nifra.config.ts`.
|
|
138
182
|
*
|
|
139
183
|
* `nifra check` holds a pre-load invariant: it never imports the app's config, because importing is
|
|
140
184
|
* 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.
|
|
185
|
+
* in the one file every tool already parses. `true` means the built-in identity-sensitive set. The
|
|
186
|
+
* object form `{ "packages": [...] | true, "strict": true }` declares the same list plus strict mode.
|
|
142
187
|
*/
|
|
143
188
|
export function readSingleCopyDeclaration(cwd: string): readonly string[] | undefined {
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
if (!isRecord(nifra)) return undefined
|
|
147
|
-
const declared = nifra.singleCopy
|
|
189
|
+
const field = singleCopyField(cwd)
|
|
190
|
+
const declared = isRecord(field) ? field.packages : field
|
|
148
191
|
if (declared === true) return IDENTITY_SENSITIVE_PACKAGES
|
|
149
192
|
if (declared === false || declared === undefined) return undefined
|
|
150
193
|
if (!Array.isArray(declared)) return undefined
|
|
@@ -154,6 +197,12 @@ export function readSingleCopyDeclaration(cwd: string): readonly string[] | unde
|
|
|
154
197
|
return names.length > 0 ? names : undefined
|
|
155
198
|
}
|
|
156
199
|
|
|
200
|
+
/** Whether the declaration's object form asks for strict mode (`"strict": true`). */
|
|
201
|
+
export function readSingleCopyStrict(cwd: string): boolean {
|
|
202
|
+
const field = singleCopyField(cwd)
|
|
203
|
+
return isRecord(field) && field.strict === true
|
|
204
|
+
}
|
|
205
|
+
|
|
157
206
|
/**
|
|
158
207
|
* Match a package name against a declaration entry: an exact name, or a `@scope/*` prefix.
|
|
159
208
|
*
|
|
@@ -388,9 +437,13 @@ export function planSingleCopy(options: SingleCopyOptions = {}): SingleCopyPlan
|
|
|
388
437
|
const ours = installedCopy(root, name, rootBoundary)
|
|
389
438
|
if (ours === undefined || ours.root === theirs.root) continue
|
|
390
439
|
if (ours.version !== theirs.version) {
|
|
440
|
+
if (skipped.some((skip) => skip.from === theirs.root)) continue
|
|
391
441
|
skipped.push({
|
|
392
442
|
package: name,
|
|
393
443
|
from: theirs.root,
|
|
444
|
+
to: ours.root,
|
|
445
|
+
fromVersion: theirs.version,
|
|
446
|
+
toVersion: ours.version,
|
|
394
447
|
reason: "version-skew",
|
|
395
448
|
detail: `${theirs.version} there, ${ours.version} here - redirecting would serve a version that copy did not ask for`,
|
|
396
449
|
})
|
|
@@ -400,8 +453,8 @@ export function planSingleCopy(options: SingleCopyOptions = {}): SingleCopyPlan
|
|
|
400
453
|
redirects.push({ package: name, from: theirs.root, to: ours.root, version: ours.version })
|
|
401
454
|
}
|
|
402
455
|
}
|
|
403
|
-
redirects.sort((a, b) => a.from
|
|
404
|
-
skipped.sort((a, b) => a.from
|
|
456
|
+
redirects.sort((a, b) => codeUnitOrder(a.from, b.from))
|
|
457
|
+
skipped.sort((a, b) => codeUnitOrder(a.from, b.from))
|
|
405
458
|
return { root, declared, redirects, skipped }
|
|
406
459
|
}
|
|
407
460
|
|
|
@@ -414,7 +467,7 @@ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\
|
|
|
414
467
|
* rich exports map. Guessing that `dist/server.js` is the `./server` subpath is guesswork that fails
|
|
415
468
|
* on the first package whose map is not one-to-one; the counterpart path is a fact, and the two trees
|
|
416
469
|
* hold the same version, so the layout matches by construction. When it does not, the file loads
|
|
417
|
-
* untouched.
|
|
470
|
+
* untouched and is reported as a `no-counterpart` skip.
|
|
418
471
|
*/
|
|
419
472
|
const counterpart = (redirect: SingleCopyRedirect, path: string): string | undefined => {
|
|
420
473
|
const rel = relative(redirect.from, path)
|
|
@@ -501,6 +554,18 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
|
|
|
501
554
|
if (target === undefined) continue
|
|
502
555
|
return { contents: reexport(target), loader: "js" }
|
|
503
556
|
}
|
|
557
|
+
const owner = plan.redirects.find((redirect) => inside(redirect.from, args.path))
|
|
558
|
+
if (owner !== undefined && options.onSkip !== undefined) {
|
|
559
|
+
options.onSkip({
|
|
560
|
+
package: owner.package,
|
|
561
|
+
from: args.path,
|
|
562
|
+
to: owner.to,
|
|
563
|
+
fromVersion: owner.version,
|
|
564
|
+
toVersion: owner.version,
|
|
565
|
+
reason: "no-counterpart",
|
|
566
|
+
detail: `${relative(owner.from, args.path)} has no file at the same path in the app's copy, so it loads from the linked copy and its module state is not shared`,
|
|
567
|
+
})
|
|
568
|
+
}
|
|
504
569
|
// A runtime `onLoad` must always return an object - returning nothing fails the load rather
|
|
505
570
|
// than falling through to the default. Hand back the file as it was, at its own path, so its
|
|
506
571
|
// relative imports keep resolving exactly as they would have.
|
|
@@ -513,14 +578,97 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
|
|
|
513
578
|
/** Set once the runtime plugin is installed, so a checker can tell "one copy" from "deduplicated". */
|
|
514
579
|
export const SINGLE_COPY_ACTIVE = Symbol.for("nifra.single-copy.active")
|
|
515
580
|
|
|
581
|
+
/** Packages already warned about in this process, so a second registrar (or file) stays quiet. */
|
|
582
|
+
const WARNED = Symbol.for("nifra.single-copy.warned")
|
|
583
|
+
/** Plans already installed in this process. A second identical plugin only adds a competing hook. */
|
|
584
|
+
const INSTALLED = Symbol.for("nifra.single-copy.installed")
|
|
585
|
+
|
|
586
|
+
const processSet = (key: symbol): Set<string> => {
|
|
587
|
+
const holder = globalThis as Record<symbol, Set<string> | undefined>
|
|
588
|
+
let set = holder[key]
|
|
589
|
+
if (set === undefined) {
|
|
590
|
+
set = new Set()
|
|
591
|
+
holder[key] = set
|
|
592
|
+
}
|
|
593
|
+
return set
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const copyLine = (label: string, path: string | undefined, version: string | undefined): string =>
|
|
597
|
+
` ${label}: ${path ?? "(not installed for the app)"}${version === undefined ? "" : ` (${version})`}`
|
|
598
|
+
|
|
599
|
+
/** One report per package: both copies, both versions, the reason, and the fix. */
|
|
600
|
+
const describeSkips = (name: string, skips: readonly SingleCopySkip[]): string => {
|
|
601
|
+
const first = skips[0]
|
|
602
|
+
const reason = first?.reason ?? "version-skew"
|
|
603
|
+
const lines = [
|
|
604
|
+
`[nifra] single-copy: ${name} is NOT deduplicated (${reason}) - a second copy loads, so its module state is not shared.`,
|
|
605
|
+
copyLine("app copy", first?.to, first?.toVersion),
|
|
606
|
+
]
|
|
607
|
+
for (const skip of skips) {
|
|
608
|
+
lines.push(copyLine("linked copy", skip.from, skip.fromVersion))
|
|
609
|
+
lines.push(` ${skip.detail}`)
|
|
610
|
+
}
|
|
611
|
+
lines.push(
|
|
612
|
+
reason === "version-skew"
|
|
613
|
+
? " Fix: align the dependency ranges so both trees install one version, then reinstall. nifra never redirects across versions."
|
|
614
|
+
: " Fix: install the same build of the package in both trees (the file layouts differ), then reinstall.",
|
|
615
|
+
)
|
|
616
|
+
return lines.join("\n")
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const strictFromEnv = (): boolean => {
|
|
620
|
+
const value = typeof process === "undefined" ? undefined : process.env[SINGLE_COPY_STRICT_ENV]
|
|
621
|
+
return value === "1" || value === "true"
|
|
622
|
+
}
|
|
623
|
+
|
|
516
624
|
/**
|
|
517
625
|
* Install the plugin into the Bun RUNTIME. Import `@nifrajs/core/single-copy/register` from a
|
|
518
626
|
* `bunfig.toml` preload rather than calling this from application code: a resolver installed from
|
|
519
627
|
* inside a module cannot affect the imports that module already resolved.
|
|
628
|
+
*
|
|
629
|
+
* A declared package that keeps a second copy is never silent: each one gets a single warning per
|
|
630
|
+
* process naming both copies, both versions and the reason. In strict mode (`"strict": true` in the
|
|
631
|
+
* declaration, {@link SINGLE_COPY_STRICT_ENV}, or `options.strict`) a version skew throws here, before
|
|
632
|
+
* anything loads, and a `no-counterpart` file fails its own import.
|
|
520
633
|
*/
|
|
521
634
|
export function registerSingleCopy(options: SingleCopyOptions = {}): SingleCopyPlan {
|
|
522
|
-
const
|
|
523
|
-
|
|
635
|
+
const root = realpathOrSelf(resolve(options.cwd ?? process.cwd()))
|
|
636
|
+
const strict = options.strict ?? (readSingleCopyStrict(root) || strictFromEnv())
|
|
637
|
+
const warned = processSet(WARNED)
|
|
638
|
+
const report = (name: string, skips: readonly SingleCopySkip[]): void => {
|
|
639
|
+
const message = describeSkips(name, skips)
|
|
640
|
+
if (strict)
|
|
641
|
+
throw new Error(`${message}\n Strict mode is on, so this process refuses to start.`)
|
|
642
|
+
const key = `${skips[0]?.reason ?? ""}\0${name}`
|
|
643
|
+
if (warned.has(key)) return
|
|
644
|
+
warned.add(key)
|
|
645
|
+
console.warn(message)
|
|
646
|
+
}
|
|
647
|
+
const plugin = singleCopyPlugin({
|
|
648
|
+
...options,
|
|
649
|
+
cwd: root,
|
|
650
|
+
onSkip: (skip) => {
|
|
651
|
+
options.onSkip?.(skip)
|
|
652
|
+
report(skip.package, [skip])
|
|
653
|
+
},
|
|
654
|
+
})
|
|
655
|
+
const byPackage = new Map<string, SingleCopySkip[]>()
|
|
656
|
+
for (const skip of plugin.plan.skipped) {
|
|
657
|
+
options.onSkip?.(skip)
|
|
658
|
+
const group = byPackage.get(skip.package) ?? []
|
|
659
|
+
group.push(skip)
|
|
660
|
+
byPackage.set(skip.package, group)
|
|
661
|
+
}
|
|
662
|
+
for (const [name, skips] of byPackage) report(name, skips)
|
|
663
|
+
// Installing twice (a preload listed twice, or an explicit call on top of the preload) would register
|
|
664
|
+
// two `onLoad` hooks for the same foreign files, and two hooks both answering one file stall the
|
|
665
|
+
// runtime loader. One install per root and declaration is the whole effect anyway.
|
|
666
|
+
const installed = processSet(INSTALLED)
|
|
667
|
+
const key = `${plugin.plan.root}\0${plugin.plan.declared.join("\0")}`
|
|
668
|
+
if (plugin.plan.declared.length > 0 && !installed.has(key)) {
|
|
669
|
+
installed.add(key)
|
|
670
|
+
Bun.plugin(plugin)
|
|
671
|
+
}
|
|
524
672
|
;(globalThis as Record<symbol, unknown>)[SINGLE_COPY_ACTIVE] = plugin.plan
|
|
525
673
|
return plugin.plan
|
|
526
674
|
}
|
package/src/tool-contract.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type ExecutionPolicyAdapter,
|
|
17
17
|
} from "./execution-policy.ts"
|
|
18
18
|
import {
|
|
19
|
+
DEFAULT_IDEMPOTENCY_PENDING_TTL_MS,
|
|
19
20
|
type IdempotencyScope,
|
|
20
21
|
validIdempotencyKey,
|
|
21
22
|
validIdempotencyNamespace,
|
|
@@ -66,6 +67,11 @@ export interface ToolIdempotencyPolicy<Input = unknown> {
|
|
|
66
67
|
readonly scope: Exclude<IdempotencyScope, "none">
|
|
67
68
|
/** Return an opaque bounded key. The key must not contain a request body or secret. */
|
|
68
69
|
readonly key: (input: Input) => string | PromiseLike<string>
|
|
70
|
+
/**
|
|
71
|
+
* With a store that implements `renew()`, how long a running call holds its key without a
|
|
72
|
+
* renewal. The call renews it every third of this until it settles. Default 60s.
|
|
73
|
+
*/
|
|
74
|
+
readonly pendingTtlMs?: number
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
export interface ToolContractOptions<
|
|
@@ -149,6 +155,8 @@ export function createToolBudget(options: CreateToolBudgetOptions): ToolBudget {
|
|
|
149
155
|
export interface ToolIdempotencyBeginInput {
|
|
150
156
|
readonly namespace: string
|
|
151
157
|
readonly key: string
|
|
158
|
+
/** Lease on a `new` reservation, passed only to a store with `renew()`: it lapses unless renewed. */
|
|
159
|
+
readonly pendingTtlMs?: number | undefined
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
export type ToolIdempotencyBeginResult =
|
|
@@ -172,11 +180,21 @@ export interface ToolIdempotencyStore {
|
|
|
172
180
|
readonly key: string
|
|
173
181
|
readonly reservation: string
|
|
174
182
|
}): boolean | PromiseLike<boolean>
|
|
183
|
+
/**
|
|
184
|
+
* Extend the pending reservation owned by `reservation` to `ttlMs` from now. Returns false when it
|
|
185
|
+
* is gone, lapsed, or completed. With it, a key whose process died frees after the lease.
|
|
186
|
+
*/
|
|
187
|
+
renew?(input: {
|
|
188
|
+
readonly namespace: string
|
|
189
|
+
readonly key: string
|
|
190
|
+
readonly reservation: string
|
|
191
|
+
readonly ttlMs: number
|
|
192
|
+
}): boolean | PromiseLike<boolean>
|
|
175
193
|
}
|
|
176
194
|
|
|
177
195
|
interface ToolIdempotencyEntry {
|
|
178
196
|
readonly reservation: string
|
|
179
|
-
|
|
197
|
+
expiresAt: number
|
|
180
198
|
completed: boolean
|
|
181
199
|
}
|
|
182
200
|
|
|
@@ -218,7 +236,7 @@ export class MemoryToolIdempotencyStore implements ToolIdempotencyStore {
|
|
|
218
236
|
if (this.entries.size >= this.maxEntries) return { state: "capacity" }
|
|
219
237
|
this.entries.set(key, {
|
|
220
238
|
reservation: crypto.randomUUID(),
|
|
221
|
-
expiresAt: now + this.ttlMs,
|
|
239
|
+
expiresAt: now + (input.pendingTtlMs ?? this.ttlMs),
|
|
222
240
|
completed: false,
|
|
223
241
|
})
|
|
224
242
|
return { state: "new", reservation: this.entries.get(key)!.reservation }
|
|
@@ -238,6 +256,25 @@ export class MemoryToolIdempotencyStore implements ToolIdempotencyStore {
|
|
|
238
256
|
)
|
|
239
257
|
return false
|
|
240
258
|
entry.completed = true
|
|
259
|
+
entry.expiresAt = this.now() + this.ttlMs
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
renew(input: {
|
|
264
|
+
readonly namespace: string
|
|
265
|
+
readonly key: string
|
|
266
|
+
readonly reservation: string
|
|
267
|
+
readonly ttlMs: number
|
|
268
|
+
}): boolean {
|
|
269
|
+
validateIdempotencyInput(input)
|
|
270
|
+
const entry = this.entries.get(`${input.namespace.length}:${input.namespace}${input.key}`)
|
|
271
|
+
if (
|
|
272
|
+
entry?.reservation !== input.reservation ||
|
|
273
|
+
entry.completed ||
|
|
274
|
+
entry.expiresAt <= this.now()
|
|
275
|
+
)
|
|
276
|
+
return false
|
|
277
|
+
entry.expiresAt = this.now() + input.ttlMs
|
|
241
278
|
return true
|
|
242
279
|
}
|
|
243
280
|
|
|
@@ -410,6 +447,7 @@ export async function executeTool<Input, Output>(
|
|
|
410
447
|
let reservation:
|
|
411
448
|
| { readonly namespace: string; readonly key: string; readonly value: string }
|
|
412
449
|
| undefined
|
|
450
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined
|
|
413
451
|
const record = (stage: ToolEvidenceStage, outcome: ToolEvidenceOutcome, code?: string): void => {
|
|
414
452
|
if (evidence.length >= MAX_EVIDENCE) return
|
|
415
453
|
evidence.push({ seq: evidence.length, stage, outcome, ...(code === undefined ? {} : { code }) })
|
|
@@ -553,7 +591,12 @@ export async function executeTool<Input, Output>(
|
|
|
553
591
|
error: { code: "idempotency_store_missing", stage: "idempotency" },
|
|
554
592
|
})
|
|
555
593
|
}
|
|
556
|
-
const
|
|
594
|
+
const store = options.idempotency
|
|
595
|
+
const pendingTtlMs =
|
|
596
|
+
store.renew === undefined
|
|
597
|
+
? undefined
|
|
598
|
+
: (tool.idempotency.pendingTtlMs ?? DEFAULT_IDEMPOTENCY_PENDING_TTL_MS)
|
|
599
|
+
const began = await store.begin({ namespace, key, pendingTtlMs })
|
|
557
600
|
if (began.state !== "new") {
|
|
558
601
|
const code =
|
|
559
602
|
began.state === "duplicate"
|
|
@@ -565,6 +608,19 @@ export async function executeTool<Input, Output>(
|
|
|
565
608
|
return finish({ ok: false, dryRun, error: { code, stage: "idempotency" } })
|
|
566
609
|
}
|
|
567
610
|
reservation = { namespace, key, value: began.reservation }
|
|
611
|
+
if (pendingTtlMs !== undefined) {
|
|
612
|
+
const renewal = { ...reservationInput(reservation), ttlMs: pendingTtlMs }
|
|
613
|
+
// Renew the lease while the call runs: only a process that died stops renewing, so its key
|
|
614
|
+
// frees after the lease rather than the store's TTL.
|
|
615
|
+
heartbeat = setInterval(() => {
|
|
616
|
+
// A failed renewal only lets the lease lapse, which completion then reports.
|
|
617
|
+
Promise.resolve()
|
|
618
|
+
.then(() => store.renew?.(renewal))
|
|
619
|
+
.catch(() => undefined)
|
|
620
|
+
}, pendingTtlMs / 3)
|
|
621
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a Node or Bun timer has unref(); on other runtimes the optional call does nothing.
|
|
622
|
+
;(heartbeat as { unref?: () => void }).unref?.()
|
|
623
|
+
}
|
|
568
624
|
}
|
|
569
625
|
record("idempotency", tool.idempotency === undefined ? "skipped" : "passed")
|
|
570
626
|
|
|
@@ -636,6 +692,8 @@ export async function executeTool<Input, Output>(
|
|
|
636
692
|
const code = signal.aborted || isAbortError(error) ? "cancelled" : "execution_failed"
|
|
637
693
|
record("execution", "failed", code)
|
|
638
694
|
return finish({ ok: false, dryRun, error: { code, stage: "execution" } })
|
|
695
|
+
} finally {
|
|
696
|
+
clearInterval(heartbeat)
|
|
639
697
|
}
|
|
640
698
|
}
|
|
641
699
|
|
|
@@ -842,6 +900,11 @@ function validateIdempotencyPolicy<Input>(policy: ToolIdempotencyPolicy<Input>):
|
|
|
842
900
|
throw new TypeError("tool contract: idempotency scope is invalid")
|
|
843
901
|
if (typeof policy.key !== "function")
|
|
844
902
|
throw new TypeError("tool contract: idempotency key must be a function")
|
|
903
|
+
if (
|
|
904
|
+
policy.pendingTtlMs !== undefined &&
|
|
905
|
+
(!Number.isSafeInteger(policy.pendingTtlMs) || policy.pendingTtlMs <= 0)
|
|
906
|
+
)
|
|
907
|
+
throw new TypeError("tool contract: idempotency pendingTtlMs must be a positive integer")
|
|
845
908
|
}
|
|
846
909
|
|
|
847
910
|
function validateIdempotencyInput(input: {
|
package/src/transport-codec.ts
CHANGED
|
@@ -240,7 +240,8 @@ export async function readBoundedBytes(
|
|
|
240
240
|
if (done) break
|
|
241
241
|
total += value.byteLength
|
|
242
242
|
if (!Number.isSafeInteger(total) || total > maxBytes) {
|
|
243
|
-
|
|
243
|
+
// Not awaited: a cancel on a teed body settles only once its other branch is consumed too.
|
|
244
|
+
reader.cancel().catch(() => {})
|
|
244
245
|
throw new TransportCodecError("transport payload exceeds maxBytes")
|
|
245
246
|
}
|
|
246
247
|
chunks.push(value)
|