@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.
Files changed (331) 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 +6 -1
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/diff.js +36 -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 +36 -1
  34. package/dist/evidence.d.ts.map +1 -1
  35. package/dist/evidence.js +219 -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 +48 -9
  40. package/dist/idempotency.js.map +1 -1
  41. package/dist/index.d.ts +9 -6
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +6 -3
  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 +1 -1
  75. package/dist/manifest.d.ts.map +1 -1
  76. package/dist/manifest.js +52 -46
  77. package/dist/manifest.js.map +1 -1
  78. package/dist/mount.d.ts +36 -0
  79. package/dist/mount.d.ts.map +1 -1
  80. package/dist/mount.js +32 -0
  81. package/dist/mount.js.map +1 -1
  82. package/dist/reflection.d.ts +26 -0
  83. package/dist/reflection.d.ts.map +1 -1
  84. package/dist/reflection.js +65 -1
  85. package/dist/reflection.js.map +1 -1
  86. package/dist/response-observer.d.ts +1 -1
  87. package/dist/response-observer.d.ts.map +1 -1
  88. package/dist/response-observer.js.map +1 -1
  89. package/dist/router/overlap.d.ts +23 -0
  90. package/dist/router/overlap.d.ts.map +1 -0
  91. package/dist/router/overlap.js +224 -0
  92. package/dist/router/overlap.js.map +1 -0
  93. package/dist/router/pattern.d.ts +117 -2
  94. package/dist/router/pattern.d.ts.map +1 -1
  95. package/dist/router/pattern.js +283 -23
  96. package/dist/router/pattern.js.map +1 -1
  97. package/dist/router/router.d.ts +18 -4
  98. package/dist/router/router.d.ts.map +1 -1
  99. package/dist/router/router.js +51 -44
  100. package/dist/router/router.js.map +1 -1
  101. package/dist/server/auth.d.ts +53 -0
  102. package/dist/server/auth.d.ts.map +1 -0
  103. package/dist/server/auth.js +9 -0
  104. package/dist/server/auth.js.map +1 -0
  105. package/dist/server/bare-error-lane.d.ts +12 -7
  106. package/dist/server/bare-error-lane.d.ts.map +1 -1
  107. package/dist/server/bare-error-lane.js +13 -11
  108. package/dist/server/bare-error-lane.js.map +1 -1
  109. package/dist/server/body-lane.d.ts +20 -2
  110. package/dist/server/body-lane.d.ts.map +1 -1
  111. package/dist/server/body-lane.js +26 -7
  112. package/dist/server/body-lane.js.map +1 -1
  113. package/dist/server/body-parser.d.ts +68 -0
  114. package/dist/server/body-parser.d.ts.map +1 -0
  115. package/dist/server/body-parser.js +172 -0
  116. package/dist/server/body-parser.js.map +1 -0
  117. package/dist/server/body.d.ts.map +1 -1
  118. package/dist/server/body.js +62 -7
  119. package/dist/server/body.js.map +1 -1
  120. package/dist/server/client-ip.d.ts +8 -0
  121. package/dist/server/client-ip.d.ts.map +1 -1
  122. package/dist/server/client-ip.js +8 -0
  123. package/dist/server/client-ip.js.map +1 -1
  124. package/dist/server/context.d.ts +71 -6
  125. package/dist/server/context.d.ts.map +1 -1
  126. package/dist/server/contract.d.ts +15 -5
  127. package/dist/server/contract.d.ts.map +1 -1
  128. package/dist/server/contract.js +25 -4
  129. package/dist/server/contract.js.map +1 -1
  130. package/dist/server/cookies.d.ts +4 -1
  131. package/dist/server/cookies.d.ts.map +1 -1
  132. package/dist/server/cookies.js +40 -2
  133. package/dist/server/cookies.js.map +1 -1
  134. package/dist/server/core-copies.d.ts +2 -0
  135. package/dist/server/core-copies.d.ts.map +1 -0
  136. package/dist/server/core-copies.js +20 -0
  137. package/dist/server/core-copies.js.map +1 -0
  138. package/dist/server/edge.d.ts.map +1 -1
  139. package/dist/server/edge.js +2 -0
  140. package/dist/server/edge.js.map +1 -1
  141. package/dist/server/http.d.ts +11 -0
  142. package/dist/server/http.d.ts.map +1 -1
  143. package/dist/server/http.js +36 -0
  144. package/dist/server/http.js.map +1 -1
  145. package/dist/server/idempotency-lane.d.ts +6 -0
  146. package/dist/server/idempotency-lane.d.ts.map +1 -1
  147. package/dist/server/idempotency-lane.js +130 -73
  148. package/dist/server/idempotency-lane.js.map +1 -1
  149. package/dist/server/install.d.ts +4 -0
  150. package/dist/server/install.d.ts.map +1 -1
  151. package/dist/server/install.js +4 -0
  152. package/dist/server/install.js.map +1 -1
  153. package/dist/server/methods.d.ts +45 -0
  154. package/dist/server/methods.d.ts.map +1 -0
  155. package/dist/server/methods.js +37 -0
  156. package/dist/server/methods.js.map +1 -0
  157. package/dist/server/multipart.d.ts +63 -0
  158. package/dist/server/multipart.d.ts.map +1 -0
  159. package/dist/server/multipart.js +374 -0
  160. package/dist/server/multipart.js.map +1 -0
  161. package/dist/server/node-outcome-hook.d.ts +20 -0
  162. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  163. package/dist/server/node-outcome-hook.js +15 -6
  164. package/dist/server/node-outcome-hook.js.map +1 -1
  165. package/dist/server/not-found-answer.d.ts +40 -0
  166. package/dist/server/not-found-answer.d.ts.map +1 -0
  167. package/dist/server/not-found-answer.js +56 -0
  168. package/dist/server/not-found-answer.js.map +1 -0
  169. package/dist/server/not-found.d.ts +23 -0
  170. package/dist/server/not-found.d.ts.map +1 -0
  171. package/dist/server/not-found.js +81 -0
  172. package/dist/server/not-found.js.map +1 -0
  173. package/dist/server/plugin.d.ts +26 -1
  174. package/dist/server/plugin.d.ts.map +1 -1
  175. package/dist/server/plugin.js.map +1 -1
  176. package/dist/server/proto-guard.d.ts +18 -5
  177. package/dist/server/proto-guard.d.ts.map +1 -1
  178. package/dist/server/proto-guard.js +114 -9
  179. package/dist/server/proto-guard.js.map +1 -1
  180. package/dist/server/query.d.ts +7 -0
  181. package/dist/server/query.d.ts.map +1 -1
  182. package/dist/server/query.js +47 -0
  183. package/dist/server/query.js.map +1 -1
  184. package/dist/server/registry.d.ts +13 -2
  185. package/dist/server/registry.d.ts.map +1 -1
  186. package/dist/server/request-context.d.ts +11 -0
  187. package/dist/server/request-context.d.ts.map +1 -1
  188. package/dist/server/request-context.js +33 -19
  189. package/dist/server/request-context.js.map +1 -1
  190. package/dist/server/request-lineage.d.ts +8 -0
  191. package/dist/server/request-lineage.d.ts.map +1 -0
  192. package/dist/server/request-lineage.js +14 -0
  193. package/dist/server/request-lineage.js.map +1 -0
  194. package/dist/server/request-target.d.ts +31 -0
  195. package/dist/server/request-target.d.ts.map +1 -0
  196. package/dist/server/request-target.js +44 -0
  197. package/dist/server/request-target.js.map +1 -0
  198. package/dist/server/respond.d.ts +1 -1
  199. package/dist/server/respond.d.ts.map +1 -1
  200. package/dist/server/respond.js +16 -4
  201. package/dist/server/respond.js.map +1 -1
  202. package/dist/server/response-contract-lane.d.ts +19 -1
  203. package/dist/server/response-contract-lane.d.ts.map +1 -1
  204. package/dist/server/response-contract-lane.js +59 -14
  205. package/dist/server/response-contract-lane.js.map +1 -1
  206. package/dist/server/response-observer-runtime.d.ts +9 -5
  207. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  208. package/dist/server/response-observer-runtime.js +10 -6
  209. package/dist/server/response-observer-runtime.js.map +1 -1
  210. package/dist/server/runtime-core.d.ts +2 -0
  211. package/dist/server/runtime-core.d.ts.map +1 -1
  212. package/dist/server/runtime-core.js.map +1 -1
  213. package/dist/server/server-types.d.ts +55 -3
  214. package/dist/server/server-types.d.ts.map +1 -1
  215. package/dist/server/server.d.ts +194 -19
  216. package/dist/server/server.d.ts.map +1 -1
  217. package/dist/server/server.js +1342 -274
  218. package/dist/server/server.js.map +1 -1
  219. package/dist/server/sse.d.ts.map +1 -1
  220. package/dist/server/sse.js +10 -7
  221. package/dist/server/sse.js.map +1 -1
  222. package/dist/server/static-headers.d.ts +3 -0
  223. package/dist/server/static-headers.d.ts.map +1 -1
  224. package/dist/server/static-headers.js +19 -1
  225. package/dist/server/static-headers.js.map +1 -1
  226. package/dist/server/transport-lane.d.ts +8 -0
  227. package/dist/server/transport-lane.d.ts.map +1 -1
  228. package/dist/server/transport-lane.js +9 -8
  229. package/dist/server/transport-lane.js.map +1 -1
  230. package/dist/server/validation.d.ts.map +1 -1
  231. package/dist/server/validation.js +5 -1
  232. package/dist/server/validation.js.map +1 -1
  233. package/dist/server/websocket.d.ts +10 -3
  234. package/dist/server/websocket.d.ts.map +1 -1
  235. package/dist/server/websocket.js +7 -4
  236. package/dist/server/websocket.js.map +1 -1
  237. package/dist/server/ws-bun.d.ts +2 -0
  238. package/dist/server/ws-bun.d.ts.map +1 -1
  239. package/dist/server/ws-bun.js +6 -3
  240. package/dist/server/ws-bun.js.map +1 -1
  241. package/dist/server/ws-hook.d.ts +2 -1
  242. package/dist/server/ws-hook.d.ts.map +1 -1
  243. package/dist/server.d.ts +8 -5
  244. package/dist/server.d.ts.map +1 -1
  245. package/dist/server.js +5 -2
  246. package/dist/server.js.map +1 -1
  247. package/dist/single-copy-register.js +5 -0
  248. package/dist/single-copy-register.js.map +1 -1
  249. package/dist/single-copy.d.ts +46 -3
  250. package/dist/single-copy.d.ts.map +1 -1
  251. package/dist/single-copy.js +125 -12
  252. package/dist/single-copy.js.map +1 -1
  253. package/dist/tool-contract.d.ts +23 -0
  254. package/dist/tool-contract.d.ts.map +1 -1
  255. package/dist/tool-contract.js +37 -3
  256. package/dist/tool-contract.js.map +1 -1
  257. package/dist/transport-codec.d.ts.map +1 -1
  258. package/dist/transport-codec.js +2 -1
  259. package/dist/transport-codec.js.map +1 -1
  260. package/package.json +27 -2
  261. package/src/assurance.ts +52 -2
  262. package/src/capabilities.ts +81 -22
  263. package/src/diff.ts +54 -7
  264. package/src/durable-adapters.ts +20 -3
  265. package/src/durable-effect.ts +10 -3
  266. package/src/edge-kit.ts +7 -0
  267. package/src/effect-lifecycle.ts +5 -1
  268. package/src/effect-scope.ts +2 -1
  269. package/src/errors.ts +3 -0
  270. package/src/evidence.ts +302 -12
  271. package/src/idempotency.ts +80 -9
  272. package/src/index.ts +41 -3
  273. package/src/internal/code-unit-order.ts +8 -0
  274. package/src/internal/durable-saga-protocol.ts +2 -0
  275. package/src/internal/route-catalog.ts +19 -18
  276. package/src/internal/route-compiler.ts +62 -13
  277. package/src/internal/route-execution.ts +13 -3
  278. package/src/internal/route-lanes.ts +37 -28
  279. package/src/internal/route-program.ts +434 -20
  280. package/src/ledger.ts +11 -5
  281. package/src/manifest.ts +52 -46
  282. package/src/mount.ts +62 -0
  283. package/src/reflection.ts +84 -1
  284. package/src/response-observer.ts +1 -0
  285. package/src/router/overlap.ts +258 -0
  286. package/src/router/pattern.ts +349 -25
  287. package/src/router/router.ts +76 -51
  288. package/src/server/auth.ts +75 -0
  289. package/src/server/bare-error-lane.ts +12 -11
  290. package/src/server/body-lane.ts +46 -6
  291. package/src/server/body-parser.ts +241 -0
  292. package/src/server/body.ts +82 -9
  293. package/src/server/client-ip.ts +9 -0
  294. package/src/server/context.ts +219 -12
  295. package/src/server/contract.ts +54 -9
  296. package/src/server/cookies.ts +34 -2
  297. package/src/server/core-copies.ts +21 -0
  298. package/src/server/edge.ts +2 -0
  299. package/src/server/http.ts +34 -0
  300. package/src/server/idempotency-lane.ts +147 -72
  301. package/src/server/install.ts +6 -0
  302. package/src/server/methods.ts +143 -0
  303. package/src/server/multipart.ts +424 -0
  304. package/src/server/node-outcome-hook.ts +40 -7
  305. package/src/server/not-found-answer.ts +109 -0
  306. package/src/server/not-found.ts +121 -0
  307. package/src/server/plugin.ts +45 -1
  308. package/src/server/proto-guard.ts +128 -9
  309. package/src/server/query.ts +49 -0
  310. package/src/server/registry.ts +17 -2
  311. package/src/server/request-context.ts +44 -16
  312. package/src/server/request-lineage.ts +15 -0
  313. package/src/server/request-target.ts +46 -0
  314. package/src/server/respond.ts +14 -3
  315. package/src/server/response-contract-lane.ts +97 -16
  316. package/src/server/response-observer-runtime.ts +37 -8
  317. package/src/server/runtime-core.ts +2 -0
  318. package/src/server/server-types.ts +61 -2
  319. package/src/server/server.ts +1798 -355
  320. package/src/server/sse.ts +10 -8
  321. package/src/server/static-headers.ts +22 -1
  322. package/src/server/transport-lane.ts +17 -9
  323. package/src/server/validation.ts +7 -1
  324. package/src/server/websocket.ts +23 -9
  325. package/src/server/ws-bun.ts +8 -3
  326. package/src/server/ws-hook.ts +2 -1
  327. package/src/server.ts +39 -2
  328. package/src/single-copy-register.ts +5 -0
  329. package/src/single-copy.ts +160 -12
  330. package/src/tool-contract.ts +66 -3
  331. package/src/transport-codec.ts +2 -1
