@daloyjs/core 1.0.0-rc.3 → 1.0.0-rc.5

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.
Files changed (61) hide show
  1. package/README.md +103 -41
  2. package/dist/adapters/bun.d.ts +20 -2
  3. package/dist/adapters/bun.js +41 -5
  4. package/dist/adapters/deno.js +24 -7
  5. package/dist/adapters/lambda.d.ts +59 -2
  6. package/dist/adapters/lambda.js +136 -20
  7. package/dist/adapters/node.d.ts +8 -1
  8. package/dist/adapters/node.js +104 -19
  9. package/dist/app.d.ts +131 -11
  10. package/dist/app.js +305 -217
  11. package/dist/bot-guard.js +30 -3
  12. package/dist/cli.js +41 -1
  13. package/dist/client.d.ts +64 -18
  14. package/dist/client.js +36 -6
  15. package/dist/combine.d.ts +11 -11
  16. package/dist/combine.js +90 -47
  17. package/dist/compression.d.ts +9 -0
  18. package/dist/compression.js +72 -1
  19. package/dist/conn-info.d.ts +5 -2
  20. package/dist/conn-info.js +5 -2
  21. package/dist/docs.d.ts +5 -9
  22. package/dist/docs.js +36 -14
  23. package/dist/errors.d.ts +12 -3
  24. package/dist/errors.js +12 -3
  25. package/dist/fetch-guard.d.ts +27 -19
  26. package/dist/fetch-guard.js +50 -8
  27. package/dist/http-signatures.d.ts +4 -1
  28. package/dist/http-signatures.js +13 -1
  29. package/dist/idempotency.js +2 -1
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +3 -3
  32. package/dist/internal-response.d.ts +15 -0
  33. package/dist/internal-response.js +27 -0
  34. package/dist/jwk.d.ts +11 -7
  35. package/dist/jwk.js +11 -7
  36. package/dist/logger.d.ts +45 -0
  37. package/dist/logger.js +137 -0
  38. package/dist/mcp.js +21 -15
  39. package/dist/middleware.d.ts +48 -7
  40. package/dist/middleware.js +129 -43
  41. package/dist/mtls.d.ts +6 -5
  42. package/dist/mtls.js +8 -9
  43. package/dist/openapi.js +1 -1
  44. package/dist/pagination.js +4 -1
  45. package/dist/response-cache.js +2 -1
  46. package/dist/router.d.ts +2 -2
  47. package/dist/router.js +24 -9
  48. package/dist/safe-redirect.d.ts +9 -2
  49. package/dist/safe-redirect.js +29 -4
  50. package/dist/sbom.cdx.json +9 -9
  51. package/dist/sbom.spdx.json +5 -5
  52. package/dist/security.d.ts +62 -0
  53. package/dist/security.js +220 -15
  54. package/dist/session.d.ts +13 -2
  55. package/dist/session.js +111 -17
  56. package/dist/tenancy.d.ts +2 -2
  57. package/dist/time-claims.js +3 -1
  58. package/dist/types.d.ts +85 -20
  59. package/dist/types.js +16 -1
  60. package/dist/waf.js +86 -26
  61. package/package.json +11 -4
package/dist/bot-guard.js CHANGED
@@ -76,8 +76,14 @@ function matchesUserAgent(ua, patterns) {
76
76
  if (pattern && lower.includes(pattern.toLowerCase()))
77
77
  return true;
78
78
  }
79
- else if (pattern.test(ua)) {
80
- return true;
79
+ else {
80
+ // Reset lastIndex so caller-supplied /g or /y regexes cannot flip-flop
81
+ // between match and miss across requests (intermittent allowlist bypass).
82
+ pattern.lastIndex = 0;
83
+ const hit = pattern.test(ua);
84
+ pattern.lastIndex = 0;
85
+ if (hit)
86
+ return true;
81
87
  }
82
88
  }
83
89
  return false;
@@ -225,12 +231,28 @@ export function botGuard(opts = {}) {
225
231
  };
