@daloyjs/core 1.0.0-beta.7 → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -509,7 +509,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
509
509
 
510
510
  ## Status
511
511
 
512
- DaloyJS is now in the **`1.0.0` beta** (`1.0.0-beta.7`). The public API is feature-complete and stable for the 1.0 line; from `1.0.0` onward, breaking changes follow SemVer and deprecations get at least one minor cycle. Small adjustments are still possible before the `1.0.0` GA if beta feedback surfaces something. The framework is already in use for production trials.
512
+ DaloyJS is now at its **first `1.0.0` release candidate** (`1.0.0-rc.0`). The public API is frozen for the 1.0 line: only bug fixes and documentation land between RC and GA, and from `1.0.0` onward breaking changes follow SemVer with deprecations getting at least one minor cycle. The framework is already in use for production trials.
513
513
 
514
514
  **Release quality bar.** Every release ships with **≥90% line + function coverage and ≥90% branch coverage**, strict TypeScript, OpenSSF Scorecard, CodeQL + Opengrep dual SAST, zizmor workflow linting, and npm provenance. Coverage was relaxed from a former 100% gate so complex security work isn't blocked chasing throwaway tests for unreachable defensive branches or tsx source-map phantoms; see [AGENTS.md](AGENTS.md) for the policy.
515
515
 
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { createServer, } from "node:http";
6
6
  import { Readable } from "node:stream";
7
- import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY } from "../app.js";
7
+ import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, } from "../app.js";
8
8
  import { setClientCertificate, normalizePeerCertificate, } from "../mtls.js";
9
9
  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";
