@nifrajs/core 3.5.0 → 4.0.2

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 (325) hide show
  1. package/README.md +26 -0
  2. package/dist/assurance.d.ts +7 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +39 -3
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +20 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +47 -15
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/diff.d.ts +2 -1
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/diff.js +26 -2
  13. package/dist/diff.js.map +1 -1
  14. package/dist/durable-adapters.d.ts.map +1 -1
  15. package/dist/durable-adapters.js +16 -3
  16. package/dist/durable-adapters.js.map +1 -1
  17. package/dist/durable-effect.d.ts.map +1 -1
  18. package/dist/durable-effect.js +6 -3
  19. package/dist/durable-effect.js.map +1 -1
  20. package/dist/edge-kit.d.ts +2 -0
  21. package/dist/edge-kit.d.ts.map +1 -1
  22. package/dist/edge-kit.js +2 -0
  23. package/dist/edge-kit.js.map +1 -1
  24. package/dist/effect-lifecycle.d.ts.map +1 -1
  25. package/dist/effect-lifecycle.js +2 -1
  26. package/dist/effect-lifecycle.js.map +1 -1
  27. package/dist/effect-scope.d.ts.map +1 -1
  28. package/dist/effect-scope.js +1 -1
  29. package/dist/effect-scope.js.map +1 -1
  30. package/dist/errors.d.ts +1 -1
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js.map +1 -1
  33. package/dist/evidence.d.ts +24 -1
  34. package/dist/evidence.d.ts.map +1 -1
  35. package/dist/evidence.js +168 -13
  36. package/dist/evidence.js.map +1 -1
  37. package/dist/idempotency.d.ts +31 -0
  38. package/dist/idempotency.d.ts.map +1 -1
  39. package/dist/idempotency.js +40 -8
  40. package/dist/idempotency.js.map +1 -1
  41. package/dist/index.d.ts +8 -5
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +5 -2
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal/code-unit-order.d.ts +7 -0
  46. package/dist/internal/code-unit-order.d.ts.map +1 -0
  47. package/dist/internal/code-unit-order.js +9 -0
  48. package/dist/internal/code-unit-order.js.map +1 -0
  49. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  50. package/dist/internal/durable-saga-protocol.js +2 -0
  51. package/dist/internal/durable-saga-protocol.js.map +1 -1
  52. package/dist/internal/route-catalog.d.ts +6 -6
  53. package/dist/internal/route-catalog.d.ts.map +1 -1
  54. package/dist/internal/route-catalog.js +20 -18
  55. package/dist/internal/route-catalog.js.map +1 -1
  56. package/dist/internal/route-compiler.d.ts +7 -6
  57. package/dist/internal/route-compiler.d.ts.map +1 -1
  58. package/dist/internal/route-compiler.js +36 -4
  59. package/dist/internal/route-compiler.js.map +1 -1
  60. package/dist/internal/route-execution.d.ts +7 -3
  61. package/dist/internal/route-execution.d.ts.map +1 -1
  62. package/dist/internal/route-execution.js.map +1 -1
  63. package/dist/internal/route-lanes.d.ts +1 -0
  64. package/dist/internal/route-lanes.d.ts.map +1 -1
  65. package/dist/internal/route-lanes.js +34 -29
  66. package/dist/internal/route-lanes.js.map +1 -1
  67. package/dist/internal/route-program.d.ts +8 -2
  68. package/dist/internal/route-program.d.ts.map +1 -1
  69. package/dist/internal/route-program.js +222 -20
  70. package/dist/internal/route-program.js.map +1 -1
  71. package/dist/ledger.d.ts.map +1 -1
  72. package/dist/ledger.js +8 -5
  73. package/dist/ledger.js.map +1 -1
  74. package/dist/manifest.d.ts.map +1 -1
  75. package/dist/manifest.js +41 -37
  76. package/dist/manifest.js.map +1 -1
  77. package/dist/mount.d.ts +36 -0
  78. package/dist/mount.d.ts.map +1 -1
  79. package/dist/mount.js +32 -0
  80. package/dist/mount.js.map +1 -1
  81. package/dist/reflection.d.ts +26 -0
  82. package/dist/reflection.d.ts.map +1 -1
  83. package/dist/reflection.js +65 -1
  84. package/dist/reflection.js.map +1 -1
  85. package/dist/response-observer.d.ts +1 -1
  86. package/dist/response-observer.d.ts.map +1 -1
  87. package/dist/response-observer.js.map +1 -1
  88. package/dist/router/overlap.d.ts +23 -0
  89. package/dist/router/overlap.d.ts.map +1 -0
  90. package/dist/router/overlap.js +224 -0
  91. package/dist/router/overlap.js.map +1 -0
  92. package/dist/router/pattern.d.ts +117 -2
  93. package/dist/router/pattern.d.ts.map +1 -1
  94. package/dist/router/pattern.js +283 -23
  95. package/dist/router/pattern.js.map +1 -1
  96. package/dist/router/router.d.ts +18 -4
  97. package/dist/router/router.d.ts.map +1 -1
  98. package/dist/router/router.js +51 -44
  99. package/dist/router/router.js.map +1 -1
  100. package/dist/server/auth.d.ts +53 -0
  101. package/dist/server/auth.d.ts.map +1 -0
  102. package/dist/server/auth.js +9 -0
  103. package/dist/server/auth.js.map +1 -0
  104. package/dist/server/bare-error-lane.d.ts +12 -7
  105. package/dist/server/bare-error-lane.d.ts.map +1 -1
  106. package/dist/server/bare-error-lane.js +13 -11
  107. package/dist/server/bare-error-lane.js.map +1 -1
  108. package/dist/server/body-lane.d.ts +20 -2
  109. package/dist/server/body-lane.d.ts.map +1 -1
  110. package/dist/server/body-lane.js +26 -7
  111. package/dist/server/body-lane.js.map +1 -1
  112. package/dist/server/body-parser.d.ts +68 -0
  113. package/dist/server/body-parser.d.ts.map +1 -0
  114. package/dist/server/body-parser.js +172 -0
  115. package/dist/server/body-parser.js.map +1 -0
  116. package/dist/server/body.d.ts.map +1 -1
  117. package/dist/server/body.js +62 -7
  118. package/dist/server/body.js.map +1 -1
  119. package/dist/server/client-ip.d.ts +8 -0
  120. package/dist/server/client-ip.d.ts.map +1 -1
  121. package/dist/server/client-ip.js +8 -0
  122. package/dist/server/client-ip.js.map +1 -1
  123. package/dist/server/context.d.ts +71 -6
  124. package/dist/server/context.d.ts.map +1 -1
  125. package/dist/server/contract.d.ts +15 -5
  126. package/dist/server/contract.d.ts.map +1 -1
  127. package/dist/server/contract.js +25 -4
  128. package/dist/server/contract.js.map +1 -1
  129. package/dist/server/core-copies.d.ts +2 -0
  130. package/dist/server/core-copies.d.ts.map +1 -0
  131. package/dist/server/core-copies.js +20 -0
  132. package/dist/server/core-copies.js.map +1 -0
  133. package/dist/server/edge.d.ts.map +1 -1
  134. package/dist/server/edge.js +2 -0
  135. package/dist/server/edge.js.map +1 -1
  136. package/dist/server/http.d.ts +11 -0
  137. package/dist/server/http.d.ts.map +1 -1
  138. package/dist/server/http.js +36 -0
  139. package/dist/server/http.js.map +1 -1
  140. package/dist/server/idempotency-lane.d.ts +6 -0
  141. package/dist/server/idempotency-lane.d.ts.map +1 -1
  142. package/dist/server/idempotency-lane.js +130 -73
  143. package/dist/server/idempotency-lane.js.map +1 -1
  144. package/dist/server/install.d.ts +4 -0
  145. package/dist/server/install.d.ts.map +1 -1
  146. package/dist/server/install.js +4 -0
  147. package/dist/server/install.js.map +1 -1
  148. package/dist/server/methods.d.ts +45 -0
  149. package/dist/server/methods.d.ts.map +1 -0
  150. package/dist/server/methods.js +37 -0
  151. package/dist/server/methods.js.map +1 -0
  152. package/dist/server/multipart.d.ts +63 -0
  153. package/dist/server/multipart.d.ts.map +1 -0
  154. package/dist/server/multipart.js +374 -0
  155. package/dist/server/multipart.js.map +1 -0
  156. package/dist/server/node-outcome-hook.d.ts +20 -0
  157. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  158. package/dist/server/node-outcome-hook.js +15 -6
  159. package/dist/server/node-outcome-hook.js.map +1 -1
  160. package/dist/server/not-found-answer.d.ts +40 -0
  161. package/dist/server/not-found-answer.d.ts.map +1 -0
  162. package/dist/server/not-found-answer.js +56 -0
  163. package/dist/server/not-found-answer.js.map +1 -0
  164. package/dist/server/not-found.d.ts +23 -0
  165. package/dist/server/not-found.d.ts.map +1 -0
  166. package/dist/server/not-found.js +81 -0
  167. package/dist/server/not-found.js.map +1 -0
  168. package/dist/server/plugin.d.ts +26 -1
  169. package/dist/server/plugin.d.ts.map +1 -1
  170. package/dist/server/plugin.js.map +1 -1
  171. package/dist/server/proto-guard.d.ts +18 -5
  172. package/dist/server/proto-guard.d.ts.map +1 -1
  173. package/dist/server/proto-guard.js +114 -9
  174. package/dist/server/proto-guard.js.map +1 -1
  175. package/dist/server/query.d.ts +7 -0
  176. package/dist/server/query.d.ts.map +1 -1
  177. package/dist/server/query.js +47 -0
  178. package/dist/server/query.js.map +1 -1
  179. package/dist/server/registry.d.ts +13 -2
  180. package/dist/server/registry.d.ts.map +1 -1
  181. package/dist/server/request-context.d.ts +11 -0
  182. package/dist/server/request-context.d.ts.map +1 -1
  183. package/dist/server/request-context.js +33 -19
  184. package/dist/server/request-context.js.map +1 -1
  185. package/dist/server/request-lineage.d.ts +8 -0
  186. package/dist/server/request-lineage.d.ts.map +1 -0
  187. package/dist/server/request-lineage.js +14 -0
  188. package/dist/server/request-lineage.js.map +1 -0
  189. package/dist/server/request-target.d.ts +31 -0
  190. package/dist/server/request-target.d.ts.map +1 -0
  191. package/dist/server/request-target.js +44 -0
  192. package/dist/server/request-target.js.map +1 -0
  193. package/dist/server/respond.d.ts +1 -1
  194. package/dist/server/respond.d.ts.map +1 -1
  195. package/dist/server/respond.js +16 -4
  196. package/dist/server/respond.js.map +1 -1
  197. package/dist/server/response-contract-lane.d.ts +19 -1
  198. package/dist/server/response-contract-lane.d.ts.map +1 -1
  199. package/dist/server/response-contract-lane.js +59 -14
  200. package/dist/server/response-contract-lane.js.map +1 -1
  201. package/dist/server/response-observer-runtime.d.ts +9 -5
  202. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  203. package/dist/server/response-observer-runtime.js +10 -6
  204. package/dist/server/response-observer-runtime.js.map +1 -1
  205. package/dist/server/runtime-core.d.ts +2 -0
  206. package/dist/server/runtime-core.d.ts.map +1 -1
  207. package/dist/server/runtime-core.js.map +1 -1
  208. package/dist/server/server-types.d.ts +53 -3
  209. package/dist/server/server-types.d.ts.map +1 -1
  210. package/dist/server/server.d.ts +191 -19
  211. package/dist/server/server.d.ts.map +1 -1
  212. package/dist/server/server.js +1206 -218
  213. package/dist/server/server.js.map +1 -1
  214. package/dist/server/sse.d.ts.map +1 -1
  215. package/dist/server/sse.js +10 -7
  216. package/dist/server/sse.js.map +1 -1
  217. package/dist/server/static-headers.d.ts +3 -0
  218. package/dist/server/static-headers.d.ts.map +1 -1
  219. package/dist/server/static-headers.js +19 -1
  220. package/dist/server/static-headers.js.map +1 -1
  221. package/dist/server/transport-lane.d.ts +8 -0
  222. package/dist/server/transport-lane.d.ts.map +1 -1
  223. package/dist/server/transport-lane.js +9 -8
  224. package/dist/server/transport-lane.js.map +1 -1
  225. package/dist/server/validation.d.ts.map +1 -1
  226. package/dist/server/validation.js +5 -1
  227. package/dist/server/validation.js.map +1 -1
  228. package/dist/server/websocket.d.ts +10 -3
  229. package/dist/server/websocket.d.ts.map +1 -1
  230. package/dist/server/websocket.js +7 -4
  231. package/dist/server/websocket.js.map +1 -1
  232. package/dist/server/ws-bun.d.ts +2 -0
  233. package/dist/server/ws-bun.d.ts.map +1 -1
  234. package/dist/server/ws-bun.js +6 -3
  235. package/dist/server/ws-bun.js.map +1 -1
  236. package/dist/server/ws-hook.d.ts +2 -1
  237. package/dist/server/ws-hook.d.ts.map +1 -1
  238. package/dist/server.d.ts +7 -4
  239. package/dist/server.d.ts.map +1 -1
  240. package/dist/server.js +4 -1
  241. package/dist/server.js.map +1 -1
  242. package/dist/single-copy-register.js +5 -0
  243. package/dist/single-copy-register.js.map +1 -1
  244. package/dist/single-copy.d.ts +46 -3
  245. package/dist/single-copy.d.ts.map +1 -1
  246. package/dist/single-copy.js +125 -12
  247. package/dist/single-copy.js.map +1 -1
  248. package/dist/tool-contract.d.ts +23 -0
  249. package/dist/tool-contract.d.ts.map +1 -1
  250. package/dist/tool-contract.js +37 -3
  251. package/dist/tool-contract.js.map +1 -1
  252. package/dist/transport-codec.d.ts.map +1 -1
  253. package/dist/transport-codec.js +2 -1
  254. package/dist/transport-codec.js.map +1 -1
  255. package/package.json +27 -2
  256. package/src/assurance.ts +52 -2
  257. package/src/capabilities.ts +81 -22
  258. package/src/diff.ts +31 -6
  259. package/src/durable-adapters.ts +20 -3
  260. package/src/durable-effect.ts +10 -3
  261. package/src/edge-kit.ts +7 -0
  262. package/src/effect-lifecycle.ts +5 -1
  263. package/src/effect-scope.ts +2 -1
  264. package/src/errors.ts +3 -0
  265. package/src/evidence.ts +237 -12
  266. package/src/idempotency.ts +72 -8
  267. package/src/index.ts +40 -3
  268. package/src/internal/code-unit-order.ts +8 -0
  269. package/src/internal/durable-saga-protocol.ts +2 -0
  270. package/src/internal/route-catalog.ts +19 -18
  271. package/src/internal/route-compiler.ts +62 -13
  272. package/src/internal/route-execution.ts +13 -3
  273. package/src/internal/route-lanes.ts +37 -28
  274. package/src/internal/route-program.ts +434 -20
  275. package/src/ledger.ts +11 -5
  276. package/src/manifest.ts +41 -36
  277. package/src/mount.ts +62 -0
  278. package/src/reflection.ts +84 -1
  279. package/src/response-observer.ts +1 -0
  280. package/src/router/overlap.ts +258 -0
  281. package/src/router/pattern.ts +349 -25
  282. package/src/router/router.ts +76 -51
  283. package/src/server/auth.ts +75 -0
  284. package/src/server/bare-error-lane.ts +12 -11
  285. package/src/server/body-lane.ts +46 -6
  286. package/src/server/body-parser.ts +241 -0
  287. package/src/server/body.ts +82 -9
  288. package/src/server/client-ip.ts +9 -0
  289. package/src/server/context.ts +219 -12
  290. package/src/server/contract.ts +54 -9
  291. package/src/server/core-copies.ts +21 -0
  292. package/src/server/edge.ts +2 -0
  293. package/src/server/http.ts +34 -0
  294. package/src/server/idempotency-lane.ts +147 -72
  295. package/src/server/install.ts +6 -0
  296. package/src/server/methods.ts +143 -0
  297. package/src/server/multipart.ts +424 -0
  298. package/src/server/node-outcome-hook.ts +40 -7
  299. package/src/server/not-found-answer.ts +109 -0
  300. package/src/server/not-found.ts +121 -0
  301. package/src/server/plugin.ts +45 -1
  302. package/src/server/proto-guard.ts +128 -9
  303. package/src/server/query.ts +49 -0
  304. package/src/server/registry.ts +17 -2
  305. package/src/server/request-context.ts +44 -16
  306. package/src/server/request-lineage.ts +15 -0
  307. package/src/server/request-target.ts +46 -0
  308. package/src/server/respond.ts +14 -3
  309. package/src/server/response-contract-lane.ts +97 -16
  310. package/src/server/response-observer-runtime.ts +37 -8
  311. package/src/server/runtime-core.ts +2 -0
  312. package/src/server/server-types.ts +59 -2
  313. package/src/server/server.ts +1613 -269
  314. package/src/server/sse.ts +10 -8
  315. package/src/server/static-headers.ts +22 -1
  316. package/src/server/transport-lane.ts +17 -9
  317. package/src/server/validation.ts +7 -1
  318. package/src/server/websocket.ts +23 -9
  319. package/src/server/ws-bun.ts +8 -3
  320. package/src/server/ws-hook.ts +2 -1
  321. package/src/server.ts +38 -2
  322. package/src/single-copy-register.ts +5 -0
  323. package/src/single-copy.ts +160 -12
  324. package/src/tool-contract.ts +66 -3
  325. package/src/transport-codec.ts +2 -1
@@ -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
- /** A foreign copy deliberately left alone, and why - never silently dropped. */
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 pkg = readJsonSync(join(resolve(cwd), "package.json"))
145
- const nifra = pkg?.nifra
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.localeCompare(b.from))
404
- skipped.sort((a, b) => a.from.localeCompare(b.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 plugin = singleCopyPlugin(options)
523
- if (plugin.plan.declared.length > 0) Bun.plugin(plugin)
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
  }
@@ -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
- readonly expiresAt: number
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 began = await options.idempotency.begin({ namespace, key })
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: {
@@ -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
- await reader.cancel()
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)