@daloyjs/core 0.35.0 → 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/README.md +145 -29
- package/dist/adapters/bun.d.ts +0 -1
- package/dist/adapters/bun.js +0 -1
- package/dist/adapters/cloudflare.d.ts +0 -1
- package/dist/adapters/cloudflare.js +0 -1
- package/dist/adapters/deno.d.ts +0 -1
- package/dist/adapters/deno.js +0 -1
- package/dist/adapters/fastly.d.ts +0 -1
- package/dist/adapters/fastly.js +0 -1
- package/dist/adapters/lambda.d.ts +0 -1
- package/dist/adapters/lambda.js +0 -1
- package/dist/adapters/node.d.ts +11 -1
- package/dist/adapters/node.js +246 -44
- package/dist/adapters/vercel.d.ts +0 -1
- package/dist/adapters/vercel.js +0 -1
- package/dist/app.d.ts +52 -1
- package/dist/app.js +557 -146
- package/dist/banner.d.ts +0 -1
- package/dist/banner.js +0 -1
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +0 -1
- package/dist/client.d.ts +0 -1
- package/dist/client.js +0 -1
- package/dist/combine.d.ts +0 -1
- package/dist/combine.js +0 -1
- package/dist/compression.d.ts +0 -1
- package/dist/compression.js +0 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -1
- package/dist/conn-info.d.ts +0 -1
- package/dist/conn-info.js +0 -1
- package/dist/contract.d.ts +0 -1
- package/dist/contract.js +0 -1
- package/dist/cookie.d.ts +0 -1
- package/dist/cookie.js +0 -1
- package/dist/dependency.d.ts +0 -1
- package/dist/dependency.js +0 -1
- package/dist/discriminator.d.ts +0 -1
- package/dist/discriminator.js +0 -1
- package/dist/docs.d.ts +0 -1
- package/dist/docs.js +0 -1
- package/dist/errors.d.ts +0 -1
- package/dist/errors.js +6 -3
- package/dist/etag.d.ts +0 -1
- package/dist/etag.js +0 -1
- package/dist/fetch-guard.d.ts +0 -1
- package/dist/fetch-guard.js +0 -1
- package/dist/hashing.d.ts +0 -1
- package/dist/hashing.js +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/ip-restriction.d.ts +0 -1
- package/dist/ip-restriction.js +0 -1
- package/dist/jwk.d.ts +0 -1
- package/dist/jwk.js +0 -1
- package/dist/jwt.d.ts +0 -1
- package/dist/jwt.js +0 -1
- package/dist/load-shedding.d.ts +0 -1
- package/dist/load-shedding.js +0 -1
- package/dist/logger.d.ts +0 -1
- package/dist/logger.js +0 -1
- package/dist/middleware.d.ts +0 -1
- package/dist/middleware.js +16 -13
- package/dist/multipart.d.ts +0 -1
- package/dist/multipart.js +0 -1
- package/dist/openapi.d.ts +0 -1
- package/dist/openapi.js +0 -1
- package/dist/rate-limit-redis.d.ts +0 -1
- package/dist/rate-limit-redis.js +0 -1
- package/dist/router.d.ts +0 -1
- package/dist/router.js +0 -1
- package/dist/safe-redirect.d.ts +2 -3
- package/dist/safe-redirect.js +2 -3
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/schema.d.ts +0 -1
- package/dist/schema.js +0 -1
- package/dist/security-schemes.d.ts +0 -1
- package/dist/security-schemes.js +0 -1
- package/dist/security.d.ts +0 -16
- package/dist/security.js +32 -4
- package/dist/session.d.ts +0 -1
- package/dist/session.js +0 -1
- package/dist/streaming.d.ts +0 -1
- package/dist/streaming.js +0 -1
- package/dist/subdomains.d.ts +0 -1
- package/dist/subdomains.js +0 -1
- package/dist/time-claims.d.ts +0 -1
- package/dist/time-claims.js +0 -1
- package/dist/tracing.d.ts +0 -1
- package/dist/tracing.js +0 -1
- package/dist/types.d.ts +0 -1
- package/dist/types.js +0 -1
- package/dist/websocket.d.ts +0 -1
- package/dist/websocket.js +0 -1
- package/package.json +6 -2
- package/dist/adapters/bun.d.ts.map +0 -1
- package/dist/adapters/bun.js.map +0 -1
- package/dist/adapters/cloudflare.d.ts.map +0 -1
- package/dist/adapters/cloudflare.js.map +0 -1
- package/dist/adapters/deno.d.ts.map +0 -1
- package/dist/adapters/deno.js.map +0 -1
- package/dist/adapters/fastly.d.ts.map +0 -1
- package/dist/adapters/fastly.js.map +0 -1
- package/dist/adapters/lambda.d.ts.map +0 -1
- package/dist/adapters/lambda.js.map +0 -1
- package/dist/adapters/node.d.ts.map +0 -1
- package/dist/adapters/node.js.map +0 -1
- package/dist/adapters/vercel.d.ts.map +0 -1
- package/dist/adapters/vercel.js.map +0 -1
- package/dist/app.d.ts.map +0 -1
- package/dist/app.js.map +0 -1
- package/dist/banner.d.ts.map +0 -1
- package/dist/banner.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/combine.d.ts.map +0 -1
- package/dist/combine.js.map +0 -1
- package/dist/compression.d.ts.map +0 -1
- package/dist/compression.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/conn-info.d.ts.map +0 -1
- package/dist/conn-info.js.map +0 -1
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js.map +0 -1
- package/dist/cookie.d.ts.map +0 -1
- package/dist/cookie.js.map +0 -1
- package/dist/dependency.d.ts.map +0 -1
- package/dist/dependency.js.map +0 -1
- package/dist/discriminator.d.ts.map +0 -1
- package/dist/discriminator.js.map +0 -1
- package/dist/docs.d.ts.map +0 -1
- package/dist/docs.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/etag.d.ts.map +0 -1
- package/dist/etag.js.map +0 -1
- package/dist/fetch-guard.d.ts.map +0 -1
- package/dist/fetch-guard.js.map +0 -1
- package/dist/hashing.d.ts.map +0 -1
- package/dist/hashing.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/ip-restriction.d.ts.map +0 -1
- package/dist/ip-restriction.js.map +0 -1
- package/dist/jwk.d.ts.map +0 -1
- package/dist/jwk.js.map +0 -1
- package/dist/jwt.d.ts.map +0 -1
- package/dist/jwt.js.map +0 -1
- package/dist/load-shedding.d.ts.map +0 -1
- package/dist/load-shedding.js.map +0 -1
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js.map +0 -1
- package/dist/middleware.d.ts.map +0 -1
- package/dist/middleware.js.map +0 -1
- package/dist/multipart.d.ts.map +0 -1
- package/dist/multipart.js.map +0 -1
- package/dist/openapi.d.ts.map +0 -1
- package/dist/openapi.js.map +0 -1
- package/dist/rate-limit-redis.d.ts.map +0 -1
- package/dist/rate-limit-redis.js.map +0 -1
- package/dist/router.d.ts.map +0 -1
- package/dist/router.js.map +0 -1
- package/dist/safe-redirect.d.ts.map +0 -1
- package/dist/safe-redirect.js.map +0 -1
- package/dist/schema.d.ts.map +0 -1
- package/dist/schema.js.map +0 -1
- package/dist/security-schemes.d.ts.map +0 -1
- package/dist/security-schemes.js.map +0 -1
- package/dist/security.d.ts.map +0 -1
- package/dist/security.js.map +0 -1
- package/dist/session.d.ts.map +0 -1
- package/dist/session.js.map +0 -1
- package/dist/streaming.d.ts.map +0 -1
- package/dist/streaming.js.map +0 -1
- package/dist/subdomains.d.ts.map +0 -1
- package/dist/subdomains.js.map +0 -1
- package/dist/time-claims.d.ts.map +0 -1
- package/dist/time-claims.js.map +0 -1
- package/dist/tracing.d.ts.map +0 -1
- package/dist/tracing.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/websocket.d.ts.map +0 -1
- package/dist/websocket.js.map +0 -1
package/dist/app.js
CHANGED
|
@@ -79,6 +79,35 @@ const DEFAULTS = {
|
|
|
79
79
|
requestTimeoutMs: 30_000,
|
|
80
80
|
validateResponses: true,
|
|
81
81
|
};
|
|
82
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
83
|
+
/**
|
|
84
|
+
* Internal Symbol used to stash the raw Uint8Array (or null) body on a
|
|
85
|
+
* `Response` produced by {@link serializeResult}. Adapters that want to
|
|
86
|
+
* avoid the cost of `await response.arrayBuffer()` for buffer-backed
|
|
87
|
+
* responses can read this property and write the bytes directly. The
|
|
88
|
+
* Symbol is intentionally module-private (not exported) to keep this an
|
|
89
|
+
* implementation detail — userland code should never depend on it.
|
|
90
|
+
*/
|
|
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");
|
|
82
111
|
/**
|
|
83
112
|
* Contract-first HTTP application.
|
|
84
113
|
*
|
|
@@ -134,6 +163,14 @@ export class App {
|
|
|
134
163
|
/** Public registry: enables OpenAPI gen, typed-client gen, dead-route detection. */
|
|
135
164
|
routes = [];
|
|
136
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;
|
|
137
174
|
/** WebSocket route registry. Adapters look up handlers via `app.webSocketRoutes.find()`. */
|
|
138
175
|
webSocketRoutes = new WebSocketRegistry();
|
|
139
176
|
prefix = "";
|
|
@@ -144,6 +181,13 @@ export class App {
|
|
|
144
181
|
routeSecurityMarkers = [];
|
|
145
182
|
/** Decorator bag merged into ctx.state on every request. */
|
|
146
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;
|
|
147
191
|
installedPlugins = new Set();
|
|
148
192
|
closeHooks = [];
|
|
149
193
|
closeHooksRun = false;
|
|
@@ -181,6 +225,25 @@ export class App {
|
|
|
181
225
|
* dashboards see the misconfiguration without flooding on every retry.
|
|
182
226
|
*/
|
|
183
227
|
trustProxyWarned = false;
|
|
228
|
+
/**
|
|
229
|
+
* Cached merge of `options.hooks` only. Used on the cold 404/405 path
|
|
230
|
+
* and as the baseline for cross-origin guard decisions when no route
|
|
231
|
+
* matches.
|
|
232
|
+
*/
|
|
233
|
+
_globalHooksCache;
|
|
234
|
+
_globalCorsAllowsCache;
|
|
235
|
+
get globalHooks() {
|
|
236
|
+
if (this._globalHooksCache === undefined) {
|
|
237
|
+
this._globalHooksCache = mergeHooks([this.options.hooks ?? {}]);
|
|
238
|
+
}
|
|
239
|
+
return this._globalHooksCache;
|
|
240
|
+
}
|
|
241
|
+
get globalCorsAllows() {
|
|
242
|
+
if (this._globalCorsAllowsCache === undefined) {
|
|
243
|
+
this._globalCorsAllowsCache = corsOriginAllowsFromHooks([this.options.hooks ?? {}]);
|
|
244
|
+
}
|
|
245
|
+
return this._globalCorsAllowsCache;
|
|
246
|
+
}
|
|
184
247
|
constructor(options = {}) {
|
|
185
248
|
this.options = {
|
|
186
249
|
validateResponses: options.validateResponses ?? DEFAULTS.validateResponses,
|
|
@@ -354,13 +417,20 @@ export class App {
|
|
|
354
417
|
* auto-mount and error response detail stripping.
|
|
355
418
|
*/
|
|
356
419
|
isProduction() {
|
|
420
|
+
if (this._productionCache !== undefined)
|
|
421
|
+
return this._productionCache;
|
|
422
|
+
let v;
|
|
357
423
|
if (this.options.env !== undefined)
|
|
358
|
-
|
|
359
|
-
if (this.options.production !== undefined)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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;
|
|
364
434
|
}
|
|
365
435
|
/**
|
|
366
436
|
* Cross-origin guard. Rejects state-changing requests (`POST` /
|
|
@@ -377,7 +447,7 @@ export class App {
|
|
|
377
447
|
* routes whose hook chain includes a `cors()` policy allowing the origin
|
|
378
448
|
* are unaffected.
|
|
379
449
|
*/
|
|
380
|
-
assertCrossOriginAllowed(request,
|
|
450
|
+
assertCrossOriginAllowed(request, requestUrl, method, corsOriginAllows) {
|
|
381
451
|
if (this.options.secureDefaults === false)
|
|
382
452
|
return;
|
|
383
453
|
if (this.options.corsCrossOriginGuard === false)
|
|
@@ -399,7 +469,8 @@ export class App {
|
|
|
399
469
|
// Malformed Origin header — refuse loudly.
|
|
400
470
|
throw new ForbiddenError("Cross-origin state-changing request rejected: malformed Origin header.");
|
|
401
471
|
}
|
|
402
|
-
|
|
472
|
+
const reqOrigin = typeof requestUrl === "string" ? new URL(requestUrl).origin : requestUrl.origin;
|
|
473
|
+
if (originUrl.origin === reqOrigin)
|
|
403
474
|
return;
|
|
404
475
|
if (corsOriginAllows.some((allows) => allows(origin)))
|
|
405
476
|
return;
|
|
@@ -728,11 +799,18 @@ export class App {
|
|
|
728
799
|
const sources = [...this.groupHooks, def.hooks ?? {}];
|
|
729
800
|
const hooks = mergeHooks(sources);
|
|
730
801
|
const corsOriginAllows = corsOriginAllowsFromHooks(sources);
|
|
802
|
+
const globalHookLayer = this.options.hooks ?? {};
|
|
803
|
+
const mergedHooks = mergeHooks([globalHookLayer, ...sources]);
|
|
804
|
+
const hasFinalizeHook = mergedHooks.onSend !== undefined || mergedHooks.onResponse !== undefined;
|
|
805
|
+
const fullCorsOriginAllows = [
|
|
806
|
+
...corsOriginAllowsFromHooks([globalHookLayer]),
|
|
807
|
+
...corsOriginAllows,
|
|
808
|
+
];
|
|
731
809
|
const securityMarkers = securityMarkersFromHooks([
|
|
732
|
-
|
|
810
|
+
globalHookLayer,
|
|
733
811
|
...sources,
|
|
734
812
|
]);
|
|
735
|
-
this.router.add(def.method, fullPath, { def: merged, hooks, corsOriginAllows }, def.operationId);
|
|
813
|
+
this.router.add(def.method, fullPath, { def: merged, hooks, mergedHooks, hasFinalizeHook, corsOriginAllows, fullCorsOriginAllows }, def.operationId);
|
|
736
814
|
this.routes.push(merged);
|
|
737
815
|
this.routeSecurityMarkers.push({
|
|
738
816
|
method: merged.method,
|
|
@@ -1193,7 +1271,10 @@ export class App {
|
|
|
1193
1271
|
if (opts.override === true && Object.prototype.hasOwnProperty.call(this.decorations, key)) {
|
|
1194
1272
|
this.log.warn({ event: "decorate.override", key }, `decorate("${key}") replaced an existing decoration.`);
|
|
1195
1273
|
}
|
|
1274
|
+
const hadKey = Object.prototype.hasOwnProperty.call(this.decorations, key);
|
|
1196
1275
|
this.decorations[key] = value;
|
|
1276
|
+
if (!hadKey)
|
|
1277
|
+
this.decorationsCount++;
|
|
1197
1278
|
return this;
|
|
1198
1279
|
}
|
|
1199
1280
|
/**
|
|
@@ -1419,14 +1500,20 @@ export class App {
|
|
|
1419
1500
|
}
|
|
1420
1501
|
this.inflight++;
|
|
1421
1502
|
const requestId = randomId();
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
+
});
|
|
1427
1514
|
const stripFingerprint = this.options.stripServerHeaders !== false;
|
|
1428
1515
|
let ctx;
|
|
1429
|
-
const globalHooks =
|
|
1516
|
+
const globalHooks = this.globalHooks;
|
|
1430
1517
|
let activeErrorHook = globalHooks.onError;
|
|
1431
1518
|
let activeResponseHook = globalHooks.onResponse;
|
|
1432
1519
|
let activeSendHook = globalHooks.onSend;
|
|
@@ -1435,26 +1522,35 @@ export class App {
|
|
|
1435
1522
|
assertNoReservedInternalHeaders(request.headers);
|
|
1436
1523
|
this.assertTrustProxyConfigured(request);
|
|
1437
1524
|
this.assertBootGuards();
|
|
1438
|
-
|
|
1439
|
-
|
|
1525
|
+
if (globalHooks.onRequest !== undefined) {
|
|
1526
|
+
const onRequestResult = globalHooks.onRequest(request);
|
|
1527
|
+
if (isPromiseLike(onRequestResult))
|
|
1528
|
+
await onRequestResult;
|
|
1529
|
+
}
|
|
1440
1530
|
const method = request.method;
|
|
1531
|
+
const requestUrl = request.url;
|
|
1532
|
+
const pathname = getPathnameFast(requestUrl);
|
|
1533
|
+
let url;
|
|
1534
|
+
const getUrl = () => (url ??= new URL(requestUrl));
|
|
1441
1535
|
const headFallback = method === "HEAD";
|
|
1442
|
-
const match = this.router.find(method,
|
|
1443
|
-
(headFallback ? this.router.find("GET",
|
|
1536
|
+
const match = this.router.find(method, pathname) ??
|
|
1537
|
+
(headFallback ? this.router.find("GET", pathname) : undefined);
|
|
1444
1538
|
// Hide internal routes from the public adapter surface. The router
|
|
1445
1539
|
// still finds them so app.inject() can dispatch normally, but
|
|
1446
1540
|
// app.fetch() responds 404 to avoid leaking existence.
|
|
1447
1541
|
const internalHidden = match?.handler.def.internal === true && opts.allowInternal !== true;
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1542
|
+
if (match && !internalHidden) {
|
|
1543
|
+
this.assertCrossOriginAllowed(request, requestUrl, method, match.handler.fullCorsOriginAllows);
|
|
1544
|
+
}
|
|
1545
|
+
else {
|
|
1546
|
+
this.assertCrossOriginAllowed(request, requestUrl, method, [...this.globalCorsAllows, ...this.corsOriginAllows]);
|
|
1547
|
+
}
|
|
1452
1548
|
if (!match || internalHidden) {
|
|
1453
1549
|
if (internalHidden) {
|
|
1454
1550
|
// Don't leak existence via 405/Allow header. Always 404.
|
|
1455
|
-
throw new NotFoundError(`No route for ${request.method} ${
|
|
1551
|
+
throw new NotFoundError(`No route for ${request.method} ${pathname}`);
|
|
1456
1552
|
}
|
|
1457
|
-
const rawAllowed = this.router.allowedMethods(
|
|
1553
|
+
const rawAllowed = this.router.allowedMethods(pathname);
|
|
1458
1554
|
// Filter out methods whose route definitions are marked
|
|
1459
1555
|
// `internal: true` unless the caller explicitly opted in via
|
|
1460
1556
|
// app.inject(). This prevents 405/Allow from leaking the
|
|
@@ -1462,19 +1558,52 @@ export class App {
|
|
|
1462
1558
|
const allowed = opts.allowInternal
|
|
1463
1559
|
? rawAllowed
|
|
1464
1560
|
: rawAllowed.filter((m) => {
|
|
1465
|
-
const candidate = this.router.find(m,
|
|
1561
|
+
const candidate = this.router.find(m, pathname);
|
|
1466
1562
|
return candidate?.handler.def.internal !== true;
|
|
1467
1563
|
});
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
+
}
|
|
1478
1607
|
if (allowed.length > 0) {
|
|
1479
1608
|
if (method === "OPTIONS") {
|
|
1480
1609
|
// Synthesize a preflight: let global hooks (e.g. CORS) intercept;
|
|
@@ -1492,10 +1621,12 @@ export class App {
|
|
|
1492
1621
|
this.options.hooks ?? {},
|
|
1493
1622
|
...this.groupHooks,
|
|
1494
1623
|
]);
|
|
1495
|
-
const
|
|
1624
|
+
const interceptedResult = preflightHooks.beforeHandle?.(synthCtx);
|
|
1625
|
+
const intercepted = isPromiseLike(interceptedResult) ? await interceptedResult : interceptedResult;
|
|
1496
1626
|
if (intercepted instanceof Response) {
|
|
1497
1627
|
copyContextHeaders(synthCtx, intercepted);
|
|
1498
|
-
|
|
1628
|
+
const fin = finalizeResponse(intercepted, synthCtx, preflightHooks, stripFingerprint);
|
|
1629
|
+
return isPromiseLike(fin) ? await fin : fin;
|
|
1499
1630
|
}
|
|
1500
1631
|
const res = new Response(null, {
|
|
1501
1632
|
status: 204,
|
|
@@ -1503,35 +1634,68 @@ export class App {
|
|
|
1503
1634
|
});
|
|
1504
1635
|
copyContextHeaders(synthCtx, res);
|
|
1505
1636
|
res.headers.set("x-request-id", requestId);
|
|
1506
|
-
|
|
1637
|
+
const fin2 = finalizeResponse(res, synthCtx, preflightHooks, stripFingerprint);
|
|
1638
|
+
return isPromiseLike(fin2) ? await fin2 : fin2;
|
|
1507
1639
|
}
|
|
1508
1640
|
throw new MethodNotAllowedError(allowed);
|
|
1509
1641
|
}
|
|
1510
|
-
throw new NotFoundError(`No route for ${request.method} ${
|
|
1642
|
+
throw new NotFoundError(`No route for ${request.method} ${pathname}`);
|
|
1511
1643
|
}
|
|
1512
|
-
const { def, hooks } = match.handler;
|
|
1513
|
-
const allHooks = mergeHooks([globalHooks, hooks]);
|
|
1644
|
+
const { def, hooks, mergedHooks: allHooks, hasFinalizeHook } = match.handler;
|
|
1514
1645
|
activeErrorHook = allHooks.onError;
|
|
1515
1646
|
activeResponseHook = allHooks.onResponse;
|
|
1516
1647
|
activeSendHook = allHooks.onSend;
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
const before = await allHooks.beforeHandle?.(ctx);
|
|
1522
|
-
if (before instanceof Response) {
|
|
1523
|
-
copyContextHeaders(ctx, before);
|
|
1524
|
-
return await finalizeResponse(before, ctx, allHooks, stripFingerprint);
|
|
1648
|
+
if (hooks.onRequest !== undefined) {
|
|
1649
|
+
const routeOnRequestResult = hooks.onRequest(request);
|
|
1650
|
+
if (isPromiseLike(routeOnRequestResult))
|
|
1651
|
+
await routeOnRequestResult;
|
|
1525
1652
|
}
|
|
1526
|
-
|
|
1653
|
+
ctx = await buildContext(request, getUrl, match.params, def, this.options);
|
|
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);
|
|
1662
|
+
if (allHooks.beforeHandle !== undefined) {
|
|
1663
|
+
const beforeResult = allHooks.beforeHandle(ctx);
|
|
1664
|
+
const before = isPromiseLike(beforeResult) ? await beforeResult : beforeResult;
|
|
1665
|
+
if (before instanceof Response) {
|
|
1666
|
+
copyContextHeaders(ctx, before);
|
|
1667
|
+
if (!before.headers.has("x-request-id"))
|
|
1668
|
+
before.headers.set("x-request-id", requestId);
|
|
1669
|
+
if (hasFinalizeHook) {
|
|
1670
|
+
const fin = finalizeResponse(before, ctx, allHooks, stripFingerprint);
|
|
1671
|
+
return isPromiseLike(fin) ? await fin : fin;
|
|
1672
|
+
}
|
|
1673
|
+
return finalizeFast(before, stripFingerprint);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
const runResult = this.options.mockMode
|
|
1527
1677
|
? mockResponseFor(def)
|
|
1528
|
-
:
|
|
1529
|
-
|
|
1530
|
-
if (
|
|
1531
|
-
|
|
1532
|
-
|
|
1678
|
+
: runHandler(def, ctx, this.options.requestTimeoutMs);
|
|
1679
|
+
let result = isPromiseLike(runResult) ? await runResult : runResult;
|
|
1680
|
+
if (allHooks.afterHandle !== undefined) {
|
|
1681
|
+
const afterResult = allHooks.afterHandle(ctx, result);
|
|
1682
|
+
const afterReturn = isPromiseLike(afterResult) ? await afterResult : afterResult;
|
|
1683
|
+
if (afterReturn !== undefined)
|
|
1684
|
+
result = afterReturn;
|
|
1685
|
+
}
|
|
1686
|
+
const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true);
|
|
1687
|
+
let response = isPromiseLike(serializeResultRes) ? await serializeResultRes : serializeResultRes;
|
|
1533
1688
|
copyContextHeaders(ctx, response);
|
|
1534
|
-
|
|
1689
|
+
if (!response.headers.has("x-request-id"))
|
|
1690
|
+
response.headers.set("x-request-id", requestId);
|
|
1691
|
+
let finalized;
|
|
1692
|
+
if (hasFinalizeHook) {
|
|
1693
|
+
const fin = finalizeResponse(response, ctx, allHooks, stripFingerprint);
|
|
1694
|
+
finalized = isPromiseLike(fin) ? await fin : fin;
|
|
1695
|
+
}
|
|
1696
|
+
else {
|
|
1697
|
+
finalized = finalizeFast(response, stripFingerprint);
|
|
1698
|
+
}
|
|
1535
1699
|
if (method === "HEAD") {
|
|
1536
1700
|
return new Response(null, {
|
|
1537
1701
|
status: finalized.status,
|
|
@@ -1542,7 +1706,15 @@ export class App {
|
|
|
1542
1706
|
return finalized;
|
|
1543
1707
|
}
|
|
1544
1708
|
catch (err) {
|
|
1545
|
-
|
|
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
|
+
}
|
|
1546
1718
|
if (handled instanceof Response) {
|
|
1547
1719
|
if (ctx)
|
|
1548
1720
|
copyContextHeaders(ctx, handled);
|
|
@@ -1557,10 +1729,12 @@ export class App {
|
|
|
1557
1729
|
// the request at `disconnectStatusCode` (default 499) instead of
|
|
1558
1730
|
// letting an AbortError bubble up as a generic 5xx. Logged at `info`
|
|
1559
1731
|
// so disconnect storms do not look like service incidents.
|
|
1560
|
-
|
|
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);
|
|
1561
1736
|
if (disconnectCode > 0 &&
|
|
1562
|
-
request.signal?.aborted === true
|
|
1563
|
-
!(err instanceof HttpError)) {
|
|
1737
|
+
request.signal?.aborted === true) {
|
|
1564
1738
|
log.info({ event: "request.disconnected", status: disconnectCode }, "Client disconnected before response was sent");
|
|
1565
1739
|
const res = new Response(null, {
|
|
1566
1740
|
status: disconnectCode,
|
|
@@ -1576,7 +1750,7 @@ export class App {
|
|
|
1576
1750
|
onResponse: activeResponseHook,
|
|
1577
1751
|
}, stripFingerprint);
|
|
1578
1752
|
}
|
|
1579
|
-
const httpErr =
|
|
1753
|
+
const httpErr = isHttp
|
|
1580
1754
|
? err
|
|
1581
1755
|
: new InternalError(err instanceof Error ? err.message : "Unexpected error");
|
|
1582
1756
|
if (httpErr.status >= 500)
|
|
@@ -1903,6 +2077,34 @@ function isStateChangingMethod(method) {
|
|
|
1903
2077
|
method === "PATCH" ||
|
|
1904
2078
|
method === "DELETE");
|
|
1905
2079
|
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Extract the pathname from a fully-qualified request URL without
|
|
2082
|
+
* constructing a `URL` object. ~10x cheaper than `new URL(...).pathname`
|
|
2083
|
+
* and used on the dispatch hot path; full `URL` parsing is deferred until
|
|
2084
|
+
* something actually reads `searchParams`. Mirrors Hono's `getPath`
|
|
2085
|
+
* approach.
|
|
2086
|
+
*
|
|
2087
|
+
* Handles the standard `http(s)://host[:port]/path[?query][#hash]` shape
|
|
2088
|
+
* produced by the Node adapter and the Fetch standard. Falls back to a
|
|
2089
|
+
* `URL` parse for inputs that don't match (e.g. opaque schemes), so
|
|
2090
|
+
* correctness is preserved even when the fast path doesn't apply.
|
|
2091
|
+
*/
|
|
2092
|
+
function getPathnameFast(url) {
|
|
2093
|
+
const schemeEnd = url.indexOf("://");
|
|
2094
|
+
if (schemeEnd === -1)
|
|
2095
|
+
return new URL(url).pathname;
|
|
2096
|
+
const pathStart = url.indexOf("/", schemeEnd + 3);
|
|
2097
|
+
if (pathStart === -1)
|
|
2098
|
+
return "/";
|
|
2099
|
+
let end = url.length;
|
|
2100
|
+
const q = url.indexOf("?", pathStart);
|
|
2101
|
+
if (q !== -1)
|
|
2102
|
+
end = q;
|
|
2103
|
+
const h = url.indexOf("#", pathStart);
|
|
2104
|
+
if (h !== -1 && h < end)
|
|
2105
|
+
end = h;
|
|
2106
|
+
return url.slice(pathStart, end);
|
|
2107
|
+
}
|
|
1906
2108
|
function mergeHooks(layers) {
|
|
1907
2109
|
const pick = (key) => layers
|
|
1908
2110
|
.map((h) => h[key])
|
|
@@ -1941,6 +2143,11 @@ function stampRequiredScopes(hooks, scopes) {
|
|
|
1941
2143
|
function mergeBeforeHandle(beforeHandle, requiredScopes) {
|
|
1942
2144
|
if (!beforeHandle && requiredScopes.length === 0)
|
|
1943
2145
|
return undefined;
|
|
2146
|
+
// No scope aggregation needed → return the inner fn as-is so the dispatch
|
|
2147
|
+
// hot path can use the single-fn fast path and avoid an extra closure +
|
|
2148
|
+
// microtask wrapper around a possibly-sync beforeHandle.
|
|
2149
|
+
if (requiredScopes.length === 0)
|
|
2150
|
+
return beforeHandle;
|
|
1944
2151
|
return async (ctx) => {
|
|
1945
2152
|
if (requiredScopes.length > 0) {
|
|
1946
2153
|
const state = ctx.state;
|
|
@@ -1958,6 +2165,8 @@ function mergeBeforeHandle(beforeHandle, requiredScopes) {
|
|
|
1958
2165
|
function responsePipeline(fns) {
|
|
1959
2166
|
if (fns.length === 0)
|
|
1960
2167
|
return undefined;
|
|
2168
|
+
if (fns.length === 1)
|
|
2169
|
+
return fns[0];
|
|
1961
2170
|
return async (res, ctx) => {
|
|
1962
2171
|
let current = res;
|
|
1963
2172
|
for (const fn of fns) {
|
|
@@ -1968,19 +2177,59 @@ function responsePipeline(fns) {
|
|
|
1968
2177
|
return current;
|
|
1969
2178
|
};
|
|
1970
2179
|
}
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
const
|
|
2180
|
+
function finalizeResponse(res, ctx, hooks, stripFingerprint = true) {
|
|
2181
|
+
let final = res;
|
|
2182
|
+
const finish = (f) => {
|
|
2183
|
+
if (stripFingerprint) {
|
|
2184
|
+
f.headers.delete("server");
|
|
2185
|
+
f.headers.delete("x-powered-by");
|
|
2186
|
+
}
|
|
2187
|
+
if (hooks.onResponse !== undefined) {
|
|
2188
|
+
const onResponseResult = hooks.onResponse(f);
|
|
2189
|
+
if (isPromiseLike(onResponseResult)) {
|
|
2190
|
+
return onResponseResult.then(() => f);
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
return f;
|
|
2194
|
+
};
|
|
2195
|
+
if (hooks.onSend !== undefined) {
|
|
2196
|
+
const sentResult = hooks.onSend(res, ctx);
|
|
2197
|
+
if (isPromiseLike(sentResult)) {
|
|
2198
|
+
return sentResult.then((sent) => {
|
|
2199
|
+
if (sent instanceof Response)
|
|
2200
|
+
final = sent;
|
|
2201
|
+
return finish(final);
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
else {
|
|
2205
|
+
if (sentResult instanceof Response)
|
|
2206
|
+
final = sentResult;
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
return finish(final);
|
|
2210
|
+
}
|
|
2211
|
+
function isPromiseLike(value) {
|
|
2212
|
+
return value !== null && typeof value === "object" && typeof value.then === "function";
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* Allocation-free finalizer for the common case (no `onSend`/`onResponse`
|
|
2216
|
+
* hooks). Avoids the two `await undefined` microtasks per request that
|
|
2217
|
+
* dominate sub-millisecond dispatch overhead in the no-middleware
|
|
2218
|
+
* benchmark. Behaviour parity with {@link finalizeResponse}: only the
|
|
2219
|
+
* server-fingerprint headers are stripped.
|
|
2220
|
+
*/
|
|
2221
|
+
function finalizeFast(res, stripFingerprint) {
|
|
1974
2222
|
if (stripFingerprint) {
|
|
1975
|
-
|
|
1976
|
-
|
|
2223
|
+
res.headers.delete("server");
|
|
2224
|
+
res.headers.delete("x-powered-by");
|
|
1977
2225
|
}
|
|
1978
|
-
|
|
1979
|
-
return final;
|
|
2226
|
+
return res;
|
|
1980
2227
|
}
|
|
1981
2228
|
function chain(fns) {
|
|
1982
2229
|
if (fns.length === 0)
|
|
1983
2230
|
return undefined;
|
|
2231
|
+
if (fns.length === 1)
|
|
2232
|
+
return fns[0];
|
|
1984
2233
|
return (async (...args) => {
|
|
1985
2234
|
for (const fn of fns)
|
|
1986
2235
|
await fn(...args);
|
|
@@ -1989,6 +2238,8 @@ function chain(fns) {
|
|
|
1989
2238
|
function firstResponse(fns) {
|
|
1990
2239
|
if (fns.length === 0)
|
|
1991
2240
|
return undefined;
|
|
2241
|
+
if (fns.length === 1)
|
|
2242
|
+
return fns[0];
|
|
1992
2243
|
return (async (...args) => {
|
|
1993
2244
|
for (const fn of fns) {
|
|
1994
2245
|
const r = await fn(...args);
|
|
@@ -2001,6 +2252,8 @@ function firstResponse(fns) {
|
|
|
2001
2252
|
function pipeline(fns) {
|
|
2002
2253
|
if (fns.length === 0)
|
|
2003
2254
|
return undefined;
|
|
2255
|
+
if (fns.length === 1)
|
|
2256
|
+
return fns[0];
|
|
2004
2257
|
return (async (ctx, value) => {
|
|
2005
2258
|
let v = value;
|
|
2006
2259
|
for (const fn of fns) {
|
|
@@ -2011,8 +2264,40 @@ function pipeline(fns) {
|
|
|
2011
2264
|
return v;
|
|
2012
2265
|
});
|
|
2013
2266
|
}
|
|
2267
|
+
/**
|
|
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.
|
|
2279
|
+
*/
|
|
2280
|
+
class LazyResponseSet {
|
|
2281
|
+
status = undefined;
|
|
2282
|
+
_h = undefined;
|
|
2283
|
+
touched = false;
|
|
2284
|
+
get headers() {
|
|
2285
|
+
const h = this._h;
|
|
2286
|
+
if (h !== undefined)
|
|
2287
|
+
return h;
|
|
2288
|
+
this.touched = true;
|
|
2289
|
+
return (this._h = new Headers());
|
|
2290
|
+
}
|
|
2291
|
+
set headers(v) {
|
|
2292
|
+
this._h = v;
|
|
2293
|
+
this.touched = true;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2014
2296
|
function copyContextHeaders(ctx, res) {
|
|
2015
|
-
ctx.set
|
|
2297
|
+
const set = ctx.set;
|
|
2298
|
+
if (set.touched !== true)
|
|
2299
|
+
return;
|
|
2300
|
+
set._h.forEach((v, k) => {
|
|
2016
2301
|
if (!res.headers.has(k))
|
|
2017
2302
|
res.headers.set(k, v);
|
|
2018
2303
|
});
|
|
@@ -2020,49 +2305,132 @@ function copyContextHeaders(ctx, res) {
|
|
|
2020
2305
|
function hasRequestSchema(request, key) {
|
|
2021
2306
|
return !!request && !!request[key];
|
|
2022
2307
|
}
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
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
|
+
}
|
|
2362
|
+
function buildContext(request, getUrl, rawParams, def, opts) {
|
|
2363
|
+
const set = new LazyResponseSet();
|
|
2364
|
+
const hasHeadersSchema = !!def.request?.headers;
|
|
2365
|
+
const hasQuerySchema = !!def.request?.query;
|
|
2366
|
+
let headersObj;
|
|
2367
|
+
let queryObj;
|
|
2368
|
+
const buildHeaders = () => (headersObj ??= headersToObject(request.headers));
|
|
2369
|
+
const buildQuery = () => (queryObj ??= queryToObject(getUrl().searchParams));
|
|
2027
2370
|
let params = rawParams;
|
|
2028
|
-
let query
|
|
2029
|
-
let headers
|
|
2371
|
+
let query;
|
|
2372
|
+
let headers;
|
|
2030
2373
|
let body = undefined;
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
const r = await validate(def.request.query, queryObj);
|
|
2039
|
-
if (r.issues)
|
|
2040
|
-
throw new ValidationError("query", toIssues(r.issues));
|
|
2041
|
-
query = r.value;
|
|
2042
|
-
}
|
|
2043
|
-
if (def.request?.headers) {
|
|
2044
|
-
const r = await validate(def.request.headers, headersObj);
|
|
2045
|
-
if (r.issues)
|
|
2046
|
-
throw new ValidationError("headers", toIssues(r.issues));
|
|
2047
|
-
headers = r.value;
|
|
2048
|
-
}
|
|
2049
|
-
if (def.request?.body) {
|
|
2050
|
-
const ct = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
2051
|
-
const allowed = def.accepts ?? opts.allowedContentTypes ?? [
|
|
2052
|
-
"application/json",
|
|
2053
|
-
"application/x-www-form-urlencoded",
|
|
2054
|
-
"multipart/form-data",
|
|
2055
|
-
];
|
|
2056
|
-
if (!allowed.some((a) => ct.includes(a))) {
|
|
2057
|
-
throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
|
|
2374
|
+
const hasSchema = def.request?.params || def.request?.query || def.request?.headers || def.request?.body;
|
|
2375
|
+
const finishContext = () => {
|
|
2376
|
+
const ctx = new RequestContext(request, params, {}, set);
|
|
2377
|
+
ctx.body = body;
|
|
2378
|
+
if (hasQuerySchema) {
|
|
2379
|
+
ctx._q = query;
|
|
2380
|
+
ctx._qSet = true;
|
|
2058
2381
|
}
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2382
|
+
else {
|
|
2383
|
+
ctx._qBuilder = buildQuery;
|
|
2384
|
+
}
|
|
2385
|
+
if (hasHeadersSchema) {
|
|
2386
|
+
ctx._h = headers;
|
|
2387
|
+
ctx._hSet = true;
|
|
2388
|
+
}
|
|
2389
|
+
else {
|
|
2390
|
+
ctx._hBuilder = buildHeaders;
|
|
2391
|
+
}
|
|
2392
|
+
return ctx;
|
|
2393
|
+
};
|
|
2394
|
+
if (!hasSchema) {
|
|
2395
|
+
return finishContext();
|
|
2396
|
+
}
|
|
2397
|
+
return (async () => {
|
|
2398
|
+
if (def.request?.params) {
|
|
2399
|
+
const r = await validate(def.request.params, rawParams);
|
|
2400
|
+
if (r.issues)
|
|
2401
|
+
throw new ValidationError("params", toIssues(r.issues));
|
|
2402
|
+
params = r.value;
|
|
2403
|
+
}
|
|
2404
|
+
if (hasQuerySchema) {
|
|
2405
|
+
const r = await validate(def.request.query, buildQuery());
|
|
2406
|
+
if (r.issues)
|
|
2407
|
+
throw new ValidationError("query", toIssues(r.issues));
|
|
2408
|
+
query = r.value;
|
|
2409
|
+
}
|
|
2410
|
+
if (hasHeadersSchema) {
|
|
2411
|
+
const r = await validate(def.request.headers, buildHeaders());
|
|
2412
|
+
if (r.issues)
|
|
2413
|
+
throw new ValidationError("headers", toIssues(r.issues));
|
|
2414
|
+
headers = r.value;
|
|
2415
|
+
}
|
|
2416
|
+
if (def.request?.body) {
|
|
2417
|
+
const ct = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
2418
|
+
const allowed = def.accepts ?? opts.allowedContentTypes ?? [
|
|
2419
|
+
"application/json",
|
|
2420
|
+
"application/x-www-form-urlencoded",
|
|
2421
|
+
"multipart/form-data",
|
|
2422
|
+
];
|
|
2423
|
+
if (!allowed.some((a) => ct.includes(a))) {
|
|
2424
|
+
throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
|
|
2425
|
+
}
|
|
2426
|
+
const raw = await readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
|
|
2427
|
+
const r = await validate(def.request.body, raw);
|
|
2428
|
+
if (r.issues)
|
|
2429
|
+
throw new ValidationError("body", toIssues(r.issues));
|
|
2430
|
+
body = r.value;
|
|
2431
|
+
}
|
|
2432
|
+
return finishContext();
|
|
2433
|
+
})();
|
|
2066
2434
|
}
|
|
2067
2435
|
function headersToObject(h) {
|
|
2068
2436
|
const o = {};
|
|
@@ -2155,41 +2523,84 @@ async function readBody(req, ct, limit, multipart) {
|
|
|
2155
2523
|
const bytes = await readBodyLimited(req, limit);
|
|
2156
2524
|
return new TextDecoder().decode(bytes);
|
|
2157
2525
|
}
|
|
2158
|
-
|
|
2526
|
+
function serializeResult(result, def, validateResponses) {
|
|
2159
2527
|
const spec = def.responses[result.status];
|
|
2160
2528
|
if (!spec) {
|
|
2161
2529
|
throw new InternalError(`Handler returned status ${result.status} which is not declared in responses for ${def.method} ${def.path}`);
|
|
2162
2530
|
}
|
|
2531
|
+
const finish = () => {
|
|
2532
|
+
const headers = new Headers(result.headers);
|
|
2533
|
+
const explicitCt = headers.get("content-type");
|
|
2534
|
+
const treatAsJson = !explicitCt || explicitCt.includes("application/json");
|
|
2535
|
+
if (!explicitCt)
|
|
2536
|
+
headers.set("content-type", "application/json");
|
|
2537
|
+
let body;
|
|
2538
|
+
let rawBody = null;
|
|
2539
|
+
let isStream = false;
|
|
2540
|
+
if (result.body === undefined || result.body === null) {
|
|
2541
|
+
body = null;
|
|
2542
|
+
}
|
|
2543
|
+
else if (!treatAsJson && typeof result.body === "string") {
|
|
2544
|
+
const bytes = TEXT_ENCODER.encode(result.body);
|
|
2545
|
+
setContentLength(headers, bytes.byteLength);
|
|
2546
|
+
body = bytes;
|
|
2547
|
+
rawBody = bytes;
|
|
2548
|
+
}
|
|
2549
|
+
else if (!treatAsJson && result.body instanceof Uint8Array) {
|
|
2550
|
+
setContentLength(headers, result.body.byteLength);
|
|
2551
|
+
body = result.body;
|
|
2552
|
+
rawBody = result.body;
|
|
2553
|
+
}
|
|
2554
|
+
else if (!treatAsJson && result.body instanceof ArrayBuffer) {
|
|
2555
|
+
setContentLength(headers, result.body.byteLength);
|
|
2556
|
+
body = result.body;
|
|
2557
|
+
rawBody = new Uint8Array(result.body);
|
|
2558
|
+
}
|
|
2559
|
+
else if (!treatAsJson && result.body instanceof ReadableStream) {
|
|
2560
|
+
body = result.body;
|
|
2561
|
+
isStream = true;
|
|
2562
|
+
}
|
|
2563
|
+
else {
|
|
2564
|
+
const bytes = TEXT_ENCODER.encode(JSON.stringify(result.body));
|
|
2565
|
+
setContentLength(headers, bytes.byteLength);
|
|
2566
|
+
body = bytes;
|
|
2567
|
+
rawBody = bytes;
|
|
2568
|
+
}
|
|
2569
|
+
const response = new Response(body, { status: result.status, headers });
|
|
2570
|
+
if (!isStream) {
|
|
2571
|
+
response[DALOY_RAW_BODY] = rawBody;
|
|
2572
|
+
}
|
|
2573
|
+
return response;
|
|
2574
|
+
};
|
|
2163
2575
|
if (validateResponses && spec.body) {
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
return new Response(body, { status: result.status, headers });
|
|
2576
|
+
// Call the Standard Schema validator directly so a sync validator
|
|
2577
|
+
// stays on the sync fast path. The public `validate()` helper always
|
|
2578
|
+
// returns a Promise (stable API surface), but here we own the schema
|
|
2579
|
+
// and can branch on whether validation actually needed to suspend.
|
|
2580
|
+
const r = spec.body["~standard"].validate(result.body);
|
|
2581
|
+
if (isPromiseLike(r)) {
|
|
2582
|
+
return r.then((resolved) => {
|
|
2583
|
+
if (resolved.issues) {
|
|
2584
|
+
throw new InternalError(`Response body for ${def.method} ${def.path} failed schema validation: ${resolved.issues
|
|
2585
|
+
.map((i) => i.message)
|
|
2586
|
+
.join("; ")}`);
|
|
2587
|
+
}
|
|
2588
|
+
return finish();
|
|
2589
|
+
});
|
|
2590
|
+
}
|
|
2591
|
+
else {
|
|
2592
|
+
if (r.issues) {
|
|
2593
|
+
throw new InternalError(`Response body for ${def.method} ${def.path} failed schema validation: ${r.issues
|
|
2594
|
+
.map((i) => i.message)
|
|
2595
|
+
.join("; ")}`);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
return finish();
|
|
2600
|
+
}
|
|
2601
|
+
function setContentLength(headers, byteLength) {
|
|
2602
|
+
if (!headers.has("content-length"))
|
|
2603
|
+
headers.set("content-length", String(byteLength));
|
|
2193
2604
|
}
|
|
2194
2605
|
function mockResponseFor(def) {
|
|
2195
2606
|
const statuses = Object.keys(def.responses).map(Number).sort();
|
|
@@ -2203,10 +2614,11 @@ function mockResponseFor(def) {
|
|
|
2203
2614
|
return { status, body: example };
|
|
2204
2615
|
}
|
|
2205
2616
|
function runHandler(def, ctx, requestTimeoutMs) {
|
|
2206
|
-
const
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2617
|
+
const result = def.handler(ctx);
|
|
2618
|
+
if (requestTimeoutMs === 0 || !isPromiseLike(result)) {
|
|
2619
|
+
return result;
|
|
2620
|
+
}
|
|
2621
|
+
return withTimeout(result, requestTimeoutMs);
|
|
2210
2622
|
}
|
|
2211
2623
|
function withTimeout(p, ms) {
|
|
2212
2624
|
return new Promise((resolve, reject) => {
|
|
@@ -2347,4 +2759,3 @@ function readHostPackageJsonInfo() {
|
|
|
2347
2759
|
export function _resetPackageJsonCacheForTests() {
|
|
2348
2760
|
PACKAGE_JSON_CACHE.value = undefined;
|
|
2349
2761
|
}
|
|
2350
|
-
//# sourceMappingURL=app.js.map
|