226
232
  const writeCache = (key, verified) => {
227
233
  const now = Date.now();
234
+ // Move this key to the newest insertion slot on every (re)write. Eviction
235
+ // below is therefore FIFO over WRITE-recency (Map preserves insertion
236
+ // order), not true LRU: cache *reads* on the verification path do not
237
+ // reorder entries, so a frequently-read-but-never-rewritten key can still
238
+ // be evicted. That is intentional — reordering on read would add a Map
239
+ // delete+set to the hot lookup path for no security benefit.
240
+ if (cache.has(key))
241
+ cache.delete(key);
228
242
  cache.set(key, { verified, expiresMs: now + cacheTtlMs });
229
243
  if (cache.size > cacheMax) {
230
244
  for (const [k, v] of cache)
231
245
  if (v.expiresMs <= now)
232
246
  cache.delete(k);
233
247
  }
248
+ // Still over the cap after pruning expired entries: evict the
249
+ // oldest-written live keys (front of insertion order) until within cacheMax.
250
+ while (cache.size > cacheMax) {
251
+ const oldest = cache.keys().next().value;
252
+ if (oldest === undefined)
253
+ break;
254
+ cache.delete(oldest);
255
+ }
234
256
  };
235
257
  const reject = (event) => {
236
258
  opts.onBlock?.(event);
@@ -252,7 +274,12 @@ export function botGuard(opts = {}) {
252
274
  reject({ reason: "blocked-user-agent", userAgent: ua });
253
275
  return undefined;
254
276
  }
255
- const rule = verifiedBots.find((r) => r.userAgent.test(ua));
277
+ const rule = verifiedBots.find((r) => {
278
+ r.userAgent.lastIndex = 0;
279
+ const hit = r.userAgent.test(ua);
280
+ r.userAgent.lastIndex = 0;
281
+ return hit;
282
+ });
256
283
  if (!rule)
257
284
  return undefined;
258
285
  const ip = resolveIp(ctx);
package/dist/cli.js CHANGED
@@ -704,6 +704,44 @@ async function runDoctor(opts, io) {
704
704
  "header-count amplification defence.",
705
705
  });
706
706
  }
707
+ // JSON structural limits audit. The new jsonMaxKeys / jsonMaxDepth
708
+ // guards protect against hash-flood / deep-nesting DoS inside the byte
709
+ // limit. Surface when disabled (0) or raised to an implausibly high
710
+ // value.
711
+ const jsonMaxKeys = o.jsonMaxKeys;
712
+ if (jsonMaxKeys === 0) {
713
+ findings.push({
714
+ level: "warn",
715
+ code: "audit.jsonMaxKeys.disabled",
716
+ message: "jsonMaxKeys is 0 — the wide-object / hash-flood structural limit " +
717
+ "is disabled. An attacker can send tens or hundreds of thousands " +
718
+ "of keys in a body that still fits under bodyLimitBytes.",
719
+ });
720
+ }
721
+ else if (typeof jsonMaxKeys === "number" && jsonMaxKeys > 100_000) {
722
+ findings.push({
723
+ level: "warn",
724
+ code: "audit.jsonMaxKeys.blanket",
725
+ message: `jsonMaxKeys is ${jsonMaxKeys} (> 100k). A cap this high weakens ` +
726
+ "protection against wide-object DoS payloads.",
727
+ });
728
+ }
729
+ const jsonMaxDepth = o.jsonMaxDepth;
730
+ if (jsonMaxDepth === 0) {
731
+ findings.push({
732
+ level: "warn",
733
+ code: "audit.jsonMaxDepth.disabled",
734
+ message: "jsonMaxDepth is 0 — deep nesting DoS protection is disabled.",
735
+ });
736
+ }
737
+ else if (typeof jsonMaxDepth === "number" && jsonMaxDepth > 200) {
738
+ findings.push({
739
+ level: "warn",
740
+ code: "audit.jsonMaxDepth.blanket",
741
+ message: `jsonMaxDepth is ${jsonMaxDepth} (> 200). Extremely deep JSON is ` +
742
+ "almost never legitimate and can amplify CPU during validation.",
743
+ });
744
+ }
707
745
  // Idle-timeout / request-timeout audit. Reaffirms the
708
746
  // existing requestTimeoutMs check; also surface an explicit zero
709
747
  // idleTimeoutMs in production. The framework also keeps adapter
