@nifrajs/core 3.5.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.
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
@@ -16,6 +16,9 @@ export type IdempotencyScope = "request" | "durable"
16
16
  /** Default retention for a stored idempotent response: 24 hours. */
17
17
  export const DEFAULT_IDEMPOTENCY_TTL_MS = 86_400_000
18
18
 
19
+ /** Default lease on a key whose handler is still running, with a store that can renew it: 60 seconds. */
20
+ export const DEFAULT_IDEMPOTENCY_PENDING_TTL_MS = 60_000
21
+
19
22
  /** Canonical request header carrying the client-chosen idempotency key. */
20
23
  export const DEFAULT_IDEMPOTENCY_HEADER = "idempotency-key"
21
24
 
@@ -80,7 +83,14 @@ export interface IdempotencyEntryKey {
80
83
 
81
84
  export interface IdempotencyBeginInput extends IdempotencyEntryKey {
82
85
  readonly fingerprint: string
86
+ /** Retention for the completed response. */
83
87
  readonly ttlMs: number
88
+ /**
89
+ * Lease on a `new` reservation: it lapses after this long unless {@link IdempotencyStore.renew}
90
+ * extends it, and `complete` then keeps the response for `ttlMs`. Passed only to a store with
91
+ * `renew`; when absent, the pending reservation holds the key for `ttlMs`.
92
+ */
93
+ readonly pendingTtlMs?: number | undefined
84
94
  }
85
95
 
86
96
  export interface IdempotencyCompletionInput extends IdempotencyEntryKey {
@@ -94,6 +104,13 @@ export interface IdempotencyAbandonInput extends IdempotencyEntryKey {
94
104
  readonly reservation: string
95
105
  }
96
106
 
107
+ export interface IdempotencyRenewInput extends IdempotencyEntryKey {
108
+ /** Opaque ownership token returned by `begin(state:"new")`. */
109
+ readonly reservation: string
110
+ /** The new lease, from now. */
111
+ readonly ttlMs: number
112
+ }
113
+
97
114
  /**
98
115
  * Storage seam for idempotent responses. `begin` MUST be atomic: for one key, exactly one concurrent
99
116
  * caller sees `new`; the rest see `in-flight` (or `replay` once completed). The in-memory store gets
@@ -115,6 +132,12 @@ export interface IdempotencyStore {
115
132
  complete(input: IdempotencyCompletionInput): boolean | Promise<boolean>
116
133
  /** Release only the pending reservation owned by `reservation`. */
117
134
  abandon(input: IdempotencyAbandonInput): boolean | Promise<boolean>
135
+ /**
136
+ * Extend the pending reservation owned by `reservation` to `ttlMs` from now. Returns false when it
137
+ * is gone, lapsed, or completed. The server calls it while the handler runs, so with it a key
138
+ * whose process died frees after `pendingTtlMs` instead of `ttlMs`.
139
+ */
140
+ renew?(input: IdempotencyRenewInput): boolean | Promise<boolean>
118
141
  }
119
142
 
120
143
  /** A key must be a non-empty, bounded, control-char-free token. Fail closed on anything else. */
@@ -312,6 +335,7 @@ export function responseFromStored(
312
335
  }
313
336
 
314
337
  interface MemoryEntry {
338
+ readonly namespace: string
315
339
  readonly fingerprint: string
316
340
  readonly reservation: string
317
341
  readonly ttlMs: number
@@ -324,6 +348,11 @@ export interface MemoryIdempotencyStoreOptions {
324
348
  readonly now?: () => number
325
349
  /** Hard memory bound. At capacity new keys fail closed; completed/pending entries are never evicted early. */
326
350
  readonly maxEntries?: number
351
+ /**
352
+ * Bound for one namespace, so a single tenant or principal cannot use up `maxEntries` for the
353
+ * rest: past it, only that namespace's new keys fail closed. Default: no separate bound.
354
+ */
355
+ readonly maxEntriesPerNamespace?: number
327
356
  }
328
357
 
329
358
  /**
@@ -334,8 +363,10 @@ export interface MemoryIdempotencyStoreOptions {
334
363
  export class MemoryIdempotencyStore implements IdempotencyStore {
335
364
  readonly durability = "memory" as const
336
365
  private readonly entries = new Map<string, MemoryEntry>()
366
+ private readonly perNamespace = new Map<string, number>()
337
367
  private readonly now: () => number
338
368
  private readonly maxEntries: number
369
+ private readonly maxPerNamespace: number
339
370
 
340
371
  constructor(options: MemoryIdempotencyStoreOptions = {}) {
341
372
  this.now = options.now ?? Date.now
@@ -343,6 +374,10 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
343
374
  if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
344
375
  throw new RangeError("idempotency: maxEntries must be a positive integer")
345
376
  }
377
+ this.maxPerNamespace = options.maxEntriesPerNamespace ?? this.maxEntries
378
+ if (!Number.isInteger(this.maxPerNamespace) || this.maxPerNamespace < 1) {
379
+ throw new RangeError("idempotency: maxEntriesPerNamespace must be a positive integer")
380
+ }
346
381
  }
347
382
 
348
383
  begin(input: IdempotencyBeginInput): IdempotencyBeginResult {
@@ -353,17 +388,19 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
353
388
  if (existing.response !== undefined) return { state: "replay", response: existing.response }
354
389
  return { state: "in-flight" }
355
390
  }
356
- if (existing !== undefined) this.entries.delete(storageKey)
357
- if (this.entries.size >= this.maxEntries) {
391
+ if (existing !== undefined) this.drop(storageKey, existing)
392
+ if (this.full(input.namespace)) {
358
393
  this.sweep()
359
- if (this.entries.size >= this.maxEntries) return { state: "capacity" }
394
+ if (this.full(input.namespace)) return { state: "capacity" }
360
395
  }
361
396
  const reservation = crypto.randomUUID()
397
+ this.perNamespace.set(input.namespace, (this.perNamespace.get(input.namespace) ?? 0) + 1)
362
398
  this.entries.set(storageKey, {
399
+ namespace: input.namespace,
363
400
  fingerprint: input.fingerprint,
364
401
  reservation,
365
402
  ttlMs: input.ttlMs,
366
- expiresAt: this.now() + input.ttlMs,
403
+ expiresAt: this.now() + (input.pendingTtlMs ?? input.ttlMs),
367
404
  response: undefined,
368
405
  })
369
406
  return { state: "new", reservation }
@@ -374,7 +411,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
374
411
  const entry = this.entries.get(key)
375
412
  if (entry === undefined || entry.reservation !== input.reservation) return false
376
413
  if (entry.expiresAt <= this.now()) {
377
- this.entries.delete(key)
414
+ this.drop(key, entry)
378
415
  return false
379
416
  }
380
417
  entry.response = input.response
@@ -386,7 +423,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
386
423
  const key = this.storageKey(input)
387
424
  const entry = this.entries.get(key)
388
425
  if (entry !== undefined && entry.expiresAt <= this.now()) {
389
- this.entries.delete(key)
426
+ this.drop(key, entry)
390
427
  return false
391
428
  }
392
429
  // Only drop a still-pending reservation; never evict a completed (replayable) response.
@@ -395,17 +432,30 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
395
432
  entry.response === undefined &&
396
433
  entry.reservation === input.reservation
397
434
  ) {
398
- this.entries.delete(key)
435
+ this.drop(key, entry)
399
436
  return true
400
437
  }
401
438
  return false
402
439
  }
403
440
 
441
+ renew(input: IdempotencyRenewInput): boolean {
442
+ const entry = this.entries.get(this.storageKey(input))
443
+ if (
444
+ entry?.reservation !== input.reservation ||
445
+ entry.response !== undefined ||
446
+ entry.expiresAt <= this.now()
447
+ ) {
448
+ return false
449
+ }
450
+ entry.expiresAt = this.now() + input.ttlMs
451
+ return true
452
+ }
453
+
404
454
  /** Evict expired entries. Callers may run this on an interval; access-time eviction covers the rest. */
405
455
  sweep(): void {
406
456
  const now = this.now()
407
457
  for (const [key, entry] of this.entries) {
408
- if (entry.expiresAt <= now) this.entries.delete(key)
458
+ if (entry.expiresAt <= now) this.drop(key, entry)
409
459
  }
410
460
  }
411
461
 
@@ -417,6 +467,20 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
417
467
  private storageKey(input: IdempotencyEntryKey): string {
418
468
  return `${input.namespace.length}:${input.namespace}${input.key}`
419
469
  }
470
+
471
+ private full(namespace: string): boolean {
472
+ return (
473
+ this.entries.size >= this.maxEntries ||
474
+ (this.perNamespace.get(namespace) ?? 0) >= this.maxPerNamespace
475
+ )
476
+ }
477
+
478
+ private drop(key: string, entry: MemoryEntry): void {
479
+ this.entries.delete(key)
480
+ const left = (this.perNamespace.get(entry.namespace) ?? 1) - 1
481
+ if (left > 0) this.perNamespace.set(entry.namespace, left)
482
+ else this.perNamespace.delete(entry.namespace)
483
+ }
420
484
  }
421
485
 
422
486
  /** Convenience factory mirroring the other core primitives' `create*` style. */
package/src/index.ts CHANGED
@@ -16,14 +16,25 @@
16
16
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
17
17
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
18
18
  */
19
- export const VERSION = "3.5.0" as const
19
+ export const VERSION = "4.0.0" as const
20
20
 
21
21
  export type Version = typeof VERSION
22
22
 
23
23
  export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
24
24
  export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
25
25
  export { isSameOriginRequest } from "./internal/same-origin.ts"
26
- export { METHODS, type Method, Router, type RouterMatch } from "./router/router.ts"
26
+ export {
27
+ ROUTE_PATTERN_OVERLAP_MAX_STATES,
28
+ RoutePatternOverlapLimitError,
29
+ routePatternOverlap,
30
+ } from "./router/overlap.ts"
31
+ export {
32
+ METHODS,
33
+ type Method,
34
+ type RouteMethod,
35
+ Router,
36
+ type RouterMatch,
37
+ } from "./router/router.ts"
27
38
  export type {
28
39
  InferInput,
29
40
  InferOutput,
@@ -33,11 +44,24 @@ export type {
33
44
  StandardTypes,
34
45
  ValidationOutcome,
35
46
  } from "./schema/standard.ts"
47
+ export {
48
+ type AuthenticationFailure,
49
+ type AuthenticationFailureReason,
50
+ type AuthenticationInput,
51
+ type AuthenticationResult,
52
+ type AuthenticationStage,
53
+ type AuthenticationSuccess,
54
+ type AuthHeaders,
55
+ type AuthMaybePromise,
56
+ authenticated,
57
+ rejected,
58
+ } from "./server/auth.ts"
36
59
  export type {
37
60
  Context,
38
61
  Params,
39
62
  Platform,
40
63
  Prettify,
64
+ RequestPath,
41
65
  ResponseControls,
42
66
  RouteSchema,
43
67
  } from "./server/context.ts"
@@ -83,7 +107,15 @@ export {
83
107
  toVercelHandler,
84
108
  type VercelHandler,
85
109
  } from "./server/platform-adapters.ts"
86
- export type { Registry, ResponseMapFor, RouteInfo, RouteInfoFor } from "./server/registry.ts"
110
+ export type {
111
+ JoinRoutePath,
112
+ PrefixRegistry,
113
+ Registry,
114
+ ResponseMapFor,
115
+ RouteInfo,
116
+ RouteInfoFor,
117
+ } from "./server/registry.ts"
118
+ export { replacedRequestOf } from "./server/request-lineage.ts"
87
119
  export {
88
120
  type PlainRender,
89
121
  type ResponseResult,
@@ -102,13 +134,18 @@ export {
102
134
  defineRouterPlugin,
103
135
  type Handler,
104
136
  type IdentityPlugin,
137
+ type ListenTlsOptions,
105
138
  type McpPromptDescriptor,
106
139
  type McpResourceDescriptor,
107
140
  type Middleware,
141
+ type MountableApp,
142
+ type MountFetchOptions,
143
+ type MountOptions,
108
144
  type NifraFeatureVersion,
109
145
  type NifraPlugin,
110
146
  type NodeRequestContext,
111
147
  type NodeRequestHook,
148
+ type NodeResponseBodyHook,
112
149
  type NodeResponseContext,
113
150
  type NodeResponseHook,
114
151
  type NodeServeOutcome,
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Code-unit order, the same on every runtime and in every locale. `localeCompare` answers by the
3
+ * runtime's locale and ICU data, so an artifact that is hashed, signed, or committed sorts with this
4
+ * instead: its bytes must not depend on the machine that built it.
5
+ */
6
+ export function codeUnitOrder(a: string, b: string): number {
7
+ return a < b ? -1 : a > b ? 1 : 0
8
+ }
@@ -22,6 +22,8 @@ function transitionSaga(
22
22
  ): SagaRecord | undefined {
23
23
  if (
24
24
  input.record.version !== input.version + 1 ||
25
+ // A record filed under another saga's key could never load again, and scans would act on its id.
26
+ input.record.sagaId !== input.sagaId ||
25
27
  (current !== undefined &&
26
28
  (current.sagaId !== input.sagaId || current.version !== input.version))
27
29
  ) {
@@ -16,7 +16,7 @@
16
16
  * registration.
17
17
  */
18
18
  import type { CompiledRoutePattern } from "../router/pattern.ts"
19
- import { type Method, Router, type RouterMatch } from "../router/router.ts"
19
+ import { Router, type RouterMatch } from "../router/router.ts"
20
20
  import type { RouteDescriptor } from "../server/server-types.ts"
21
21
  import type { AssuranceDeclaration } from "./route-assurance.ts"
22
22
  import type { RouteEntry } from "./route-execution.ts"
@@ -24,7 +24,7 @@ import type { RouteEntry } from "./route-execution.ts"
24
24
  /** One canonical runtime route fact. The catalog owns matching, reflection, assurance, tool metadata,
25
25
  * replay, and native compilation input so batch registration has one commit point. */
26
26
  export interface CatalogRoute {
27
- readonly method: Method
27
+ readonly method: string
28
28
  readonly path: string
29
29
  readonly pattern: CompiledRoutePattern
30
30
  readonly entry: RouteEntry
@@ -33,10 +33,10 @@ export interface CatalogRoute {
33
33
  }
34
34
 
35
35
  /**
36
- * Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
37
- * the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
38
- * after every route validates. Failed `implement()`/`merge()` batches therefore leave matching and
39
- * reflection unchanged.
36
+ * Runtime route catalog. Single-route registration mutates directly. A batch adds its routes the same
37
+ * way and, if one is rejected, restores the catalog to what it was before the batch. A failed
38
+ * `implement()`/`merge()` batch therefore leaves matching and reflection unchanged, and a batch that
39
+ * validates costs what its routes cost one by one.
40
40
  *
41
41
  * Coverage note: this file reports 90% functions with every method exercised. `bun test --coverage`
42
42
  * (1.3.14) counts one synthetic function per class that it never marks hit - a one-method class with a
@@ -58,18 +58,19 @@ export class RouteCatalog {
58
58
  }
59
59
 
60
60
  addBatch(routes: readonly CatalogRoute[]): void {
61
- if (routes.length === 0) return
62
- const nextRecords = this.records.concat(routes)
63
- const nextDescriptors = this.descriptors.concat(routes.map(({ descriptor }) => descriptor))
64
- const nextAssurancePresent =
65
- this.assurancePresent || routes.some((route) => route.assurance.length > 0)
66
- const staged = new Router<RouteEntry>()
67
- for (const route of this.records) staged.add(route.method, route.pattern, route.entry)
68
- for (const route of routes) staged.add(route.method, route.pattern, route.entry)
69
- this.matcher = staged
70
- this.records = nextRecords
71
- this.descriptors = nextDescriptors
72
- this.assurancePresent = nextAssurancePresent
61
+ const kept = this.records.length
62
+ const assurancePresent = this.assurancePresent
63
+ try {
64
+ for (const route of routes) this.add(route)
65
+ } catch (error) {
66
+ // The rejected route may have left nodes in the trie, so the matcher is not patched: it is
67
+ // rebuilt from the routes that were there before the batch. They validated once already.
68
+ this.records.length = this.descriptors.length = kept
69
+ this.assurancePresent = assurancePresent
70
+ this.matcher = new Router<RouteEntry>()
71
+ for (const route of this.records) this.matcher.add(route.method, route.pattern, route.entry)
72
+ throw error
73
+ }
73
74
  }
74
75
 
75
76
  find(method: string, path: string): RouterMatch<RouteEntry> {
@@ -13,13 +13,14 @@
13
13
  import type { EffectLifecycleObserver } from "../effect-lifecycle.ts"
14
14
  import { RouteConfigError } from "../errors.ts"
15
15
  import { type CompiledRoutePattern, compileRoutePattern } from "../router/pattern.ts"
16
- import type { Method } from "../router/router.ts"
17
- import type { StandardSchemaV1 } from "../schema/standard.ts"
18
16
  import { assertByteLimit } from "../server/body.ts"
19
17
  import type { RouteSchema } from "../server/context.ts"
20
18
  import type { IdempotencyRuntime, ResolvedIdempotency } from "../server/idempotency-lane.ts"
21
19
  import type { EffectLedgerRuntime, ResolvedEffectLedger } from "../server/ledger-lane.ts"
22
- import type { ResponseContractRuntime } from "../server/response-contract-lane.ts"
20
+ import type {
21
+ ResponseContractDefinition,
22
+ ResponseContractRuntime,
23
+ } from "../server/response-contract-lane.ts"
23
24
  import {
24
25
  CAPABILITY_GUARD,
25
26
  type CapabilityUseEvent,
@@ -37,6 +38,7 @@ import {
37
38
  import type {
38
39
  RawAfterHandle,
39
40
  RawAround,
41
+ RawAuthStage,
40
42
  RawBeforeHandle,
41
43
  RawDerive,
42
44
  RawErrorHandler,
@@ -55,6 +57,7 @@ export interface RouteCompilerContext {
55
57
  readonly idempotencyRuntime: IdempotencyRuntime | undefined
56
58
  readonly responseContractRuntime: ResponseContractRuntime | undefined
57
59
  readonly derives: readonly RawDerive[]
60
+ readonly authStages: readonly RawAuthStage[]
58
61
  readonly beforeHandleHooks: readonly RawBeforeHandle[]
59
62
  readonly afterHandleHooks: readonly RawAfterHandle[]
60
63
  readonly onErrorHooks: readonly RawErrorHandler[]
@@ -72,16 +75,18 @@ export interface CompiledRouteOptions {
72
75
  readonly idempotent: ResolvedIdempotency | undefined
73
76
  readonly ledgered: ResolvedEffectLedger | undefined
74
77
  readonly responseContract:
75
- | { readonly runtime: ResponseContractRuntime; readonly schema: StandardSchemaV1 }
78
+ | { readonly runtime: ResponseContractRuntime; readonly definition: ResponseContractDefinition }
76
79
  | undefined
77
80
  readonly lanes: RouteLaneSelection
78
81
  readonly routeAssurance: readonly AssuranceDeclaration[]
82
+ readonly authStages: readonly RawAuthStage[]
83
+ readonly authenticated: boolean
79
84
  }
80
85
 
81
86
  /** Resolve all registration-time policy and lane facts for one route. */
82
87
  export function compileRouteOptions(
83
88
  context: RouteCompilerContext,
84
- method: Method,
89
+ method: string,
85
90
  path: string,
86
91
  schema: RouteSchema | undefined,
87
92
  handler: (context: never) => unknown,
@@ -91,6 +96,25 @@ export function compileRouteOptions(
91
96
  const invalidBodyLimit = (message: string): never => {
92
97
  throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`)
93
98
  }
99
+ if (schema?.wire !== undefined && schema.wire !== "raw") {
100
+ throw new RouteConfigError("INVALID_WIRE", `route ${method} ${path}: wire must be "raw"`)
101
+ }
102
+ if (schema?.wire === "raw" && schema.body !== undefined) {
103
+ throw new RouteConfigError(
104
+ "INVALID_WIRE",
105
+ `route ${method} ${path}: wire: "raw" cannot be combined with a body schema`,
106
+ )
107
+ }
108
+ if (
109
+ schema?.validationOrder !== undefined &&
110
+ schema.validationOrder !== "validate-before-auth" &&
111
+ schema.validationOrder !== "auth-before-validation"
112
+ ) {
113
+ throw new RouteConfigError(
114
+ "INVALID_VALIDATION_ORDER",
115
+ `route ${method} ${path}: validationOrder is invalid`,
116
+ )
117
+ }
94
118
  if (schema?.bodyLimitReason !== undefined && schema.bodyLimit !== "unlimited") {
95
119
  invalidBodyLimit('bodyLimitReason is only valid with bodyLimit: "unlimited"')
96
120
  }
@@ -124,11 +148,13 @@ export function compileRouteOptions(
124
148
  )
125
149
  }
126
150
 
127
- const authenticated = assuranceEvidenceFor(
128
- [...context.activeAssurance, ...handlerAssurance, ...context.globalAssurance],
129
- method,
130
- path,
131
- ).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
151
+ const authenticated =
152
+ context.authStages.length > 0 ||
153
+ assuranceEvidenceFor(
154
+ [...context.activeAssurance, ...handlerAssurance, ...context.globalAssurance],
155
+ method,
156
+ path,
157
+ ).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
132
158
  const routeDecorations: Record<PropertyKey, unknown> = { ...context.decorations }
133
159
  if (capabilities.length > 0) {
134
160
  routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(
@@ -155,12 +181,20 @@ export function compileRouteOptions(
155
181
  context.maxBodyBytes,
156
182
  )
157
183
  const ledgered = context.effectLedgerRuntime?.resolve(capabilities, method, path)
184
+ const responseDefinition =
185
+ schema?.response !== undefined || schema?.errors !== undefined
186
+ ? {
187
+ ...(schema.response === undefined ? {} : { response: schema.response }),
188
+ ...(schema.errors === undefined ? {} : { errors: schema.errors }),
189
+ }
190
+ : undefined
158
191
  const contracted =
159
- context.responseContractRuntime !== undefined && schema?.response !== undefined
160
- ? { runtime: context.responseContractRuntime, schema: schema.response }
192
+ context.responseContractRuntime !== undefined && responseDefinition !== undefined
193
+ ? { runtime: context.responseContractRuntime, definition: responseDefinition }
161
194
  : undefined
162
195
  const lanes = selectRouteLanes({
163
196
  schema,
197
+ hasAuth: context.authStages.length > 0,
164
198
  hasIdempotency: idempotent !== undefined,
165
199
  hasLedger: ledgered !== undefined,
166
200
  hasResponseContract: contracted !== undefined,
@@ -174,7 +208,7 @@ export function compileRouteOptions(
174
208
  })
175
209
 
176
210
  const routeAssurance: AssuranceDeclaration[] = [...context.activeAssurance, ...handlerAssurance]
177
- if (contracted?.runtime.mode === "enforce" && schema?.response !== undefined) {
211
+ if (contracted?.runtime.mode === "enforce") {
178
212
  routeAssurance.push(
179
213
  Object.freeze({
180
214
  id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
@@ -217,6 +251,19 @@ export function compileRouteOptions(
217
251
  }),
218
252
  )
219
253
  }
254
+ if (context.authStages.length > 0) {
255
+ // `authenticate()` is an actual runtime enforcement stage, not a route assertion. Publish its
256
+ // proof directly so a core-authenticated route cannot accidentally become assurance
257
+ // classification-only when it is not using an integration plugin such as better-auth.
258
+ routeAssurance.push(
259
+ Object.freeze({
260
+ id: NIFRA_ASSURANCE_IDS.AUTHENTICATED,
261
+ source: "nifra.authenticate",
262
+ scope: "plugin",
263
+ provenance: "runtime" as const,
264
+ }),
265
+ )
266
+ }
220
267
 
221
268
  return {
222
269
  pattern,
@@ -230,5 +277,7 @@ export function compileRouteOptions(
230
277
  responseContract: contracted,
231
278
  lanes,
232
279
  routeAssurance: Object.freeze(routeAssurance),
280
+ authStages: Object.freeze([...context.authStages]),
281
+ authenticated,
233
282
  }
234
283
  }
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import type { RequestBudget } from "../budget.ts"
20
20
  import type { RequestLedger } from "../ledger.ts"
21
- import type { StandardSchemaV1 } from "../schema/standard.ts"
21
+ import type { AuthenticationStage } from "../server/auth.ts"
22
22
  import { UNLIMITED_BODY_BYTES } from "../server/body.ts"
23
23
  import type { Platform, RouteSchema } from "../server/context.ts"
24
24
  import type { ResolvedIdempotency } from "../server/idempotency-lane.ts"
@@ -26,7 +26,10 @@ import type { EffectLedgerRuntime, ResolvedEffectLedger } from "../server/ledger
26
26
  import type { ProtoPoisoning } from "../server/proto-guard.ts"
27
27
  import type { Registry } from "../server/registry.ts"
28
28
  import { RequestContext } from "../server/request-context.ts"
29
- import type { ResponseContractRuntime } from "../server/response-contract-lane.ts"
29
+ import type {
30
+ ResponseContractDefinition,
31
+ ResponseContractRuntime,
32
+ } from "../server/response-contract-lane.ts"
30
33
  import type { HandlerResult, ResponseResult } from "../server/runtime-core.ts"
31
34
  import type { CtxSet, MaybePromise, RawContext, RequestSource, Server } from "../server/server.ts"
32
35
  import type { RouteProgram } from "./route-program.ts"
@@ -35,6 +38,8 @@ export type InternalHandler = (ctx: RawContext) => MaybePromise<HandlerResult>
35
38
 
36
39
  /** A `derive` computes per-request context extensions; stored path-erased. */
37
40
  export type RawDerive = (ctx: RawContext) => MaybePromise<object>
41
+ /** A route-scoped authentication provider, stored path-erased after registration. */
42
+ export type RawAuthStage = AuthenticationStage<unknown, unknown>
38
43
  export type RawBeforeHandle = (ctx: RawContext) => MaybePromise<unknown>
39
44
  export type RawAfterHandle = (result: unknown, ctx: RawContext) => MaybePromise<unknown>
40
45
  export type RawErrorHandler = (error: unknown, ctx: RawContext) => MaybePromise<unknown>
@@ -311,10 +316,15 @@ export interface RouteEntry {
311
316
  * at registration; `undefined` = not checked (the default, and the only state in which the route can
312
317
  * still take the fused/native lanes). */
313
318
  readonly responseContract:
314
- | { readonly runtime: ResponseContractRuntime; readonly schema: StandardSchemaV1 }
319
+ | {
320
+ readonly runtime: ResponseContractRuntime
321
+ readonly definition: ResponseContractDefinition
322
+ }
315
323
  | undefined
316
324
  /** Per-request context extensions captured at registration (order-scoped). */
317
325
  readonly derives: ReadonlyArray<RawDerive>
326
+ /** Dedicated auth stages run before validation; ordinary derives keep their existing order. */
327
+ readonly authStages: ReadonlyArray<RawAuthStage>
318
328
  /** Static context extensions captured at registration. */
319
329
  readonly decorations: Record<PropertyKey, unknown>
320
330
  /** Whether {@link decorations} has any keys - precomputed so the hot path skips a no-op