10
10
  /**
@@ -291,6 +291,209 @@ function writeAdapterError(res, e) {
291
291
  res.destroy(e);
292
292
  }
293
293
  }
294
+ /**
295
+ * Lazily-materializing stand-in for an incoming WHATWG `Request`.
296
+ *
297
+ * Constructing a real (undici) `Request` costs ~4µs for GET and far more for
298
+ * POST-with-body (the constructor wraps the body bytes in a WHATWG
299
+ * `ReadableStream` that DaloyJS then bypasses anyway via the
300
+ * `DALOY_REQUEST_RAW_BODY` fast path). The dispatch hot path only ever reads
301
+ * `url`, `method`, `headers`, `signal`, and the raw-body symbol — so this
302
+ * shell carries those directly and defers the real `Request` until one of the
303
+ * less common surfaces (`json()`, `clone()`, `body`, `formData()`, …) is
304
+ * actually touched.
305
+ *
306
+ * Fidelity notes:
307
+ * - `instanceof Request` holds (prototype chain is re-rooted onto
308
+ * `Request.prototype`), and every WHATWG method/getter is overridden here,
309
+ * so nothing hits undici's brand-checked prototype accessors.
310
+ * - `signal` is an inert per-instance `AbortSignal`. This matches the real
311
+ * adapter behaviour today: the Node adapter never wires socket aborts into
312
+ * the request signal, so the signal never fires in either implementation.
313
+ * - Passing this object directly to `fetch()` is not supported (undici
314
+ * brand-checks its input) — forward with `request.clone()` instead, which
315
+ * returns a real `Request`. This mirrors @hono/node-server's shim.
316
+ *
317
+ * Security parity: the `Headers` instance is built eagerly from `rawHeaders`
318
+ * exactly as before, so the duplicate-singleton / reserved-header /
319
+ * header-count guards in `App.dispatch` see the identical view they saw with
320
+ * a real `Request`.
321
+ */
322
+ /** Shared decoder for LightRequest's direct body reads. */
323
+ const LIGHT_TEXT_DECODER = new TextDecoder();
324
+ class LightRequest {
325
+ #url;
326
+ #method;
327
+ #headers;
328
+ #bodyBytes;
329
+ #real;
330
+ #signal;
331
+ constructor(url, method, headers, bodyBytes) {
332
+ this.#url = url;
333
+ this.#method = method;
334
+ this.#headers = headers;
335
+ this.#bodyBytes = bodyBytes;
336
+ }
337
+ /** Build (once) and return the real undici `Request` for rare surfaces. */
338
+ #materialize() {
339
+ return (this.#real ??=
340
+ this.#bodyBytes !== undefined
341
+ ? new Request(this.#url, {
342
+ method: this.#method,
343
+ headers: this.#headers,
344
+ body: this.#bodyBytes,
345
+ })
346
+ : new Request(this.#url, { method: this.#method, headers: this.#headers }));
347
+ }
348
+ get url() {
349
+ return this.#url;
350
+ }
351
+ get method() {
352
+ return this.#method;
353
+ }
354
+ get headers() {
355
+ return this.#headers;
356
+ }
357
+ get signal() {
358
+ // Inert, lazily created: the Node adapter has never wired socket
359
+ // teardown into the request signal, so a never-firing signal is
360
+ // behaviourally identical to the one a real `Request` would carry.
361
+ return (this.#signal ??= new AbortController().signal);
362
+ }
363
+ get body() {
364
+ return this.#materialize().body;
365
+ }
366
+ get bodyUsed() {
367
+ if (this.#real !== undefined)
368
+ return this.#real.bodyUsed;
369
+ return this.#directlyConsumed;
370
+ }
371
+ /**
372
+ * Set when a body method served the pre-buffered bytes directly (no real
373
+ * `Request` ever existed). Subsequent body reads reject with a `TypeError`
374
+ * exactly like a consumed WHATWG body would.
375
+ */
376
+ #directlyConsumed = false;
377
+ /**
378
+ * Serve a body read straight from the pre-buffered bytes when possible.
379
+ * Returns `undefined` when the caller must delegate to the materialized
380
+ * real `Request` (no buffered bytes, or a real `Request` already owns the
381
+ * body state). Enforces single-read semantics via {@link #directlyConsumed}.
382
+ */
383
+ #consumeBytes() {
384
+ if (this.#real !== undefined || this.#bodyBytes === undefined)
385
+ return undefined;
386
+ if (this.#directlyConsumed) {
387
+ throw new TypeError("Body is unusable: Body has already been read");
388
+ }
389
+ this.#directlyConsumed = true;
390
+ return this.#bodyBytes;
391
+ }
392
+ // Spec-constant getters: these are exactly the values undici assigns to a
393
+ // server-side `new Request(url, { method, headers, body })`, hardcoded so
394
+ // reading them does not force materialization.
395
+ get cache() {
396
+ return "default";
397
+ }
398
+ get credentials() {
399
+ return "same-origin";
400
+ }
401
+ get destination() {
402
+ return "";
403
+ }
404
+ get integrity() {
405
+ return "";
406
+ }
407
+ get keepalive() {
408
+ return false;
409
+ }
410
+ get mode() {
411
+ return "cors";
412
+ }
413
+ get redirect() {
414
+ return "follow";
415
+ }
416
+ get referrer() {
417
+ return "about:client";
418
+ }
419
+ get referrerPolicy() {
420
+ return "";
421
+ }
422
+ arrayBuffer() {
423
+ try {
424
+ const bytes = this.#consumeBytes();
425
+ if (bytes === undefined)
426
+ return this.#materialize().arrayBuffer();
427
+ // Copy: the underlying buffer is also the framework's raw-body cache.
428
+ return Promise.resolve(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
429
+ }
430
+ catch (e) {
431
+ return Promise.reject(e);
432
+ }
433
+ }
434
+ blob() {
435
+ return this.#materialize().blob();
436
+ }
437
+ bytes() {
438
+ try {
439
+ const bytes = this.#consumeBytes();
440
+ if (bytes === undefined)
441
+ return this.#materialize().bytes();
442
+ return Promise.resolve(new Uint8Array(bytes));
443
+ }
444
+ catch (e) {
445
+ return Promise.reject(e);
446
+ }
447
+ }
448
+ formData() {
449
+ return this.#materialize().formData();
450
+ }
451
+ json() {
452
+ try {
453
+ const bytes = this.#consumeBytes();
454
+ if (bytes === undefined)
455
+ return this.#materialize().json();
456
+ // JSON.parse (not the framework's safeJsonParse): request.json() is the
457
+ // raw WHATWG surface, and its error semantics (SyntaxError rejection)
458
+ // must match a real Request exactly. Framework-parsed bodies go through
459
+ // readBody()/safeJsonParse and never hit this method.
460
+ return Promise.resolve(JSON.parse(LIGHT_TEXT_DECODER.decode(bytes)));
461
+ }
462
+ catch (e) {
463
+ return Promise.reject(e);
464
+ }
465
+ }
466
+ text() {
467
+ try {
468
+ const bytes = this.#consumeBytes();
469
+ if (bytes === undefined)
470
+ return this.#materialize().text();
471
+ return Promise.resolve(LIGHT_TEXT_DECODER.decode(bytes));
472
+ }
473
+ catch (e) {
474
+ return Promise.reject(e);
475
+ }
476
+ }
477
+ /**
478
+ * Returns a real, fully-branded `Request` clone — safe to pass to `fetch()`.
479
+ * Throws a `TypeError` if the body has already been read, per spec.
480
+ */
481
+ clone() {
482
+ if (this.#directlyConsumed) {
483
+ throw new TypeError("Request body is already used");
484
+ }
485
+ return this.#materialize().clone();
486
+ }
487
+ }
488
+ // `instanceof Request` must hold for handler code and the framework's own
489
+ // checks. Every own getter/method above shadows the brand-checked undici
490
+ // accessors, so the re-rooted chain is only consulted for identity.
491
+ Object.setPrototypeOf(LightRequest.prototype, Request.prototype);
492
+ // This adapter consumes responses via status/headers/DALOY_RAW_BODY only
493
+ // (see sendWebResponse), so serializeResult may skip the undici Response
494
+ // construction for requests dispatched through this shim. Set once on the
495
+ // prototype: zero per-request cost.
496
+ LightRequest.prototype[DALOY_LIGHT_RESPONSE_OK] = true;
294
497
  function toWebRequest(req, trustProxy, bufferedBody) {
295
498
  const reqHeaders = req.headers;
296
499
  const forwardedHost = trustProxy
@@ -323,18 +526,20 @@ function toWebRequest(req, trustProxy, bufferedBody) {
323
526
  const headers = new Headers(headerPairs);
324
527
  const method = req.method ?? "GET";
325
528
  if (method === "GET" || method === "HEAD") {
326
- return new Request(url, { method, headers });
529
+ // LightRequest: skips the ~4µs undici Request constructor on the GET
530
+ // hot path. Headers are still built eagerly above, so every header
531
+ // guard sees the same view as before.
532
+ return new LightRequest(url, method, headers, undefined);
327
533
  }
328
534
  if (bufferedBody !== undefined) {
329
- const req2 = new Request(url, {
330
- method,
331
- headers,
332
- body: bufferedBody,
333
- });
334
- // Stash the validated bytes so readBodyLimited (and any other internal
335
- // body reader) can skip the WHATWG ReadableStream reader loop. The
336
- // adapter has already enforced BUFFERED_BODY_MAX_BYTES + Content-Length
337
- // here; readBodyLimited re-checks against the caller's limit.
535
+ // LightRequest with the pre-buffered bytes: skips the (much more
536
+ // expensive) body-wrapping undici Request constructor. The bytes are
537
+ // stashed via DALOY_REQUEST_RAW_BODY so readBodyLimited (and any other
538
+ // internal body reader) skips the WHATWG ReadableStream reader loop.
539
+ // The adapter has already enforced BUFFERED_BODY_MAX_BYTES +
540
+ // Content-Length here; readBodyLimited re-checks against the caller's
541
+ // limit.
542
+ const req2 = new LightRequest(url, method, headers, bufferedBody);
338
543
  req2[DALOY_REQUEST_RAW_BODY] = bufferedBody;
339
544
  return req2;
340
545
  }
package/dist/app.d.ts CHANGED
@@ -756,6 +756,17 @@ export declare const DALOY_REQUEST_RAW_BODY: unique symbol;
756
756
  * opt in; userland code should not depend on it.
757
757
  */
758
758
  export declare const DALOY_RAW_STREAM: unique symbol;
759
+ /**
760
+ * Internal Symbol an adapter sets (once, on its request shim's prototype) to
761
+ * declare: "the object that ultimately consumes this request's `Response`
762
+ * only reads `status` / `headers` / {@link DALOY_RAW_BODY} — it never needs a
763
+ * branded WHATWG `Response`". When present on the incoming request,
764
+ * {@link serializeResult} may return a {@link LightResponse} and skip the
765
+ * ~2µs undici `Response` construction per request. Requests without the
766
+ * marker (Bun / Deno / Workers adapters, tests, direct `app.fetch()` callers)
767
+ * always get a real `Response`, so the public contract is unchanged.
768
+ */
769
+ export declare const DALOY_LIGHT_RESPONSE_OK: unique symbol;
759
770
  /**
760
771
  * Contract-first HTTP application.
761
772
  *
package/dist/app.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { Router } from "./router.js";
2
2
  import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
3
3
  import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, TooManyRequestsError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
4
- import { validate } from "./schema.js";
5
4
  import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
6
5
  import { createLogger, noopLogger } from "./logger.js";
7
6
  import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
@@ -194,6 +193,99 @@ export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
194
193
  * opt in; userland code should not depend on it.
195
194
  */
196
195
  export const DALOY_RAW_STREAM = Symbol.for("daloyjs.response.rawStream");
196
+ /**
197
+ * Internal Symbol an adapter sets (once, on its request shim's prototype) to
198
+ * declare: "the object that ultimately consumes this request's `Response`
199
+ * only reads `status` / `headers` / {@link DALOY_RAW_BODY} — it never needs a
200
+ * branded WHATWG `Response`". When present on the incoming request,
201
+ * {@link serializeResult} may return a {@link LightResponse} and skip the
202
+ * ~2µs undici `Response` construction per request. Requests without the
203
+ * marker (Bun / Deno / Workers adapters, tests, direct `app.fetch()` callers)
204
+ * always get a real `Response`, so the public contract is unchanged.
205
+ */
206
+ export const DALOY_LIGHT_RESPONSE_OK = Symbol.for("daloyjs.response.lightOk");
207
+ /**
208
+ * Minimal `Response` stand-in returned on the Node-adapter hot path (gated by
209
+ * {@link DALOY_LIGHT_RESPONSE_OK}). Carries `status` + a real `Headers`
210
+ * instance + the raw body bytes via {@link DALOY_RAW_BODY}; every other
211
+ * WHATWG surface (body streams, `json()`, `clone()`, …) delegates to a
212
+ * lazily-materialized real `Response`, so hook code that inspects the
213
+ * response body still behaves exactly as before — it just pays the
214
+ * construction cost only when it actually does so. `instanceof Response`
215
+ * holds via prototype re-rooting; all overridden accessors below shadow
216
+ * undici's brand-checked ones.
217
+ */
218
+ class LightResponse {
219
+ #status;
220
+ #headers;
221
+ #rawBody;
222
+ #real;
223
+ constructor(status, headers, rawBody) {
224
+ this.#status = status;
225
+ this.#headers = headers;
226
+ this.#rawBody = rawBody;
227
+ this[DALOY_RAW_BODY] = rawBody;
228
+ }
229
+ /** Build (once) and return an equivalent real `Response` for rare surfaces. */
230
+ #materialize() {
231
+ return (this.#real ??= new Response(this.#rawBody, {
232
+ status: this.#status,
233
+ headers: this.#headers,
234
+ }));
235
+ }
236
+ get status() {
237
+ return this.#status;
238
+ }
239
+ get headers() {
240
+ return this.#headers;
241
+ }
242
+ get ok() {
243
+ return this.#status >= 200 && this.#status <= 299;
244
+ }
245
+ // Spec constants for a synthesized (non-network) Response.
246
+ get statusText() {
247
+ return "";
248
+ }
249
+ get type() {
250
+ return "default";
251
+ }
252
+ get url() {
253
+ return "";
254
+ }
255
+ get redirected() {
256
+ return false;
257
+ }
258
+ get body() {
259
+ return this.#materialize().body;
260
+ }
261
+ get bodyUsed() {
262
+ return this.#real !== undefined ? this.#real.bodyUsed : false;
263
+ }
264
+ arrayBuffer() {
265
+ return this.#materialize().arrayBuffer();
266
+ }
267
+ blob() {
268
+ return this.#materialize().blob();
269
+ }
270
+ bytes() {
271
+ return this.#materialize().bytes();
272
+ }
273
+ formData() {
274
+ return this.#materialize().formData();
275
+ }
276
+ json() {
277
+ return this.#materialize().json();
278
+ }
279
+ text() {
280
+ return this.#materialize().text();
281
+ }
282
+ clone() {
283
+ return this.#materialize().clone();
284
+ }
285
+ }
286
+ // `instanceof Response` must hold for hook code and adapter checks. Every
287
+ // own getter/method above shadows undici's brand-checked accessors.
288
+ Object.setPrototypeOf(LightResponse.prototype, Response.prototype);
197
289
  /**
198
290
  * The DaloyJS application: a contract-first router plus a web-standard
199
291
  * `fetch(Request): Promise<Response>` handler that runs unchanged on Node,
@@ -614,6 +706,26 @@ export class App {
614
706
  const origin = request.headers.get("origin");
615
707
  if (!origin || origin === "null")
616
708
  return;
709
+ // Fast path: when both the Origin header and the request URL are in the
710
+ // trivially-normalized shape (lowercase ASCII scheme://host[:port] with
711
+ // no userinfo / percent-escapes / IPv6 brackets), their origins can be
712
+ // compared as plain strings without two `new URL()` constructions per
713
+ // request. Anything unusual returns `undefined` and falls back to the
714
+ // exact WHATWG comparison below — the guard's accept/reject semantics
715
+ // are identical on both paths.
716
+ const fastHeaderOrigin = getOriginFast(origin);
717
+ if (fastHeaderOrigin !== undefined) {
718
+ const fastReqOrigin = typeof requestUrl === "string" ? getOriginFast(requestUrl) : requestUrl.origin;
719
+ if (fastReqOrigin !== undefined) {
720
+ if (fastHeaderOrigin === fastReqOrigin)
721
+ return;
722
+ if (corsOriginAllows.some((allows) => allows(origin)))
723
+ return;
724
+ throw new ForbiddenError(`Cross-origin ${method} from "${fastHeaderOrigin}" rejected: no registered cors() policy allows that origin. ` +
725
+ `Register cors({ origin: [...] }) via app.use(...) to allow it, or pass ` +
726
+ `app({ corsCrossOriginGuard: false }) / app({ secureDefaults: false }) to disable this guard.`);
727
+ }
728
+ }
617
729
  let originUrl;
618
730
  try {
619
731
  originUrl = new URL(origin);
@@ -2287,7 +2399,11 @@ export class App {
2287
2399
  if (isPromiseLike(routeOnRequestResult))
2288
2400
  await routeOnRequestResult;
2289
2401
  }
2290
- ctx = await buildContext(request, getUrl, match.params, def, this.options);
2402
+ // buildContext is sync unless a schema validator or body read actually
2403
+ // suspends — branch on the promise so the fully-sync case never
2404
+ // schedules a microtask.
2405
+ const builtCtx = buildContext(request, getUrl, match.params, def, this.options);
2406
+ ctx = isPromiseLike(builtCtx) ? await builtCtx : builtCtx;
2291
2407
  // Stable two-field write keeps `ctx.state`'s hidden class consistent across
2292
2408
  // requests for the common no-decorator case. The decorations spread only
2293
2409
  // fires when `app.decorate()` was actually called.
@@ -2358,7 +2474,11 @@ export class App {
2358
2474
  }
2359
2475
  return finalizedRaw;
2360
2476
  }
2361
- const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true);
2477
+ const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true,
2478
+ // Adapter shims set this marker on their request prototype to declare
2479
+ // that the response consumer only reads status/headers/raw-body — see
2480
+ // DALOY_LIGHT_RESPONSE_OK. Everyone else gets a real Response.
2481
+ request[DALOY_LIGHT_RESPONSE_OK] === true);
2362
2482
  let response = isPromiseLike(serializeResultRes)
2363
2483
  ? await serializeResultRes
2364
2484
  : serializeResultRes;
@@ -2807,6 +2927,85 @@ function getPathnameFast(url) {
2807
2927
  end = h;
2808
2928
  return url.slice(pathStart, end);
2809
2929
  }
2930
+ /**
2931
+ * Extract the WHATWG origin (`scheme://host[:port]`) from an absolute
2932
+ * `http`/`https` URL or Origin-header value without constructing a `URL`.
2933
+ * Companion to {@link getPathnameFast}, used by the cross-origin guard on
2934
+ * state-changing requests.
2935
+ *
2936
+ * Returns `undefined` — signalling "fall back to `new URL(...).origin`" —
2937
+ * whenever the input is not in the trivially-normalized shape a `URL` parse
2938
+ * would return unchanged: non-`http(s)` schemes, uppercase characters
2939
+ * (scheme/host case-folding), userinfo (`user@host`, which `URL.origin`
2940
+ * strips), percent-escapes, non-ASCII hosts (IDNA/punycode), IPv6 literals
2941
+ * (zero-compression normalization), empty hosts, backslashes (URL treats
2942
+ * `\` as `/`), and explicit default ports (`:80` / `:443`, which
2943
+ * `URL.origin` elides). The fast path therefore never *disagrees* with the
2944
+ * WHATWG origin — it only answers when the answer is unambiguous.
2945
+ */
2946
+ function getOriginFast(url) {
2947
+ let hostStart;
2948
+ if (url.startsWith("http://"))
2949
+ hostStart = 7;
2950
+ else if (url.startsWith("https://"))
2951
+ hostStart = 8;
2952
+ else
2953
+ return undefined;
2954
+ // Find the end of the authority: first "/", "?", or "#" after the scheme.
2955
+ let end = url.length;
2956
+ for (let i = hostStart; i < url.length; i++) {
2957
+ const c = url.charCodeAt(i);
2958
+ if (c === 47 /* / */ || c === 63 /* ? */ || c === 35 /* # */) {
2959
+ end = i;
2960
+ break;
2961
+ }
2962
+ }
2963
+ if (end === hostStart)
2964
+ return undefined; // empty host
2965
+ for (let i = hostStart; i < end; i++) {
2966
+ const c = url.charCodeAt(i);
2967
+ // Reject anything that could normalize differently under a real URL
2968
+ // parse: uppercase A-Z, userinfo "@", percent "%", IPv6 "[", backslash
2969
+ // "\", raw whitespace/controls, and all non-ASCII.
2970
+ if ((c >= 65 && c <= 90) /* A-Z */ ||
2971
+ c === 64 /* @ */ ||
2972
+ c === 37 /* % */ ||
2973
+ c === 91 /* [ */ ||
2974
+ c === 92 /* \ */ ||
2975
+ c <= 32 /* controls + space */ ||
2976
+ c >= 127 /* DEL + non-ASCII */) {
2977
+ return undefined;
2978
+ }
2979
+ }
2980
+ const authority = url.slice(hostStart, end);
2981
+ const colon = authority.indexOf(":");
2982
+ if (colon !== -1) {
2983
+ const port = authority.slice(colon + 1);
2984
+ // Trailing ":" alone, an empty port, or a default port all normalize to
2985
+ // no port under URL — fall back rather than replicate that here. A
2986
+ // second ":" (malformed / IPv6-ish) also falls back.
2987
+ if (port.length === 0 || port.indexOf(":") !== -1)
2988
+ return undefined;
2989
+ if ((hostStart === 7 && port === "80") /* http default */ ||
2990
+ (hostStart === 8 && port === "443") /* https default */) {
2991
+ return undefined;
2992
+ }
2993
+ // Port must be all digits; anything else is not trivially normalized.
2994
+ for (let i = 0; i < port.length; i++) {
2995
+ const c = port.charCodeAt(i);
2996
+ if (c < 48 || c > 57)
2997
+ return undefined;
2998
+ }
2999
+ // Leading zeros normalize away under URL ("0080" -> "80"), and ports
3000
+ // above 65535 make the URL constructor *throw* (the guard's malformed-
3001
+ // origin rejection) — both must take the exact WHATWG path.
3002
+ if (port.length > 1 && port.charCodeAt(0) === 48 /* 0 */)
3003
+ return undefined;
3004
+ if (port.length > 5 || (port.length === 5 && Number(port) > 65535))
3005
+ return undefined;
3006
+ }
3007
+ return url.slice(0, end);
3008
+ }
2810
3009
  function mergeHooks(layers) {
2811
3010
  const pick = (key) => layers.map((h) => h[key]).filter((f) => typeof f === "function");
2812
3011
  const requiredScopes = requiredScopesFromHooks(layers);
@@ -3183,44 +3382,77 @@ function buildContext(request, getUrl, rawParams, def, opts) {
3183
3382
  if (!hasSchema) {
3184
3383
  return finishContext();
3185
3384
  }
3186
- return (async () => {
3187
- if (def.request?.params) {
3188
- const r = await validate(def.request.params, rawParams);
3189
- if (r.issues)
3190
- throw new ValidationError("params", toIssues(r.issues));
3191
- params = r.value;
3385
+ const applyChecked = (r, part) => {
3386
+ if (r.issues)
3387
+ throw new ValidationError(part, toIssues(r.issues));
3388
+ return r.value;
3389
+ };
3390
+ const validateBodyAndFinish = (raw) => {
3391
+ const r = def.request.body["~standard"].validate(raw);
3392
+ if (isPromiseLike(r)) {
3393
+ return r.then((resolved) => {
3394
+ body = applyChecked(resolved, "body");
3395
+ return finishContext();
3396
+ });
3192
3397
  }
3193
- if (hasQuerySchema) {
3194
- const r = await validate(def.request.query, buildQuery());
3195
- if (r.issues)
3196
- throw new ValidationError("query", toIssues(r.issues));
3197
- query = r.value;
3398
+ body = applyChecked(r, "body");
3399
+ return finishContext();
3400
+ };
3401
+ const stepBody = () => {
3402
+ if (!def.request?.body)
3403
+ return finishContext();
3404
+ const ct = (request.headers.get("content-type") ?? "").toLowerCase();
3405
+ const allowed = def.accepts ??
3406
+ opts.allowedContentTypes ?? [
3407
+ "application/json",
3408
+ "application/x-www-form-urlencoded",
3409
+ "multipart/form-data",
3410
+ ];
3411
+ if (!allowed.some((a) => ct.includes(a))) {
3412
+ throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
3198
3413
  }
3199
- if (hasHeadersSchema) {
3200
- const r = await validate(def.request.headers, buildHeaders());
3201
- if (r.issues)
3202
- throw new ValidationError("headers", toIssues(r.issues));
3203
- headers = r.value;
3204
- }
3205
- if (def.request?.body) {
3206
- const ct = (request.headers.get("content-type") ?? "").toLowerCase();
3207
- const allowed = def.accepts ??
3208
- opts.allowedContentTypes ?? [
3209
- "application/json",
3210
- "application/x-www-form-urlencoded",
3211
- "multipart/form-data",
3212
- ];
3213
- if (!allowed.some((a) => ct.includes(a))) {
3214
- throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
3215
- }
3216
- const raw = await readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
3217
- const r = await validate(def.request.body, raw);
3218
- if (r.issues)
3219
- throw new ValidationError("body", toIssues(r.issues));
3220
- body = r.value;
3414
+ const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
3415
+ if (isPromiseLike(raw))
3416
+ return raw.then(validateBodyAndFinish);
3417
+ return validateBodyAndFinish(raw);
3418
+ };
3419
+ const stepHeaders = () => {
3420
+ if (!hasHeadersSchema)
3421
+ return stepBody();
3422
+ const r = def.request.headers["~standard"].validate(buildHeaders());
3423
+ if (isPromiseLike(r)) {
3424
+ return r.then((resolved) => {
3425
+ headers = applyChecked(resolved, "headers");
3426
+ return stepBody();
3427
+ });
3221
3428
  }
3222
- return finishContext();
3223
- })();
3429
+ headers = applyChecked(r, "headers");
3430
+ return stepBody();
3431
+ };
3432
+ const stepQuery = () => {
3433
+ if (!hasQuerySchema)
3434
+ return stepHeaders();
3435
+ const r = def.request.query["~standard"].validate(buildQuery());
3436
+ if (isPromiseLike(r)) {
3437
+ return r.then((resolved) => {
3438
+ query = applyChecked(resolved, "query");
3439
+ return stepHeaders();
3440
+ });
3441
+ }
3442
+ query = applyChecked(r, "query");
3443
+ return stepHeaders();
3444
+ };
3445
+ if (def.request?.params) {
3446
+ const r = def.request.params["~standard"].validate(rawParams);
3447
+ if (isPromiseLike(r)) {
3448
+ return r.then((resolved) => {
3449
+ params = applyChecked(resolved, "params");
3450
+ return stepQuery();
3451
+ });
3452
+ }
3453
+ params = applyChecked(r, "params");
3454
+ }
3455
+ return stepQuery();
3224
3456
  }
3225
3457
  function headersToObject(h) {
3226
3458
  const o = {};
@@ -3251,26 +3483,79 @@ function toIssues(issues) {
3251
3483
  .join("."),
3252
3484
  }));
3253
3485
  }
3254
- async function readBody(req, ct, limit, multipart) {
3486
+ /** Shared decoder for request-body text. Allocating one per request is wasted work. */
3487
+ const TEXT_DECODER = new TextDecoder();
3488
+ /**
3489
+ * Synchronous fast path for {@link readBodyLimited}: returns the adapter's
3490
+ * pre-buffered body bytes when they are available on the request via
3491
+ * {@link DALOY_REQUEST_RAW_BODY}, or `undefined` when the caller must fall
3492
+ * back to the async streaming read. Runs the exact same Content-Length
3493
+ * validation and size-limit checks (in the same order, throwing the same
3494
+ * errors) as `readBodyLimited`, so the security posture is identical — the
3495
+ * only difference is that a symbol-cache hit never touches the microtask
3496
+ * queue.
3497
+ *
3498
+ * @throws {BadRequestError} When `Content-Length` is present but invalid.
3499
+ * @throws {PayloadTooLargeError} When the declared or actual size exceeds `limit`.
3500
+ */
3501
+ function readBodyBytesFast(req, limit) {
3502
+ const cl = req.headers.get("content-length");
3503
+ if (cl) {
3504
+ const n = Number(cl);
3505
+ if (!Number.isFinite(n) || n < 0)
3506
+ throw new BadRequestError("Invalid Content-Length");
3507
+ if (n > limit)
3508
+ throw new PayloadTooLargeError(limit);
3509
+ }
3510
+ const cached = req[DALOY_REQUEST_RAW_BODY];
3511
+ if (cached instanceof Uint8Array) {
3512
+ if (cached.byteLength > limit)
3513
+ throw new PayloadTooLargeError(limit);
3514
+ return cached;
3515
+ }
3516
+ return undefined;
3517
+ }
3518
+ function parseJsonBodyBytes(bytes) {
3519
+ if (bytes.byteLength === 0)
3520
+ return undefined;
3521
+ return safeJsonParse(TEXT_DECODER.decode(bytes));
3522
+ }
3523
+ function parseUrlencodedBodyBytes(bytes) {
3524
+ const params = new URLSearchParams(TEXT_DECODER.decode(bytes));
3525
+ // Same Spring4Shell-class defense as queryToObject: Object.fromEntries
3526
+ // would set __proto__ / constructor / prototype as own properties.
3527
+ const out = {};
3528
+ for (const [k, v] of params) {
3529
+ if (isForbiddenObjectKey(k))
3530
+ continue;
3531
+ out[k] = v;
3532
+ }
3533
+ return out;
3534
+ }
3535
+ /**
3536
+ * Read and parse a request body according to its content type. Plain
3537
+ * (non-`async`) on purpose: when the adapter pre-buffered the body bytes
3538
+ * (the common JSON POST case on Node), the parse completes synchronously and
3539
+ * the caller stays on the sync dispatch fast path. Falls back to the
3540
+ * streaming `readBodyLimited` promise otherwise. All parsing keeps the
3541
+ * prototype-pollution-safe semantics of the previous implementation.
3542
+ */
3543
+ function readBody(req, ct, limit, multipart) {
3255
3544
  if (ct.includes("application/json")) {
3256
- const bytes = await readBodyLimited(req, limit);
3257
- if (bytes.byteLength === 0)
3258
- return undefined;
3259
- return safeJsonParse(new TextDecoder().decode(bytes));
3545
+ const fast = readBodyBytesFast(req, limit);
3546
+ if (fast !== undefined)
3547
+ return parseJsonBodyBytes(fast);
3548
+ return readBodyLimited(req, limit).then(parseJsonBodyBytes);
3260
3549
  }
3261
3550
  if (ct.includes("application/x-www-form-urlencoded")) {
3262
- const bytes = await readBodyLimited(req, limit);
3263
- const params = new URLSearchParams(new TextDecoder().decode(bytes));
3264
- // Same Spring4Shell-class defense as queryToObject: Object.fromEntries
3265
- // would set __proto__ / constructor / prototype as own properties.
3266
- const out = {};
3267
- for (const [k, v] of params) {
3268
- if (isForbiddenObjectKey(k))
3269
- continue;
3270
- out[k] = v;
3271
- }
3272
- return out;
3551
+ const fast = readBodyBytesFast(req, limit);
3552
+ if (fast !== undefined)
3553
+ return parseUrlencodedBodyBytes(fast);
3554
+ return readBodyLimited(req, limit).then(parseUrlencodedBodyBytes);
3273
3555
  }
3556
+ return readBodySlow(req, ct, limit, multipart);
3557
+ }
3558
+ async function readBodySlow(req, ct, limit, multipart) {
3274
3559
  if (ct.includes("multipart/form-data")) {
3275
3560
  // Fast-fail on an honestly-declared oversize body.
3276
3561
  const cl = req.headers.get("content-length");
@@ -3362,7 +3647,7 @@ function normalizeSunset(value, method, path) {
3362
3647
  }
3363
3648
  return date.toUTCString();
3364
3649
  }
3365
- function serializeResult(result, def, validateResponses) {
3650
+ function serializeResult(result, def, validateResponses, lightOk = false) {
3366
3651
  const spec = def.responses[result.status];
3367
3652
  if (!spec) {
3368
3653
  throw new InternalError(`Handler returned status ${result.status} which is not declared in responses for ${def.method} ${def.path}`);
@@ -3424,6 +3709,13 @@ function serializeResult(result, def, validateResponses) {
3424
3709
  body = bytes;
3425
3710
  rawBody = bytes;
3426
3711
  }
3712
+ // Node-adapter hot path (opt-in via DALOY_LIGHT_RESPONSE_OK on the
3713
+ // incoming request): skip the ~2µs undici Response construction. Only
3714
+ // buffer-backed bodies qualify — streams keep the real Response so the
3715
+ // adapter's stream plumbing is untouched.
3716
+ if (lightOk && !isStream) {
3717
+ return new LightResponse(result.status, headers, rawBody);
3718
+ }
3427
3719
  const response = new Response(body, { status: result.status, headers });
3428
3720
  if (!isStream) {
3429
3721
  response[DALOY_RAW_BODY] = rawBody;
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:b5480fcc-c870-525f-bf19-08a30ac9e3d6",
4
+ "serialNumber": "urn:uuid:e297e402-67fc-54e1-a292-02441ebb5d71",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-07-02T15:07:00.378Z",
7
+ "timestamp": "2026-07-03T11:51:00.910Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "1.0.0-beta.7"
12
+ "version": "1.0.0-rc.0"
13
13
  }
14
14
  ],
15
15
  "authors": [
@@ -19,11 +19,11 @@
19
19
  ],
20
20
  "component": {
21
21
  "type": "library",
22
- "bom-ref": "pkg:npm/@daloyjs/core@1.0.0-beta.7",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.0",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.0.0-beta.7",
24
+ "version": "1.0.0-rc.0",
25
25
  "description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
26
- "purl": "pkg:npm/@daloyjs/core@1.0.0-beta.7",
26
+ "purl": "pkg:npm/@daloyjs/core@1.0.0-rc.0",
27
27
  "licenses": [
28
28
  {
29
29
  "license": {
@@ -46,9 +46,9 @@
46
46
  }
47
47
  ],
48
48
  "swid": {
49
- "tagId": "swidtag--daloyjs-core-1.0.0-beta.7",
49
+ "tagId": "swidtag--daloyjs-core-1.0.0-rc.0",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.0.0-beta.7",
51
+ "version": "1.0.0-rc.0",
52
52
  "tagVersion": 0,
53
53
  "patch": false
54
54
  }
@@ -57,7 +57,7 @@
57
57
  "components": [],
58
58
  "dependencies": [
59
59
  {
60
- "ref": "pkg:npm/@daloyjs/core@1.0.0-beta.7",
60
+ "ref": "pkg:npm/@daloyjs/core@1.0.0-rc.0",
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-1.0.0-beta.7",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-beta.7-b5480fcc-c870-525f-bf19-08a30ac9e3d6",
5
+ "name": "@daloyjs/core-1.0.0-rc.0",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.0-e297e402-67fc-54e1-a292-02441ebb5d71",
7
7
  "creationInfo": {
8
- "created": "2026-07-02T15:07:00.378Z",
8
+ "created": "2026-07-03T11:51:00.910Z",
9
9
  "creators": [
10
10
  "Tool: daloy-generate-sbom",
11
11
  "Organization: DaloyJS"
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "SPDXID": "SPDXRef-Package--daloyjs-core",
18
18
  "name": "@daloyjs/core",
19
- "versionInfo": "1.0.0-beta.7",
19
+ "versionInfo": "1.0.0-rc.0",
20
20
  "downloadLocation": "https://github.com/daloyjs/daloy",
21
21
  "filesAnalyzed": false,
22
22
  "licenseConcluded": "MIT",
@@ -27,7 +27,7 @@
27
27
  {
28
28
  "referenceCategory": "PACKAGE-MANAGER",
29
29
  "referenceType": "purl",
30
- "referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-beta.7"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.0"
31
31
  }
32
32
  ]
33
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-rc.0",
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": {