@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.
- package/README.md +26 -0
- package/dist/assurance.d.ts +7 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +39 -3
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +20 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +47 -15
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +6 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +36 -2
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +16 -3
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-effect.d.ts.map +1 -1
- package/dist/durable-effect.js +6 -3
- package/dist/durable-effect.js.map +1 -1
- package/dist/edge-kit.d.ts +2 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +2 -0
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-lifecycle.d.ts.map +1 -1
- package/dist/effect-lifecycle.js +2 -1
- package/dist/effect-lifecycle.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +1 -1
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +36 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +219 -13
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts +31 -0
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +48 -9
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +7 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +9 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
- package/dist/internal/durable-saga-protocol.js +2 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -1
- package/dist/internal/route-catalog.d.ts +6 -6
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +20 -18
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +7 -6
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +36 -4
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +7 -3
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +1 -0
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +34 -29
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +8 -2
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +222 -20
- package/dist/internal/route-program.js.map +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +8 -5
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +52 -46
- package/dist/manifest.js.map +1 -1
- package/dist/mount.d.ts +36 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +32 -0
- package/dist/mount.js.map +1 -1
- package/dist/reflection.d.ts +26 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +65 -1
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -1
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js.map +1 -1
- package/dist/router/overlap.d.ts +23 -0
- package/dist/router/overlap.d.ts.map +1 -0
- package/dist/router/overlap.js +224 -0
- package/dist/router/overlap.js.map +1 -0
- package/dist/router/pattern.d.ts +117 -2
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +283 -23
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts +18 -4
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +51 -44
- package/dist/router/router.js.map +1 -1
- package/dist/server/auth.d.ts +53 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +9 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +12 -7
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +13 -11
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/body-lane.d.ts +20 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +26 -7
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts +68 -0
- package/dist/server/body-parser.d.ts.map +1 -0
- package/dist/server/body-parser.js +172 -0
- package/dist/server/body-parser.js.map +1 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +62 -7
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +8 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +8 -0
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +71 -6
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -5
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +25 -4
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts +4 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +40 -2
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.d.ts +2 -0
- package/dist/server/core-copies.d.ts.map +1 -0
- package/dist/server/core-copies.js +20 -0
- package/dist/server/core-copies.js.map +1 -0
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +36 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +130 -73
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +4 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +4 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/methods.d.ts +45 -0
- package/dist/server/methods.d.ts.map +1 -0
- package/dist/server/methods.js +37 -0
- package/dist/server/methods.js.map +1 -0
- package/dist/server/multipart.d.ts +63 -0
- package/dist/server/multipart.d.ts.map +1 -0
- package/dist/server/multipart.js +374 -0
- package/dist/server/multipart.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +20 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +15 -6
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/not-found-answer.d.ts +40 -0
- package/dist/server/not-found-answer.d.ts.map +1 -0
- package/dist/server/not-found-answer.js +56 -0
- package/dist/server/not-found-answer.js.map +1 -0
- package/dist/server/not-found.d.ts +23 -0
- package/dist/server/not-found.d.ts.map +1 -0
- package/dist/server/not-found.js +81 -0
- package/dist/server/not-found.js.map +1 -0
- package/dist/server/plugin.d.ts +26 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +18 -5
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +114 -9
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts +7 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +47 -0
- package/dist/server/query.js.map +1 -1
- package/dist/server/registry.d.ts +13 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +11 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +33 -19
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-lineage.d.ts +8 -0
- package/dist/server/request-lineage.d.ts.map +1 -0
- package/dist/server/request-lineage.js +14 -0
- package/dist/server/request-lineage.js.map +1 -0
- package/dist/server/request-target.d.ts +31 -0
- package/dist/server/request-target.d.ts.map +1 -0
- package/dist/server/request-target.js +44 -0
- package/dist/server/request-target.js.map +1 -0
- package/dist/server/respond.d.ts +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +16 -4
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +19 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +59 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +9 -5
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -6
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +55 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +194 -19
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1342 -274
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +10 -7
- package/dist/server/sse.js.map +1 -1
- package/dist/server/static-headers.d.ts +3 -0
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +19 -1
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +9 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/validation.d.ts.map +1 -1
- package/dist/server/validation.js +5 -1
- package/dist/server/validation.js.map +1 -1
- package/dist/server/websocket.d.ts +10 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +7 -4
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +2 -0
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +6 -3
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +2 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +8 -5
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +5 -2
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.js +5 -0
- package/dist/single-copy-register.js.map +1 -1
- package/dist/single-copy.d.ts +46 -3
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +125 -12
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts +23 -0
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +37 -3
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +27 -2
- package/src/assurance.ts +52 -2
- package/src/capabilities.ts +81 -22
- package/src/diff.ts +54 -7
- package/src/durable-adapters.ts +20 -3
- package/src/durable-effect.ts +10 -3
- package/src/edge-kit.ts +7 -0
- package/src/effect-lifecycle.ts +5 -1
- package/src/effect-scope.ts +2 -1
- package/src/errors.ts +3 -0
- package/src/evidence.ts +302 -12
- package/src/idempotency.ts +80 -9
- package/src/index.ts +41 -3
- package/src/internal/code-unit-order.ts +8 -0
- package/src/internal/durable-saga-protocol.ts +2 -0
- package/src/internal/route-catalog.ts +19 -18
- package/src/internal/route-compiler.ts +62 -13
- package/src/internal/route-execution.ts +13 -3
- package/src/internal/route-lanes.ts +37 -28
- package/src/internal/route-program.ts +434 -20
- package/src/ledger.ts +11 -5
- package/src/manifest.ts +52 -46
- package/src/mount.ts +62 -0
- package/src/reflection.ts +84 -1
- package/src/response-observer.ts +1 -0
- package/src/router/overlap.ts +258 -0
- package/src/router/pattern.ts +349 -25
- package/src/router/router.ts +76 -51
- package/src/server/auth.ts +75 -0
- package/src/server/bare-error-lane.ts +12 -11
- package/src/server/body-lane.ts +46 -6
- package/src/server/body-parser.ts +241 -0
- package/src/server/body.ts +82 -9
- package/src/server/client-ip.ts +9 -0
- package/src/server/context.ts +219 -12
- package/src/server/contract.ts +54 -9
- package/src/server/cookies.ts +34 -2
- package/src/server/core-copies.ts +21 -0
- package/src/server/edge.ts +2 -0
- package/src/server/http.ts +34 -0
- package/src/server/idempotency-lane.ts +147 -72
- package/src/server/install.ts +6 -0
- package/src/server/methods.ts +143 -0
- package/src/server/multipart.ts +424 -0
- package/src/server/node-outcome-hook.ts +40 -7
- package/src/server/not-found-answer.ts +109 -0
- package/src/server/not-found.ts +121 -0
- package/src/server/plugin.ts +45 -1
- package/src/server/proto-guard.ts +128 -9
- package/src/server/query.ts +49 -0
- package/src/server/registry.ts +17 -2
- package/src/server/request-context.ts +44 -16
- package/src/server/request-lineage.ts +15 -0
- package/src/server/request-target.ts +46 -0
- package/src/server/respond.ts +14 -3
- package/src/server/response-contract-lane.ts +97 -16
- package/src/server/response-observer-runtime.ts +37 -8
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +61 -2
- package/src/server/server.ts +1798 -355
- package/src/server/sse.ts +10 -8
- package/src/server/static-headers.ts +22 -1
- package/src/server/transport-lane.ts +17 -9
- package/src/server/validation.ts +7 -1
- package/src/server/websocket.ts +23 -9
- package/src/server/ws-bun.ts +8 -3
- package/src/server/ws-hook.ts +2 -1
- package/src/server.ts +39 -2
- package/src/single-copy-register.ts +5 -0
- package/src/single-copy.ts +160 -12
- package/src/tool-contract.ts +66 -3
- 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
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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:
|
|
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
|
}
|
package/src/server/install.ts
CHANGED
|
@@ -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
|
+
}
|