@zudojs/http 1.2.0 → 1.4.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.
Files changed (127) hide show
  1. package/README.md +221 -0
  2. package/dist/httpAdapter/http.adapters.d.ts +24 -3
  3. package/dist/httpAdapter/http.adapters.js +20 -11
  4. package/dist/httpAdapter/node/httpNode.adapter.d.ts +2 -1
  5. package/dist/httpAdapter/node/httpNode.adapter.js +17 -2
  6. package/dist/httpAdapter/node/httpNode.request.js +7 -1
  7. package/dist/httpAdapter/node/httpNode.type.d.ts +14 -0
  8. package/dist/httpAgent/http.agent.d.ts +22 -3
  9. package/dist/httpAgent/http.agent.js +52 -14
  10. package/dist/httpCacheControl/httpCacheControl.freshness.d.ts +7 -1
  11. package/dist/httpCacheControl/httpCacheControl.freshness.js +30 -3
  12. package/dist/httpClient/httpClient.retry.d.ts +17 -12
  13. package/dist/httpClient/httpClient.retry.js +35 -10
  14. package/dist/httpClient/httpClient.type.d.ts +14 -0
  15. package/dist/httpErrors/httpError.base.js +2 -2
  16. package/dist/httpErrors/httpError.util.d.ts +8 -0
  17. package/dist/httpErrors/httpError.util.js +12 -0
  18. package/dist/httpFetchMount/httpFetchMount.core.d.ts +25 -0
  19. package/dist/httpFetchMount/httpFetchMount.core.js +84 -0
  20. package/dist/httpFetchMount/httpFetchMount.request.d.ts +21 -0
  21. package/dist/httpFetchMount/httpFetchMount.request.js +100 -0
  22. package/dist/httpFetchMount/httpFetchMount.type.d.ts +56 -0
  23. package/dist/httpFetchMount/httpFetchMount.type.js +5 -0
  24. package/dist/httpFetchMount/index.d.ts +11 -0
  25. package/dist/httpFetchMount/index.js +10 -0
  26. package/dist/httpKeepAlive/httpKeepAlive.core.js +14 -2
  27. package/dist/httpMiddleware/builtin/logging/httpMiddleware.logging.d.ts +18 -0
  28. package/dist/httpMiddleware/builtin/logging/httpMiddleware.logging.js +17 -1
  29. package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.d.ts +6 -3
  30. package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.js +34 -6
  31. package/dist/httpMiddleware/builtin/security/httpMiddleware.security.d.ts +11 -0
  32. package/dist/httpMiddleware/builtin/security/httpMiddleware.security.js +25 -12
  33. package/dist/httpMiddleware/httpMiddleware.type.d.ts +9 -1
  34. package/dist/httpMiddleware/pipeline/httpPipeline.execution.js +22 -45
  35. package/dist/httpMiddleware/pipeline/httpPipeline.guardResponse.d.ts +36 -0
  36. package/dist/httpMiddleware/pipeline/httpPipeline.guardResponse.js +57 -0
  37. package/dist/httpMiddleware/pipeline/httpPipeline.helper.d.ts +2 -1
  38. package/dist/httpMiddleware/pipeline/httpPipeline.helper.js +10 -0
  39. package/dist/httpMiddleware/pipeline/index.d.ts +1 -0
  40. package/dist/httpMiddleware/pipeline/index.js +1 -0
  41. package/dist/httpNegotiation/httpNegotiation.core.d.ts +13 -0
  42. package/dist/httpNegotiation/httpNegotiation.core.js +60 -8
  43. package/dist/httpOpenApi/httpOpenApi.document.d.ts +44 -0
  44. package/dist/httpOpenApi/httpOpenApi.document.js +61 -0
  45. package/dist/httpOpenApi/httpOpenApi.mount.d.ts +31 -0
  46. package/dist/httpOpenApi/httpOpenApi.mount.js +58 -0
  47. package/dist/httpOpenApi/httpOpenApi.type.d.ts +54 -0
  48. package/dist/httpOpenApi/httpOpenApi.type.js +5 -0
  49. package/dist/httpOpenApi/index.d.ts +13 -0
  50. package/dist/httpOpenApi/index.js +12 -0
  51. package/dist/httpOpenApi/routeTable/index.d.ts +11 -0
  52. package/dist/httpOpenApi/routeTable/index.js +11 -0
  53. package/dist/httpOpenApi/routeTable/routeTable.collect.d.ts +19 -0
  54. package/dist/httpOpenApi/routeTable/routeTable.collect.js +89 -0
  55. package/dist/httpOpenApi/routeTable/routeTable.merge.d.ts +15 -0
  56. package/dist/httpOpenApi/routeTable/routeTable.merge.js +37 -0
  57. package/dist/httpOpenApi/routeTable/routeTable.template.d.ts +30 -0
  58. package/dist/httpOpenApi/routeTable/routeTable.template.js +67 -0
  59. package/dist/httpProxy/http.proxy.d.ts +9 -0
  60. package/dist/httpProxy/http.proxy.js +35 -3
  61. package/dist/httpQuery/index.d.ts +11 -2
  62. package/dist/httpQuery/index.js +11 -2
  63. package/dist/httpQuery/queryParse/index.d.ts +10 -0
  64. package/dist/httpQuery/queryParse/index.js +10 -0
  65. package/dist/httpQuery/queryParse/queryParse.flat.d.ts +13 -0
  66. package/dist/httpQuery/queryParse/queryParse.flat.js +44 -0
  67. package/dist/httpQuery/queryParse/queryParse.nested.d.ts +25 -0
  68. package/dist/httpQuery/queryParse/queryParse.nested.js +112 -0
  69. package/dist/httpQuery/queryParse/queryParse.tokenizer.d.ts +37 -0
  70. package/dist/httpQuery/queryParse/queryParse.tokenizer.js +95 -0
  71. package/dist/httpQuery/queryRequest/index.d.ts +9 -0
  72. package/dist/httpQuery/queryRequest/index.js +9 -0
  73. package/dist/httpQuery/queryRequest/query.request.d.ts +43 -0
  74. package/dist/httpQuery/queryRequest/query.request.js +96 -0
  75. package/dist/httpQuery/querySerialize/index.d.ts +10 -0
  76. package/dist/httpQuery/querySerialize/index.js +10 -0
  77. package/dist/httpQuery/querySerialize/query.util.d.ts +21 -0
  78. package/dist/httpQuery/querySerialize/query.util.js +67 -0
  79. package/dist/httpQuery/querySerialize/querySerialize.core.d.ts +12 -0
  80. package/dist/httpQuery/querySerialize/querySerialize.core.js +97 -0
  81. package/dist/httpQuery/queryTypes/index.d.ts +11 -0
  82. package/dist/httpQuery/queryTypes/index.js +9 -0
  83. package/dist/httpQuery/queryTypes/query.container.d.ts +16 -0
  84. package/dist/httpQuery/queryTypes/query.container.js +51 -0
  85. package/dist/httpQuery/queryTypes/query.limit.d.ts +25 -0
  86. package/dist/httpQuery/queryTypes/query.limit.js +32 -0
  87. package/dist/httpQuery/queryTypes/query.type.d.ts +62 -0
  88. package/dist/httpQuery/queryTypes/query.type.js +2 -0
  89. package/dist/httpRedirect/http.redirect.d.ts +6 -0
  90. package/dist/httpRedirect/http.redirect.js +53 -2
  91. package/dist/httpRequest/http.request.d.ts +61 -2
  92. package/dist/httpRequest/http.request.js +86 -35
  93. package/dist/httpRequest/httpRequest.context.d.ts +8 -0
  94. package/dist/httpRequest/httpRequest.context.js +23 -18
  95. package/dist/httpRequest/index.d.ts +1 -0
  96. package/dist/httpRequest/index.js +1 -0
  97. package/dist/httpRequest/requestId/httpRequest.requestId.d.ts +25 -0
  98. package/dist/httpRequest/requestId/httpRequest.requestId.js +34 -0
  99. package/dist/httpRequest/requestId/index.d.ts +7 -0
  100. package/dist/httpRequest/requestId/index.js +7 -0
  101. package/dist/httpRequest/target/httpRequest.target.d.ts +2 -2
  102. package/dist/httpRequest/target/httpRequest.target.js +23 -5
  103. package/dist/httpResponse/httpResponse.writer.js +15 -0
  104. package/dist/httpRouter/core/factory/httpRoute.factory.base.d.ts +24 -4
  105. package/dist/httpRouter/core/factory/httpRoute.factory.base.js +73 -17
  106. package/dist/httpRouter/core/factory/httpRoute.factory.js +3 -3
  107. package/dist/httpRouter/core/group/httpRouterGroup.core.js +13 -0
  108. package/dist/httpRouter/core/register/httpRouter.register.js +19 -21
  109. package/dist/httpRouter/core/types/httpRouter.type.d.ts +37 -1
  110. package/dist/httpRouter/core/util/httpRoute.util.d.ts +55 -0
  111. package/dist/httpRouter/core/util/httpRoute.util.js +101 -4
  112. package/dist/httpRouter/dispatch/httpRoute.dispatcher.d.ts +11 -0
  113. package/dist/httpRouter/dispatch/httpRoute.dispatcher.js +25 -6
  114. package/dist/httpRouter/matching/httpRoute.matcher.core.js +51 -6
  115. package/dist/httpRouter/matching/httpRoute.matcher.d.ts +0 -1
  116. package/dist/httpRouter/matching/httpRoute.matcher.js +64 -26
  117. package/dist/httpRouter/pattern/httpRoute.pattern.parse.d.ts +16 -0
  118. package/dist/httpRouter/pattern/httpRoute.pattern.parse.js +40 -11
  119. package/dist/httpRouter/pattern/index.d.ts +1 -1
  120. package/dist/httpRouter/pattern/index.js +1 -1
  121. package/dist/httpSecurity/httpSecurity.config.js +4 -1
  122. package/dist/httpSecurity/httpSecurity.validator.js +16 -7
  123. package/dist/httpServer/factory/httpServer.factory.d.ts +10 -9
  124. package/dist/httpServer/factory/httpServer.factory.js +8 -0
  125. package/dist/index.d.ts +2 -0
  126. package/dist/index.js +2 -0
  127. package/package.json +11 -8
