@nifrajs/core 5.4.0 → 5.5.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 (89) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/internal/route-lanes.d.ts.map +1 -1
  5. package/dist/internal/route-lanes.js +2 -1
  6. package/dist/internal/route-lanes.js.map +1 -1
  7. package/dist/internal/route-program.d.ts +5 -0
  8. package/dist/internal/route-program.d.ts.map +1 -1
  9. package/dist/internal/route-program.js +36 -15
  10. package/dist/internal/route-program.js.map +1 -1
  11. package/dist/router/router.d.ts +3 -2
  12. package/dist/router/router.d.ts.map +1 -1
  13. package/dist/router/router.js +22 -6
  14. package/dist/router/router.js.map +1 -1
  15. package/dist/security/baseline.d.ts.map +1 -1
  16. package/dist/security/baseline.js +4 -3
  17. package/dist/security/baseline.js.map +1 -1
  18. package/dist/server/body-lane.d.ts.map +1 -1
  19. package/dist/server/body-lane.js +4 -3
  20. package/dist/server/body-lane.js.map +1 -1
  21. package/dist/server/body.d.ts +2 -0
  22. package/dist/server/body.d.ts.map +1 -1
  23. package/dist/server/body.js +5 -0
  24. package/dist/server/body.js.map +1 -1
  25. package/dist/server/cookies.d.ts.map +1 -1
  26. package/dist/server/cookies.js +9 -2
  27. package/dist/server/cookies.js.map +1 -1
  28. package/dist/server/core-copies.js +1 -1
  29. package/dist/server/idempotency-lane.d.ts +11 -12
  30. package/dist/server/idempotency-lane.d.ts.map +1 -1
  31. package/dist/server/idempotency-lane.js +301 -151
  32. package/dist/server/idempotency-lane.js.map +1 -1
  33. package/dist/server/methods.d.ts +3 -2
  34. package/dist/server/methods.d.ts.map +1 -1
  35. package/dist/server/methods.js.map +1 -1
  36. package/dist/server/multipart.d.ts.map +1 -1
  37. package/dist/server/multipart.js +42 -13
  38. package/dist/server/multipart.js.map +1 -1
  39. package/dist/server/node-outcome.d.ts.map +1 -1
  40. package/dist/server/node-outcome.js +2 -2
  41. package/dist/server/node-outcome.js.map +1 -1
  42. package/dist/server/query.d.ts +1 -1
  43. package/dist/server/query.d.ts.map +1 -1
  44. package/dist/server/query.js +13 -5
  45. package/dist/server/query.js.map +1 -1
  46. package/dist/server/request-context.d.ts.map +1 -1
  47. package/dist/server/request-context.js +3 -3
  48. package/dist/server/request-context.js.map +1 -1
  49. package/dist/server/respond.d.ts +17 -0
  50. package/dist/server/respond.d.ts.map +1 -1
  51. package/dist/server/respond.js +81 -3
  52. package/dist/server/respond.js.map +1 -1
  53. package/dist/server/runtime-core.d.ts +15 -0
  54. package/dist/server/runtime-core.d.ts.map +1 -1
  55. package/dist/server/runtime-core.js +41 -1
  56. package/dist/server/runtime-core.js.map +1 -1
  57. package/dist/server/server.d.ts +14 -5
  58. package/dist/server/server.d.ts.map +1 -1
  59. package/dist/server/server.js +179 -59
  60. package/dist/server/server.js.map +1 -1
  61. package/dist/server/transport-lane.d.ts +2 -2
  62. package/dist/server/transport-lane.d.ts.map +1 -1
  63. package/dist/server/transport-lane.js +20 -20
  64. package/dist/server/transport-lane.js.map +1 -1
  65. package/dist/static-file.d.ts +29 -6
  66. package/dist/static-file.d.ts.map +1 -1
  67. package/dist/static-file.js +43 -12
  68. package/dist/static-file.js.map +1 -1
  69. package/package.json +1 -1
  70. package/src/index.ts +1 -1
  71. package/src/internal/route-lanes.ts +18 -16
  72. package/src/internal/route-program.ts +76 -12
  73. package/src/router/router.ts +26 -5
  74. package/src/security/baseline.ts +4 -3
  75. package/src/server/body-lane.ts +4 -4
  76. package/src/server/body.ts +6 -0
  77. package/src/server/cookies.ts +7 -2
  78. package/src/server/core-copies.ts +1 -1
  79. package/src/server/idempotency-lane.ts +343 -209
  80. package/src/server/methods.ts +3 -2
  81. package/src/server/multipart.ts +62 -14
  82. package/src/server/node-outcome.ts +2 -1
  83. package/src/server/query.ts +12 -5
  84. package/src/server/request-context.ts +6 -3
  85. package/src/server/respond.ts +108 -6
  86. package/src/server/runtime-core.ts +54 -1
  87. package/src/server/server.ts +249 -99
  88. package/src/server/transport-lane.ts +28 -22
  89. package/src/static-file.ts +72 -16
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * The kernel resolves a route's declared idempotency at registration (via {@link IdempotencyRuntime.resolve})
7
7
  * and, on the request path, hands a matched idempotent route to {@link IdempotencyRuntime.run}. Server
