@daloyjs/core 0.35.1 → 0.35.2
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.d.ts +11 -0
- package/dist/adapters/node.js +90 -28
- package/dist/app.d.ts +34 -0
- package/dist/app.js +222 -89
- package/dist/errors.js +6 -2
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +2 -2
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security.d.ts +4 -19
- package/dist/security.js +36 -7
- package/package.json +1 -1
package/dist/adapters/node.d.ts
CHANGED
|
@@ -23,6 +23,17 @@ export interface NodeServerOptions {
|
|
|
23
23
|
* clients can spoof the scheme/host. Default: false.
|
|
24
24
|
*/
|
|
25
25
|
trustProxy?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Maximum declared `Content-Length` (in bytes) for which the Node adapter
|
|
28
|
+
* pre-buffers the request body into a `Uint8Array` before constructing the
|
|
29
|
+
* `Request`. Bodies above this threshold fall back to the streaming
|
|
30
|
+
* `Readable.toWeb(req)` path so the adapter never holds an unbounded buffer
|
|
31
|
+
* per in-flight request — important under high concurrency where N
|
|
32
|
+
* simultaneous large uploads would otherwise pin N × threshold bytes of
|
|
33
|
+
* memory. The threshold is independently capped by `App.bodyLimitBytes`,
|
|
34
|
+
* which is the actual security limit. Default: 256 KiB.
|
|
35
|
+
*/
|
|
36
|
+
bufferedBodyMaxBytes?: number;
|
|
26
37
|
}
|
|
27
38
|
/** Handle returned by {@link serve} exposing the underlying Node `Server` plus a `close()` for graceful shutdown. */
|
|
28
39
|
export interface NodeServerHandle {
|
package/dist/adapters/node.js
CHANGED
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { createServer, } from "node:http";
|
|
6
6
|
import { Readable } from "node:stream";
|
|
7
|
-
import { DALOY_RAW_BODY } from "../app.js";
|
|
7
|
+
import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY } from "../app.js";
|
|
8
8
|
import { FrameSink, encodeFrame, encodeClosePayload, encodeSendPayload, validateUpgrade, validateSelectedSubprotocol, checkWebSocketOrigin, WS_OPCODE, WS_CLOSE_CODE, WS_READY_STATE, WS_MAX_CONTROL_PAYLOAD, WebSocketProtocolError, WebSocketPayloadTooLargeError, } from "../websocket.js";
|
|
9
9
|
/** Start a Node.js HTTP (and optional WebSocket) server bound to the given {@link App}. */
|
|
10
10
|
export function serve(app, opts = {}) {
|
|
11
11
|
const trustProxy = opts.trustProxy === true;
|
|
12
|
+
const bufferedBodyMaxBytes = typeof opts.bufferedBodyMaxBytes === "number" && opts.bufferedBodyMaxBytes >= 0
|
|
13
|
+
? opts.bufferedBodyMaxBytes
|
|
14
|
+
: DEFAULT_BUFFERED_BODY_MAX_BYTES;
|
|
12
15
|
const server = createServer({ maxHeaderSize: opts.maxHeaderBytes ?? 16 * 1024 }, (req, res) => {
|
|
13
16
|
// GET/HEAD: no body work, dispatch directly. Keep this first so the GET
|
|
14
17
|
// hot path doesn't pay for any of the buffering bookkeeping below.
|
|
@@ -23,7 +26,7 @@ export function serve(app, opts = {}) {
|
|
|
23
26
|
// WHATWG-stream adapter that dominates POST throughput on Node.
|
|
24
27
|
const cl = req.headers["content-length"];
|
|
25
28
|
const n = cl ? Number(cl) : NaN;
|
|
26
|
-
if (Number.isFinite(n) && n >= 0 && n <=
|
|
29
|
+
if (Number.isFinite(n) && n >= 0 && n <= bufferedBodyMaxBytes) {
|
|
27
30
|
bufferRequestBody(req, n).then((bytes) => dispatchToApp(app, req, res, trustProxy, bytes), (e) => writeAdapterError(res, e));
|
|
28
31
|
return;
|
|
29
32
|
}
|
|
@@ -70,12 +73,14 @@ export function serve(app, opts = {}) {
|
|
|
70
73
|
return { server, port, close };
|
|
71
74
|
}
|
|
72
75
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
76
|
+
* Default pre-buffer ceiling for the Node adapter. 256 KiB is a compromise:
|
|
77
|
+
* large enough that the vast majority of JSON / form requests stay on the
|
|
78
|
+
* fast (Uint8Array) path, small enough that N concurrent in-flight bodies
|
|
79
|
+
* don't pin huge amounts of memory. Override via
|
|
80
|
+
* {@link NodeServerOptions.bufferedBodyMaxBytes}. The actual security cap
|
|
81
|
+
* on body size remains `App.bodyLimitBytes`.
|
|
77
82
|
*/
|
|
78
|
-
const
|
|
83
|
+
const DEFAULT_BUFFERED_BODY_MAX_BYTES = 256 * 1024;
|
|
79
84
|
function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
|
|
80
85
|
let request;
|
|
81
86
|
try {
|
|
@@ -111,21 +116,31 @@ function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
|
|
|
111
116
|
}
|
|
112
117
|
function bufferRequestBody(req, expected) {
|
|
113
118
|
return new Promise((resolve, reject) => {
|
|
114
|
-
|
|
119
|
+
// Pre-allocate to the declared Content-Length. The caller has already
|
|
120
|
+
// checked `expected <= BUFFERED_BODY_MAX_BYTES` (1 MiB) so this
|
|
121
|
+
// allocation is bounded and DoS-safe. Skipping the intermediate
|
|
122
|
+
// `chunks: Buffer[]` array + `Buffer.concat` avoids one full-body
|
|
123
|
+
// copy per request — significant at 1 MiB bodies under load.
|
|
124
|
+
// Use `Buffer.alloc` (zero-filled) rather than `Buffer.allocUnsafe`:
|
|
125
|
+
// the unsafe variant returns uninitialized memory and is forbidden by
|
|
126
|
+
// `verify:no-unsafe-buffer`. Any unwritten tail is sliced off below.
|
|
127
|
+
const out = expected > 0 ? Buffer.alloc(expected) : null;
|
|
115
128
|
let received = 0;
|
|
116
129
|
let settled = false;
|
|
117
130
|
const onData = (chunk) => {
|
|
118
131
|
if (settled)
|
|
119
132
|
return;
|
|
120
|
-
received
|
|
121
|
-
if (
|
|
133
|
+
const next = received + chunk.length;
|
|
134
|
+
if (next > expected) {
|
|
122
135
|
settled = true;
|
|
123
136
|
cleanup();
|
|
124
137
|
req.destroy();
|
|
125
138
|
reject(new Error("Request body exceeded declared Content-Length"));
|
|
126
139
|
return;
|
|
127
140
|
}
|
|
128
|
-
|
|
141
|
+
// out is non-null here because next > 0 implies expected > 0.
|
|
142
|
+
chunk.copy(out, received);
|
|
143
|
+
received = next;
|
|
129
144
|
};
|
|
130
145
|
const onEnd = () => {
|
|
131
146
|
if (settled)
|
|
@@ -136,7 +151,9 @@ function bufferRequestBody(req, expected) {
|
|
|
136
151
|
resolve(new Uint8Array(0));
|
|
137
152
|
return;
|
|
138
153
|
}
|
|
139
|
-
|
|
154
|
+
// Trust Content-Length: if the client under-delivered we still
|
|
155
|
+
// resolve the prefix actually received (matches prior behavior).
|
|
156
|
+
const buf = received === expected ? out : out.subarray(0, received);
|
|
140
157
|
resolve(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
|
|
141
158
|
};
|
|
142
159
|
const onErr = (err) => {
|
|
@@ -184,23 +201,40 @@ function toWebRequest(req, trustProxy, bufferedBody) {
|
|
|
184
201
|
const proto = forwardedProto ??
|
|
185
202
|
(req.socket.encrypted ? "https" : "http");
|
|
186
203
|
const url = `${proto}://${host}${req.url ?? "/"}`;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
204
|
+
// Build headers from `rawHeaders` (a flat [k0,v0,k1,v1,...] array) instead
|
|
205
|
+
// of the parsed `req.headers` object. This matches @hono/node-server's
|
|
206
|
+
// `newHeadersFromIncoming`: one `new Headers([[k,v],...])` constructor
|
|
207
|
+
// call rather than N `headers.set()` calls. It is also stricter for
|
|
208
|
+
// duplicate-Host smuggling — Node coalesces some singleton headers down
|
|
209
|
+
// to the first value on `req.headers`, but `rawHeaders` preserves every
|
|
210
|
+
// occurrence so `assertNoDuplicateSingletonHeaders` actually sees them.
|
|
211
|
+
// Skip HTTP/2 pseudo-headers (leading ':') defensively, even though
|
|
212
|
+
// node:http's createServer is HTTP/1.1 only today.
|
|
213
|
+
const rawHeaders = req.rawHeaders;
|
|
214
|
+
const headerPairs = [];
|
|
215
|
+
for (let i = 0; i < rawHeaders.length; i += 2) {
|
|
216
|
+
const k = rawHeaders[i];
|
|
217
|
+
if (k.charCodeAt(0) === 58 /* ':' */)
|
|
191
218
|
continue;
|
|
192
|
-
|
|
219
|
+
headerPairs.push([k, rawHeaders[i + 1]]);
|
|
193
220
|
}
|
|
221
|
+
const headers = new Headers(headerPairs);
|
|
194
222
|
const method = req.method ?? "GET";
|
|
195
223
|
if (method === "GET" || method === "HEAD") {
|
|
196
224
|
return new Request(url, { method, headers });
|
|
197
225
|
}
|
|
198
226
|
if (bufferedBody !== undefined) {
|
|
199
|
-
|
|
227
|
+
const req2 = new Request(url, {
|
|
200
228
|
method,
|
|
201
229
|
headers,
|
|
202
230
|
body: bufferedBody,
|
|
203
231
|
});
|
|
232
|
+
// Stash the validated bytes so readBodyLimited (and any other internal
|
|
233
|
+
// body reader) can skip the WHATWG ReadableStream reader loop. The
|
|
234
|
+
// adapter has already enforced BUFFERED_BODY_MAX_BYTES + Content-Length
|
|
235
|
+
// here; readBodyLimited re-checks against the caller's limit.
|
|
236
|
+
req2[DALOY_REQUEST_RAW_BODY] = bufferedBody;
|
|
237
|
+
return req2;
|
|
204
238
|
}
|
|
205
239
|
return new Request(url, {
|
|
206
240
|
method,
|
|
@@ -234,6 +268,30 @@ function sendWebResponse(res, out) {
|
|
|
234
268
|
}
|
|
235
269
|
return;
|
|
236
270
|
}
|
|
271
|
+
// Fast-path: handler returned a raw stream. Check before `!res.body` —
|
|
272
|
+
// a Node Readable is stashed alongside a null Response body, and the
|
|
273
|
+
// `new Response(null)` constructor also auto-sets `content-length: 0`
|
|
274
|
+
// which we must strip so Node falls back to chunked transfer-encoding.
|
|
275
|
+
const rawStream = res[DALOY_RAW_STREAM];
|
|
276
|
+
if (rawStream !== undefined) {
|
|
277
|
+
if (typeof rawStream.pipe === "function" && !(rawStream instanceof ReadableStream)) {
|
|
278
|
+
// Node `Readable` from the handler: skip the Web-stream bridge entirely
|
|
279
|
+
// and `.pipe(out)` like Fastify/Koa/Express do.
|
|
280
|
+
out.removeHeader("content-length");
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
const r = rawStream;
|
|
283
|
+
const onError = (err) => {
|
|
284
|
+
r.destroy();
|
|
285
|
+
reject(err);
|
|
286
|
+
};
|
|
287
|
+
r.once("error", onError);
|
|
288
|
+
out.once("error", onError);
|
|
289
|
+
out.once("finish", () => resolve());
|
|
290
|
+
r.pipe(out);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return pumpBody(rawStream, out);
|
|
294
|
+
}
|
|
237
295
|
if (!res.body) {
|
|
238
296
|
out.end();
|
|
239
297
|
return;
|
|
@@ -253,16 +311,20 @@ function sendWebResponse(res, out) {
|
|
|
253
311
|
}
|
|
254
312
|
return pumpBody(res.body, out);
|
|
255
313
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
314
|
+
function pumpBody(body, out) {
|
|
315
|
+
// Delegate to Node's native pipe: it honors backpressure and avoids the
|
|
316
|
+
// per-chunk microtask overhead of an explicit `await reader.read()` loop.
|
|
317
|
+
return new Promise((resolve, reject) => {
|
|
318
|
+
const readable = Readable.fromWeb(body);
|
|
319
|
+
const onError = (err) => {
|
|
320
|
+
readable.destroy();
|
|
321
|
+
reject(err);
|
|
322
|
+
};
|
|
323
|
+
readable.once("error", onError);
|
|
324
|
+
out.once("error", onError);
|
|
325
|
+
out.once("finish", () => resolve());
|
|
326
|
+
readable.pipe(out);
|
|
327
|
+
});
|
|
266
328
|
}
|
|
267
329
|
// ---------- WebSocket upgrade ----------
|
|
268
330
|
async function handleUpgrade(app, req, socket, head, trustProxy) {
|
package/dist/app.d.ts
CHANGED
|
@@ -480,6 +480,25 @@ export interface IntrospectedRoute {
|
|
|
480
480
|
* implementation detail — userland code should never depend on it.
|
|
481
481
|
*/
|
|
482
482
|
export declare const DALOY_RAW_BODY: unique symbol;
|
|
483
|
+
/**
|
|
484
|
+
* Internal Symbol used by adapters to stash a pre-buffered request body on
|
|
485
|
+
* the `Request` instance. When set, {@link readBodyLimited} skips the
|
|
486
|
+
* `ReadableStream` reader loop and returns the cached bytes directly after
|
|
487
|
+
* re-checking them against the caller-supplied limit. Adapters MUST only
|
|
488
|
+
* attach bytes they have already validated against the configured
|
|
489
|
+
* {@link AppOptions.bodyLimitBytes}; the limit re-check in
|
|
490
|
+
* `readBodyLimited` is defense-in-depth, not the primary cap. Module-public
|
|
491
|
+
* so first-party adapters can opt in; not part of the userland API surface.
|
|
492
|
+
*/
|
|
493
|
+
export declare const DALOY_REQUEST_RAW_BODY: unique symbol;
|
|
494
|
+
/**
|
|
495
|
+
* Internal Symbol set by handlers/serializers to attach a raw stream
|
|
496
|
+
* (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
|
|
497
|
+
* adapter pipes the stream straight to the socket, skipping the
|
|
498
|
+
* Web-stream reader bridge. Module-public so first-party adapters can
|
|
499
|
+
* opt in; userland code should not depend on it.
|
|
500
|
+
*/
|
|
501
|
+
export declare const DALOY_RAW_STREAM: unique symbol;
|
|
483
502
|
/**
|
|
484
503
|
* Contract-first HTTP application.
|
|
485
504
|
*
|
|
@@ -535,6 +554,14 @@ export declare class App {
|
|
|
535
554
|
/** Public registry: enables OpenAPI gen, typed-client gen, dead-route detection. */
|
|
536
555
|
readonly routes: RouteDefinition<any, any, any, any>[];
|
|
537
556
|
private router;
|
|
557
|
+
/**
|
|
558
|
+
* Memoized result of `isProduction()`. The inputs (`options.env`,
|
|
559
|
+
* `options.production`, `process.env.NODE_ENV`) cannot change between
|
|
560
|
+
* the moment a route is dispatched and the moment its error is rendered,
|
|
561
|
+
* so reading `process.env.NODE_ENV` on every error response is wasted
|
|
562
|
+
* work in the hot path. Computed lazily on first read.
|
|
563
|
+
*/
|
|
564
|
+
private _productionCache;
|
|
538
565
|
/** WebSocket route registry. Adapters look up handlers via `app.webSocketRoutes.find()`. */
|
|
539
566
|
readonly webSocketRoutes: WebSocketRegistry;
|
|
540
567
|
private prefix;
|
|
@@ -545,6 +572,13 @@ export declare class App {
|
|
|
545
572
|
private routeSecurityMarkers;
|
|
546
573
|
/** Decorator bag merged into ctx.state on every request. */
|
|
547
574
|
private decorations;
|
|
575
|
+
/**
|
|
576
|
+
* Count of own keys on {@link decorations}. Tracked alongside the bag so the
|
|
577
|
+
* dispatch hot path can take a `count === 0` fast path and skip the
|
|
578
|
+
* `Object.assign` spread on the common case (no `app.decorate()` calls).
|
|
579
|
+
* Updated only when {@link decorate} mutates the bag.
|
|
580
|
+
*/
|
|
581
|
+
private decorationsCount;
|
|
548
582
|
private installedPlugins;
|
|
549
583
|
private closeHooks;
|
|
550
584
|
private closeHooksRun;
|
package/dist/app.js
CHANGED
|
@@ -89,6 +89,25 @@ const TEXT_ENCODER = new TextEncoder();
|
|
|
89
89
|
* implementation detail — userland code should never depend on it.
|
|
90
90
|
*/
|
|
91
91
|
export const DALOY_RAW_BODY = Symbol.for("daloyjs.response.rawBody");
|
|
92
|
+
/**
|
|
93
|
+
* Internal Symbol used by adapters to stash a pre-buffered request body on
|
|
94
|
+
* the `Request` instance. When set, {@link readBodyLimited} skips the
|
|
95
|
+
* `ReadableStream` reader loop and returns the cached bytes directly after
|
|
96
|
+
* re-checking them against the caller-supplied limit. Adapters MUST only
|
|
97
|
+
* attach bytes they have already validated against the configured
|
|
98
|
+
* {@link AppOptions.bodyLimitBytes}; the limit re-check in
|
|
99
|
+
* `readBodyLimited` is defense-in-depth, not the primary cap. Module-public
|
|
100
|
+
* so first-party adapters can opt in; not part of the userland API surface.
|
|
101
|
+
*/
|
|
102
|
+
export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
|
|
103
|
+
/**
|
|
104
|
+
* Internal Symbol set by handlers/serializers to attach a raw stream
|
|
105
|
+
* (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
|
|
106
|
+
* adapter pipes the stream straight to the socket, skipping the
|
|
107
|
+
* Web-stream reader bridge. Module-public so first-party adapters can
|
|
108
|
+
* opt in; userland code should not depend on it.
|
|
109
|
+
*/
|
|
110
|
+
export const DALOY_RAW_STREAM = Symbol.for("daloyjs.response.rawStream");
|
|
92
111
|
/**
|
|
93
112
|
* Contract-first HTTP application.
|
|
94
113
|
*
|
|
@@ -144,6 +163,14 @@ export class App {
|
|
|
144
163
|
/** Public registry: enables OpenAPI gen, typed-client gen, dead-route detection. */
|
|
145
164
|
routes = [];
|
|
146
165
|
router = new Router();
|
|
166
|
+
/**
|
|
167
|
+
* Memoized result of `isProduction()`. The inputs (`options.env`,
|
|
168
|
+
* `options.production`, `process.env.NODE_ENV`) cannot change between
|
|
169
|
+
* the moment a route is dispatched and the moment its error is rendered,
|
|
170
|
+
* so reading `process.env.NODE_ENV` on every error response is wasted
|
|
171
|
+
* work in the hot path. Computed lazily on first read.
|
|
172
|
+
*/
|
|
173
|
+
_productionCache;
|
|
147
174
|
/** WebSocket route registry. Adapters look up handlers via `app.webSocketRoutes.find()`. */
|
|
148
175
|
webSocketRoutes = new WebSocketRegistry();
|
|
149
176
|
prefix = "";
|
|
@@ -154,6 +181,13 @@ export class App {
|
|
|
154
181
|
routeSecurityMarkers = [];
|
|
155
182
|
/** Decorator bag merged into ctx.state on every request. */
|
|
156
183
|
decorations = {};
|
|
184
|
+
/**
|
|
185
|
+
* Count of own keys on {@link decorations}. Tracked alongside the bag so the
|
|
186
|
+
* dispatch hot path can take a `count === 0` fast path and skip the
|
|
187
|
+
* `Object.assign` spread on the common case (no `app.decorate()` calls).
|
|
188
|
+
* Updated only when {@link decorate} mutates the bag.
|
|
189
|
+
*/
|
|
190
|
+
decorationsCount = 0;
|
|
157
191
|
installedPlugins = new Set();
|
|
158
192
|
closeHooks = [];
|
|
159
193
|
closeHooksRun = false;
|
|
@@ -383,13 +417,20 @@ export class App {
|
|
|
383
417
|
* auto-mount and error response detail stripping.
|
|
384
418
|
*/
|
|
385
419
|
isProduction() {
|
|
420
|
+
if (this._productionCache !== undefined)
|
|
421
|
+
return this._productionCache;
|
|
422
|
+
let v;
|
|
386
423
|
if (this.options.env !== undefined)
|
|
387
|
-
|
|
388
|
-
if (this.options.production !== undefined)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
424
|
+
v = this.options.env === "production";
|
|
425
|
+
else if (this.options.production !== undefined)
|
|
426
|
+
v = this.options.production;
|
|
427
|
+
else
|
|
428
|
+
v =
|
|
429
|
+
typeof process !== "undefined" &&
|
|
430
|
+
typeof process.env !== "undefined" &&
|
|
431
|
+
process.env.NODE_ENV === "production";
|
|
432
|
+
this._productionCache = v;
|
|
433
|
+
return v;
|
|
393
434
|
}
|
|
394
435
|
/**
|
|
395
436
|
* Cross-origin guard. Rejects state-changing requests (`POST` /
|
|
@@ -1230,7 +1271,10 @@ export class App {
|
|
|
1230
1271
|
if (opts.override === true && Object.prototype.hasOwnProperty.call(this.decorations, key)) {
|
|
1231
1272
|
this.log.warn({ event: "decorate.override", key }, `decorate("${key}") replaced an existing decoration.`);
|
|
1232
1273
|
}
|
|
1274
|
+
const hadKey = Object.prototype.hasOwnProperty.call(this.decorations, key);
|
|
1233
1275
|
this.decorations[key] = value;
|
|
1276
|
+
if (!hadKey)
|
|
1277
|
+
this.decorationsCount++;
|
|
1234
1278
|
return this;
|
|
1235
1279
|
}
|
|
1236
1280
|
/**
|
|
@@ -1456,11 +1500,17 @@ export class App {
|
|
|
1456
1500
|
}
|
|
1457
1501
|
this.inflight++;
|
|
1458
1502
|
const requestId = randomId();
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1503
|
+
// Skip the per-request child-logger allocation when the app was
|
|
1504
|
+
// constructed with `{ logger: false }`. noopLogger.child() returns
|
|
1505
|
+
// itself, so the binding is wasted work on every request.
|
|
1506
|
+
const baseLog = this.log;
|
|
1507
|
+
const log = baseLog === noopLogger
|
|
1508
|
+
? noopLogger
|
|
1509
|
+
: baseLog.child({
|
|
1510
|
+
requestId,
|
|
1511
|
+
method: request.method,
|
|
1512
|
+
url: request.url,
|
|
1513
|
+
});
|
|
1464
1514
|
const stripFingerprint = this.options.stripServerHeaders !== false;
|
|
1465
1515
|
let ctx;
|
|
1466
1516
|
const globalHooks = this.globalHooks;
|
|
@@ -1496,12 +1546,11 @@ export class App {
|
|
|
1496
1546
|
this.assertCrossOriginAllowed(request, requestUrl, method, [...this.globalCorsAllows, ...this.corsOriginAllows]);
|
|
1497
1547
|
}
|
|
1498
1548
|
if (!match || internalHidden) {
|
|
1499
|
-
const url404 = getUrl();
|
|
1500
1549
|
if (internalHidden) {
|
|
1501
1550
|
// Don't leak existence via 405/Allow header. Always 404.
|
|
1502
|
-
throw new NotFoundError(`No route for ${request.method} ${
|
|
1551
|
+
throw new NotFoundError(`No route for ${request.method} ${pathname}`);
|
|
1503
1552
|
}
|
|
1504
|
-
const rawAllowed = this.router.allowedMethods(
|
|
1553
|
+
const rawAllowed = this.router.allowedMethods(pathname);
|
|
1505
1554
|
// Filter out methods whose route definitions are marked
|
|
1506
1555
|
// `internal: true` unless the caller explicitly opted in via
|
|
1507
1556
|
// app.inject(). This prevents 405/Allow from leaking the
|
|
@@ -1509,19 +1558,52 @@ export class App {
|
|
|
1509
1558
|
const allowed = opts.allowInternal
|
|
1510
1559
|
? rawAllowed
|
|
1511
1560
|
: rawAllowed.filter((m) => {
|
|
1512
|
-
const candidate = this.router.find(m,
|
|
1561
|
+
const candidate = this.router.find(m, pathname);
|
|
1513
1562
|
return candidate?.handler.def.internal !== true;
|
|
1514
1563
|
});
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1564
|
+
// On the throw paths (405 -> MethodNotAllowedError, 404 -> NotFoundError)
|
|
1565
|
+
// ctx is only read by a registered onError hook. Build it lazily so
|
|
1566
|
+
// the common no-hook 404 doesn't allocate a context object, spread
|
|
1567
|
+
// `decorations`, iterate headers, or materialize a `Headers`
|
|
1568
|
+
// instance just to be thrown away. The 204 OPTIONS preflight branch
|
|
1569
|
+
// below uses its own `synthCtx`, so this skip is safe for it too.
|
|
1570
|
+
const needsCtx = allowed.length > 0 && method === "OPTIONS"
|
|
1571
|
+
? false // OPTIONS path builds synthCtx
|
|
1572
|
+
: activeErrorHook !== undefined;
|
|
1573
|
+
if (needsCtx) {
|
|
1574
|
+
// `query` and `headers` are materialized lazily — the common
|
|
1575
|
+
// `onError` hook reads `requestId` / path and never touches them,
|
|
1576
|
+
// so we skip `new URL(...)` + `Object.fromEntries` + the
|
|
1577
|
+
// `Headers.forEach` on 404 GETs entirely. Setters preserve write
|
|
1578
|
+
// semantics for hooks that reassign these fields.
|
|
1579
|
+
let _query;
|
|
1580
|
+
let _headers;
|
|
1581
|
+
const reqRef = request;
|
|
1582
|
+
const reqUrl = requestUrl;
|
|
1583
|
+
ctx = {
|
|
1584
|
+
request,
|
|
1585
|
+
params: {},
|
|
1586
|
+
get query() {
|
|
1587
|
+
if (_query !== undefined)
|
|
1588
|
+
return _query;
|
|
1589
|
+
const qi = reqUrl.indexOf("?");
|
|
1590
|
+
if (qi === -1)
|
|
1591
|
+
return (_query = {});
|
|
1592
|
+
const hi = reqUrl.indexOf("#", qi + 1);
|
|
1593
|
+
const qs = hi === -1 ? reqUrl.slice(qi + 1) : reqUrl.slice(qi + 1, hi);
|
|
1594
|
+
return (_query = Object.fromEntries(new URLSearchParams(qs)));
|
|
1595
|
+
},
|
|
1596
|
+
set query(v) { _query = v; },
|
|
1597
|
+
get headers() {
|
|
1598
|
+
return (_headers ??= headersToObject(reqRef.headers));
|
|
1599
|
+
},
|
|
1600
|
+
set headers(v) { _headers = v; },
|
|
1601
|
+
body: undefined,
|
|
1602
|
+
state: { ...this.decorations, requestId, log },
|
|
1603
|
+
set: { headers: new Headers() },
|
|
1604
|
+
};
|
|
1605
|
+
ctx.set.headers.set("x-request-id", requestId);
|
|
1606
|
+
}
|
|
1525
1607
|
if (allowed.length > 0) {
|
|
1526
1608
|
if (method === "OPTIONS") {
|
|
1527
1609
|
// Synthesize a preflight: let global hooks (e.g. CORS) intercept;
|
|
@@ -1557,7 +1639,7 @@ export class App {
|
|
|
1557
1639
|
}
|
|
1558
1640
|
throw new MethodNotAllowedError(allowed);
|
|
1559
1641
|
}
|
|
1560
|
-
throw new NotFoundError(`No route for ${request.method} ${
|
|
1642
|
+
throw new NotFoundError(`No route for ${request.method} ${pathname}`);
|
|
1561
1643
|
}
|
|
1562
1644
|
const { def, hooks, mergedHooks: allHooks, hasFinalizeHook } = match.handler;
|
|
1563
1645
|
activeErrorHook = allHooks.onError;
|
|
@@ -1569,7 +1651,14 @@ export class App {
|
|
|
1569
1651
|
await routeOnRequestResult;
|
|
1570
1652
|
}
|
|
1571
1653
|
ctx = await buildContext(request, getUrl, match.params, def, this.options);
|
|
1572
|
-
|
|
1654
|
+
// Stable two-field write keeps `ctx.state`'s hidden class consistent across
|
|
1655
|
+
// requests for the common no-decorator case. The decorations spread only
|
|
1656
|
+
// fires when `app.decorate()` was actually called.
|
|
1657
|
+
const state = ctx.state;
|
|
1658
|
+
state.requestId = requestId;
|
|
1659
|
+
state.log = log;
|
|
1660
|
+
if (this.decorationsCount !== 0)
|
|
1661
|
+
Object.assign(state, this.decorations);
|
|
1573
1662
|
if (allHooks.beforeHandle !== undefined) {
|
|
1574
1663
|
const beforeResult = allHooks.beforeHandle(ctx);
|
|
1575
1664
|
const before = isPromiseLike(beforeResult) ? await beforeResult : beforeResult;
|
|
@@ -1617,7 +1706,15 @@ export class App {
|
|
|
1617
1706
|
return finalized;
|
|
1618
1707
|
}
|
|
1619
1708
|
catch (err) {
|
|
1620
|
-
|
|
1709
|
+
// Skip the unconditional `await activeErrorHook?.(...)`: when no
|
|
1710
|
+
// error hook is registered (the common case), `await undefined`
|
|
1711
|
+
// still schedules a microtask. Branching first lets the hot error
|
|
1712
|
+
// path stay synchronous.
|
|
1713
|
+
let handled;
|
|
1714
|
+
if (activeErrorHook !== undefined) {
|
|
1715
|
+
const r = activeErrorHook(err, ctx);
|
|
1716
|
+
handled = isPromiseLike(r) ? await r : r;
|
|
1717
|
+
}
|
|
1621
1718
|
if (handled instanceof Response) {
|
|
1622
1719
|
if (ctx)
|
|
1623
1720
|
copyContextHeaders(ctx, handled);
|
|
@@ -1632,10 +1729,12 @@ export class App {
|
|
|
1632
1729
|
// the request at `disconnectStatusCode` (default 499) instead of
|
|
1633
1730
|
// letting an AbortError bubble up as a generic 5xx. Logged at `info`
|
|
1634
1731
|
// so disconnect storms do not look like service incidents.
|
|
1635
|
-
|
|
1732
|
+
// `err instanceof HttpError` first: the framework's own thrown
|
|
1733
|
+
// problem errors short-circuit before any signal/option lookup.
|
|
1734
|
+
const isHttp = err instanceof HttpError;
|
|
1735
|
+
const disconnectCode = isHttp ? 0 : (this.options.disconnectStatusCode ?? 499);
|
|
1636
1736
|
if (disconnectCode > 0 &&
|
|
1637
|
-
request.signal?.aborted === true
|
|
1638
|
-
!(err instanceof HttpError)) {
|
|
1737
|
+
request.signal?.aborted === true) {
|
|
1639
1738
|
log.info({ event: "request.disconnected", status: disconnectCode }, "Client disconnected before response was sent");
|
|
1640
1739
|
const res = new Response(null, {
|
|
1641
1740
|
status: disconnectCode,
|
|
@@ -1651,7 +1750,7 @@ export class App {
|
|
|
1651
1750
|
onResponse: activeResponseHook,
|
|
1652
1751
|
}, stripFingerprint);
|
|
1653
1752
|
}
|
|
1654
|
-
const httpErr =
|
|
1753
|
+
const httpErr = isHttp
|
|
1655
1754
|
? err
|
|
1656
1755
|
: new InternalError(err instanceof Error ? err.message : "Unexpected error");
|
|
1657
1756
|
if (httpErr.status >= 500)
|
|
@@ -2166,37 +2265,39 @@ function pipeline(fns) {
|
|
|
2166
2265
|
});
|
|
2167
2266
|
}
|
|
2168
2267
|
/**
|
|
2169
|
-
*
|
|
2170
|
-
*
|
|
2171
|
-
*
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2268
|
+
* Per-request response-side `set` object. Implemented as a class so every
|
|
2269
|
+
* instance shares one V8 hidden class — the previous {@link Object.defineProperty}
|
|
2270
|
+
* based factory installed fresh accessor descriptors on every request, which
|
|
2271
|
+
* forced V8 to treat each `ctx.set` as a unique shape and tanked inline-cache
|
|
2272
|
+
* sharing in `copyContextHeaders` and downstream hooks.
|
|
2273
|
+
*
|
|
2274
|
+
* `_h` is a public-but-underscored slot rather than a `#`-private field so
|
|
2275
|
+
* the compiled output stays target-agnostic; consumer code that reads
|
|
2276
|
+
* `ctx.set.headers` flows through the prototype getter and never sees it.
|
|
2277
|
+
* `touched` replaces the prior `SET_HEADERS_TOUCHED` symbol — same intent,
|
|
2278
|
+
* stable field offset.
|
|
2174
2279
|
*/
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
h = new Headers();
|
|
2183
|
-
set[SET_HEADERS_TOUCHED] = true;
|
|
2184
|
-
}
|
|
2280
|
+
class LazyResponseSet {
|
|
2281
|
+
status = undefined;
|
|
2282
|
+
_h = undefined;
|
|
2283
|
+
touched = false;
|
|
2284
|
+
get headers() {
|
|
2285
|
+
const h = this._h;
|
|
2286
|
+
if (h !== undefined)
|
|
2185
2287
|
return h;
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
});
|
|
2194
|
-
return set;
|
|
2288
|
+
this.touched = true;
|
|
2289
|
+
return (this._h = new Headers());
|
|
2290
|
+
}
|
|
2291
|
+
set headers(v) {
|
|
2292
|
+
this._h = v;
|
|
2293
|
+
this.touched = true;
|
|
2294
|
+
}
|
|
2195
2295
|
}
|
|
2196
2296
|
function copyContextHeaders(ctx, res) {
|
|
2197
|
-
|
|
2297
|
+
const set = ctx.set;
|
|
2298
|
+
if (set.touched !== true)
|
|
2198
2299
|
return;
|
|
2199
|
-
|
|
2300
|
+
set._h.forEach((v, k) => {
|
|
2200
2301
|
if (!res.headers.has(k))
|
|
2201
2302
|
res.headers.set(k, v);
|
|
2202
2303
|
});
|
|
@@ -2204,8 +2305,62 @@ function copyContextHeaders(ctx, res) {
|
|
|
2204
2305
|
function hasRequestSchema(request, key) {
|
|
2205
2306
|
return !!request && !!request[key];
|
|
2206
2307
|
}
|
|
2308
|
+
/**
|
|
2309
|
+
* Stable-shape per-request context. All fields are initialised in fixed
|
|
2310
|
+
* order in the constructor so every dispatched request produces an instance
|
|
2311
|
+
* with the same V8 hidden class — replacing the prior object-literal +
|
|
2312
|
+
* {@link Object.defineProperty} pattern, which gave each request a unique
|
|
2313
|
+
* shape and forced inline-cache misses through every downstream hook.
|
|
2314
|
+
*
|
|
2315
|
+
* `query` / `headers` are prototype getters that either return the value
|
|
2316
|
+
* already stored on `_q` / `_h` (set eagerly by schema validation) or
|
|
2317
|
+
* materialise it lazily from the captured builder closure on first read.
|
|
2318
|
+
* The `_qSet` / `_hSet` flags distinguish "validated, value cached" from
|
|
2319
|
+
* "not yet read" so setters from user hooks remain observable.
|
|
2320
|
+
*/
|
|
2321
|
+
class RequestContext {
|
|
2322
|
+
request;
|
|
2323
|
+
params;
|
|
2324
|
+
body = undefined;
|
|
2325
|
+
state;
|
|
2326
|
+
set;
|
|
2327
|
+
_q = undefined;
|
|
2328
|
+
_qBuilder = undefined;
|
|
2329
|
+
_qSet = false;
|
|
2330
|
+
_h = undefined;
|
|
2331
|
+
_hBuilder = undefined;
|
|
2332
|
+
_hSet = false;
|
|
2333
|
+
constructor(request, params, state, set) {
|
|
2334
|
+
this.request = request;
|
|
2335
|
+
this.params = params;
|
|
2336
|
+
this.state = state;
|
|
2337
|
+
this.set = set;
|
|
2338
|
+
}
|
|
2339
|
+
get query() {
|
|
2340
|
+
if (this._qSet)
|
|
2341
|
+
return this._q;
|
|
2342
|
+
const b = this._qBuilder;
|
|
2343
|
+
this._qSet = true;
|
|
2344
|
+
return (this._q = b !== undefined ? b() : undefined);
|
|
2345
|
+
}
|
|
2346
|
+
set query(v) {
|
|
2347
|
+
this._q = v;
|
|
2348
|
+
this._qSet = true;
|
|
2349
|
+
}
|
|
2350
|
+
get headers() {
|
|
2351
|
+
if (this._hSet)
|
|
2352
|
+
return this._h;
|
|
2353
|
+
const b = this._hBuilder;
|
|
2354
|
+
this._hSet = true;
|
|
2355
|
+
return (this._h = b !== undefined ? b() : undefined);
|
|
2356
|
+
}
|
|
2357
|
+
set headers(v) {
|
|
2358
|
+
this._h = v;
|
|
2359
|
+
this._hSet = true;
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2207
2362
|
function buildContext(request, getUrl, rawParams, def, opts) {
|
|
2208
|
-
const set =
|
|
2363
|
+
const set = new LazyResponseSet();
|
|
2209
2364
|
const hasHeadersSchema = !!def.request?.headers;
|
|
2210
2365
|
const hasQuerySchema = !!def.request?.query;
|
|
2211
2366
|
let headersObj;
|
|
@@ -2218,24 +2373,21 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
2218
2373
|
let body = undefined;
|
|
2219
2374
|
const hasSchema = def.request?.params || def.request?.query || def.request?.headers || def.request?.body;
|
|
2220
2375
|
const finishContext = () => {
|
|
2221
|
-
const ctx = {
|
|
2222
|
-
|
|
2223
|
-
params,
|
|
2224
|
-
body,
|
|
2225
|
-
state: {},
|
|
2226
|
-
set,
|
|
2227
|
-
};
|
|
2376
|
+
const ctx = new RequestContext(request, params, {}, set);
|
|
2377
|
+
ctx.body = body;
|
|
2228
2378
|
if (hasQuerySchema) {
|
|
2229
|
-
ctx.
|
|
2379
|
+
ctx._q = query;
|
|
2380
|
+
ctx._qSet = true;
|
|
2230
2381
|
}
|
|
2231
2382
|
else {
|
|
2232
|
-
|
|
2383
|
+
ctx._qBuilder = buildQuery;
|
|
2233
2384
|
}
|
|
2234
2385
|
if (hasHeadersSchema) {
|
|
2235
|
-
ctx.
|
|
2386
|
+
ctx._h = headers;
|
|
2387
|
+
ctx._hSet = true;
|
|
2236
2388
|
}
|
|
2237
2389
|
else {
|
|
2238
|
-
|
|
2390
|
+
ctx._hBuilder = buildHeaders;
|
|
2239
2391
|
}
|
|
2240
2392
|
return ctx;
|
|
2241
2393
|
};
|
|
@@ -2280,25 +2432,6 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
2280
2432
|
return finishContext();
|
|
2281
2433
|
})();
|
|
2282
2434
|
}
|
|
2283
|
-
function defineLazyContextProperty(ctx, key, build) {
|
|
2284
|
-
let initialized = false;
|
|
2285
|
-
let value;
|
|
2286
|
-
Object.defineProperty(ctx, key, {
|
|
2287
|
-
get() {
|
|
2288
|
-
if (!initialized) {
|
|
2289
|
-
value = build();
|
|
2290
|
-
initialized = true;
|
|
2291
|
-
}
|
|
2292
|
-
return value;
|
|
2293
|
-
},
|
|
2294
|
-
set(next) {
|
|
2295
|
-
value = next;
|
|
2296
|
-
initialized = true;
|
|
2297
|
-
},
|
|
2298
|
-
enumerable: true,
|
|
2299
|
-
configurable: true,
|
|
2300
|
-
});
|
|
2301
|
-
}
|
|
2302
2435
|
function headersToObject(h) {
|
|
2303
2436
|
const o = {};
|
|
2304
2437
|
h.forEach((v, k) => {
|
package/dist/errors.js
CHANGED
|
@@ -202,8 +202,12 @@ export class HttpError extends Error {
|
|
|
202
202
|
if (opts.requestId)
|
|
203
203
|
out.instance = `urn:request:${opts.requestId}`;
|
|
204
204
|
const headers = new Headers({ "content-type": "application/problem+json" });
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
// Skip the Object.entries({}) churn on the common case where the
|
|
206
|
+
// error was constructed without extra response headers.
|
|
207
|
+
if (this.headers !== undefined) {
|
|
208
|
+
for (const [name, value] of Object.entries(this.headers)) {
|
|
209
|
+
headers.set(name, value);
|
|
210
|
+
}
|
|
207
211
|
}
|
|
208
212
|
// Merge Context.set.headers (CSRF rotation, session renewal,
|
|
209
213
|
// request-id, secureHeaders output) without overriding headers the
|
package/dist/safe-redirect.d.ts
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* });
|
|
39
39
|
* ```
|
|
40
40
|
*
|
|
41
|
-
* @since 0.35.
|
|
41
|
+
* @since 0.35.2
|
|
42
42
|
*/
|
|
43
43
|
/** Reason an open-redirect candidate was refused. */
|
|
44
44
|
export type SafeRedirectBlockReason = "empty-target" | "invalid-control-characters" | "protocol-relative" | "backslash-path" | "path-not-allowed" | "origin-not-allowed" | "scheme-not-allowed" | "parse-failed";
|
|
@@ -86,6 +86,6 @@ export interface SafeRedirectOptions {
|
|
|
86
86
|
* @param target - User-supplied URL candidate (path or absolute URL).
|
|
87
87
|
* @param options - Allowlist + response configuration.
|
|
88
88
|
*
|
|
89
|
-
* @since 0.35.
|
|
89
|
+
* @since 0.35.2
|
|
90
90
|
*/
|
|
91
91
|
export declare function safeRedirect(target: string, options?: SafeRedirectOptions): Response;
|
package/dist/safe-redirect.js
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* });
|
|
39
39
|
* ```
|
|
40
40
|
*
|
|
41
|
-
* @since 0.35.
|
|
41
|
+
* @since 0.35.2
|
|
42
42
|
*/
|
|
43
43
|
/** Thrown when {@link safeRedirect} refuses a candidate URL and no `fallback` is configured. */
|
|
44
44
|
export class OpenRedirectBlockedError extends Error {
|
|
@@ -126,7 +126,7 @@ function classify(target, allowedPaths, allowedOrigins) {
|
|
|
126
126
|
* @param target - User-supplied URL candidate (path or absolute URL).
|
|
127
127
|
* @param options - Allowlist + response configuration.
|
|
128
128
|
*
|
|
129
|
-
* @since 0.35.
|
|
129
|
+
* @since 0.35.2
|
|
130
130
|
*/
|
|
131
131
|
export function safeRedirect(target, options = {}) {
|
|
132
132
|
const allowedPaths = options.allowedPaths ?? [];
|
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:1d9fa950-874b-5a89-9b39-98f1bbdfdb30",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-05-
|
|
7
|
+
"timestamp": "2026-05-28T07:51:16.251Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "0.35.
|
|
12
|
+
"version": "0.35.2"
|
|
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@0.35.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@0.35.2",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "0.35.
|
|
24
|
+
"version": "0.35.2",
|
|
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@0.35.
|
|
26
|
+
"purl": "pkg:npm/@daloyjs/core@0.35.2",
|
|
27
27
|
"licenses": [
|
|
28
28
|
{
|
|
29
29
|
"license": {
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
}
|
|
47
47
|
],
|
|
48
48
|
"swid": {
|
|
49
|
-
"tagId": "swidtag--daloyjs-core-0.35.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-0.35.2",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "0.35.
|
|
51
|
+
"version": "0.35.2",
|
|
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@0.35.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@0.35.2",
|
|
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-0.35.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.35.
|
|
5
|
+
"name": "@daloyjs/core-0.35.2",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.35.2-1d9fa950-874b-5a89-9b39-98f1bbdfdb30",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-05-
|
|
8
|
+
"created": "2026-05-28T07:51:16.251Z",
|
|
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": "0.35.
|
|
19
|
+
"versionInfo": "0.35.2",
|
|
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@0.35.
|
|
30
|
+
"referenceLocator": "pkg:npm/@daloyjs/core@0.35.2"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
package/dist/security.d.ts
CHANGED
|
@@ -85,21 +85,6 @@ export declare function sanitizeHeaderValue(value: string): string;
|
|
|
85
85
|
* @since 0.1.0
|
|
86
86
|
*/
|
|
87
87
|
export declare function timingSafeEqual(a: string, b: string): boolean;
|
|
88
|
-
/**
|
|
89
|
-
* Generate a cryptographically strong, URL-safe identifier (~22 chars).
|
|
90
|
-
*
|
|
91
|
-
* Uses Web Crypto's `crypto.randomUUID()` when available, falling back to
|
|
92
|
-
* 16 random bytes via `crypto.getRandomValues()`. The last-resort fallback
|
|
93
|
-
* (timestamp + `Math.random()`) only triggers in environments without
|
|
94
|
-
* WebCrypto, which is none of Node 20+/Bun/Deno/Cloudflare Workers/Vercel
|
|
95
|
-
* Edge.
|
|
96
|
-
*
|
|
97
|
-
* Suitable for request ids, session ids, and short-lived correlation tokens.
|
|
98
|
-
* Do not use for long-lived secrets unless you also sign or wrap them.
|
|
99
|
-
*
|
|
100
|
-
* @returns A random URL-safe id string.
|
|
101
|
-
* @since 0.1.0
|
|
102
|
-
*/
|
|
103
88
|
export declare function randomId(): string;
|
|
104
89
|
/**
|
|
105
90
|
* Header names that MUST appear at most once on a request per RFC 7230.
|
|
@@ -338,7 +323,7 @@ export declare function signWebhookPayload(opts: {
|
|
|
338
323
|
* @returns A sanitized basename safe to combine with a trusted directory.
|
|
339
324
|
* @throws {BadRequestError} When the input reduces to an empty string or
|
|
340
325
|
* matches a Windows-reserved device name.
|
|
341
|
-
* @since 0.35.
|
|
326
|
+
* @since 0.35.0
|
|
342
327
|
*/
|
|
343
328
|
export declare function sanitizeFilename(name: string): string;
|
|
344
329
|
/**
|
|
@@ -365,7 +350,7 @@ export declare function sanitizeFilename(name: string): string;
|
|
|
365
350
|
* @param input - The candidate relative path.
|
|
366
351
|
* @returns The input unchanged when safe.
|
|
367
352
|
* @throws {BadRequestError} When the path could escape its base directory.
|
|
368
|
-
* @since 0.35.
|
|
353
|
+
* @since 0.35.0
|
|
369
354
|
*/
|
|
370
355
|
export declare function assertSafeRelativePath(input: string): string;
|
|
371
356
|
/**
|
|
@@ -376,7 +361,7 @@ export declare function assertSafeRelativePath(input: string): string;
|
|
|
376
361
|
* Returns `true` on the first hit. Use before passing untrusted data
|
|
377
362
|
* into a query object that may be interpreted as an operator expression.
|
|
378
363
|
*
|
|
379
|
-
* @since 0.35.
|
|
364
|
+
* @since 0.35.0
|
|
380
365
|
*/
|
|
381
366
|
export declare function hasMongoOperatorKeys(value: unknown): boolean;
|
|
382
367
|
/**
|
|
@@ -385,6 +370,6 @@ export declare function hasMongoOperatorKeys(value: unknown): boolean;
|
|
|
385
370
|
* threading it into a NoSQL driver — closes the
|
|
386
371
|
* `{"password": {"$ne": null}}` authentication-bypass class of bug.
|
|
387
372
|
*
|
|
388
|
-
* @since 0.35.
|
|
373
|
+
* @since 0.35.0
|
|
389
374
|
*/
|
|
390
375
|
export declare function assertNoMongoOperators(value: unknown): void;
|
package/dist/security.js
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* - randomId: cryptographically strong request id.
|
|
10
10
|
*/
|
|
11
11
|
import { PayloadTooLargeError, BadRequestError, } from "./errors.js";
|
|
12
|
+
// Resolved once at module load. Mirror of `DALOY_REQUEST_RAW_BODY` in
|
|
13
|
+
// app.ts; defined here via the global Symbol registry to avoid an import
|
|
14
|
+
// cycle (app.ts -> security.ts). Adapters attach a pre-validated
|
|
15
|
+
// Uint8Array under this key so readBodyLimited can skip the WHATWG stream
|
|
16
|
+
// reader loop.
|
|
17
|
+
const REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
|
|
12
18
|
/**
|
|
13
19
|
* Read a `Request` body to a `Uint8Array` while enforcing a hard byte cap.
|
|
14
20
|
*
|
|
@@ -36,6 +42,15 @@ export async function readBodyLimited(req, limit) {
|
|
|
36
42
|
if (n > limit)
|
|
37
43
|
throw new PayloadTooLargeError(limit);
|
|
38
44
|
}
|
|
45
|
+
// Fast path: adapter pre-buffered the body and stashed it via the
|
|
46
|
+
// REQUEST_RAW_BODY symbol. Re-check the limit (defense-in-depth) and
|
|
47
|
+
// return zero-copy. Skips the WHATWG ReadableStream reader loop entirely.
|
|
48
|
+
const cached = req[REQUEST_RAW_BODY];
|
|
49
|
+
if (cached instanceof Uint8Array) {
|
|
50
|
+
if (cached.byteLength > limit)
|
|
51
|
+
throw new PayloadTooLargeError(limit);
|
|
52
|
+
return cached;
|
|
53
|
+
}
|
|
39
54
|
if (!req.body)
|
|
40
55
|
return new Uint8Array(0);
|
|
41
56
|
const reader = req.body.getReader();
|
|
@@ -177,8 +192,20 @@ export function timingSafeEqual(a, b) {
|
|
|
177
192
|
* @returns A random URL-safe id string.
|
|
178
193
|
* @since 0.1.0
|
|
179
194
|
*/
|
|
195
|
+
// Cache the Web Crypto entry points at module load so randomId() doesn't pay
|
|
196
|
+
// for a `globalThis.crypto` + optional-chain property lookup per request.
|
|
197
|
+
// Falls back to the runtime lookup path if Web Crypto is patched/replaced
|
|
198
|
+
// after module load (test harnesses, custom runtimes) — the cache is only
|
|
199
|
+
// trusted when `globalThis.crypto` is still the same reference, otherwise
|
|
200
|
+
// the stubbed object would be silently bypassed.
|
|
201
|
+
const _webCrypto = globalThis.crypto;
|
|
202
|
+
const _randomUUID = _webCrypto && typeof _webCrypto.randomUUID === "function"
|
|
203
|
+
? _webCrypto.randomUUID.bind(_webCrypto)
|
|
204
|
+
: undefined;
|
|
180
205
|
export function randomId() {
|
|
181
206
|
const c = globalThis.crypto;
|
|
207
|
+
if (c === _webCrypto && _randomUUID !== undefined)
|
|
208
|
+
return _randomUUID();
|
|
182
209
|
if (c?.randomUUID)
|
|
183
210
|
return c.randomUUID();
|
|
184
211
|
if (c?.getRandomValues) {
|
|
@@ -263,11 +290,13 @@ export const RESERVED_INBOUND_HEADER_PREFIXES = Object.freeze([
|
|
|
263
290
|
* @since 0.36.0
|
|
264
291
|
*/
|
|
265
292
|
export function assertNoReservedInternalHeaders(headers) {
|
|
293
|
+
// WHATWG Headers.forEach() yields lowercased names, so the per-header
|
|
294
|
+
// .toLowerCase() call this function used to do was dead work on every
|
|
295
|
+
// request. Skip it.
|
|
266
296
|
headers.forEach((_value, name) => {
|
|
267
|
-
const lower = name.toLowerCase();
|
|
268
297
|
for (const prefix of RESERVED_INBOUND_HEADER_PREFIXES) {
|
|
269
|
-
if (
|
|
270
|
-
throw new BadRequestError(`Reserved internal header rejected: ${
|
|
298
|
+
if (name.startsWith(prefix)) {
|
|
299
|
+
throw new BadRequestError(`Reserved internal header rejected: ${name}`);
|
|
271
300
|
}
|
|
272
301
|
}
|
|
273
302
|
});
|
|
@@ -631,7 +660,7 @@ const WINDOWS_RESERVED_NAMES = new Set([
|
|
|
631
660
|
* @returns A sanitized basename safe to combine with a trusted directory.
|
|
632
661
|
* @throws {BadRequestError} When the input reduces to an empty string or
|
|
633
662
|
* matches a Windows-reserved device name.
|
|
634
|
-
* @since 0.35.
|
|
663
|
+
* @since 0.35.0
|
|
635
664
|
*/
|
|
636
665
|
export function sanitizeFilename(name) {
|
|
637
666
|
if (typeof name !== "string") {
|
|
@@ -683,7 +712,7 @@ export function sanitizeFilename(name) {
|
|
|
683
712
|
* @param input - The candidate relative path.
|
|
684
713
|
* @returns The input unchanged when safe.
|
|
685
714
|
* @throws {BadRequestError} When the path could escape its base directory.
|
|
686
|
-
* @since 0.35.
|
|
715
|
+
* @since 0.35.0
|
|
687
716
|
*/
|
|
688
717
|
export function assertSafeRelativePath(input) {
|
|
689
718
|
if (typeof input !== "string" || input.length === 0) {
|
|
@@ -744,7 +773,7 @@ function walkForMongoOperators(value) {
|
|
|
744
773
|
* Returns `true` on the first hit. Use before passing untrusted data
|
|
745
774
|
* into a query object that may be interpreted as an operator expression.
|
|
746
775
|
*
|
|
747
|
-
* @since 0.35.
|
|
776
|
+
* @since 0.35.0
|
|
748
777
|
*/
|
|
749
778
|
export function hasMongoOperatorKeys(value) {
|
|
750
779
|
return walkForMongoOperators(value);
|
|
@@ -755,7 +784,7 @@ export function hasMongoOperatorKeys(value) {
|
|
|
755
784
|
* threading it into a NoSQL driver — closes the
|
|
756
785
|
* `{"password": {"$ne": null}}` authentication-bypass class of bug.
|
|
757
786
|
*
|
|
758
|
-
* @since 0.35.
|
|
787
|
+
* @since 0.35.0
|
|
759
788
|
*/
|
|
760
789
|
export function assertNoMongoOperators(value) {
|
|
761
790
|
if (walkForMongoOperators(value)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daloyjs/core",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.2",
|
|
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 — distributed via pnpm.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|