@@ -921,7 +959,9 @@ function aiResponses(responses) {
921
959
  for (const [status, spec] of Object.entries(responses)) {
922
960
  if (!spec)
923
961
  continue;
924
- const entry = { description: spec.description };
962
+ const entry = {
963
+ description: spec.description ?? `HTTP ${status} response`,
964
+ };
925
965
  if (spec.body)
926
966
  entry.body = aiSchema(spec.body);
927
967
  if (spec.examples)
package/dist/client.d.ts CHANGED
@@ -10,38 +10,58 @@
10
10
  * can still be generated from the OpenAPI doc for non-TS clients).
11
11
  */
12
12
  import type { App } from "./app.js";
13
- import type { HandlerReturn, InferRequest, RequestSchemas, ResponsesMap, RouteDefinition } from "./types.js";
13
+ import type { HandlerReturn, InferRequest, ParamsOf, RequestSchemas, ResponsesMap, RouteDefinition } from "./types.js";
14
14
  /** Union of every {@link RouteDefinition} registered on an `App`. */
15
15
  export type RoutesOf<A extends App> = A["routes"][number];
16
16
  /**
17
17
  * Typed client surface generated from an `App`. The result is a record keyed
18
18
  * by each route's `operationId` whose values are async methods inferred from
19
- * the route's request and response schemas.
19
+ * the route's request and response schemas. Required query and header fields
20
+ * remain required on the client input, while schemas that accept an empty
21
+ * object keep their corresponding client field optional.
20
22
  *
21
23
  * The per-method types are recovered from the `App`'s accumulated route tuple,
22
- * which is built up as you **chain** `app.route(...)` calls. If the `App` type
23
- * is widened back to its bare default — e.g. a `const app: App` annotation, a
24
- * `: App` factory return type, or registering routes as separate statements
25
- * rather than a chain — the tuple is erased and this type collapses to an
26
- * untyped, string-indexed record.
24
+ * built by chained registrations or `app.registerRoutes([...])`. If the result
25
+ * is widened back to a bare `App` annotation, the tuple is intentionally erased
26
+ * and this type becomes a string-indexed record.
27
27
  */
28
28
  export type ClientFor<A extends App> = {
29
29
  [R in Extract<RoutesOf<A>, {
30
30
  operationId: string;
31
31
  }> as R["operationId"]]: ClientMethod<R>;
32
32
  };
33
- type ClientMethod<R> = R extends RouteDefinition<infer P, infer _M, infer Req, infer Res> ? (input: ClientInput<P, Req>) => Promise<ClientOutput<Res>> : never;
34
- type ClientInput<P extends string, Req extends RequestSchemas | undefined> = {
33
+ type ClientMethod<R> = R extends RouteDefinition<infer P, infer _M, infer Req, infer Res> ? {} extends ClientInput<P, Req> ? (input?: ClientInput<P, Req>) => Promise<ClientOutput<Res>> : (input: ClientInput<P, Req>) => Promise<ClientOutput<Res>> : never;
34
+ type ClientInput<P extends string, Req extends RequestSchemas | undefined> = ([
35
+ ParamsOf<P>
36
+ ] extends [never] ? {
37
+ params?: Record<string, never>;
38
+ } : {
35
39
  params: InferRequest<Req, P>["params"];
36
- query?: Partial<InferRequest<Req, P>["query"]>;
37
- headers?: Record<string, string>;
38
- } & (Req extends {
40
+ }) & ClientQueryInput<P, Req> & ClientHeadersInput<P, Req> & (Req extends {
39
41
  body: infer _B;
40
42
  } ? {
41
43
  body: InferRequest<Req, P>["body"];
42
44
  } : {
43
45
  body?: undefined;
44
46
  });
47
+ type ClientQueryInput<P extends string, Req extends RequestSchemas | undefined> = Req extends {
48
+ query: infer _Query;
49
+ } ? {} extends NonNullable<InferRequest<Req, P>["query"]> ? {
50
+ query?: NonNullable<InferRequest<Req, P>["query"]>;
51
+ } : {
52
+ query: NonNullable<InferRequest<Req, P>["query"]>;
53
+ } : {
54
+ query?: Record<string, string | string[] | number | boolean | undefined>;
55
+ };
56
+ type ClientHeadersInput<P extends string, Req extends RequestSchemas | undefined> = Req extends {
57
+ headers: infer _Headers;
58
+ } ? {} extends NonNullable<InferRequest<Req, P>["headers"]> ? {
59
+ headers?: NonNullable<InferRequest<Req, P>["headers"]>;
60
+ } : {
61
+ headers: NonNullable<InferRequest<Req, P>["headers"]>;
62
+ } : {
63
+ headers?: Record<string, string>;
64
+ };
45
65
  type ClientOutput<Res extends ResponsesMap> = HandlerReturn<Res>;
46
66
  /** Options for {@link createClient}. */
47
67
  export interface ClientOptions {
@@ -52,11 +72,21 @@ export interface ClientOptions {
52
72
  /** Default headers merged into every request (per-call `input.headers` wins). */
53
73
  headers?: Record<string, string>;
54
74
  }
75
+ /** Options for {@link createInProcessClient}. */
76
+ export interface InProcessClientOptions {
77
+ /** Synthetic absolute origin used while constructing requests. Default: `http://daloy.local`. */
78
+ baseUrl?: string;
79
+ /** Default headers merged into every request. Per-call headers win. */
80
+ headers?: Record<string, string>;
81
+ }
55
82
  /**
56
- * Build a typed, in-process fetch client whose methods are keyed by
83
+ * Build a typed fetch client whose methods are keyed by
57
84
  * `operationId`. Parameters and response types are inferred from the same
58
85
  * route definitions registered on `app`, so the client and server cannot
59
86
  * drift apart at the type level.
87
+ * Required `params`, `query`, `headers`, and `body` inputs are preserved from
88
+ * the route contract; query or header schemas that accept an empty object keep
89
+ * those top-level fields optional.
60
90
  *
61
91
  * The returned object is a plain `Record<operationId, (input) => Promise<...>>`
62
92
  * — each call serializes `params`/`query`/`headers`/`body` and dispatches
@@ -64,13 +94,14 @@ export interface ClientOptions {
64
94
  *
65
95
  * For non-TypeScript consumers, run `pnpm gen` to emit a fully-typed SDK
66
96
  * from the OpenAPI document instead.
97
+ * Routes without path parameters omit the `params` input, and routes with no
98
+ * required request inputs may be called without an argument.
67
99
  *
68
100
  * @remarks
69
- * The method signatures are inferred from the `App`'s accumulated route tuple,
70
- * so chain your `app.route(...)` registrations and let TypeScript infer the
71
- * variable's type. A widening `const app: App` annotation, a `: App` factory
72
- * return type, or registering routes as separate statements erases the
73
- * per-route types and yields an untyped client.
101
+ * The method signatures are inferred from the `App`'s accumulated route tuple.
102
+ * Chain registrations or compose independently exported contracts with
103
+ * `app.registerRoutes([...])`, and avoid widening the result to a bare `App`
104
+ * annotation because that deliberately discards the per-route tuple.
74
105
  *
75
106
  * @example
76
107
  * ```ts
@@ -96,4 +127,19 @@ export interface ClientOptions {
96
127
  * @since 0.1.0
97
128
  */
98
129
  export declare function createClient<A extends App>(app: A, opts: ClientOptions): ClientFor<A>;
130
+ /**
131
+ * Build a typed client that dispatches directly through an App without
132
+ * opening a socket or binding a port.
133
+ *
134
+ * Requests still traverse the complete validation, middleware, security, and
135
+ * serialization pipeline through {@link "./app.js".App.fetch}.
136
+ * Routes without path parameters omit the `params` input, and routes with no
137
+ * required request inputs may be called without an argument.
138
+ *
139
+ * @param app - App whose registered route tuple drives the client surface.
140
+ * @param opts - Optional synthetic origin and default request headers.
141
+ * @returns A typed operation-id client backed by in-process dispatch.
142
+ * @since 1.0.0
143
+ */
144
+ export declare function createInProcessClient<A extends App>(app: A, opts?: InProcessClientOptions): ClientFor<A>;
99
145
  export {};
package/dist/client.js CHANGED
@@ -10,10 +10,13 @@
10
10
  * can still be generated from the OpenAPI doc for non-TS clients).
11
11
  */
12
12
  /**
13
- * Build a typed, in-process fetch client whose methods are keyed by
13
+ * Build a typed fetch client whose methods are keyed by
14
14
  * `operationId`. Parameters and response types are inferred from the same
15
15
  * route definitions registered on `app`, so the client and server cannot
16
16
  * drift apart at the type level.
17
+ * Required `params`, `query`, `headers`, and `body` inputs are preserved from
18
+ * the route contract; query or header schemas that accept an empty object keep
19
+ * those top-level fields optional.
17
20
  *
18
21
  * The returned object is a plain `Record<operationId, (input) => Promise<...>>`
19
22
  * — each call serializes `params`/`query`/`headers`/`body` and dispatches
@@ -21,13 +24,14 @@
21
24
  *
22
25
  * For non-TypeScript consumers, run `pnpm gen` to emit a fully-typed SDK
23
26
  * from the OpenAPI document instead.
27
+ * Routes without path parameters omit the `params` input, and routes with no
28
+ * required request inputs may be called without an argument.
24
29
  *
25
30
  * @remarks
26
- * The method signatures are inferred from the `App`'s accumulated route tuple,
27
- * so chain your `app.route(...)` registrations and let TypeScript infer the
28
- * variable's type. A widening `const app: App` annotation, a `: App` factory
29
- * return type, or registering routes as separate statements erases the
30
- * per-route types and yields an untyped client.
31
+ * The method signatures are inferred from the `App`'s accumulated route tuple.
32
+ * Chain registrations or compose independently exported contracts with
33
+ * `app.registerRoutes([...])`, and avoid widening the result to a bare `App`
34
+ * annotation because that deliberately discards the per-route tuple.
31
35
  *
32
36
  * @example
33
37
  * ```ts
@@ -93,6 +97,32 @@ export function createClient(app, opts) {
93
97
  }
94
98
  return out;
95
99
  }
100
+ /**
101
+ * Build a typed client that dispatches directly through an App without
102
+ * opening a socket or binding a port.
103
+ *
104
+ * Requests still traverse the complete validation, middleware, security, and
105
+ * serialization pipeline through {@link "./app.js".App.fetch}.
106
+ * Routes without path parameters omit the `params` input, and routes with no
107
+ * required request inputs may be called without an argument.
108
+ *
109
+ * @param app - App whose registered route tuple drives the client surface.
110
+ * @param opts - Optional synthetic origin and default request headers.
111
+ * @returns A typed operation-id client backed by in-process dispatch.
112
+ * @since 1.0.0
113
+ */
114
+ export function createInProcessClient(app, opts = {}) {
115
+ const clientOptions = {
116
+ baseUrl: opts.baseUrl ?? "http://daloy.local",
117
+ fetch: (input, init) => {
118
+ const request = input instanceof Request ? input : new Request(input, init);
119
+ return app.fetch(request);
120
+ },
121
+ };
122
+ if (opts.headers)
123
+ clientOptions.headers = opts.headers;
124
+ return createClient(app, clientOptions);
125
+ }
96
126
  function safeJson(text) {
97
127
  try {
98
128
  return JSON.parse(text);
package/dist/combine.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @since 0.19.0
7
7
  */
8
- import type { Hooks, BaseContext } from "./types.js";
8
+ import type { Hooks, BaseContext, PreBodyContext } from "./types.js";
9
9
  /**
10
10
  * Run every supplied {@link Hooks} bundle in order, pipeline-style.
11
11
  * Equivalent to passing the bundles to `app.use(...)` one after another,
@@ -13,7 +13,7 @@ import type { Hooks, BaseContext } from "./types.js";
13
13
  * stack for the admin section"). All lifecycle phases compose:
14
14
  *
15
15
  * - `onRequest` / `onResponse` run in registration order.
16
- * - `beforeHandle` / `onError` short-circuit on the first `Response`.
16
+ * - `preBody` / `beforeHandle` / `onError` short-circuit on the first `Response`.
17
17
  * - `afterHandle` / `onSend` thread the value through every bundle.
18
18
  *
19
19
  * Symbol-keyed security markers (CORS / CSRF / session / secure-headers)
@@ -35,14 +35,14 @@ import type { Hooks, BaseContext } from "./types.js";
35
35
  */
36
36
  export declare function every(...layers: Hooks[]): Hooks;
37
37
  /**
38
- * Run the supplied bundles until one of them passes its `beforeHandle`
38
+ * Run the supplied bundles until one of them passes its auth gate
39
39
  * check without throwing. Useful for "this route accepts a bearer token
40
40
  * OR a signed cookie OR an API key" patterns where any single proof of
41
41
  * identity is enough.
42
42
  *
43
43
  * Semantics:
44
44
  *
45
- * - The bundles' `beforeHandle` hooks are awaited in order. The first one
45
+ * - The bundles' `preBody` or `beforeHandle` hooks are awaited in order. The first one
46
46
  * that resolves without throwing wins; its `ctx` mutations (headers,
47
47
  * `ctx.state`, etc.) are preserved.
48
48
  * - When a bundle returns a `Response`, that response is treated as a
@@ -52,7 +52,7 @@ export declare function every(...layers: Hooks[]): Hooks;
52
52
  * client gets a deterministic status code. Place the auth method whose
53
53
  * `WWW-Authenticate` challenge you want clients to see first.
54
54
  * - `afterHandle`, `onSend`, `onResponse`, and `onError` from every bundle
55
- * still compose normally — `some()` only changes the `beforeHandle`
55
+ * still compose normally — `some()` only changes the auth-gate
56
56
  * evaluation strategy.
57
57
  *
58
58
  * @example
@@ -63,8 +63,8 @@ export declare function every(...layers: Hooks[]): Hooks;
63
63
  * ));
64
64
  * ```
65
65
  *
66
- * @param layers Candidate hook bundles; the first `beforeHandle` that passes wins.
67
- * @returns A merged {@link Hooks} bundle with the OR-style `beforeHandle` strategy.
66
+ * @param layers Candidate hook bundles; the first auth gate that passes wins.
67
+ * @returns A merged {@link Hooks} bundle with an OR-style auth-gate strategy.
68
68
  * @since 0.19.0
69
69
  */
70
70
  export declare function some(...layers: Hooks[]): Hooks;
@@ -76,7 +76,7 @@ export declare function some(...layers: Hooks[]): Hooks;
76
76
  *
77
77
  * @since 0.19.0
78
78
  */
79
- export type ExceptPredicate = string | string[] | ((ctx: BaseContext<any, any>) => boolean | Promise<boolean>);
79
+ export type ExceptPredicate = string | string[] | ((ctx: PreBodyContext<any> | BaseContext<any, any>) => boolean | Promise<boolean>);
80
80
  /**
81
81
  * Run a hook bundle on every request EXCEPT those matching `when`. The
82
82
  * canonical use is "apply auth everywhere except the public endpoints":
@@ -89,15 +89,15 @@ export type ExceptPredicate = string | string[] | ((ctx: BaseContext<any, any>)
89
89
  * ));
90
90
  * ```
91
91
  *
92
- * Only the `beforeHandle` phase is gated — the surrounding
92
+ * The `preBody` and `beforeHandle` phases are gated — the surrounding
93
93
  * `onRequest`/`afterHandle`/`onSend`/`onResponse` phases still run so
94
94
  * shared concerns like request-id propagation are not accidentally
95
95
  * exempted. Wrap each bundle with {@link except} individually when you
96
96
  * need to gate other phases.
97
97
  *
98
98
  * @param when Paths or predicate ({@link ExceptPredicate}) that exempt a request.
99
- * @param hooks The hook bundle whose `beforeHandle` is skipped on a match.
100
- * @returns A {@link Hooks} bundle whose `beforeHandle` is gated by `when`.
99
+ * @param hooks The hook bundle whose `preBody` and `beforeHandle` gates are skipped on a match.
100
+ * @returns A {@link Hooks} bundle whose request gates are controlled by `when`.
101
101
  * @throws Error at composition time if a string pattern does not start with `/`.
102
102
  * @since 0.19.0
103
103
  */
package/dist/combine.js CHANGED
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * @since 0.19.0
7
7
  */
8
+ import { _mergePreBodyWithEarlyRejections, EARLY_REJECTION_HOOK_MARKER } from "./middleware.js";
8
9
  /**
9
10
  * Run every supplied {@link Hooks} bundle in order, pipeline-style.
10
11
  * Equivalent to passing the bundles to `app.use(...)` one after another,
@@ -12,7 +13,7 @@
12
13
  * stack for the admin section"). All lifecycle phases compose:
13
14
  *
14
15
  * - `onRequest` / `onResponse` run in registration order.
15
- * - `beforeHandle` / `onError` short-circuit on the first `Response`.
16
+ * - `preBody` / `beforeHandle` / `onError` short-circuit on the first `Response`.
16
17
  * - `afterHandle` / `onSend` thread the value through every bundle.
17
18
  *
18
19
  * Symbol-keyed security markers (CORS / CSRF / session / secure-headers)
@@ -36,14 +37,14 @@ export function every(...layers) {
36
37
  return mergeCombineHooks(layers);
37
38
  }
38
39
  /**
39
- * Run the supplied bundles until one of them passes its `beforeHandle`
40
+ * Run the supplied bundles until one of them passes its auth gate
40
41
  * check without throwing. Useful for "this route accepts a bearer token
41
42
  * OR a signed cookie OR an API key" patterns where any single proof of
42
43
  * identity is enough.
43
44
  *
44
45
  * Semantics:
45
46
  *
46
- * - The bundles' `beforeHandle` hooks are awaited in order. The first one
47
+ * - The bundles' `preBody` or `beforeHandle` hooks are awaited in order. The first one
47
48
  * that resolves without throwing wins; its `ctx` mutations (headers,
48
49
  * `ctx.state`, etc.) are preserved.
49
50
  * - When a bundle returns a `Response`, that response is treated as a
@@ -53,7 +54,7 @@ export function every(...layers) {
53
54
  * client gets a deterministic status code. Place the auth method whose
54
55
  * `WWW-Authenticate` challenge you want clients to see first.
55
56
  * - `afterHandle`, `onSend`, `onResponse`, and `onError` from every bundle
56
- * still compose normally — `some()` only changes the `beforeHandle`
57
+ * still compose normally — `some()` only changes the auth-gate
57
58
  * evaluation strategy.
58
59
  *
59
60
  * @example
@@ -64,45 +65,65 @@ export function every(...layers) {
64
65
  * ));
65
66
  * ```
66
67
  *
67
- * @param layers Candidate hook bundles; the first `beforeHandle` that passes wins.
68
- * @returns A merged {@link Hooks} bundle with the OR-style `beforeHandle` strategy.
68
+ * @param layers Candidate hook bundles; the first auth gate that passes wins.
69
+ * @returns A merged {@link Hooks} bundle with an OR-style auth-gate strategy.
69
70
  * @since 0.19.0
70
71
  */
71
72
  export function some(...layers) {
72
73
  if (layers.length === 0)
73
74
  return {};
74
- const stripped = layers.map(({ beforeHandle: _b, ...rest }) => rest);
75
+ const stripped = layers.map(({ preBody: _p, beforeHandle: _b, ...rest }) => rest);
75
76
  const base = mergeCombineHooks(stripped);
76
- const candidates = layers
77
+ const preBodyCandidates = layers
78
+ .map((h) => h.preBody)
79
+ .filter((f) => typeof f === "function");
80
+ const beforeHandleCandidates = layers
77
81
  .map((h) => h.beforeHandle)
78
82
  .filter((f) => typeof f === "function");
83
+ const usePreBody = preBodyCandidates.length > 0 && beforeHandleCandidates.length === 0;
84
+ const candidates = usePreBody
85
+ ? preBodyCandidates
86
+ : layers.flatMap((hooks) => {
87
+ if (hooks.preBody && hooks.beforeHandle) {
88
+ return [
89
+ async (ctx) => {
90
+ const early = await hooks.preBody(ctx);
91
+ return early instanceof Response ? early : hooks.beforeHandle(ctx);
92
+ },
93
+ ];
94
+ }
95
+ const gate = hooks.preBody ?? hooks.beforeHandle;
96
+ return gate ? [gate] : [];
97
+ });
79
98
  if (candidates.length === 0)
80
99
  return base;
81
- return {
82
- ...base,
83
- async beforeHandle(ctx) {
84
- let firstFailure;
85
- for (const fn of candidates) {
86
- try {
87
- const r = await fn(ctx);
88
- if (r instanceof Response) {
89
- // Treat as a denial — try the next layer.
90
- if (!firstFailure)
91
- firstFailure = { kind: "response", res: r };
92
- continue;
93
- }
94
- // Undefined = pass; bundle accepts the request.
95
- return undefined;
96
- }
97
- catch (err) {
100
+ const runCandidates = async (ctx) => {
101
+ let firstFailure;
102
+ for (const fn of candidates) {
103
+ try {
104
+ const r = await fn(ctx);
105
+ if (r instanceof Response) {
98
106
  if (!firstFailure)
99
- firstFailure = { kind: "throw", err };
107
+ firstFailure = { kind: "response", res: r };
108
+ continue;
100
109
  }
110
+ return undefined;
111
+ }
112
+ catch (err) {
113
+ if (!firstFailure)
114
+ firstFailure = { kind: "throw", err };
101
115
  }
102
- if (firstFailure?.kind === "response")
103
- return firstFailure.res;
104
- throw firstFailure.err;
105
- },
116
+ }
117
+ if (firstFailure?.kind === "response")
118
+ return firstFailure.res;
119
+ throw firstFailure.err;
120
+ };
121
+ if (usePreBody) {
122
+ return { ...base, preBody: runCandidates };
123
+ }
124
+ return {
125
+ ...base,
126
+ beforeHandle: runCandidates,
106
127
  };
107
128
  }
108
129
  /**
@@ -117,31 +138,41 @@ export function some(...layers) {
117
138
  * ));
118
139
  * ```
119
140
  *
120
- * Only the `beforeHandle` phase is gated — the surrounding
141
+ * The `preBody` and `beforeHandle` phases are gated — the surrounding
121
142
  * `onRequest`/`afterHandle`/`onSend`/`onResponse` phases still run so
122
143
  * shared concerns like request-id propagation are not accidentally
123
144
  * exempted. Wrap each bundle with {@link except} individually when you
124
145
  * need to gate other phases.
125
146
  *
126
147
  * @param when Paths or predicate ({@link ExceptPredicate}) that exempt a request.
127
- * @param hooks The hook bundle whose `beforeHandle` is skipped on a match.
128
- * @returns A {@link Hooks} bundle whose `beforeHandle` is gated by `when`.
148
+ * @param hooks The hook bundle whose `preBody` and `beforeHandle` gates are skipped on a match.
149
+ * @returns A {@link Hooks} bundle whose request gates are controlled by `when`.
129
150
  * @throws Error at composition time if a string pattern does not start with `/`.
130
151
  * @since 0.19.0
131
152
  */
132
153
  export function except(when, hooks) {
133
- const matches = compileExceptMatcher(when);
134
- const original = hooks.beforeHandle;
135
- if (!original)
154
+ const earlyRejectionHooks = hooks[EARLY_REJECTION_HOOK_MARKER];
155
+ if (!hooks.preBody && !hooks.beforeHandle && !Array.isArray(earlyRejectionHooks))
136
156
  return hooks;
137
- return {
138
- ...hooks,
139
- async beforeHandle(ctx) {
140
- if (await matches(ctx))
141
- return undefined;
142
- return original(ctx);
143
- },
144
- };
157
+ const matches = compileExceptMatcher(when);
158
+ const wrapped = { ...hooks };
159
+ if (Array.isArray(earlyRejectionHooks)) {
160
+ wrapped[EARLY_REJECTION_HOOK_MARKER] =
161
+ earlyRejectionHooks.map((hook) => {
162
+ if (typeof hook !== "function")
163
+ return hook;
164
+ return async (ctx) => (await matches(ctx)) ? undefined : hook(ctx);
165
+ });
166
+ }
167
+ if (hooks.preBody) {
168
+ const original = hooks.preBody;
169
+ wrapped.preBody = async (ctx) => ((await matches(ctx)) ? undefined : original(ctx));
170
+ }
171
+ if (hooks.beforeHandle) {
172
+ const original = hooks.beforeHandle;
173
+ wrapped.beforeHandle = async (ctx) => ((await matches(ctx)) ? undefined : original(ctx));
174
+ }
175
+ return wrapped;
145
176
  }
146
177
  function compileExceptMatcher(when) {
147
178
  if (typeof when === "function") {
@@ -175,9 +206,7 @@ function compilePathPattern(pattern) {
175
206
  return (path) => regex.test(path);
176
207
  }
177
208
  function mergeCombineHooks(layers) {
178
- const pick = (key) => layers
179
- .map((h) => h[key])
180
- .filter((f) => typeof f === "function");
209
+ const pick = (key) => layers.map((h) => h[key]).filter((f) => typeof f === "function");
181
210
  const merged = {};
182
211
  const onRequest = pick("onRequest");
183
212
  if (onRequest.length > 0) {
@@ -186,6 +215,9 @@ function mergeCombineHooks(layers) {
186
215
  await fn(req);
187
216
  };
188
217
  }
218
+ const preBody = _mergePreBodyWithEarlyRejections(layers);
219
+ if (preBody !== undefined)
220
+ merged.preBody = preBody;
189
221
  const beforeHandle = pick("beforeHandle");
190
222
  if (beforeHandle.length > 0) {
191
223
  merged.beforeHandle = async (ctx) => {
@@ -245,6 +277,17 @@ function mergeCombineHooks(layers) {
245
277
  for (const hooks of layers) {
246
278
  const record = hooks;
247
279
  for (const key of Object.getOwnPropertySymbols(record)) {
280
+ if (key === EARLY_REJECTION_HOOK_MARKER && merged.preBody !== undefined)
281
+ continue;
282
+ if (key === EARLY_REJECTION_HOOK_MARKER) {
283
+ const existing = merged[key];
284
+ const incoming = record[key];
285
+ merged[key] = [
286
+ ...(Array.isArray(existing) ? existing : []),
287
+ ...(Array.isArray(incoming) ? incoming : []),
288
+ ];
289
+ continue;
290
+ }
248
291
  if (!(key in merged)) {
249
292
  merged[key] = record[key];
250
293
  }
@@ -38,6 +38,15 @@ export interface CompressionOptions {
38
38
  * below `0` or above `2 ** 31 - 1` are refused at construction.
39
39
  */
40
40
  minimumSize?: number;
41
+ /**
42
+ * Maximum response body size (in bytes) the middleware will buffer for
43
+ * compression. Larger (or unknown-and-growing) bodies are left uncompressed
44
+ * so a large GET cannot force unbounded heap growth. Default: `1_048_576`
45
+ * (1 MiB). Must be a positive integer.
46
+ *
47
+ * @since 1.0.0
48
+ */
49
+ maxCompressibleBytes?: number;
41
50
  /**
42
51
  * Allowed encodings, in caller-preferred order. Defaults to
43
52
  * `["br", "gzip", "deflate"]` — the middleware will pick the