8
- * execution is supplied through {@link IdempotencyHost}; the opt-in module adapts the matched
9
- * route's program for replay without adding replay preparation to the shared server kernel.
8
+ * execution is supplied as a callback; the opt-in module owns body preparation, cleanup, and replay
9
+ * without adding that machinery to the shared server kernel.
10
10
  */
11
11
  import { RouteConfigError } from "../errors.ts"
12
12
  import {
@@ -29,36 +29,35 @@ import {
29
29
  requestEffectEvidence,
30
30
  requestIsSafeToRetry,
31
31
  } from "../internal/effect-execution.ts"
32
- import {
33
- compileRouteExecutionPlan,
34
- type InternalHandler,
35
- type RouteEntry,
36
- } from "../internal/route-execution.ts"
37
- import { readBoundedBytes } from "./body.ts"
32
+ import type { InternalHandler, RouteEntry } from "../internal/route-execution.ts"
33
+ import type { IdempotencyResume } from "../internal/route-program.ts"
34
+ import { markTransportCap, readBoundedBytes, transportCapOf, UNLIMITED_BODY_BYTES } from "./body.ts"
38
35
  import type { Platform, RouteSchema } from "./context.ts"
39
36
  import { jsonError, urlPartsOf } from "./http.ts"
40
37
  import { INSTALL_IDEMPOTENCY } from "./install.ts"
41
- import type { AnyServer, IdentityPlugin } from "./server.ts"
38
+ import { CONTEXT_REQUEST, PRE_DECODED_BODY, preDecodedBody, requestOf } from "./runtime-core.ts"
39
+ import type { AnyServer, IdentityPlugin, RawContext, RequestSource } from "./server.ts"
42
40
 
43
41
  /** Requests whose route handler was entered. One rejected before it - by auth, validation, a guard -
44
42
  * began no execution, so its key is released rather than stored. */
45
43
  const HANDLER_ENTERED = new WeakSet<Request>()
46
44
 
45
+ /** The gate's half of a body teed by {@link prepareRequest}, and the request's platform. */
46
+ interface PreparedRequest {
47
+ readonly body: ReadableStream<Uint8Array> | null
48
+ readonly route: ReadableStream<Uint8Array> | null
49
+ readonly cancel?: () => void
50
+ readonly platform: Platform | undefined
51
+ /** Why the request could not be copied; the gate rethrows it inside the route's error handling. */
52
+ readonly failure?: { readonly error: unknown }
53
+ }
54
+
55
+ const PREPARED = new WeakMap<Request, PreparedRequest>()
56
+
47
57
  /** Statuses that say the request was not carried out (RFC 9110). A handler answering with one, with no
48
58
  * owned effect begun, releases its key, so a refused caller cannot hold a store entry for the TTL. */
49
59
  const REFUSALS = new Set([401, 403, 408, 409, 425, 429])
50
60
 
51
- // Compile once in the opt-in plugin. The around runner also handles an empty hook list.
52
- const REPLAY_EXECUTION = compileRouteExecutionPlan({
53
- lane: "lifecycle",
54
- contextless: false,
55
- hasAround: true,
56
- hasLedger: false,
57
- fusedWeb: undefined,
58
- fusedBody: undefined,
59
- fusedLane: undefined,
60
- })
61
-
62
61
  /** Registration-resolved idempotency for a route: the config with its store + defaults pinned. */
63
62
  export interface ResolvedIdempotency {
64
63
  readonly store: IdempotencyStore
@@ -70,22 +69,6 @@ export interface ResolvedIdempotency {
70
69
  readonly maxResponseBytes: number
71
70
  }
72
71
 
73
- /**
74
- * What the dedupe lane needs from the server to run a fresh key: the app-wide body cap and a way to
75
- * run the buffered request through the route's normal lanes to a concrete `Response`. The replay
76
- * path supplies an entry with the stored response replacing the handler and stored transformations.
77
- */
78
- export interface IdempotencyHost {
79
- readonly maxBodyBytes: number
80
- runLanes(
81
- buffered: Request,
82
- platform: Platform | undefined,
83
- entry: unknown,
84
- params: Record<string, string>,
85
- search: string | undefined,
86
- ): Response | Promise<Response>
87
- }
88
-
89
72
  /** The injected idempotency implementation the kernel calls through when the plugin is installed. */
90
73
  export interface IdempotencyRuntime {
91
74
  readonly trackEffect: (context: object, committed: boolean) => void
@@ -99,17 +82,24 @@ export interface IdempotencyRuntime {
99
82
  wrapHandler<C extends { readonly req: Request }>(
100
83
  handler: (context: C) => unknown,
101
84
  ): (context: C) => unknown
102
- /** The dedupe lane: read the key, fingerprint the request, consult the store, run/replay/reject. */
103
- run<T>(
104
- config: ResolvedIdempotency,
105
- req: Request,
85
+ /** Prepare the body lanes and release their owned reader on every response, error or deadline. */
86
+ run(
87
+ source: RequestSource,
106
88
  platform: Platform | undefined,
107
- entry: unknown,
108
- params: Record<string, string>,
109
- search: string | undefined,
110
- wrapResponse: (response: Response) => T,
111
- host: IdempotencyHost,
112
- ): Promise<T>
89
+ execute: (request: Request) => Response | Promise<Response>,
90
+ ): Promise<Response>
91
+ /**
92
+ * The dedupe gate, entered from inside the route program: after authentication on an auth-first
93
+ * route, first on any other, and always under the request deadline. It reads the key, fingerprints
94
+ * the payload, consults the store, then resumes the program, replays, or rejects.
95
+ */
96
+ gate(
97
+ entry: RouteEntry,
98
+ ctx: RawContext,
99
+ wrapResponse: (response: Response) => Response,
100
+ resume: IdempotencyResume<Response>,
101
+ fail: (error: unknown) => Response | Promise<Response>,
102
+ ): Promise<Response>
113
103
  }
114
104
 
115
105
  /** The install seam a server exposes so the `idempotency()` plugin can hand it a runtime. */
@@ -244,184 +234,328 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
244
234
  })
245
235
  },