@@ -7,6 +7,7 @@
7
7
  * The context is intentionally framework-agnostic so adapters can populate it
8
8
  * from Node.js, Bun, Deno, or another HTTP runtime.
9
9
  */
10
+ import { AsyncLocalStorage } from "node:async_hooks";
10
11
  import { parseRequestTarget } from "./target/httpRequest.target.js";
11
12
  /* -------------------------------------------------------------------------- */
12
13
  /* Constants */
@@ -44,6 +45,7 @@ export class HttpRequestContext {
44
45
  protocolValue;
45
46
  hostnameValue;
46
47
  portValue;
48
+ signalValue;
47
49
  constructor(init) {
48
50
  this.id = init.id ?? generateRequestId();
49
51
  this.method = normalizeMethod(init.method);
@@ -60,6 +62,16 @@ export class HttpRequestContext {
60
62
  this.protocolValue = init.protocol;
61
63
  this.hostnameValue = init.hostname;
62
64
  this.portValue = init.port;
65
+ this.signalValue = init.signal;
66
+ }
67
+ /**
68
+ * Aborts when the request is abandoned — for the Node adapter, when the
69
+ * client disconnects before the response finished. `RequestContextInit`
70
+ * always accepted a `signal`, but the constructor dropped it, so the
71
+ * router handed every handler a signal that could never fire.
72
+ */
73
+ get signal() {
74
+ return this.signalValue;
63
75
  }
64
76
  /* ------------------------------------------------------------------------ */
65
77
  /* Headers */
@@ -289,6 +301,7 @@ export class HttpRequestContext {
289
301
  protocol: this.protocolValue,
290
302
  hostname: this.hostnameValue,
291
303
  port: this.portValue,
304
+ signal: this.signalValue,
292
305
  state: {
293
306
  ...Object.fromEntries(this.stateMap),
294
307
  },
@@ -378,24 +391,16 @@ function validateHeaderValue(value) {
378
391
  throw new TypeError("HTTP header value cannot contain CR or LF characters.");
379
392
  }
380
393
  }
394
+ /**
395
+ * Builds the request-context store.
396
+ *
397
+ * `AsyncLocalStorage` used to be reached through `globalThis.require`, which
398
+ * does not exist in ESM under Node. The `typeof` guard turned that into a
399
+ * silent `undefined`, so `runWithRequestContext` merely called its callback
400
+ * and `getCurrentRequestContext` always returned `undefined`. It is imported
401
+ * statically now, as the rest of the package imports its Node built-ins.
402
+ */
381
403
  function createAsyncContextStorage() {
382
- /*
383
- * AsyncLocalStorage is intentionally loaded lazily so the HTTP package
384
- * remains usable in browser and non-Node runtimes.
385
- */
386
- try {
387
- const runtimeRequire = globalThis.require;
388
- if (typeof runtimeRequire !== "function") {
389
- return undefined;
390
- }
391
- const asyncHooks = runtimeRequire("node:async_hooks");
392
- if (!asyncHooks.AsyncLocalStorage) {
393
- return undefined;
394
- }
395
- return new asyncHooks.AsyncLocalStorage();
396
- }
397
- catch {
398
- return undefined;
399
- }
404
+ return new AsyncLocalStorage();
400
405
  }
401
406
  //# sourceMappingURL=httpRequest.context.js.map
@@ -6,4 +6,5 @@
6
6
  export * from "./http.request.js";
7
7
  export * from "./httpRequest.context.js";
8
8
  export * from "./target/index.js";
9
+ export * from "./requestId/index.js";
9
10
  //# sourceMappingURL=index.d.ts.map
@@ -6,4 +6,5 @@
6
6
  export * from "./http.request.js";
7
7
  export * from "./httpRequest.context.js";
8
8
  export * from "./target/index.js";
9
+ export * from "./requestId/index.js";
9
10
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Incoming request id handling.
3
+ *
4
+ * @module httpRequest/requestId
5
+ */
6
+ /** Longest incoming `x-request-id` value reused as `request.id`. */
7
+ export declare const MAX_INCOMING_REQUEST_ID_LENGTH = 128;
8
+ /**
9
+ * Characters an incoming request id may contain: letters, digits and
10
+ * `.`, `_`, `:`, `-`. That covers UUIDs, ULIDs, W3C trace ids and the
11
+ * `service:counter` style many proxies emit, and rules out spaces, quotes,
12
+ * control characters and anything else that could forge a log field.
13
+ */
14
+ export declare const INCOMING_REQUEST_ID_PATTERN: RegExp;
15
+ /**
16
+ * Returns the incoming request id when it is safe to reuse, otherwise
17
+ * `undefined` (the caller then generates one).
18
+ *
19
+ * The header is client-controlled, so it is only trusted when it is 1 to
20
+ * {@link MAX_INCOMING_REQUEST_ID_LENGTH} characters from
21
+ * {@link INCOMING_REQUEST_ID_PATTERN}. A header sent more than once (joined
22
+ * with `", "` by Node) fails the pattern and is ignored.
23
+ */
24
+ export declare function resolveIncomingRequestId(value: string | readonly string[] | undefined): string | undefined;
25
+ //# sourceMappingURL=httpRequest.requestId.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Incoming request id handling.
3
+ *
4
+ * @module httpRequest/requestId
5
+ */
6
+ /** Longest incoming `x-request-id` value reused as `request.id`. */
7
+ export const MAX_INCOMING_REQUEST_ID_LENGTH = 128;
8
+ /**
9
+ * Characters an incoming request id may contain: letters, digits and
10
+ * `.`, `_`, `:`, `-`. That covers UUIDs, ULIDs, W3C trace ids and the
11
+ * `service:counter` style many proxies emit, and rules out spaces, quotes,
12
+ * control characters and anything else that could forge a log field.
13
+ */
14
+ export const INCOMING_REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]+$/;
15
+ /**
16
+ * Returns the incoming request id when it is safe to reuse, otherwise
17
+ * `undefined` (the caller then generates one).
18
+ *
19
+ * The header is client-controlled, so it is only trusted when it is 1 to
20
+ * {@link MAX_INCOMING_REQUEST_ID_LENGTH} characters from
21
+ * {@link INCOMING_REQUEST_ID_PATTERN}. A header sent more than once (joined
22
+ * with `", "` by Node) fails the pattern and is ignored.
23
+ */
24
+ export function resolveIncomingRequestId(value) {
25
+ if (typeof value !== "string") {
26
+ return undefined;
27
+ }
28
+ const id = value.trim();
29
+ if (id.length === 0 || id.length > MAX_INCOMING_REQUEST_ID_LENGTH) {
30
+ return undefined;
31
+ }
32
+ return INCOMING_REQUEST_ID_PATTERN.test(id) ? id : undefined;
33
+ }
34
+ //# sourceMappingURL=httpRequest.requestId.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @zudojs/http/httpRequest/requestId
3
+ *
4
+ * Validation of an incoming `x-request-id` before it becomes `request.id`.
5
+ */
6
+ export { MAX_INCOMING_REQUEST_ID_LENGTH, INCOMING_REQUEST_ID_PATTERN, resolveIncomingRequestId, } from "./httpRequest.requestId.js";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @zudojs/http/httpRequest/requestId
3
+ *
4
+ * Validation of an incoming `x-request-id` before it becomes `request.id`.
5
+ */
6
+ export { MAX_INCOMING_REQUEST_ID_LENGTH, INCOMING_REQUEST_ID_PATTERN, resolveIncomingRequestId, } from "./httpRequest.requestId.js";
7
+ //# sourceMappingURL=index.js.map
@@ -17,8 +17,8 @@
17
17
  /**
18
18
  * Parses a request-target (origin-form, absolute-form or `*`) into a URL.
19
19
  *
20
- * An origin-form target is never parsed as an authority. Unparseable input
21
- * yields the root URL.
20
+ * An origin-form target is never parsed as an authority, and repeated slashes
21
+ * in the path are collapsed. Unparseable input yields the root URL.
22
22
  */
23
23
  export declare function parseRequestTarget(target: string): URL;
24
24
  /**
@@ -17,21 +17,39 @@
17
17
  const TARGET_BASE = "http://zudojs.invalid";
18
18
  const ABSOLUTE_FORM = /^https?:\/\//i;
19
19
  const ENCODED_DOT = /%2e/gi;
20
+ const REPEATED_SLASH = /\/{2,}/g;
21
+ /**
22
+ * Collapses repeated slashes in a parsed target's path.
23
+ *
24
+ * The router normalises `/{2,}` away before matching, while the request
25
+ * context kept them, so `//admin/secret` dispatched to the route registered
26
+ * at `/admin/secret` while a guard reading `request.path` saw a path that did
27
+ * not start with `/admin/`. Both sides parse here, so collapsing once here
28
+ * keeps them in agreement. The query and fragment are untouched.
29
+ */
30
+ function collapsePathSlashes(url) {
31
+ if (!url.pathname.includes("//")) {
32
+ return url;
33
+ }
34
+ const collapsed = new URL(url.href);
35
+ collapsed.pathname = url.pathname.replace(REPEATED_SLASH, "/");
36
+ return collapsed;
37
+ }
20
38
  /**
21
39
  * Parses a request-target (origin-form, absolute-form or `*`) into a URL.
22
40
  *
23
- * An origin-form target is never parsed as an authority. Unparseable input
24
- * yields the root URL.
41
+ * An origin-form target is never parsed as an authority, and repeated slashes
42
+ * in the path are collapsed. Unparseable input yields the root URL.
25
43
  */
26
44
  export function parseRequestTarget(target) {
27
45
  try {
28
46
  if (target.startsWith("/")) {
29
- return new URL(`${TARGET_BASE}${target}`);
47
+ return collapsePathSlashes(new URL(`${TARGET_BASE}${target}`));
30
48
  }
31
49
  if (ABSOLUTE_FORM.test(target)) {
32
- return new URL(target);
50
+ return collapsePathSlashes(new URL(target));
33
51
  }
34
- return new URL(`${TARGET_BASE}/${target === "*" ? "" : target}`);
52
+ return collapsePathSlashes(new URL(`${TARGET_BASE}/${target === "*" ? "" : target}`));
35
53
  }
36
54
  catch {
37
55
  return new URL(`${TARGET_BASE}/`);
@@ -183,10 +183,21 @@ export function normalizeBody(body) {
183
183
  /* -------------------------------------------------------------------------- */
184
184
  export async function writeReadableStream(stream, writer) {
185
185
  const reader = stream.getReader();
186
+ let finished = false;
186
187
  try {
187
188
  while (true) {
189
+ /*
190
+ * A sink that stopped accepting data (the client disconnected) will
191
+ * never drain. Without this check an unbounded stream — server-sent
192
+ * events, a proxied download — kept being pulled into a dead socket
193
+ * for as long as its source produced.
194
+ */
195
+ if (writer.writable === false) {
196
+ break;
197
+ }
188
198
  const result = await reader.read();
189
199
  if (result.done) {
200
+ finished = true;
190
201
  break;
191
202
  }
192
203
  if (result.value) {
@@ -198,6 +209,10 @@ export async function writeReadableStream(stream, writer) {
198
209
  }
199
210
  }
200
211
  finally {
212
+ if (!finished) {
213
+ /* Tell the source to stop producing; its failure is not ours. */
214
+ await reader.cancel().catch(() => undefined);
215
+ }
201
216
  reader.releaseLock();
202
217
  }
203
218
  }
@@ -4,12 +4,23 @@
4
4
  * Internal normalization, validation, execution, and response utilities for
5
5
  * route creation and dispatch.
6
6
  */
7
- import type { HttpMethod, MatchedRoute, CompiledRoute, HttpRouterContext, HttpRouterRequestContext } from "../types/httpRouter.type.js";
7
+ import type { HttpMethod, MatchedRoute, CompiledRoute, HttpRouterContext, HttpRouterRequestContext, RouterHandlerResult } from "../types/httpRouter.type.js";
8
8
  import { type HttpResponseContext as ResponseContext } from "../../../httpResponse/httpResponse.context.js";
9
9
  export declare function normalizeMethod(method: string): HttpMethod | "*";
10
10
  export declare function normalizeMethods(method: HttpMethod | readonly HttpMethod[] | "*"): readonly (HttpMethod | "*")[];
11
11
  export declare function isHttpMethod(value: string): value is HttpMethod;
12
- export declare function collectAllowedMethods(routes: readonly CompiledRoute[], path: string): HttpMethod[];
12
+ /**
13
+ * Collects the methods registered for a path.
14
+ *
15
+ * @param routes - The compiled routes to consider.
16
+ * @param path - The request path.
17
+ * @param caseSensitive - The router's case sensitivity. This used to be
18
+ * hardcoded to `false`, so a case-sensitive router advertised `Allow`
19
+ * methods belonging to a route that only differed by case — a method the
20
+ * client would then get a 404 from, and a disclosure of the other route.
21
+ * @returns The allowed methods, with `HEAD` implied by `GET`.
22
+ */
23
+ export declare function collectAllowedMethods(routes: readonly CompiledRoute[], path: string, caseSensitive?: boolean): HttpMethod[];
13
24
  /**
14
25
  * Extracts the monotonic registration sequence from a generated route id.
15
26
  *
@@ -22,9 +33,12 @@ export declare function extractRouteSequence(id: string): number;
22
33
  */
23
34
  export declare function createFallbackRoute(path: string, method: string): MatchedRoute;
24
35
  /**
25
- * Coerces a handler result into a response context.
36
+ * Coerces a handler result into a response context: a response context or
37
+ * web `Response` as built, `undefined`/`null` as `204`, and any other value
38
+ * as a `200` JSON body, the way server handlers treat a plain value. A
39
+ * plain object used to be dropped for an empty `204`.
26
40
  */
27
- export declare function normalizeResponse(value: ResponseContext | Response | void): Promise<ResponseContext>;
41
+ export declare function normalizeResponse(value: RouterHandlerResult): Promise<ResponseContext>;
28
42
  /**
29
43
  * Builds the automatic `OPTIONS` response for a matched path.
30
44
  */
@@ -39,6 +53,12 @@ export declare function defaultNotFoundHandler(context: HttpRouterRequestContext
39
53
  export declare function defaultMethodNotAllowedHandler(context: HttpRouterRequestContext, allowedMethods: readonly HttpMethod[]): ResponseContext;
40
54
  /**
41
55
  * Runs a matched route's middleware chain followed by its handler.
56
+ *
57
+ * Every result is folded into the ambient response context
58
+ * (`context.middleware.response`), which is the object route middleware
59
+ * writes to. Returning the handler's brand new response instead — as this
60
+ * used to — silently discarded every header, cookie and status a route
61
+ * middleware had set before calling `next()`.
42
62
  */
43
63
  export declare function executeRoute(route: MatchedRoute, context: HttpRouterContext): Promise<ResponseContext>;
44
64
  //# sourceMappingURL=httpRoute.factory.base.d.ts.map
@@ -4,10 +4,13 @@
4
4
  * Internal normalization, validation, execution, and response utilities for
5
5
  * route creation and dispatch.
6
6
  */
7
+ import { isGuardResponse } from "@zudojs/middleware";
7
8
  import { HttpRouterError } from "../error/httpRouter.error.js";
8
9
  import { formatAllowHeader } from "../../../httpMethods/http.methods.js";
9
10
  import { matchCompiledRoute } from "../../matching/httpRoute.matcher.core.js";
10
11
  import { HttpResponseContext, } from "../../../httpResponse/httpResponse.context.js";
12
+ import { bufferWebResponse } from "../../../httpResponse/httpResponse.fromWeb.js";
13
+ import { applyGuardResponse } from "../../../httpMiddleware/pipeline/httpPipeline.guardResponse.js";
11
14
  /* -------------------------------------------------------------------------- */
12
15
  /* Method Helpers */
13
16
  /* -------------------------------------------------------------------------- */
@@ -36,10 +39,21 @@ export function isHttpMethod(value) {
36
39
  value === "CONNECT" ||
37
40
  value === "TRACE");
38
41
  }
39
- export function collectAllowedMethods(routes, path) {
42
+ /**
43
+ * Collects the methods registered for a path.
44
+ *
45
+ * @param routes - The compiled routes to consider.
46
+ * @param path - The request path.
47
+ * @param caseSensitive - The router's case sensitivity. This used to be
48
+ * hardcoded to `false`, so a case-sensitive router advertised `Allow`
49
+ * methods belonging to a route that only differed by case — a method the
50
+ * client would then get a 404 from, and a disclosure of the other route.
51
+ * @returns The allowed methods, with `HEAD` implied by `GET`.
52
+ */
53
+ export function collectAllowedMethods(routes, path, caseSensitive = false) {
40
54
  const methods = new Set();
41
55
  for (const route of routes) {
42
- if (!matchCompiledRoute(route, path, false)) {
56
+ if (!matchCompiledRoute(route, path, caseSensitive)) {
43
57
  continue;
44
58
  }
45
59
  if (isHttpMethod(route.definition.method)) {
@@ -88,24 +102,27 @@ export function createFallbackRoute(path, method) {
88
102
  /* Response Helpers */
89
103
  /* -------------------------------------------------------------------------- */
90
104
  /**
91
- * Coerces a handler result into a response context.
105
+ * Coerces a handler result into a response context: a response context or
106
+ * web `Response` as built, `undefined`/`null` as `204`, and any other value
107
+ * as a `200` JSON body, the way server handlers treat a plain value. A
108
+ * plain object used to be dropped for an empty `204`.
92
109
  */
93
110
  export async function normalizeResponse(value) {
94
111
  if (value instanceof HttpResponseContext) {
95
112
  return value;
96
113
  }
97
114
  if (typeof Response !== "undefined" && value instanceof Response) {
98
- const body = value.body === null
99
- ? undefined
100
- : new Uint8Array(await value.arrayBuffer());
101
- return new HttpResponseContext({
102
- status: value.status,
103
- statusText: value.statusText,
104
- headers: Object.fromEntries(value.headers.entries()),
105
- body,
106
- });
115
+ /*
116
+ * `Object.fromEntries(headers.entries())` folded every `Set-Cookie` into
117
+ * one comma-joined value, which browsers read as a single malformed
118
+ * cookie. `bufferWebResponse` keeps each cookie separate.
119
+ */
120
+ return bufferWebResponse(value);
107
121
  }
108
- return new HttpResponseContext({ status: 204 });
122
+ if (value === undefined || value === null) {
123
+ return new HttpResponseContext({ status: 204 });
124
+ }
125
+ return new HttpResponseContext({ status: 200 }).json(value);
109
126
  }
110
127
  /**
111
128
  * Builds the automatic `OPTIONS` response for a matched path.
@@ -153,11 +170,43 @@ export function defaultMethodNotAllowedHandler(context, allowedMethods) {
153
170
  /* -------------------------------------------------------------------------- */
154
171
  /* Route Execution */
155
172
  /* -------------------------------------------------------------------------- */
173
+ /**
174
+ * Merges one response context into another.
175
+ *
176
+ * Status, status text, headers, cookies, metadata and body are all carried
177
+ * over, so nothing a handler produced is lost.
178
+ *
179
+ * @param target - The response that stays authoritative.
180
+ * @param source - The response to fold into it.
181
+ * @returns The target response.
182
+ */
183
+ function mergeRouteResponse(target, source) {
184
+ if (source === target) {
185
+ return target;
186
+ }
187
+ target.setStatus(source.status, source.statusText);
188
+ target.headers_obj(source.headers);
189
+ for (const cookie of source.cookies) {
190
+ target.setCookie(cookie);
191
+ }
192
+ for (const [key, value] of Object.entries(source.metadata)) {
193
+ target.setMetadata(key, value);
194
+ }
195
+ target.setBody(source.body);
196
+ return target;
197
+ }
156
198
  /**
157
199
  * Runs a matched route's middleware chain followed by its handler.
200
+ *
201
+ * Every result is folded into the ambient response context
202
+ * (`context.middleware.response`), which is the object route middleware
203
+ * writes to. Returning the handler's brand new response instead — as this
204
+ * used to — silently discarded every header, cookie and status a route
205
+ * middleware had set before calling `next()`.
158
206
  */
159
207
  export async function executeRoute(route, context) {
160
208
  const layers = route.middleware;
209
+ const ambient = context.middleware.response;
161
210
  let invoked = -1;
162
211
  const run = async (index) => {
163
212
  if (index <= invoked) {
@@ -166,7 +215,7 @@ export async function executeRoute(route, context) {
166
215
  invoked = index;
167
216
  const layer = layers[index];
168
217
  if (layer === undefined) {
169
- return normalizeResponse(await route.handler(context));
218
+ return mergeRouteResponse(ambient, await normalizeResponse(await route.handler(context)));
170
219
  }
171
220
  let downstream;
172
221
  const result = await layer(context.middleware, async () => {
@@ -174,12 +223,19 @@ export async function executeRoute(route, context) {
174
223
  return downstream;
175
224
  });
176
225
  if (result instanceof HttpResponseContext) {
177
- return result;
226
+ return mergeRouteResponse(ambient, result);
227
+ }
228
+ /*
229
+ * A guard (permissions, tenancy) refusing the request. Before this was
230
+ * honoured the returned object was ignored and the ambient 200 went out.
231
+ */
232
+ if (isGuardResponse(result)) {
233
+ return applyGuardResponse(ambient, result);
178
234
  }
179
235
  if (typeof Response !== "undefined" && result instanceof Response) {
180
- return normalizeResponse(result);
236
+ return mergeRouteResponse(ambient, await normalizeResponse(result));
181
237
  }
182
- return downstream ?? context.middleware.response;
238
+ return downstream ?? ambient;
183
239
  };
184
240
  return run(0);
185
241
  }
@@ -4,7 +4,7 @@
4
4
  import { HttpRouter } from "../register/httpRouter.register.js";
5
5
  import { HttpRouterGroup } from "../group/httpRouterGroup.core.js";
6
6
  import { RouteConflictError, InvalidRoutePatternError, } from "../error/httpRouter.error.js";
7
- import { normalizePath } from "../util/httpRoute.util.js";
7
+ import { normalizeRoutePattern } from "../util/httpRoute.util.js";
8
8
  /**
9
9
  * Creates a new HTTP router instance.
10
10
  */
@@ -17,7 +17,7 @@ export function createRouter(options = {}) {
17
17
  export function createRoute(definition) {
18
18
  return {
19
19
  ...definition,
20
- path: normalizePath(definition.path),
20
+ path: normalizeRoutePattern(definition.path),
21
21
  middleware: Object.freeze([...(definition.middleware ?? [])]),
22
22
  metadata: Object.freeze({ ...(definition.metadata ?? {}) }),
23
23
  };
@@ -26,7 +26,7 @@ export function createRoute(definition) {
26
26
  * Builds a route path from a pattern and parameters.
27
27
  */
28
28
  export function buildRoutePath(pattern, params = {}) {
29
- const normalized = normalizePath(pattern);
29
+ const normalized = normalizeRoutePattern(pattern);
30
30
  return normalized
31
31
  .replace(/:([a-zA-Z_][a-zA-Z0-9_-]*)(\?)?/g, (_match, name, optional) => {
32
32
  const value = params[name];
@@ -4,6 +4,7 @@
4
4
  * Registers routes against a parent router under a shared path prefix and a
5
5
  * shared set of default route options.
6
6
  */
7
+ import { mergeRouteOpenAPI } from "../../../httpOpenApi/routeTable/routeTable.merge.js";
7
8
  export class HttpRouterGroup {
8
9
  router;
9
10
  prefix;
@@ -51,7 +52,15 @@ export class HttpRouterGroup {
51
52
  return `${left}/${right}` || "/";
52
53
  }
53
54
  mergeOptions(options) {
55
+ /*
56
+ * `metadata.openapi` is the same setting as `openapi` (the router stores
57
+ * one as the other). Reading only `openapi` let a group's documentation
58
+ * defaults replace a route's `metadata: { openapi: false }`, publishing
59
+ * a route its author had hidden.
60
+ */
61
+ const openapi = mergeRouteOpenAPI(openAPIOf(this.defaults), openAPIOf(options));
54
62
  return {
63
+ ...(openapi === undefined ? {} : { openapi }),
55
64
  name: options.name ?? this.defaults.name,
56
65
  middleware: [
57
66
  ...(this.defaults.middleware ?? []),
@@ -65,4 +74,8 @@ export class HttpRouterGroup {
65
74
  };
66
75
  }
67
76
  }
77
+ function openAPIOf(options) {
78
+ return options.openapi ??
79
+ options.metadata?.["openapi"];
80
+ }
68
81
  //# sourceMappingURL=httpRouterGroup.core.js.map
@@ -4,9 +4,9 @@
4
4
  import { HttpRouterError, RouteConflictError, } from "../error/httpRouter.error.js";
5
5
  import { HttpRouterGroup } from "../group/httpRouterGroup.core.js";
6
6
  import { collectAllowedMethods, createFallbackRoute, createOptionsResponse, defaultMethodNotAllowedHandler, defaultNotFoundHandler, executeRoute, extractRouteSequence, isHttpMethod, normalizeMethod, normalizeMethods, normalizeResponse, } from "../factory/httpRoute.factory.base.js";
7
- import { getRequestMethod, getRequestSignal, getRequestUrl, normalizePath, parseQuery, parseUrl, } from "../util/httpRoute.util.js";
7
+ import { getRequestMethod, getRequestSignal, applyRouteParams, getRequestUrl, normalizeMatchPath, normalizePath, normalizeRoutePattern, parseQuery, parseUrl, } from "../util/httpRoute.util.js";
8
8
  import { matchCompiledRoute } from "../../matching/httpRoute.matcher.core.js";
9
- import { compileRoute } from "../../pattern/httpRoute.pattern.parse.js";
9
+ import { compareSegmentSpecificity, compileRoute, } from "../../pattern/httpRoute.pattern.parse.js";
10
10
  import { createRouterMiddlewareContext } from "../../httpRouter.context.js";
11
11
  export class HttpRouter {
12
12
  routes = [];
@@ -36,6 +36,7 @@ export class HttpRouter {
36
36
  middleware: definition.middleware,
37
37
  metadata: definition.metadata,
38
38
  strictTrailingSlash: definition.strictTrailingSlash,
39
+ openapi: definition.openapi,
39
40
  });
40
41
  }
41
42
  return () => {
@@ -90,7 +91,7 @@ export class HttpRouter {
90
91
  remove(method, path) {
91
92
  const normalizedMethod = normalizeMethod(method);
92
93
  const index = this.routes.findIndex((route) => route.definition.method === normalizedMethod &&
93
- route.definition.path === normalizePath(path));
94
+ route.definition.path === normalizeRoutePattern(path));
94
95
  if (index === -1) {
95
96
  return false;
96
97
  }
@@ -122,11 +123,13 @@ export class HttpRouter {
122
123
  match(method, path) {
123
124
  const normalizedMethod = method.toUpperCase();
124
125
  const normalizedPath = normalizePath(path);
126
+ const matchPath = normalizeMatchPath(path);
125
127
  const candidates = this.sortedRoutes();
128
+ const allowedForPath = () => collectAllowedMethods(candidates, matchPath, this.routerOptions.caseSensitive);
126
129
  const allowed = new Set();
127
130
  let pathMatched = false;
128
131
  for (const route of candidates) {
129
- const params = matchCompiledRoute(route, normalizedPath, this.routerOptions.caseSensitive);
132
+ const params = matchCompiledRoute(route, matchPath, this.routerOptions.caseSensitive);
130
133
  if (!params) {
131
134
  continue;
132
135
  }
@@ -137,9 +140,7 @@ export class HttpRouter {
137
140
  matched: true,
138
141
  route: route.definition,
139
142
  params,
140
- allowedMethods: Object.freeze([
141
- ...collectAllowedMethods(candidates, normalizedPath),
142
- ]),
143
+ allowedMethods: Object.freeze([...allowedForPath()]),
143
144
  path: normalizedPath,
144
145
  method: normalizedMethod,
145
146
  };
@@ -153,16 +154,13 @@ export class HttpRouter {
153
154
  if (route.definition.method !== "GET") {
154
155
  continue;
155
156
  }
156
- const params = matchCompiledRoute(route, normalizedPath, this.routerOptions.caseSensitive);
157
+ const params = matchCompiledRoute(route, matchPath, this.routerOptions.caseSensitive);
157
158
  if (params) {
158
159
  return {
159
160
  matched: true,
160
161
  route: route.definition,
161
162
  params,
162
- allowedMethods: Object.freeze([
163
- ...collectAllowedMethods(candidates, normalizedPath),
164
- "HEAD",
165
- ]),
163
+ allowedMethods: Object.freeze([...allowedForPath(), "HEAD"]),
166
164
  path: normalizedPath,
167
165
  method: normalizedMethod,
168
166
  };
@@ -176,10 +174,7 @@ export class HttpRouter {
176
174
  matched: true,
177
175
  route: undefined,
178
176
  params: {},
179
- allowedMethods: Object.freeze([
180
- ...collectAllowedMethods(candidates, normalizedPath),
181
- "OPTIONS",
182
- ]),
177
+ allowedMethods: Object.freeze([...allowedForPath(), "OPTIONS"]),
183
178
  path: normalizedPath,
184
179
  method: normalizedMethod,
185
180
  };
@@ -217,6 +212,7 @@ export class HttpRouter {
217
212
  signal,
218
213
  };
219
214
  if (match.matched && match.route) {
215
+ applyRouteParams(request, match.params);
220
216
  const response = await executeRoute(match.route, routerContext);
221
217
  return {
222
218
  response: await normalizeResponse(response),
@@ -261,11 +257,11 @@ export class HttpRouter {
261
257
  /* ------------------------------------------------------------------------ */
262
258
  register(method, path, handler, options) {
263
259
  const normalizedMethod = normalizeMethod(method);
264
- const normalizedPath = normalizePath(path);
260
+ const normalizedPath = normalizeRoutePattern(path);
265
261
  if (typeof handler !== "function") {
266
262
  throw new HttpRouterError("Route handler must be a function.");
267
263
  }
268
- const compiled = compileRoute(normalizedPath, this.routerOptions.strictTrailingSlash ||
264
+ const compiled = compileRoute(path, this.routerOptions.strictTrailingSlash ||
269
265
  options.strictTrailingSlash === true);
270
266
  const existing = this.routes.find((route) => route.definition.method === normalizedMethod &&
271
267
  route.definition.path === normalizedPath);
@@ -281,6 +277,7 @@ export class HttpRouter {
281
277
  params: {},
282
278
  metadata: Object.freeze({
283
279
  ...(options.metadata ?? {}),
280
+ ...(options.openapi === undefined ? {} : { openapi: options.openapi }),
284
281
  }),
285
282
  handler,
286
283
  middleware: Object.freeze([...(options.middleware ?? [])]),
@@ -290,6 +287,7 @@ export class HttpRouter {
290
287
  segments: compiled.segments,
291
288
  score: compiled.score,
292
289
  strictTrailingSlash: compiled.strictTrailingSlash,
290
+ expectsTrailingSlash: compiled.expectsTrailingSlash,
293
291
  });
294
292
  return () => {
295
293
  this.remove(normalizedMethod, normalizedPath);
@@ -297,9 +295,9 @@ export class HttpRouter {
297
295
  }
298
296
  sortedRoutes() {
299
297
  return [...this.routes].sort((left, right) => {
300
- const score = right.score - left.score;
301
- if (score !== 0) {
302
- return score;
298
+ const specificity = compareSegmentSpecificity(left.segments, right.segments);
299
+ if (specificity !== 0) {
300
+ return specificity;
303
301
  }
304
302
  return (extractRouteSequence(left.definition.id) -
305
303
  extractRouteSequence(right.definition.id));