@daloyjs/core 1.0.0-rc.8 → 1.0.0-rc.9
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/dist/adapters/node.js +67 -12
- package/dist/app.d.ts +25 -0
- package/dist/app.js +84 -0
- package/dist/idempotency.d.ts +13 -0
- package/dist/idempotency.js +16 -1
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/package.json +1 -1
package/dist/adapters/node.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
6
|
import { Readable } from "node:stream";
|
|
7
|
-
import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, DALOY_REQUEST_ABORT, } from "../app.js";
|
|
7
|
+
import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, DALOY_REQUEST_ABORT, DALOY_REQUEST_BODY_SOLICIT, } from "../app.js";
|
|
8
8
|
import { BadRequestError } from "../errors.js";
|
|
9
9
|
import { setClientCertificate, normalizePeerCertificate, } from "../mtls.js";
|
|
10
10
|
import { setConnInfo } from "../conn-info.js";
|
|
@@ -34,15 +34,12 @@ export function serve(app, opts = {}) {
|
|
|
34
34
|
const connectionsCheckingInterval = connectionTimeoutMs > 0
|
|
35
35
|
? Math.max(1_000, Math.min(5_000, Math.floor(connectionTimeoutMs / 2)))
|
|
36
36
|
: undefined;
|
|
37
|
-
const
|
|
38
|
-
maxHeaderSize: opts.maxHeaderBytes ?? 16 * 1024,
|
|
39
|
-
...(connectionsCheckingInterval !== undefined ? { connectionsCheckingInterval } : {}),
|
|
40
|
-
}, (req, res) => {
|
|
37
|
+
const handleRequest = (req, res, onBodyPull) => {
|
|
41
38
|
// GET/HEAD: no body work, dispatch directly. Keep this first so the GET
|
|
42
39
|
// hot path doesn't pay for any of the buffering bookkeeping below.
|
|
43
40
|
const method = req.method;
|
|
44
41
|
if (method === "GET" || method === "HEAD" || method === undefined) {
|
|
45
|
-
dispatchToApp(app, req, res, trustProxy, undefined);
|
|
42
|
+
dispatchToApp(app, req, res, trustProxy, undefined, onBodyPull);
|
|
46
43
|
return;
|
|
47
44
|
}
|
|
48
45
|
// Refuse Fetch-forbidden methods (CONNECT/TRACE/TRACK) before building a
|
|
@@ -61,10 +58,60 @@ export function serve(app, opts = {}) {
|
|
|
61
58
|
const cl = req.headers["content-length"];
|
|
62
59
|
const n = cl ? Number(cl) : NaN;
|
|
63
60
|
if (Number.isFinite(n) && n >= 0 && n <= bufferedBodyMaxBytes) {
|
|
64
|
-
bufferRequestBody(req, n).then((bytes) => dispatchToApp(app, req, res, trustProxy, bytes), (e) => writeAdapterError(res, e));
|
|
61
|
+
bufferRequestBody(req, n).then((bytes) => dispatchToApp(app, req, res, trustProxy, bytes, onBodyPull), (e) => writeAdapterError(res, e));
|
|
65
62
|
return;
|
|
66
63
|
}
|
|
67
|
-
dispatchToApp(app, req, res, trustProxy, undefined);
|
|
64
|
+
dispatchToApp(app, req, res, trustProxy, undefined, onBodyPull);
|
|
65
|
+
};
|
|
66
|
+
const server = createServer({
|
|
67
|
+
maxHeaderSize: opts.maxHeaderBytes ?? 16 * 1024,
|
|
68
|
+
...(connectionsCheckingInterval !== undefined ? { connectionsCheckingInterval } : {}),
|
|
69
|
+
}, handleRequest);
|
|
70
|
+
// `Expect: 100-continue`: hold the interim response until the framework
|
|
71
|
+
// actually reaches for the body.
|
|
72
|
+
//
|
|
73
|
+
// Node's default is to answer `100 Continue` for anyone who asks, which
|
|
74
|
+
// solicits a body the framework may be about to refuse outright. A route with
|
|
75
|
+
// a request-body schema and a declared `Content-Length` over `bodyLimitBytes`
|
|
76
|
+
// is rejected by `readBodyLimited` *before* it reads a byte, so the interim
|
|
77
|
+
// `100` invited megabytes that could only ever be discarded — measured as
|
|
78
|
+
// `100` then `413` on the wire.
|
|
79
|
+
//
|
|
80
|
+
// Deferring to the first actual read makes the framework's own read decision
|
|
81
|
+
// the predicate, and that is what keeps this honest. An earlier attempt
|
|
82
|
+
// refused at header time against `bodyLimitBytes` directly, but that limit is
|
|
83
|
+
// only enforced where a body is *parsed*, so a route that declares no body
|
|
84
|
+
// schema never applies it: the same request answered `413` with `Expect` and
|
|
85
|
+
// `200` without it. `Expect` is a hint about when to send the body (RFC 9110
|
|
86
|
+
// §10.1.1), so it must never change the outcome — only when the client
|
|
87
|
+
// learns it. Keying off the read keeps the two paths in agreement by
|
|
88
|
+
// construction rather than by test coverage.
|
|
89
|
+
server.on("checkContinue", (req, res) => {
|
|
90
|
+
const cl = req.headers["content-length"];
|
|
91
|
+
const n = cl ? Number(cl) : NaN;
|
|
92
|
+
if (Number.isFinite(n) && n >= 0 && n <= bufferedBodyMaxBytes) {
|
|
93
|
+
// Small declared body: `handleRequest` buffers it eagerly, before dispatch,
|
|
94
|
+
// so there is no later read to key off. It is also under the buffer cap,
|
|
95
|
+
// so soliciting it immediately costs nothing worth deferring.
|
|
96
|
+
res.writeContinue();
|
|
97
|
+
handleRequest(req, res);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Streaming path (no `Content-Length`, or one above the buffer cap): answer
|
|
101
|
+
// the interim `100` the first time the framework pulls the body stream.
|
|
102
|
+
//
|
|
103
|
+
// The socket's own `resume` event is deliberately NOT the trigger. Node also
|
|
104
|
+
// resumes the stream on a microtask when it drains a body nobody read, which
|
|
105
|
+
// races the response: measured, that fired the `100` even for a request whose
|
|
106
|
+
// body was never wanted, and beat the `413` to the wire. The consumer's first
|
|
107
|
+
// `pull` is the only signal that means "the framework wants these bytes".
|
|
108
|
+
let sent = false;
|
|
109
|
+
handleRequest(req, res, () => {
|
|
110
|
+
if (sent || res.headersSent || res.writableEnded)
|
|
111
|
+
return;
|
|
112
|
+
sent = true;
|
|
113
|
+
res.writeContinue();
|
|
114
|
+
});
|
|
68
115
|
});
|
|
69
116
|
server.requestTimeout = connectionTimeoutMs;
|
|
70
117
|
server.headersTimeout = connectionTimeoutMs;
|
|
@@ -148,10 +195,10 @@ export function serve(app, opts = {}) {
|
|
|
148
195
|
* on body size remains `App.bodyLimitBytes`.
|
|
149
196
|
*/
|
|
150
197
|
const DEFAULT_BUFFERED_BODY_MAX_BYTES = 256 * 1024;
|
|
151
|
-
function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
|
|
198
|
+
function dispatchToApp(app, req, res, trustProxy, bufferedBody, onBodyPull) {
|
|
152
199
|
let request;
|
|
153
200
|
try {
|
|
154
|
-
request = toWebRequest(req, trustProxy, bufferedBody);
|
|
201
|
+
request = toWebRequest(req, trustProxy, bufferedBody, onBodyPull);
|
|
155
202
|
}
|
|
156
203
|
catch (e) {
|
|
157
204
|
writeAdapterError(res, e);
|
|
@@ -537,7 +584,11 @@ Object.setPrototypeOf(LightRequest.prototype, Request.prototype);
|
|
|
537
584
|
// construction for requests dispatched through this shim. Set once on the
|
|
538
585
|
// prototype: zero per-request cost.
|
|
539
586
|
LightRequest.prototype[DALOY_LIGHT_RESPONSE_OK] = true;
|
|
540
|
-
function toWebRequest(req, trustProxy, bufferedBody) {
|
|
587
|
+
function toWebRequest(req, trustProxy, bufferedBody, onBodyPull) {
|
|
588
|
+
// `onBodyPull` is attached to the finished Request below rather than wrapping
|
|
589
|
+
// the body stream: undici pulls a streaming body during `new Request(...)`,
|
|
590
|
+
// so a stream-level hook fired at construction — before the framework had
|
|
591
|
+
// decided anything — and re-solicited bodies it went on to refuse.
|
|
541
592
|
const reqHeaders = req.headers;
|
|
542
593
|
const forwardedHost = trustProxy ? firstHeader(reqHeaders["x-forwarded-host"]) : undefined;
|
|
543
594
|
const host = forwardedHost ?? reqHeaders.host ?? "localhost";
|
|
@@ -588,12 +639,16 @@ function toWebRequest(req, trustProxy, bufferedBody) {
|
|
|
588
639
|
req2[DALOY_REQUEST_RAW_BODY] = bufferedBody;
|
|
589
640
|
return req2;
|
|
590
641
|
}
|
|
591
|
-
|
|
642
|
+
const streamed = new Request(url, {
|
|
592
643
|
method,
|
|
593
644
|
headers,
|
|
594
645
|
body: Readable.toWeb(req),
|
|
595
646
|
duplex: "half",
|
|
596
647
|
});
|
|
648
|
+
if (onBodyPull !== undefined) {
|
|
649
|
+
streamed[DALOY_REQUEST_BODY_SOLICIT] = onBodyPull;
|
|
650
|
+
}
|
|
651
|
+
return streamed;
|
|
597
652
|
}
|
|
598
653
|
function firstHeader(v) {
|
|
599
654
|
if (v === undefined)
|
package/dist/app.d.ts
CHANGED
|
@@ -793,6 +793,31 @@ export declare const DALOY_REQUEST_RAW_BODY: unique symbol;
|
|
|
793
793
|
* adapters can opt in; not part of the userland API surface.
|
|
794
794
|
*/
|
|
795
795
|
export declare const DALOY_REQUEST_ABORT: unique symbol;
|
|
796
|
+
/**
|
|
797
|
+
* Internal Symbol an adapter may set to a callback that the framework invokes
|
|
798
|
+
* immediately before it reads the request body — and only once it has decided
|
|
799
|
+
* the body is both wanted and within {@link AppOptions.bodyLimitBytes}.
|
|
800
|
+
*
|
|
801
|
+
* It exists for `Expect: 100-continue`. Node answers the interim `100` to
|
|
802
|
+
* anyone who asks, which solicits a body the framework may be about to refuse:
|
|
803
|
+
* a route with a body schema and a declared `Content-Length` over the limit is
|
|
804
|
+
* rejected before a byte is read, so the `100` invited megabytes that could
|
|
805
|
+
* only be discarded. The adapter defers its `writeContinue()` into this hook so
|
|
806
|
+
* the invitation tracks the framework's own decision.
|
|
807
|
+
*
|
|
808
|
+
* The framework's read decision has to be the trigger, because it is the only
|
|
809
|
+
* thing that makes the outcome independent of the `Expect` header. `Expect` is
|
|
810
|
+
* a hint about *when* to send the body (RFC 9110 §10.1.1); refusing at header
|
|
811
|
+
* time against `bodyLimitBytes` instead looks equivalent but is not, since that
|
|
812
|
+
* limit is only applied where a body is parsed — a route declaring no body
|
|
813
|
+
* schema never applies it, so the same request answered `413` with `Expect` and
|
|
814
|
+
* `200` without.
|
|
815
|
+
*
|
|
816
|
+
* Adapter-facing only; userland code should not depend on it.
|
|
817
|
+
*
|
|
818
|
+
* @since 1.0.0-rc.9
|
|
819
|
+
*/
|
|
820
|
+
export declare const DALOY_REQUEST_BODY_SOLICIT: unique symbol;
|
|
796
821
|
/**
|
|
797
822
|
* Internal Symbol set by handlers/serializers to attach a raw stream
|
|
798
823
|
* (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
|
package/dist/app.js
CHANGED
|
@@ -136,6 +136,8 @@ const MCP_ROUTE_MARKER = Symbol.for("daloyjs.mcp.route");
|
|
|
136
136
|
*/
|
|
137
137
|
const RESPONSE_CACHE_HOOK_MARKER = Symbol.for("daloyjs.response-cache.hook");
|
|
138
138
|
const TENANCY_HOOK_MARKER = Symbol.for("daloyjs.tenancy.hook");
|
|
139
|
+
const IDEMPOTENCY_HOOK_MARKER = Symbol.for("daloyjs.idempotency.hook");
|
|
140
|
+
const EARLY_REJECTION_MARKER = Symbol.for("daloyjs.middleware.earlyRejectionHooks");
|
|
139
141
|
/**
|
|
140
142
|
* Apply a topology-aware security preset on top of caller-supplied
|
|
141
143
|
* options. Returns a new options object where preset defaults fill in
|
|
@@ -222,6 +224,31 @@ export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
|
|
|
222
224
|
* adapters can opt in; not part of the userland API surface.
|
|
223
225
|
*/
|
|
224
226
|
export const DALOY_REQUEST_ABORT = Symbol.for("daloyjs.request.abort");
|
|
227
|
+
/**
|
|
228
|
+
* Internal Symbol an adapter may set to a callback that the framework invokes
|
|
229
|
+
* immediately before it reads the request body — and only once it has decided
|
|
230
|
+
* the body is both wanted and within {@link AppOptions.bodyLimitBytes}.
|
|
231
|
+
*
|
|
232
|
+
* It exists for `Expect: 100-continue`. Node answers the interim `100` to
|
|
233
|
+
* anyone who asks, which solicits a body the framework may be about to refuse:
|
|
234
|
+
* a route with a body schema and a declared `Content-Length` over the limit is
|
|
235
|
+
* rejected before a byte is read, so the `100` invited megabytes that could
|
|
236
|
+
* only be discarded. The adapter defers its `writeContinue()` into this hook so
|
|
237
|
+
* the invitation tracks the framework's own decision.
|
|
238
|
+
*
|
|
239
|
+
* The framework's read decision has to be the trigger, because it is the only
|
|
240
|
+
* thing that makes the outcome independent of the `Expect` header. `Expect` is
|
|
241
|
+
* a hint about *when* to send the body (RFC 9110 §10.1.1); refusing at header
|
|
242
|
+
* time against `bodyLimitBytes` instead looks equivalent but is not, since that
|
|
243
|
+
* limit is only applied where a body is parsed — a route declaring no body
|
|
244
|
+
* schema never applies it, so the same request answered `413` with `Expect` and
|
|
245
|
+
* `200` without.
|
|
246
|
+
*
|
|
247
|
+
* Adapter-facing only; userland code should not depend on it.
|
|
248
|
+
*
|
|
249
|
+
* @since 1.0.0-rc.9
|
|
250
|
+
*/
|
|
251
|
+
export const DALOY_REQUEST_BODY_SOLICIT = Symbol.for("daloyjs.request.bodySolicit");
|
|
225
252
|
/**
|
|
226
253
|
* Internal Symbol set by handlers/serializers to attach a raw stream
|
|
227
254
|
* (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
|
|
@@ -1028,6 +1055,29 @@ export class App {
|
|
|
1028
1055
|
this.bootGuard.error = err;
|
|
1029
1056
|
throw err;
|
|
1030
1057
|
}
|
|
1058
|
+
// Guard 3b: a stored-response layer mounted ahead of a request budget.
|
|
1059
|
+
// `responseCache()` and `idempotency()` both answer from `beforeHandle` and
|
|
1060
|
+
// end the hook chain, and `rateLimit()` / `loginThrottle()` enforce from that
|
|
1061
|
+
// same phase — so a limiter mounted behind either one never counts the
|
|
1062
|
+
// requests it serves. Measured: `rateLimit({ max: 2 })` behind a cache or a
|
|
1063
|
+
// replay admitted six of six. The budget silently becomes infinite for
|
|
1064
|
+
// exactly the traffic that repeats most, which is what the limit was written
|
|
1065
|
+
// for. Same shape as the responseCache-ahead-of-gates finding, and the reason
|
|
1066
|
+
// the five network-identity gates moved to `preBody`; `rateLimit` cannot
|
|
1067
|
+
// follow them there because its `keyGenerator` is caller-supplied and may
|
|
1068
|
+
// read `ctx.state`, so the unsafe order is refused instead.
|
|
1069
|
+
const replayBeforeBudget = this.routeSecurityMarkers.find((r) => r.replayBeforeBudget !== null);
|
|
1070
|
+
if (replayBeforeBudget !== undefined && this.bootGuard.error === undefined) {
|
|
1071
|
+
this.bootGuard.error = new Error(`Route ${replayBeforeBudget.method} ${replayBeforeBudget.path} runs ` +
|
|
1072
|
+
`${replayBeforeBudget.replayBeforeBudget} before rateLimit() / loginThrottle() in its ` +
|
|
1073
|
+
`effective hook chain. Both act from beforeHandle, so a cache hit or an idempotent ` +
|
|
1074
|
+
`replay returns a response and ends the chain before the limiter counts the request — ` +
|
|
1075
|
+
`the declared budget is never spent on repeat traffic and is effectively unlimited. ` +
|
|
1076
|
+
`Register rateLimit() first — as a global hook (new App({ hooks: rateLimit(...) })) or ` +
|
|
1077
|
+
`an earlier app.use(...) — so every request is counted before a stored response can ` +
|
|
1078
|
+
`short-circuit it. See https://daloyjs.dev/docs/security/boot-guards.`);
|
|
1079
|
+
throw this.bootGuard.error;
|
|
1080
|
+
}
|
|
1031
1081
|
// Guard 4: session() + state-changing route without csrf().
|
|
1032
1082
|
if (this.options.csrf === "off")
|
|
1033
1083
|
return;
|
|
@@ -3185,6 +3235,12 @@ function securityMarkersFromHooks(layers) {
|
|
|
3185
3235
|
// to tell whether the cache reads state before tenancy has written it.
|
|
3186
3236
|
let cacheIndex = -1;
|
|
3187
3237
|
let tenancyIndex = -1;
|
|
3238
|
+
// First stored-response layer of either kind, and the first request-budget
|
|
3239
|
+
// layer. Only the earliest of each matters: if any replay precedes any budget
|
|
3240
|
+
// hook, that budget is preemptable.
|
|
3241
|
+
let replayIndex = -1;
|
|
3242
|
+
let replayName = "";
|
|
3243
|
+
let budgetIndex = -1;
|
|
3188
3244
|
for (let i = 0; i < layers.length; i++) {
|
|
3189
3245
|
const record = layers[i];
|
|
3190
3246
|
if (record[SESSION_HOOK_MARKER] === true)
|
|
@@ -3197,12 +3253,25 @@ function securityMarkersFromHooks(layers) {
|
|
|
3197
3253
|
cacheIndex = i;
|
|
3198
3254
|
if (tenancyIndex === -1 && record[TENANCY_HOOK_MARKER] === true)
|
|
3199
3255
|
tenancyIndex = i;
|
|
3256
|
+
if (replayIndex === -1) {
|
|
3257
|
+
if (record[RESPONSE_CACHE_HOOK_MARKER] === true) {
|
|
3258
|
+
replayIndex = i;
|
|
3259
|
+
replayName = "responseCache()";
|
|
3260
|
+
}
|
|
3261
|
+
else if (record[IDEMPOTENCY_HOOK_MARKER] === true) {
|
|
3262
|
+
replayIndex = i;
|
|
3263
|
+
replayName = "idempotency()";
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
if (budgetIndex === -1 && Array.isArray(record[EARLY_REJECTION_MARKER]))
|
|
3267
|
+
budgetIndex = i;
|
|
3200
3268
|
}
|
|
3201
3269
|
return {
|
|
3202
3270
|
hasSession,
|
|
3203
3271
|
hasCsrf,
|
|
3204
3272
|
hasAuth,
|
|
3205
3273
|
cacheBeforeTenancy: cacheIndex !== -1 && tenancyIndex !== -1 && cacheIndex < tenancyIndex,
|
|
3274
|
+
replayBeforeBudget: replayIndex !== -1 && budgetIndex !== -1 && replayIndex < budgetIndex ? replayName : null,
|
|
3206
3275
|
};
|
|
3207
3276
|
}
|
|
3208
3277
|
function isStateChangingMethod(method) {
|
|
@@ -3718,6 +3787,21 @@ function validateContext(ctx, def, opts) {
|
|
|
3718
3787
|
if (!allowed.some((a) => ct.includes(a))) {
|
|
3719
3788
|
throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
|
|
3720
3789
|
}
|
|
3790
|
+
// Refuse an over-limit *declared* length here, before soliciting the body.
|
|
3791
|
+
// `readBodyLimited` already makes the identical check on the identical
|
|
3792
|
+
// boundary, so this changes no outcome — it is load-bearing purely for
|
|
3793
|
+
// ordering, so that an adapter deferring `Expect: 100-continue` (see
|
|
3794
|
+
// {@link DALOY_REQUEST_BODY_SOLICIT}) never invites bytes this request was
|
|
3795
|
+
// always going to be refused for. Kept below the content-type check so a
|
|
3796
|
+
// wrong media type still answers `415` rather than `413`, as before.
|
|
3797
|
+
const declared = request.headers.get("content-length");
|
|
3798
|
+
if (declared !== null) {
|
|
3799
|
+
const declaredBytes = Number(declared);
|
|
3800
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > opts.bodyLimitBytes) {
|
|
3801
|
+
throw new PayloadTooLargeError(opts.bodyLimitBytes);
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
request[DALOY_REQUEST_BODY_SOLICIT]?.();
|
|
3721
3805
|
const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart, opts.jsonMaxKeys, opts.jsonMaxDepth);
|
|
3722
3806
|
if (isPromiseLike(raw))
|
|
3723
3807
|
return raw.then(validateBodyAndFinish);
|
package/dist/idempotency.d.ts
CHANGED
|
@@ -246,6 +246,19 @@ export declare class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
246
246
|
/** Test helper. Number of stored records (including expired). */
|
|
247
247
|
size(): number;
|
|
248
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Internal Symbol stamped on the hooks {@link idempotency} returns, so `App`'s
|
|
251
|
+
* boot guards can see where a stored-response replay sits in a route's effective
|
|
252
|
+
* hook chain.
|
|
253
|
+
*
|
|
254
|
+
* A replay is returned from `beforeHandle` and ends the hook chain, which means
|
|
255
|
+
* anything enforcing from the *same* phase but mounted later never runs. That is
|
|
256
|
+
* how a `rateLimit()` behind a replay stops counting. The marker lets the boot
|
|
257
|
+
* path refuse that order rather than leave the limiter silently infinite.
|
|
258
|
+
*
|
|
259
|
+
* @internal
|
|
260
|
+
*/
|
|
261
|
+
export declare const IDEMPOTENCY_HOOK_MARKER: unique symbol;
|
|
249
262
|
/**
|
|
250
263
|
* Idempotency-key middleware. Mount it ahead of the routes that need
|
|
251
264
|
* exactly-once semantics under retries (typically the payment / write
|
package/dist/idempotency.js
CHANGED
|
@@ -294,6 +294,19 @@ function buildReplayResponse(stored, replayHeaderName) {
|
|
|
294
294
|
return markSchemaValidatedResponse(new Response(body, { status: stored.status, headers }));
|
|
295
295
|
}
|
|
296
296
|
// ---------- Middleware ----------
|
|
297
|
+
/**
|
|
298
|
+
* Internal Symbol stamped on the hooks {@link idempotency} returns, so `App`'s
|
|
299
|
+
* boot guards can see where a stored-response replay sits in a route's effective
|
|
300
|
+
* hook chain.
|
|
301
|
+
*
|
|
302
|
+
* A replay is returned from `beforeHandle` and ends the hook chain, which means
|
|
303
|
+
* anything enforcing from the *same* phase but mounted later never runs. That is
|
|
304
|
+
* how a `rateLimit()` behind a replay stops counting. The marker lets the boot
|
|
305
|
+
* path refuse that order rather than leave the limiter silently infinite.
|
|
306
|
+
*
|
|
307
|
+
* @internal
|
|
308
|
+
*/
|
|
309
|
+
export const IDEMPOTENCY_HOOK_MARKER = Symbol.for("daloyjs.idempotency.hook");
|
|
297
310
|
/**
|
|
298
311
|
* Idempotency-key middleware. Mount it ahead of the routes that need
|
|
299
312
|
* exactly-once semantics under retries (typically the payment / write
|
|
@@ -361,7 +374,7 @@ export function idempotency(opts = {}) {
|
|
|
361
374
|
store = new MemoryIdempotencyStore();
|
|
362
375
|
}
|
|
363
376
|
const keyPrefix = opts.groupId ? `${opts.groupId}:` : "";
|
|
364
|
-
|
|
377
|
+
const hooks = {
|
|
365
378
|
async beforeHandle(ctx) {
|
|
366
379
|
const method = ctx.request.method.toUpperCase();
|
|
367
380
|
if (!methods.has(method))
|
|
@@ -483,6 +496,8 @@ export function idempotency(opts = {}) {
|
|
|
483
496
|
return undefined;
|
|
484
497
|
},
|
|
485
498
|
};
|
|
499
|
+
hooks[IDEMPOTENCY_HOOK_MARKER] = true;
|
|
500
|
+
return hooks;
|
|
486
501
|
}
|
|
487
502
|
function isPromiseLike(value) {
|
|
488
503
|
return (value !== null &&
|
package/dist/sbom.cdx.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.5",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:85b1efae-7e9c-5b6d-8081-af5e65efa14a",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-
|
|
7
|
+
"timestamp": "2026-08-01T11:35:20.075Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "1.0.0-rc.
|
|
12
|
+
"version": "1.0.0-rc.9"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"authors": [
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
],
|
|
20
20
|
"component": {
|
|
21
21
|
"type": "library",
|
|
22
|
-
"bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.9",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "1.0.0-rc.
|
|
24
|
+
"version": "1.0.0-rc.9",
|
|
25
25
|
"description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
|
|
26
|
-
"purl": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
26
|
+
"purl": "pkg:npm/@daloyjs/core@1.0.0-rc.9",
|
|
27
27
|
"licenses": [
|
|
28
28
|
{
|
|
29
29
|
"license": {
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
}
|
|
47
47
|
],
|
|
48
48
|
"swid": {
|
|
49
|
-
"tagId": "swidtag--daloyjs-core-1.0.0-rc.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-1.0.0-rc.9",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "1.0.0-rc.
|
|
51
|
+
"version": "1.0.0-rc.9",
|
|
52
52
|
"tagVersion": 0,
|
|
53
53
|
"patch": false
|
|
54
54
|
}
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"components": [],
|
|
58
58
|
"dependencies": [
|
|
59
59
|
{
|
|
60
|
-
"ref": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@1.0.0-rc.9",
|
|
61
61
|
"dependsOn": []
|
|
62
62
|
}
|
|
63
63
|
]
|
package/dist/sbom.spdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"spdxVersion": "SPDX-2.3",
|
|
3
3
|
"dataLicense": "CC0-1.0",
|
|
4
4
|
"SPDXID": "SPDXRef-DOCUMENT",
|
|
5
|
-
"name": "@daloyjs/core-1.0.0-rc.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.
|
|
5
|
+
"name": "@daloyjs/core-1.0.0-rc.9",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.9-85b1efae-7e9c-5b6d-8081-af5e65efa14a",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-
|
|
8
|
+
"created": "2026-08-01T11:35:20.075Z",
|
|
9
9
|
"creators": [
|
|
10
10
|
"Tool: daloy-generate-sbom",
|
|
11
11
|
"Organization: DaloyJS"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"SPDXID": "SPDXRef-Package--daloyjs-core",
|
|
18
18
|
"name": "@daloyjs/core",
|
|
19
|
-
"versionInfo": "1.0.0-rc.
|
|
19
|
+
"versionInfo": "1.0.0-rc.9",
|
|
20
20
|
"downloadLocation": "https://github.com/daloyjs/daloy",
|
|
21
21
|
"filesAnalyzed": false,
|
|
22
22
|
"licenseConcluded": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
29
29
|
"referenceType": "purl",
|
|
30
|
-
"referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
30
|
+
"referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.9"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daloyjs/core",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.9",
|
|
4
4
|
"description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops \u2014 distributed via pnpm.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|