@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.
@@ -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 {
@@ -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 <= BUFFERED_BODY_MAX_BYTES) {
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
- * Maximum content-length (in bytes) that the Node adapter will pre-buffer
74
- * before constructing the `Request`. Bodies above this fall back to the
75
- * streaming `Readable.toWeb` path so unbounded uploads can't exhaust
76
- * adapter memory. 1 MiB matches the default `App.bodyLimitBytes`.
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 BUFFERED_BODY_MAX_BYTES = 1024 * 1024;
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
- const chunks = [];
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 += chunk.length;
121
- if (received > expected) {
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
- chunks.push(chunk);
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
- const buf = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, received);
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
- const headers = new Headers();
188
- for (const k in reqHeaders) {
189
- const v = reqHeaders[k];
190
- if (v === undefined)
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
- headers.set(k, Array.isArray(v) ? v.join(", ") : v);
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
- return new Request(url, {
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
- async function pumpBody(body, out) {
257
- const reader = body.getReader();
258
- while (true) {
259
- const { done, value } = await reader.read();
260
- if (done)
261
- break;
262
- if (value)
263
- out.write(value);
264
- }
265
- out.end();
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
- return this.options.env === "production";
388
- if (this.options.production !== undefined)
389
- return this.options.production;
390
- return (typeof process !== "undefined" &&
391
- typeof process.env !== "undefined" &&
392
- process.env.NODE_ENV === "production");
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
- const log = this.log.child({
1460
- requestId,
1461
- method: request.method,
1462
- url: request.url,
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} ${url404.pathname}`);
1551
+ throw new NotFoundError(`No route for ${request.method} ${pathname}`);
1503
1552
  }
1504
- const rawAllowed = this.router.allowedMethods(url404.pathname);
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, url404.pathname);
1561
+ const candidate = this.router.find(m, pathname);
1513
1562
  return candidate?.handler.def.internal !== true;
1514
1563
  });
1515
- ctx = {
1516
- request,
1517
- params: {},
1518
- query: Object.fromEntries(url404.searchParams.entries()),
1519
- headers: headersToObject(request.headers),
1520
- body: undefined,
1521
- state: { ...this.decorations, requestId, log },
1522
- set: { headers: new Headers() },
1523
- };
1524
- ctx.set.headers.set("x-request-id", requestId);
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} ${url404.pathname}`);
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
- Object.assign(ctx.state, this.decorations, { requestId, log });
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
- const handled = await activeErrorHook?.(err, ctx);
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
- const disconnectCode = this.options.disconnectStatusCode ?? 499;
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 = err instanceof HttpError
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
- * Marker stamped on `ctx.set` once the `headers` getter has been
2170
- * materialised. {@link copyContextHeaders} consults this to skip the
2171
- * forEach loop entirely on requests where no middleware or handler ever
2172
- * touched `ctx.set.headers` the default case for the no-middleware
2173
- * benchmark.
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
- const SET_HEADERS_TOUCHED = Symbol.for("daloyjs.app.setHeadersTouched");
2176
- function makeLazySet() {
2177
- let h;
2178
- const set = {};
2179
- Object.defineProperty(set, "headers", {
2180
- get() {
2181
- if (h === undefined) {
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
- set(v) {
2188
- h = v;
2189
- set[SET_HEADERS_TOUCHED] = true;
2190
- },
2191
- enumerable: true,
2192
- configurable: true,
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
- if (!ctx.set[SET_HEADERS_TOUCHED])
2297
+ const set = ctx.set;
2298
+ if (set.touched !== true)
2198
2299
  return;
2199
- ctx.set.headers.forEach((v, k) => {
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 = makeLazySet();
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
- request,
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.query = query;
2379
+ ctx._q = query;
2380
+ ctx._qSet = true;
2230
2381
  }
2231
2382
  else {
2232
- defineLazyContextProperty(ctx, "query", buildQuery);
2383
+ ctx._qBuilder = buildQuery;
2233
2384
  }
2234
2385
  if (hasHeadersSchema) {
2235
- ctx.headers = headers;
2386
+ ctx._h = headers;
2387
+ ctx._hSet = true;
2236
2388
  }
2237
2389
  else {
2238
- defineLazyContextProperty(ctx, "headers", buildHeaders);
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
- for (const [name, value] of Object.entries(this.headers ?? {})) {
206
- headers.set(name, value);
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
@@ -38,7 +38,7 @@
38
38
  * });
39
39
  * ```
40
40
  *
41
- * @since 0.35.1
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.1
89
+ * @since 0.35.2
90
90
  */
91
91
  export declare function safeRedirect(target: string, options?: SafeRedirectOptions): Response;
@@ -38,7 +38,7 @@
38
38
  * });
39
39
  * ```
40
40
  *
41
- * @since 0.35.1
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.1
129
+ * @since 0.35.2
130
130
  */
131
131
  export function safeRedirect(target, options = {}) {
132
132
  const allowedPaths = options.allowedPaths ?? [];
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:3d46c2b6-0b38-564b-9dd2-79098e99155b",
4
+ "serialNumber": "urn:uuid:1d9fa950-874b-5a89-9b39-98f1bbdfdb30",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-05-26T23:20:20.188Z",
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.1"
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.1",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@0.35.2",
23
23
  "name": "@daloyjs/core",
24
- "version": "0.35.1",
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.1",
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.1",
49
+ "tagId": "swidtag--daloyjs-core-0.35.2",
50
50
  "name": "@daloyjs/core",
51
- "version": "0.35.1",
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.1",
60
+ "ref": "pkg:npm/@daloyjs/core@0.35.2",
61
61
  "dependsOn": []
62
62
  }
63
63
  ]
@@ -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.1",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.35.1-3d46c2b6-0b38-564b-9dd2-79098e99155b",
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-26T23:20:20.188Z",
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.1",
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.1"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@0.35.2"
31
31
  }
32
32
  ]
33
33
  }
@@ -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.1
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.1
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.1
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.1
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 (lower.startsWith(prefix)) {
270
- throw new BadRequestError(`Reserved internal header rejected: ${lower}`);
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.1
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.1
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.1
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.1
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.1",
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": {