246
236
 
247
- async run(config, req, platform, entry, params, search, wrapResponse, host) {
248
- const key = req.headers.get(config.headerName)
249
- if (key === null || !validIdempotencyKey(key)) {
250
- return wrapResponse(jsonError(400, "idempotency_key_required"))
237
+ async run(source, platform, execute) {
238
+ const request = prepareRequest(source, platform)
239
+ try {
240
+ return await execute(request)
241
+ } finally {
242
+ finishRequest(request)
251
243
  }
252
- const namespace =
253
- typeof config.namespace === "function"
254
- ? await config.namespace(
255
- { method: req.method, url: req.url, headers: req.headers },
256
- platform,
257
- )
258
- : config.namespace
259
- if (!validIdempotencyNamespace(namespace)) {
260
- return wrapResponse(jsonError(500, "idempotency_namespace_invalid"))
244
+ },
245
+
246
+ gate(entry, ctx, wrapResponse, resume, fail) {
247
+ let escaped: { readonly error: unknown } | undefined
248
+ const resumed: IdempotencyResume<Response> = async (next, program) => {
249
+ try {
250
+ return await resume(next, program)
251
+ } catch (error) {
252
+ escaped = { error }
253
+ throw error
254
+ }
261
255
  }
262
- const read = await readBoundedBytes(req, host.maxBodyBytes)
263
- if (!read.ok) {
264
- return wrapResponse(
265
- jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"),
256
+ return admit(entry, ctx, wrapResponse, resumed).catch((error: unknown) => {
257
+ // A failure the resumed program let escape has met its error boundary already.
258
+ if (escaped !== undefined && escaped.error === error) throw error
259
+ return fail(error)
260
+ })
261
+ },
262
+ }
263
+ }
264
+
265
+ /** The gate proper; {@link IdempotencyRuntime.gate} hands its own failures to the route's boundary. */
266
+ async function admit(
267
+ entry: RouteEntry,
268
+ ctx: RawContext,
269
+ wrapResponse: (response: Response) => Response,
270
+ resume: IdempotencyResume<Response>,
271
+ ): Promise<Response> {
272
+ const config = entry.idempotent
273
+ const req = ctx[CONTEXT_REQUEST]
274
+ const prepared = PREPARED.get(req)
275
+ if (config === undefined || prepared === undefined)
276
+ throw new Error("idempotency: the route ran on an unprepared request")
277
+ if (prepared.failure !== undefined) throw prepared.failure.error
278
+ const maxBodyBytes = entry.bodyLimit ?? UNLIMITED_BODY_BYTES
279
+ const key = req.headers.get(config.headerName)
280
+ if (key === null || !validIdempotencyKey(key)) {
281
+ return wrapResponse(jsonError(400, "idempotency_key_required"))
282
+ }
283
+ const namespace =
284
+ typeof config.namespace === "function"
285
+ ? await config.namespace(
286
+ { method: req.method, url: req.url, headers: req.headers },
287
+ prepared.platform,
266
288
  )
267
- }
268
- const url = urlPartsOf(req.url)
269
- const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"))
270
- const fingerprint = await computeIdempotencyFingerprint(
271
- req.method,
272
- `${url.pathname}${url.search}`,
273
- canonicalBody,
274
- req.headers.get("content-type") ?? "",
289
+ : config.namespace
290
+ if (!validIdempotencyNamespace(namespace)) {
291
+ return wrapResponse(jsonError(500, "idempotency_namespace_invalid"))
292
+ }
293
+ // A codec-decoded body reached the route as a stand-in: fingerprint what the caller sent.
294
+ const decoded = preDecodedBody(req)
295
+ let bytes: Uint8Array
296
+ let contentType: string
297
+ if (decoded !== undefined) {
298
+ releaseGateHalf(prepared.body)
299
+ if (decoded.byteLength > maxBodyBytes) return wrapResponse(jsonError(413, "payload_too_large"))
300
+ bytes = decoded.encoded
301
+ contentType = decoded.contentType
302
+ } else {
303
+ const read = await readBoundedBytes(gateCopy(req, prepared.body), maxBodyBytes)
304
+ if (!read.ok) {
305
+ return wrapResponse(
306
+ jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"),
275
307
  )
276
- const buffered = new Request(req.url, {
277
- method: req.method,
278
- headers: req.headers,
279
- signal: req.signal,
280
- // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: readBoundedBytes owns this byte view; Request accepts the same bytes on every supported runtime.
281
- body: read.bytes.byteLength ? (read.bytes as NonNullable<RequestInit["body"]>) : null,
282
- })
308
+ }
309
+ bytes = read.bytes
310
+ contentType = req.headers.get("content-type") ?? ""
311
+ }
312
+ // Past the deadline the kernel has already answered; claim nothing for a request it abandoned.
313
+ if (ctx.signal.aborted) return wrapResponse(jsonError(503, "request_timeout"))
314
+ const url = urlPartsOf(req.url)
315
+ const fingerprint = await computeIdempotencyFingerprint(
316
+ req.method,
317
+ `${url.pathname}${url.search}`,
318
+ canonicalizeIdempotencyBody(bytes, contentType),
319
+ contentType,
320
+ )
283
321
 
284
- const pendingTtlMs = config.pendingTtlMs
285
- const begin = await config.store.begin({
322
+ const pendingTtlMs = config.pendingTtlMs
323
+ const begin = await config.store.begin({
324
+ namespace,
325
+ key,
326
+ fingerprint,
327
+ ttlMs: config.ttlMs,
328
+ pendingTtlMs,
329
+ })
330
+ if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
331
+ if (begin.state === "in-flight") {
332
+ return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
333
+ }
334
+ if (begin.state === "capacity") {
335
+ return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
336
+ }
337
+ if (ctx.signal.aborted) {
338
+ if (begin.state === "new") {
339
+ await config.store.abandon({ namespace, key, reservation: begin.reservation })
340
+ }
341
+ return wrapResponse(jsonError(503, "request_timeout"))
342
+ }
343
+ beginRequestEffectTracking(req)
344
+ if (begin.state === "replay") {
345
+ const replay = replayEntry(
346
+ entry,
347
+ responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }),
348
+ )
349
+ return await resume(replay, replay.program)
350
+ }
351
+ const reservation = begin.reservation
352
+ let heartbeat: ReturnType<typeof setInterval> | undefined
353
+ if (pendingTtlMs !== undefined) {
354
+ // Renew the lease while the handler runs: only a process that died stops renewing, so its
355
+ // key frees after the lease rather than the replay TTL.
356
+ heartbeat = setInterval(() => {
357
+ // A failed renewal only lets the lease lapse, which completion then reports.
358
+ Promise.resolve()
359
+ .then(() => config.store.renew?.({ namespace, key, reservation, ttlMs: pendingTtlMs }))
360
+ .catch(() => undefined)
361
+ }, pendingTtlMs / 3)
362
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a Node or Bun timer has unref(); on other runtimes the optional call does nothing.
363
+ ;(heartbeat as { unref?: () => void }).unref?.()
364
+ }
365
+ try {
366
+ // The handler threw, or its response body failed while being captured: no response to store.
367
+ const settleThrown = async (err: unknown): Promise<Response> => {
368
+ if (!requestEffectEvidence(req).began) {
369
+ // The request-local boundary proves no owned effect started, so this is the one safe case
370
+ // where releasing the key cannot duplicate an effect.
371
+ await config.store.abandon({ namespace, key, reservation })
372
+ throw err
373
+ }
374
+ // A committed or ambiguous effect must never be repeated. Persist a payload-free terminal
375
+ // response even though the normal handler lifecycle escaped without producing one.
376
+ const terminal = new Response(null, { status: 500 })
377
+ const completed = await config.store.complete({
286
378
  namespace,
287
379
  key,
288
- fingerprint,
289
- ttlMs: config.ttlMs,
290
- pendingTtlMs,
380
+ reservation,
381
+ response: await serializeResponse(terminal, { maxBytes: config.maxResponseBytes }),
291
382
  })
292
- if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
293
- if (begin.state === "in-flight") {
294
- return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
383
+ if (!completed) {
384
+ return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
295
385
  }
296
- if (begin.state === "capacity") {
297
- return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
386
+ return wrapResponse(terminal)
387
+ }
388
+ let response: Response
389
+ try {
390
+ response = await resume(pastGate(entry), entry.program)
391
+ } catch (err) {
392
+ return await settleThrown(err)
393
+ }
394
+ if (!HANDLER_ENTERED.has(req) && !requestEffectEvidence(req).began) {
395
+ // Rejected before the handler ran: no execution began, so there is nothing a retry could
396
+ // duplicate and no result is saved - storing every rejection would let callers who never
397
+ // pass auth fill the store and lock everyone else out.
398
+ const abandoned = await config.store.abandon({ namespace, key, reservation })
399
+ if (!abandoned) {
400
+ return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
298
401
  }
299
- beginRequestEffectTracking(buffered)
300
- if (begin.state === "replay") {
301
- return wrapResponse(
302
- await host.runLanes(
303
- buffered,
304
- platform,
305
- replayEntry(
306
- entry,
307
- responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }),
308
- ),
309
- params,
310
- search,
311
- ),
312
- )
313
- }
314
- const reservation = begin.reservation
315
- let heartbeat: ReturnType<typeof setInterval> | undefined
316
- if (pendingTtlMs !== undefined) {
317
- // Renew the lease while the handler runs: only a process that died stops renewing, so its
318
- // key frees after the lease rather than the replay TTL.
319
- heartbeat = setInterval(() => {
320
- // A failed renewal only lets the lease lapse, which completion then reports.
321
- Promise.resolve()
322
- .then(() => config.store.renew?.({ namespace, key, reservation, ttlMs: pendingTtlMs }))
323
- .catch(() => undefined)
324
- }, pendingTtlMs / 3)
325
- // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a Node or Bun timer has unref(); on other runtimes the optional call does nothing.
326
- ;(heartbeat as { unref?: () => void }).unref?.()
402
+ return wrapResponse(response)
403
+ }
404
+ if (
405
+ (REFUSALS.has(response.status) || (response.status >= 500 && requestIsSafeToRetry(req))) &&
406
+ !requestEffectEvidence(req).began
407
+ ) {
408
+ const abandoned = await config.store.abandon({ namespace, key, reservation })
409
+ if (!abandoned) {
410
+ return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
327
411
  }
412
+ return wrapResponse(response)
413
+ }
414
+ // Once the handler ran, every other concrete response is terminal for this key. A non-2xx may
415
+ // follow an already-committed external effect; abandoning it would let a retry duplicate it.
416
+ let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
417
+ try {
418
+ storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
419
+ } catch (error) {
420
+ if (!(error instanceof IdempotencyResponseTooLargeError)) return await settleThrown(error)
421
+ // The effect may already have happened, so never abandon and permit a duplicate execution.
422
+ // Commit a small terminal response under the winning key and return that same response now.
423
+ response = jsonError(507, "idempotency_response_too_large")
328
424
  try {
329
- // The handler threw, or its response body failed while being captured: no response to store.
330
- const settleThrown = async (err: unknown): Promise<ReturnType<typeof wrapResponse>> => {
331
- if (!requestEffectEvidence(buffered).began) {
332
- // The request-local boundary proves no owned effect started, so this is the one safe case
333
- // where releasing the key cannot duplicate an effect.
334
- await config.store.abandon({ namespace, key, reservation })
335
- throw err
336
- }
337
- // A committed or ambiguous effect must never be repeated. Persist a payload-free terminal
338
- // response even though the normal handler lifecycle escaped without producing one.
339
- const terminal = new Response(null, { status: 500 })
340
- const completed = await config.store.complete({
341
- namespace,
342
- key,
343
- reservation,
344
- response: await serializeResponse(terminal, { maxBytes: config.maxResponseBytes }),
345
- })
346
- if (!completed) {
347
- return wrapResponse(
348
- jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
349
- )
350
- }
351
- return wrapResponse(terminal)
352
- }
353
- let response: Response
354
- try {
355
- response = await host.runLanes(buffered, platform, entry, params, search)
356
- } catch (err) {
357
- return await settleThrown(err)
358
- }
359
- if (!HANDLER_ENTERED.has(buffered) && !requestEffectEvidence(buffered).began) {
360
- // Rejected before the handler ran: no execution began, so there is nothing a retry could
361
- // duplicate and no result is saved - storing every rejection would let callers who never
362
- // pass auth fill the store and lock everyone else out.
363
- const abandoned = await config.store.abandon({ namespace, key, reservation })
364
- if (!abandoned) {
365
- return wrapResponse(
366
- jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
367
- )
368
- }
369
- return wrapResponse(response)
370
- }
371
- if (
372
- (REFUSALS.has(response.status) ||
373
- (response.status >= 500 && requestIsSafeToRetry(buffered))) &&
374
- !requestEffectEvidence(buffered).began
375
- ) {
376
- const abandoned = await config.store.abandon({ namespace, key, reservation })
377
- if (!abandoned) {
378
- return wrapResponse(
379
- jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
380
- )
381
- }
382
- return wrapResponse(response)
383
- }
384
- // Once the handler ran, every other concrete response is terminal for this key. A non-2xx may
385
- // follow an already-committed external effect; abandoning it would let a retry duplicate it.
386
- let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
425
+ storedResponse = await serializeResponse(response, {
426
+ maxBytes: config.maxResponseBytes,
427
+ })
428
+ } catch (terminalError) {
429
+ if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
430
+ // Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
431
+ // status + replay safety without silently storing more bytes than the route permits.
432
+ response = new Response(null, { status: 507 })
433
+ storedResponse = await serializeResponse(response, {
434
+ maxBytes: config.maxResponseBytes,
435
+ })
436
+ }
437
+ }
438
+ const completed = await config.store.complete({
439
+ namespace,
440
+ key,
441
+ reservation,
442
+ response: storedResponse,
443
+ })
444
+ if (!completed) {
445
+ return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
446
+ }
447
+ return wrapResponse(response)
448
+ } finally {
449
+ clearInterval(heartbeat)
450
+ }
451
+ }
452
+
453
+ function prepareRequest(source: RequestSource, platform: Platform | undefined): Request {
454
+ const request = requestOf(source)
455
+ let prepared = request
456
+ let state: PreparedRequest
457
+ let input: ReturnType<typeof ownedInput> | undefined
458
+ try {
459
+ input = ownedInput(request.body)
460
+ const [route, gate] = input.body === null ? [null, null] : input.body.tee()
461
+ state = { body: gate, route, cancel: input.cancel, platform }
462
+ const init: StreamingRequestInit = {
463
+ method: request.method,
464
+ headers: request.headers,
465
+ signal: request.signal,
466
+ body: route,
467
+ duplex: "half",
468
+ }
469
+ prepared = new Request(request.url, init)
470
+ const decoded = preDecodedBody(request)
471
+ if (decoded !== undefined) Object.defineProperty(prepared, PRE_DECODED_BODY, { value: decoded })
472
+ } catch (error) {
473
+ // A request that cannot be copied (a body an earlier hook already read) still enters the
474
+ // program, so the failure surfaces from the gate like any other route error.
475
+ input?.cancel()
476
+ state = { body: null, route: null, platform, failure: { error } }
477
+ }
478
+ // The lanes, and any hook an auth-first route runs before the gate, read this copy's body.
479
+ const cap = transportCapOf(source)
480
+ if (cap !== undefined) markTransportCap(prepared, cap)
481
+ PREPARED.set(prepared, state)
482
+ return prepared
483
+ }
484
+
485
+ function finishRequest(request: Request): void {
486
+ const state = PREPARED.get(request)
487
+ if (state === undefined) return
488
+ state.cancel?.()
489
+ releaseGateHalf(state.body)
490
+ releaseGateHalf(state.route)
491
+ }
492
+
493
+ type StreamingRequestInit = RequestInit & { readonly duplex: "half" }
494
+
495
+ /** Own the source reader so cancellation cannot be held hostage by an unread or locked tee branch. */
496
+ function ownedInput(body: ReadableStream<Uint8Array> | null): {
497
+ readonly body: ReadableStream<Uint8Array> | null
498
+ readonly cancel: () => void
499
+ } {
500
+ if (body === null) return { body: null, cancel() {} }
501
+ const reader = body.getReader()
502
+ let ended = false
503
+ let controller: ReadableStreamDefaultController<Uint8Array>
504
+ const cancel = (): void => {
505
+ if (ended) return
506
+ ended = true
507
+ controller.error(new DOMException("Request finished", "AbortError"))
508
+ // Cleanup must not wait for an upstream producer's cancellation promise.
509
+ void reader.cancel().catch(() => {})
510
+ reader.releaseLock()
511
+ }
512
+ return {
513
+ cancel,
514
+ body: new ReadableStream<Uint8Array>({
515
+ start(value) {
516
+ controller = value
517
+ },
518
+ async pull(output) {
387
519
  try {
388
- storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
520
+ const chunk = await reader.read()
521
+ if (ended) return
522
+ if (chunk.done) output.close()
523
+ else output.enqueue(chunk.value)
389
524
  } catch (error) {
390
- if (!(error instanceof IdempotencyResponseTooLargeError)) return await settleThrown(error)
391
- // The effect may already have happened, so never abandon and permit a duplicate execution.
392
- // Commit a small terminal response under the winning key and return that same response now.
393
- response = jsonError(507, "idempotency_response_too_large")
394
- try {
395
- storedResponse = await serializeResponse(response, {
396
- maxBytes: config.maxResponseBytes,
397
- })
398
- } catch (terminalError) {
399
- if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
400
- // Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
401
- // status + replay safety without silently storing more bytes than the route permits.
402
- response = new Response(null, { status: 507 })
403
- storedResponse = await serializeResponse(response, {
404
- maxBytes: config.maxResponseBytes,
405
- })
406
- }
525
+ output.error(error)
407
526
  }
408
- const completed = await config.store.complete({
409
- namespace,
410
- key,
411
- reservation,
412
- response: storedResponse,
413
- })
414
- if (!completed) {
415
- return wrapResponse(
416
- jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
417
- )
418
- }
419
- return wrapResponse(response)
420
- } finally {
421
- clearInterval(heartbeat)
422
- }
423
- },
527
+ },
528
+ cancel,
529
+ }),
530
+ }
531
+ }
532
+
533
+ /** The gate's half of the body under the request's own framing headers, for the bounded reader. */
534
+ function gateCopy(request: Request, body: ReadableStream<Uint8Array> | null): Request {
535
+ const init: StreamingRequestInit = {
536
+ method: body === null ? "GET" : "POST",
537
+ headers: request.headers,
538
+ body,
539
+ duplex: "half",
540
+ }
541
+ return new Request(request.url, init)
542
+ }
543
+
544
+ /** Drop the gate's unread half of the body; a failed cancel has no caller left to report to. */
545
+ function releaseGateHalf(body: ReadableStream<Uint8Array> | null): void {
546
+ body?.cancel().catch(() => {})
547
+ }
548
+
549
+ // The entry a program resumes on past its gate: the same route without idempotency, so a program
550
+ // re-entered from the top does not meet the gate again. One per route, built on first use.
551
+ const PAST_GATE = new WeakMap<RouteEntry, RouteEntry>()
552
+ function pastGate(entry: RouteEntry): RouteEntry {
553
+ let past = PAST_GATE.get(entry)
554
+ if (past === undefined) {
555
+ past = { ...entry, idempotent: undefined }
556
+ PAST_GATE.set(entry, past)
424
557
  }
558
+ return past
425
559
  }
426
560
 
427
561
  function replayEntry(entry: unknown, response: Response): RouteEntry {
@@ -431,8 +565,8 @@ function replayEntry(entry: unknown, response: Response): RouteEntry {
431
565
  return {
432
566
  ...route,
433
567
  handler,
568
+ idempotent: undefined,
434
569
  responseContract: undefined,
435
570
  program: { ...route.program, handler, afterHandle: [] },
436
- execution: REPLAY_EXECUTION,
437
571
  }
438
572
  }
@@ -54,7 +54,7 @@ function plugin(
54
54
  * ```
55
55
  *
56
56
  * It is the same as calling `get`, `post`, `put`, `patch`, `delete`, `head` and `options` with the
57
- * one handler, as a single registration: a method already registered on the path makes the call
57
+ * one handler, and `method("QUERY", ...)`, as a single registration: a method already registered on the path makes the call
58
58
  * throw and adds nothing. A schema applies to every method, so with a `body` schema a request that
59
59
  * carries no body is refused under any of them, `GET` included.
60
60
  */
@@ -95,7 +95,8 @@ export function all(
95
95
  * or none is.
96
96
  *
97
97
  * Only the standard methods among the names join the app's typed registry, so the typed client has
98
- * a call for those and none for a custom token.
98
+ * a call for those and none for a custom token. `QUERY` is one: its route is typed, takes a `body`
99
+ * schema, and the client calls it as `$query(body)`.
99
100
  */
100
101
  export function method<
101
102
  const M extends string,