@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.
- package/CHANGELOG.md +29 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +2 -1
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +5 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +36 -15
- package/dist/internal/route-program.js.map +1 -1
- package/dist/router/router.d.ts +3 -2
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +22 -6
- package/dist/router/router.js.map +1 -1
- package/dist/security/baseline.d.ts.map +1 -1
- package/dist/security/baseline.js +4 -3
- package/dist/security/baseline.js.map +1 -1
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +4 -3
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body.d.ts +2 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +5 -0
- package/dist/server/body.js.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +9 -2
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/idempotency-lane.d.ts +11 -12
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +301 -151
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/methods.d.ts +3 -2
- package/dist/server/methods.d.ts.map +1 -1
- package/dist/server/methods.js.map +1 -1
- package/dist/server/multipart.d.ts.map +1 -1
- package/dist/server/multipart.js +42 -13
- package/dist/server/multipart.js.map +1 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +2 -2
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/query.d.ts +1 -1
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +13 -5
- package/dist/server/query.js.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +3 -3
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +81 -3
- package/dist/server/respond.js.map +1 -1
- package/dist/server/runtime-core.d.ts +15 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +41 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server.d.ts +14 -5
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +179 -59
- package/dist/server/server.js.map +1 -1
- package/dist/server/transport-lane.d.ts +2 -2
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +20 -20
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/static-file.d.ts +29 -6
- package/dist/static-file.d.ts.map +1 -1
- package/dist/static-file.js +43 -12
- package/dist/static-file.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/internal/route-lanes.ts +18 -16
- package/src/internal/route-program.ts +76 -12
- package/src/router/router.ts +26 -5
- package/src/security/baseline.ts +4 -3
- package/src/server/body-lane.ts +4 -4
- package/src/server/body.ts +6 -0
- package/src/server/cookies.ts +7 -2
- package/src/server/core-copies.ts +1 -1
- package/src/server/idempotency-lane.ts +343 -209
- package/src/server/methods.ts +3 -2
- package/src/server/multipart.ts +62 -14
- package/src/server/node-outcome.ts +2 -1
- package/src/server/query.ts +12 -5
- package/src/server/request-context.ts +6 -3
- package/src/server/respond.ts +108 -6
- package/src/server/runtime-core.ts +54 -1
- package/src/server/server.ts +249 -99
- package/src/server/transport-lane.ts +28 -22
- 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
|
|
9
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
/**
|
|
103
|
-
run
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
return
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
pendingTtlMs,
|
|
380
|
+
reservation,
|
|
381
|
+
response: await serializeResponse(terminal, { maxBytes: config.maxResponseBytes }),
|
|
291
382
|
})
|
|
292
|
-
if (
|
|
293
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
}
|
package/src/server/methods.ts
CHANGED
|
@@ -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,
|