@@ -12,6 +12,7 @@ import {
12
12
  canonicalizeIdempotencyBody,
13
13
  computeIdempotencyFingerprint,
14
14
  DEFAULT_IDEMPOTENCY_HEADER,
15
+ DEFAULT_IDEMPOTENCY_PENDING_TTL_MS,
15
16
  DEFAULT_IDEMPOTENCY_TTL_MS,
16
17
  IdempotencyResponseTooLargeError,
17
18
  type IdempotencyStore,
@@ -33,10 +34,20 @@ import { jsonError, urlPartsOf } from "./http.ts"
33
34
  import { INSTALL_IDEMPOTENCY } from "./install.ts"
34
35
  import type { AnyServer, IdentityPlugin } from "./server.ts"
35
36
 
37
+ /** Requests whose route handler was entered. One rejected before it - by auth, validation, a guard -
38
+ * began no execution, so its key is released rather than stored. */
39
+ const HANDLER_ENTERED = new WeakSet<Request>()
40
+
41
+ /** Statuses that say the request was not carried out (RFC 9110). A handler answering with one, with no
42
+ * owned effect begun, releases its key, so a refused caller cannot hold a store entry for the TTL. */
43
+ const REFUSALS = new Set([401, 403, 408, 409, 425, 429])
44
+
36
45
  /** Registration-resolved idempotency for a route: the config with its store + defaults pinned. */
37
46
  export interface ResolvedIdempotency {
38
47
  readonly store: IdempotencyStore
39
48
  readonly ttlMs: number
49
+ /** The lease renewed while a handler runs; unset when the store cannot renew. */
50
+ readonly pendingTtlMs: number | undefined
40
51
  readonly headerName: string
41
52
  readonly namespace: NonNullable<RouteSchema["idempotency"]>["namespace"]
42
53
  readonly maxResponseBytes: number
@@ -66,6 +77,10 @@ export interface IdempotencyRuntime {
66
77
  authenticated: boolean,
67
78
  maxBodyBytes: number,
68
79
  ): ResolvedIdempotency | undefined
80
+ /** Wrap an idempotent route's handler so the lane can tell a request rejected before it ran. */
81
+ wrapHandler<C extends { readonly req: Request }>(
82
+ handler: (context: C) => unknown,
83
+ ): (context: C) => unknown
69
84
  /** The dedupe lane: read the key, fingerprint the request, consult the store, run/replay/reject. */
70
85
  run<T>(
71
86
  config: ResolvedIdempotency,
@@ -122,6 +137,12 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
122
137
  trackEffect(context, committed) {
123
138
  markEffect(context, committed)
124
139
  },
140
+ wrapHandler(handler) {
141
+ return (context) => {
142
+ HANDLER_ENTERED.add(context.req)
143
+ return handler(context)
144
+ }
145
+ },
125
146
  resolve(schema, authenticated, maxBodyBytes) {
126
147
  const config = schema?.idempotency
127
148
  if (config === undefined) return undefined
@@ -145,6 +166,16 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
145
166
  "idempotency ttlMs must be a finite positive number",
146
167
  )
147
168
  }
169
+ const pendingTtlMs = config.pendingTtlMs
170
+ if (
171
+ pendingTtlMs !== undefined &&
172
+ (store.renew === undefined || !Number.isSafeInteger(pendingTtlMs) || pendingTtlMs <= 0)
173
+ ) {
174
+ throw new RouteConfigError(
175
+ "INVALID_IDEMPOTENCY",
176
+ "idempotency pendingTtlMs must be a positive integer, on a store that implements renew()",
177
+ )
178
+ }
148
179
  const headerName = (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase()
149
180
  try {
150
181
  new Headers().set(headerName, "probe")
@@ -183,6 +214,10 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
183
214
  return Object.freeze({
184
215
  store,
185
216
  ttlMs,
217
+ pendingTtlMs:
218
+ store.renew === undefined
219
+ ? undefined
220
+ : (pendingTtlMs ?? DEFAULT_IDEMPOTENCY_PENDING_TTL_MS),
186
221
  headerName,
187
222
  namespace,
188
223
  maxResponseBytes,
@@ -216,7 +251,14 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
216
251
  req.headers.get("content-type") ?? "",
217
252
  )
218
253
 
219
- const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs })
254
+ const pendingTtlMs = config.pendingTtlMs
255
+ const begin = await config.store.begin({
256
+ namespace,
257
+ key,
258
+ fingerprint,
259
+ ttlMs: config.ttlMs,
260
+ pendingTtlMs,
261
+ })
220
262
  if (begin.state === "replay") {
221
263
  return wrapResponse(
222
264
  responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }),
@@ -230,37 +272,115 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
230
272
  return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
231
273
  }
232
274
  const reservation = begin.reservation
233
-
234
- // Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
235
- // then run those lanes to a concrete Response.
236
- const bufferedInit: RequestInit = {
237
- method: req.method,
238
- headers: req.headers,
239
- signal: req.signal,
275
+ let heartbeat: ReturnType<typeof setInterval> | undefined
276
+ if (pendingTtlMs !== undefined) {
277
+ // Renew the lease while the handler runs: only a process that died stops renewing, so its
278
+ // key frees after the lease rather than the replay TTL.
279
+ heartbeat = setInterval(() => {
280
+ // A failed renewal only lets the lease lapse, which completion then reports.
281
+ Promise.resolve()
282
+ .then(() => config.store.renew?.({ namespace, key, reservation, ttlMs: pendingTtlMs }))
283
+ .catch(() => undefined)
284
+ }, pendingTtlMs / 3)
285
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a Node or Bun timer has unref(); on other runtimes the optional call does nothing.
286
+ ;(heartbeat as { unref?: () => void }).unref?.()
240
287
  }
241
- if (read.bytes.byteLength > 0)
242
- bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
243
- const buffered = new Request(req.url, bufferedInit)
244
- beginRequestEffectTracking(buffered)
245
- let response: Response
246
288
  try {
247
- response = await host.runLanes(buffered, platform, entry, params, search)
248
- } catch (err) {
249
- const evidence = requestEffectEvidence(buffered)
250
- if (!evidence.began) {
251
- // The request-local boundary proves no owned effect started, so this is the one safe case
252
- // where releasing the key cannot duplicate an effect.
253
- await config.store.abandon({ namespace, key, reservation })
254
- throw err
289
+ // Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
290
+ // then run those lanes to a concrete Response.
291
+ const bufferedInit: RequestInit = {
292
+ method: req.method,
293
+ headers: req.headers,
294
+ signal: req.signal,
295
+ }
296
+ if (read.bytes.byteLength > 0)
297
+ bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
298
+ const buffered = new Request(req.url, bufferedInit)
299
+ beginRequestEffectTracking(buffered)
300
+ // The handler threw, or its response body failed while being captured: no response to store.
301
+ const settleThrown = async (err: unknown): Promise<ReturnType<typeof wrapResponse>> => {
302
+ if (!requestEffectEvidence(buffered).began) {
303
+ // The request-local boundary proves no owned effect started, so this is the one safe case
304
+ // where releasing the key cannot duplicate an effect.
305
+ await config.store.abandon({ namespace, key, reservation })
306
+ throw err
307
+ }
308
+ // A committed or ambiguous effect must never be repeated. Persist a payload-free terminal
309
+ // response even though the normal handler lifecycle escaped without producing one.
310
+ const terminal = new Response(null, { status: 500 })
311
+ const completed = await config.store.complete({
312
+ namespace,
313
+ key,
314
+ reservation,
315
+ response: await serializeResponse(terminal, { maxBytes: config.maxResponseBytes }),
316
+ })
317
+ if (!completed) {
318
+ return wrapResponse(
319
+ jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
320
+ )
321
+ }
322
+ return wrapResponse(terminal)
323
+ }
324
+ let response: Response
325
+ try {
326
+ response = await host.runLanes(buffered, platform, entry, params, search)
327
+ } catch (err) {
328
+ return await settleThrown(err)
329
+ }
330
+ if (!HANDLER_ENTERED.has(buffered) && !requestEffectEvidence(buffered).began) {
331
+ // Rejected before the handler ran: no execution began, so there is nothing a retry could
332
+ // duplicate and no result is saved - storing every rejection would let callers who never
333
+ // pass auth fill the store and lock everyone else out.
334
+ const abandoned = await config.store.abandon({ namespace, key, reservation })
335
+ if (!abandoned) {
336
+ return wrapResponse(
337
+ jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
338
+ )
339
+ }
340
+ return wrapResponse(response)
341
+ }
342
+ if (
343
+ (REFUSALS.has(response.status) ||
344
+ (response.status >= 500 && requestIsSafeToRetry(buffered))) &&
345
+ !requestEffectEvidence(buffered).began
346
+ ) {
347
+ const abandoned = await config.store.abandon({ namespace, key, reservation })
348
+ if (!abandoned) {
349
+ return wrapResponse(
350
+ jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
351
+ )
352
+ }
353
+ return wrapResponse(response)
354
+ }
355
+ // Once the handler ran, every other concrete response is terminal for this key. A non-2xx may
356
+ // follow an already-committed external effect; abandoning it would let a retry duplicate it.
357
+ let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
358
+ try {
359
+ storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
360
+ } catch (error) {
361
+ if (!(error instanceof IdempotencyResponseTooLargeError)) return await settleThrown(error)
362
+ // The effect may already have happened, so never abandon and permit a duplicate execution.
363
+ // Commit a small terminal response under the winning key and return that same response now.
364
+ response = jsonError(507, "idempotency_response_too_large")
365
+ try {
366
+ storedResponse = await serializeResponse(response, {
367
+ maxBytes: config.maxResponseBytes,
368
+ })
369
+ } catch (terminalError) {
370
+ if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
371
+ // Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
372
+ // status + replay safety without silently storing more bytes than the route permits.
373
+ response = new Response(null, { status: 507 })
374
+ storedResponse = await serializeResponse(response, {
375
+ maxBytes: config.maxResponseBytes,
376
+ })
377
+ }
255
378
  }
256
- // A committed or ambiguous effect must never be repeated. Persist a payload-free terminal
257
- // response even though the normal handler lifecycle escaped without producing one.
258
- response = new Response(null, { status: 500 })
259
379
  const completed = await config.store.complete({
260
380
  namespace,
261
381
  key,
262
382
  reservation,
263
- response: await serializeResponse(response, { maxBytes: config.maxResponseBytes }),
383
+ response: storedResponse,
264
384
  })
265
385
  if (!completed) {
266
386
  return wrapResponse(
@@ -268,54 +388,9 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
268
388
  )
269
389
  }
270
390
  return wrapResponse(response)
391
+ } finally {
392
+ clearInterval(heartbeat)
271
393
  }
272
- if (
273
- response.status >= 500 &&
274
- requestIsSafeToRetry(buffered) &&
275
- !requestEffectEvidence(buffered).began
276
- ) {
277
- const abandoned = await config.store.abandon({ namespace, key, reservation })
278
- if (!abandoned) {
279
- return wrapResponse(
280
- jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
281
- )
282
- }
283
- return wrapResponse(response)
284
- }
285
- // Once the handler ran, every concrete response is terminal for this key. A non-2xx may follow
286
- // an already-committed external effect; abandoning it would let a retry duplicate that effect.
287
- let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
288
- try {
289
- storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
290
- } catch (error) {
291
- if (!(error instanceof IdempotencyResponseTooLargeError)) throw error
292
- // The effect may already have happened, so never abandon and permit a duplicate execution.
293
- // Commit a small terminal response under the winning key and return that same response now.
294
- response = jsonError(507, "idempotency_response_too_large")
295
- try {
296
- storedResponse = await serializeResponse(response, {
297
- maxBytes: config.maxResponseBytes,
298
- })
299
- } catch (terminalError) {
300
- if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
301
- // Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
302
- // status + replay safety without silently storing more bytes than the route permits.
303
- response = new Response(null, { status: 507 })
304
- storedResponse = await serializeResponse(response, {
305
- maxBytes: config.maxResponseBytes,
306
- })
307
- }
308
- }
309
- const completed = await config.store.complete({
310
- namespace,
311
- key,
312
- reservation,
313
- response: storedResponse,
314
- })
315
- if (!completed) {
316
- return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
317
- }
318
- return wrapResponse(response)
319
394
  },
320
395
  }
321
396
  }
@@ -25,9 +25,15 @@ export const INSTALL_SSE: unique symbol = Symbol.for("@nifrajs/core/install-sse"
25
25
  /** @internal Install the Node-direct renderer on a server (called by the `nodeDirect()` plugin). */
26
26
  export const INSTALL_NODE_DIRECT: unique symbol = Symbol.for("@nifrajs/core/install-node-direct")
27
27
 
28
+ /** @internal Install the not-found lane on a server (called by the `notFound()` plugin). */
29
+ export const INSTALL_NOT_FOUND: unique symbol = Symbol.for("@nifrajs/core/install-not-found")
30
+
28
31
  /** @internal Install the WebSocket runtime on a server (called by the `websocket()` plugin). */
29
32
  export const INSTALL_WS: unique symbol = Symbol.for("@nifrajs/core/install-ws")
30
33
 
34
+ /** @internal Read the installed WebSocket runtime when a server is composed into another server. */
35
+ export const GET_WS_RUNTIME: unique symbol = Symbol.for("@nifrajs/core/get-ws-runtime")
36
+
31
37
  /** @internal Install the response-contract runtime on a server (called by the `responseContract()` plugin). */
32
38
  export const INSTALL_RESPONSE_CONTRACT: unique symbol = Symbol.for(
33
39
  "@nifrajs/core/install-response-contract",
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `all()` and `method()`. A method with no route on a path is still a `405`, and a runtime's HTTP
3
+ * parser may refuse a token it does not know before the server sees the request.
4
+ */
5
+ import { RouteConfigError } from "../errors.ts"
6
+ import { isRegistrableMethod, METHODS, type Method } from "../router/router.ts"
7
+ import type { RouteSchema } from "./context.ts"
8
+ import type { MethodRoutesPlugin } from "./plugin.ts"
9
+ import type { OutputOf } from "./registry.ts"
10
+ import type { Handler } from "./server.ts"
11
+
12
+ export type { MethodRoutesPlugin } from "./plugin.ts"
13
+
14
+ /** The part of a server these plugins use: one atomic batch registration. */
15
+ interface RouteHost {
16
+ registerBatch(
17
+ routes: readonly {
18
+ readonly method: string
19
+ readonly path: string
20
+ readonly schema: RouteSchema | undefined
21
+ readonly handler: (context: never) => unknown
22
+ }[],
23
+ ): void
24
+ }
25
+
26
+ type ErasedHandler = (context: never) => unknown
27
+
28
+ const ASCII_NAME = /^[A-Za-z0-9-]+$/
29
+
30
+ function plugin(
31
+ methods: readonly string[],
32
+ path: string,
33
+ schemaOrHandler: RouteSchema | ErasedHandler,
34
+ handler: ErasedHandler | undefined,
35
+ ): (app: RouteHost) => RouteHost {
36
+ const schema = handler === undefined ? undefined : (schemaOrHandler as RouteSchema)
37
+ const run = handler ?? schemaOrHandler
38
+ if (typeof run !== "function") throw new TypeError("a route needs a handler function")
39
+ const routes = methods.map((method) => ({ method, path, schema, handler: run }))
40
+ return (app) => {
41
+ app.registerBatch(routes)
42
+ return app
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Register one handler under every standard method: `app.use(all(path, handler))`.
48
+ *
49
+ * ```ts
50
+ * import { all } from "@nifrajs/core/methods"
51
+ *
52
+ * const app = server().use(all("/echo", (c) => ({ method: c.req.method })))
53
+ * ```
54
+ *
55
+ * It is the same as calling `get`, `post`, `put`, `patch`, `delete`, `head` and `options` with the
56
+ * one handler, as a single registration: a method already registered on the path makes the call
57
+ * throw and adds nothing. A schema applies to every method, so with a `body` schema a request that
58
+ * carries no body is refused under any of them, `GET` included.
59
+ */
60
+ export function all<
61
+ Path extends string,
62
+ S extends RouteSchema,
63
+ H extends Handler<Path, S, Ctx>,
64
+ Ctx = NonNullable<unknown>,
65
+ >(path: Path, schema: S, handler: H): MethodRoutesPlugin<Method, Path, S, OutputOf<H>, Ctx>
66
+ export function all<
67
+ Path extends string,
68
+ H extends Handler<Path, RouteSchema, Ctx>,
69
+ Ctx = NonNullable<unknown>,
70
+ >(path: Path, handler: H): MethodRoutesPlugin<Method, Path, Record<never, never>, OutputOf<H>, Ctx>
71
+ export function all(
72
+ path: string,
73
+ schemaOrHandler: RouteSchema | ErasedHandler,
74
+ handler?: ErasedHandler,
75
+ ): unknown {
76
+ return plugin(METHODS, path, schemaOrHandler, handler)
77
+ }
78
+
79
+ /**
80
+ * Register one handler under the method, or the methods, you name:
81
+ * `app.use(method("PROPFIND", path, handler))`.
82
+ *
83
+ * ```ts
84
+ * import { method } from "@nifrajs/core/methods"
85
+ *
86
+ * const app = server()
87
+ * .use(method("PROPFIND", "/dav/*path", (c) => listing(c.params.path)))
88
+ * .use(method(["GET", "POST"], "/search", (c) => search(c.req)))
89
+ * ```
90
+ *
91
+ * A name is case-insensitive and is registered uppercase. It must be a token of letters, digits and
92
+ * hyphens that starts with a letter, at most 32 characters, and not `TRACE`, `CONNECT` or `TRACK`;
93
+ * anything else throws when `method()` is called. A list is one registration: every method is added,
94
+ * or none is.
95
+ *
96
+ * Only the standard methods among the names join the app's typed registry, so the typed client has
97
+ * a call for those and none for a custom token.
98
+ */
99
+ export function method<
100
+ const M extends string,
101
+ Path extends string,
102
+ S extends RouteSchema,
103
+ H extends Handler<Path, S, Ctx>,
104
+ Ctx = NonNullable<unknown>,
105
+ >(
106
+ name: M | readonly M[],
107
+ path: Path,
108
+ schema: S,
109
+ handler: H,
110
+ ): MethodRoutesPlugin<M, Path, S, OutputOf<H>, Ctx>
111
+ export function method<
112
+ const M extends string,
113
+ Path extends string,
114
+ H extends Handler<Path, RouteSchema, Ctx>,
115
+ Ctx = NonNullable<unknown>,
116
+ >(
117
+ name: M | readonly M[],
118
+ path: Path,
119
+ handler: H,
120
+ ): MethodRoutesPlugin<M, Path, Record<never, never>, OutputOf<H>, Ctx>
121
+ export function method(
122
+ name: string | readonly string[],
123
+ path: string,
124
+ schemaOrHandler: RouteSchema | ErasedHandler,
125
+ handler?: ErasedHandler,
126
+ ): unknown {
127
+ const names = typeof name === "string" ? [name] : name
128
+ if (!Array.isArray(names) || names.length === 0) {
129
+ throw new RouteConfigError("INVALID_METHOD", "method() needs a method name or a list of them")
130
+ }
131
+ const methods = names.map((value: unknown) => {
132
+ // Checked before the case fold: a letter outside ASCII can fold to an ASCII one.
133
+ const upper = typeof value === "string" && ASCII_NAME.test(value) ? value.toUpperCase() : ""
134
+ if (!isRegistrableMethod(upper)) {
135
+ throw new RouteConfigError(
136
+ "INVALID_METHOD",
137
+ `unsupported HTTP method ${JSON.stringify(value)}`,
138
+ )
139
+ }
140
+ return upper
141
+ })
142
+ return plugin(methods, path, schemaOrHandler, handler)
143
+ }