@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
@@ -3,11 +3,25 @@
3
3
  *
4
4
  * Core types for routing, route definitions, and router context.
5
5
  */
6
+ import type { RouteOpenAPIMetadata } from "@zudojs/openapi";
6
7
  import type { HttpRequestContext as RequestContext } from "../../../httpRequest/httpRequest.context.js";
7
8
  import type { HttpResponseContext as ResponseContext } from "../../../httpResponse/httpResponse.context.js";
8
9
  import type { HttpMiddleware, HttpMiddlewareContext } from "../../../httpMiddleware/httpMiddleware.type.js";
9
10
  export type HttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "CONNECT" | "TRACE";
10
- export type RouterHandler = (context: HttpRouterContext) => ResponseContext | Response | void | Promise<ResponseContext | Response | void>;
11
+ /**
12
+ * A plain, JSON-serialisable value a route handler may return: an object,
13
+ * array, string, number, boolean or `null`.
14
+ */
15
+ export type RouterJsonValue = object | string | number | boolean | null;
16
+ /**
17
+ * What a route handler may return, as server handlers do:
18
+ * - an `HttpResponseContext` or a web `Response`: sent as built;
19
+ * - a plain value: sent as `200` with a JSON body
20
+ * (`return { id }` is `ctx.middleware.response.json({ id })`);
21
+ * - `undefined` / `null`: `204 No Content`.
22
+ */
23
+ export type RouterHandlerResult = ResponseContext | Response | RouterJsonValue | void;
24
+ export type RouterHandler = (context: HttpRouterContext) => RouterHandlerResult | Promise<RouterHandlerResult>;
11
25
  export type RouterHandlerLike = RouterHandler | HttpMiddleware;
12
26
  export interface HttpRouterContext {
13
27
  readonly request: RequestContext;
@@ -18,6 +32,14 @@ export interface HttpRouterContext {
18
32
  readonly middleware: HttpMiddlewareContext;
19
33
  readonly signal: AbortSignal;
20
34
  }
35
+ /**
36
+ * OpenAPI documentation a route carries: summary, tags, operationId,
37
+ * `params` / `query` / `headers` / `body` schemas, responses, security,
38
+ * `deprecated`, `hidden`. It is `@zudojs/openapi`'s `RouteOpenAPIMetadata`,
39
+ * so the schemas it names document the route in `generateOpenAPIDocument`.
40
+ * `false` hides the route from the generated document.
41
+ */
42
+ export type HttpRouteOpenAPI = RouteOpenAPIMetadata | false;
21
43
  export interface RouteDefinition {
22
44
  readonly method: HttpMethod | readonly HttpMethod[] | "*";
23
45
  readonly path: string;
@@ -26,12 +48,20 @@ export interface RouteDefinition {
26
48
  readonly name?: string;
27
49
  readonly metadata?: Readonly<Record<string, unknown>>;
28
50
  readonly strictTrailingSlash?: boolean;
51
+ /** OpenAPI documentation; stored as `metadata.openapi`. */
52
+ readonly openapi?: HttpRouteOpenAPI;
29
53
  }
30
54
  export interface RouteOptions {
31
55
  readonly name?: string;
32
56
  readonly middleware?: readonly HttpMiddleware[];
33
57
  readonly metadata?: Readonly<Record<string, unknown>>;
34
58
  readonly strictTrailingSlash?: boolean;
59
+ /**
60
+ * OpenAPI documentation; stored as `metadata.openapi`, where
61
+ * `generateOpenAPIDocument` reads it. Takes precedence over a
62
+ * `metadata.openapi` passed alongside it.
63
+ */
64
+ readonly openapi?: HttpRouteOpenAPI;
35
65
  }
36
66
  export interface MatchedRoute {
37
67
  readonly id: string;
@@ -104,6 +134,12 @@ export interface CompiledRoute {
104
134
  readonly segments: readonly CompiledSegment[];
105
135
  readonly score: number;
106
136
  readonly strictTrailingSlash: boolean;
137
+ /**
138
+ * Whether the registered pattern ended with a slash.
139
+ *
140
+ * Only consulted when {@link CompiledRoute.strictTrailingSlash} is set.
141
+ */
142
+ readonly expectsTrailingSlash?: boolean;
107
143
  }
108
144
  export { HttpRouterError, RouteConflictError, } from "../error/httpRouter.error.js";
109
145
  //# sourceMappingURL=httpRouter.type.d.ts.map
@@ -8,6 +8,14 @@ import type { HttpRequestContext as RequestContext } from "../../../httpRequest/
8
8
  export declare function getRequestMethod(request: RequestContext): string;
9
9
  export declare function getRequestUrl(request: RequestContext): string;
10
10
  export declare function getRequestSignal(request: RequestContext): AbortSignal | undefined;
11
+ /**
12
+ * Copies the matched route's parameters onto the request before any route
13
+ * middleware runs, so `request.getParam("id")` / `request.params` see them
14
+ * in middleware as well as in the handler. They were only on the router
15
+ * context, so a guard reading the request (a permissions
16
+ * `extractResource`, an ownership check) always saw `undefined` and denied.
17
+ */
18
+ export declare function applyRouteParams(request: RequestContext, params: Readonly<Record<string, string>>): void;
11
19
  /**
12
20
  * Parses a request-target with the canonical parser shared by the request
13
21
  * context and path-scoped middleware.
@@ -24,8 +32,55 @@ export declare function parseUrl(value: string): URL;
24
32
  * for the same parameter.
25
33
  */
26
34
  export declare function parseQuery(params: URLSearchParams): Readonly<Record<string, string | string[]>>;
35
+ /**
36
+ * Normalizes a **request** path.
37
+ *
38
+ * Strips the query string, forces a leading slash, collapses repeated
39
+ * slashes, and trims a trailing slash.
40
+ *
41
+ * This must not be used on a route *pattern*: a pattern may legitimately
42
+ * contain `?` (the optional-parameter marker), which this function treats as
43
+ * the start of a query string. Use {@link normalizeRoutePattern} for
44
+ * patterns.
45
+ */
27
46
  export declare function normalizePath(path: string): string;
47
+ /**
48
+ * Normalizes a request path while preserving a single trailing slash.
49
+ *
50
+ * The trailing slash is the only information a strict-trailing-slash route
51
+ * needs and {@link normalizePath} destroys it, so matching runs on this
52
+ * spelling instead.
53
+ *
54
+ * @param path - The raw request path, possibly with a query or fragment.
55
+ * @returns The normalized path, keeping one trailing slash if present.
56
+ */
57
+ export declare function normalizeMatchPath(path: string): string;
58
+ /**
59
+ * Normalizes a route **pattern**.
60
+ *
61
+ * Identical to {@link normalizePath} except that `?` is left alone, so the
62
+ * documented optional-parameter syntax (`/users/:id?`, `/files/{name?}`)
63
+ * survives registration.
64
+ *
65
+ * @param pattern - The raw route pattern.
66
+ * @returns The normalized pattern.
67
+ */
68
+ export declare function normalizeRoutePattern(pattern: string): string;
69
+ /**
70
+ * Reports whether a path carries a meaningful trailing slash.
71
+ *
72
+ * @param path - The path to inspect.
73
+ * @returns `true` when the path ends with `/` and is not the root path.
74
+ */
75
+ export declare function hasTrailingSlash(path: string): boolean;
28
76
  export declare function splitPath(path: string): string[];
77
+ /**
78
+ * Splits a route pattern into its segments, keeping `?` markers intact.
79
+ *
80
+ * @param pattern - The raw route pattern.
81
+ * @returns The pattern's non-empty segments.
82
+ */
83
+ export declare function splitRoutePattern(pattern: string): string[];
29
84
  export declare function validateParameterName(name: string, path: string): void;
30
85
  export declare function decodeRouteValue(value: string): string;
31
86
  /**
@@ -5,7 +5,7 @@
5
5
  * router core.
6
6
  */
7
7
  import { parseRequestTarget } from "../../../httpRequest/target/httpRequest.target.js";
8
- import { parseQueryString } from "../../../httpQuery/http.query.js";
8
+ import { parseQueryString } from "../../../httpQuery/index.js";
9
9
  import { InvalidRoutePatternError } from "../error/httpRouter.error.js";
10
10
  export function getRequestMethod(request) {
11
11
  const value = request.method;
@@ -21,6 +21,22 @@ export function getRequestUrl(request) {
21
21
  export function getRequestSignal(request) {
22
22
  return request.signal;
23
23
  }
24
+ /**
25
+ * Copies the matched route's parameters onto the request before any route
26
+ * middleware runs, so `request.getParam("id")` / `request.params` see them
27
+ * in middleware as well as in the handler. They were only on the router
28
+ * context, so a guard reading the request (a permissions
29
+ * `extractResource`, an ownership check) always saw `undefined` and denied.
30
+ */
31
+ export function applyRouteParams(request, params) {
32
+ const target = request;
33
+ if (typeof target.setParam !== "function") {
34
+ return;
35
+ }
36
+ for (const [name, value] of Object.entries(params)) {
37
+ target.setParam(name, value);
38
+ }
39
+ }
24
40
  /**
25
41
  * Parses a request-target with the canonical parser shared by the request
26
42
  * context and path-scoped middleware.
@@ -44,14 +60,82 @@ export function parseQuery(params) {
44
60
  /* -------------------------------------------------------------------------- */
45
61
  /* Path Helpers */
46
62
  /* -------------------------------------------------------------------------- */
63
+ /**
64
+ * Normalizes a **request** path.
65
+ *
66
+ * Strips the query string, forces a leading slash, collapses repeated
67
+ * slashes, and trims a trailing slash.
68
+ *
69
+ * This must not be used on a route *pattern*: a pattern may legitimately
70
+ * contain `?` (the optional-parameter marker), which this function treats as
71
+ * the start of a query string. Use {@link normalizeRoutePattern} for
72
+ * patterns.
73
+ */
47
74
  export function normalizePath(path) {
48
75
  if (!path || path === "") {
49
76
  return "/";
50
77
  }
51
78
  const withoutQuery = path.split("?", 1)[0] ?? path;
52
- let normalized = withoutQuery.startsWith("/")
53
- ? withoutQuery
54
- : `/${withoutQuery}`;
79
+ return collapsePath(withoutQuery);
80
+ }
81
+ /**
82
+ * Normalizes a request path while preserving a single trailing slash.
83
+ *
84
+ * The trailing slash is the only information a strict-trailing-slash route
85
+ * needs and {@link normalizePath} destroys it, so matching runs on this
86
+ * spelling instead.
87
+ *
88
+ * @param path - The raw request path, possibly with a query or fragment.
89
+ * @returns The normalized path, keeping one trailing slash if present.
90
+ */
91
+ export function normalizeMatchPath(path) {
92
+ if (!path || path.trim() === "") {
93
+ return "/";
94
+ }
95
+ let target = path.trim();
96
+ if (target.startsWith("http://") || target.startsWith("https://")) {
97
+ try {
98
+ target = new URL(target).pathname;
99
+ }
100
+ catch {
101
+ /* Keep the original value if it is not a valid absolute URL. */
102
+ }
103
+ }
104
+ const withoutQuery = target.split("?", 1)[0] ?? target;
105
+ const withoutHash = withoutQuery.split("#", 1)[0] ?? withoutQuery;
106
+ const collapsed = collapsePath(withoutHash);
107
+ return hasTrailingSlash(withoutHash) && collapsed !== "/"
108
+ ? `${collapsed}/`
109
+ : collapsed;
110
+ }
111
+ /**
112
+ * Normalizes a route **pattern**.
113
+ *
114
+ * Identical to {@link normalizePath} except that `?` is left alone, so the
115
+ * documented optional-parameter syntax (`/users/:id?`, `/files/{name?}`)
116
+ * survives registration.
117
+ *
118
+ * @param pattern - The raw route pattern.
119
+ * @returns The normalized pattern.
120
+ */
121
+ export function normalizeRoutePattern(pattern) {
122
+ if (!pattern || pattern === "") {
123
+ return "/";
124
+ }
125
+ return collapsePath(pattern);
126
+ }
127
+ /**
128
+ * Reports whether a path carries a meaningful trailing slash.
129
+ *
130
+ * @param path - The path to inspect.
131
+ * @returns `true` when the path ends with `/` and is not the root path.
132
+ */
133
+ export function hasTrailingSlash(path) {
134
+ const trimmed = path.replace(/\/{2,}/g, "/");
135
+ return trimmed.length > 1 && trimmed.endsWith("/");
136
+ }
137
+ function collapsePath(value) {
138
+ let normalized = value.startsWith("/") ? value : `/${value}`;
55
139
  normalized = normalized.replace(/\/{2,}/g, "/");
56
140
  if (normalized.length > 1 && normalized.endsWith("/")) {
57
141
  normalized = normalized.slice(0, -1);
@@ -65,6 +149,19 @@ export function splitPath(path) {
65
149
  }
66
150
  return normalized.split("/").filter(Boolean);
67
151
  }
152
+ /**
153
+ * Splits a route pattern into its segments, keeping `?` markers intact.
154
+ *
155
+ * @param pattern - The raw route pattern.
156
+ * @returns The pattern's non-empty segments.
157
+ */
158
+ export function splitRoutePattern(pattern) {
159
+ const normalized = normalizeRoutePattern(pattern);
160
+ if (normalized === "/") {
161
+ return [];
162
+ }
163
+ return normalized.split("/").filter(Boolean);
164
+ }
68
165
  export function validateParameterName(name, path) {
69
166
  if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(name)) {
70
167
  throw new InvalidRoutePatternError(path, `Invalid parameter name "${name}".`);
@@ -24,6 +24,17 @@ export interface RouteDispatchContext {
24
24
  export interface RouteDispatchOptions {
25
25
  readonly onError?: RouteDispatchErrorHandler;
26
26
  readonly onComplete?: RouteDispatchCompleteHandler;
27
+ /**
28
+ * Keeps the response the middleware chain produced.
29
+ *
30
+ * By default a response returned by a route handler is merged into the
31
+ * response passed to `dispatch()`, overwriting its status, headers, cookies
32
+ * and body. With this set, the handler's response is left alone and the
33
+ * dispatch response is returned exactly as the middleware chain built it.
34
+ *
35
+ * The option was previously declared and never read, so it silently did
36
+ * nothing.
37
+ */
27
38
  readonly preserveResponse?: boolean;
28
39
  }
29
40
  export type RouteDispatchErrorHandler = (error: unknown, context: RouteDispatchContext) => void | Promise<void>;
@@ -5,10 +5,13 @@
5
5
  * handler pipeline. Route registration and route matching remain separate
6
6
  * concerns.
7
7
  */
8
+ import { isGuardResponse } from "@zudojs/middleware";
9
+ import { applyGuardResponse } from "../../httpMiddleware/pipeline/httpPipeline.guardResponse.js";
8
10
  import { HttpResponseContext } from "../../httpResponse/httpResponse.context.js";
9
11
  import { createRouterContext } from "../httpRouter.context.js";
12
+ import { normalizeResponse } from "../core/factory/httpRoute.factory.base.js";
10
13
  import { RouterMiddlewareState } from "../httpRouter.state.js";
11
- import { getRequestSignal } from "../core/util/httpRoute.util.js";
14
+ import { applyRouteParams, getRequestSignal, } from "../core/util/httpRoute.util.js";
12
15
  /* -------------------------------------------------------------------------- */
13
16
  /* Route Dispatcher */
14
17
  /* -------------------------------------------------------------------------- */
@@ -104,7 +107,7 @@ export class RouteDispatcher {
104
107
  async execute(context) {
105
108
  const state = new RouterMiddlewareState();
106
109
  const middleware = context.route.middleware.map((layer) => toRouteMiddleware(layer, context.route, state));
107
- const handler = toDispatchHandler(context.route.handler, context);
110
+ const handler = toDispatchHandler(context.route.handler, context, this.options.preserveResponse === true);
108
111
  let index = -1;
109
112
  const dispatchNext = async (current) => {
110
113
  if (current <= index) {
@@ -177,6 +180,8 @@ export async function dispatchRoute(dispatcher, request, response) {
177
180
  /* Context Creation */
178
181
  /* -------------------------------------------------------------------------- */
179
182
  function createDispatchContext(request, response, match) {
183
+ /* Route middleware reads params from the request, so set them first. */
184
+ applyRouteParams(request, match.params);
180
185
  return Object.freeze({
181
186
  request,
182
187
  response,
@@ -220,15 +225,25 @@ function normalizeHandler(handler) {
220
225
  * A response context returned by the handler is merged into the dispatch
221
226
  * response so the dispatcher's response object stays authoritative.
222
227
  */
223
- function toDispatchHandler(handler, context) {
228
+ function toDispatchHandler(handler, context, preserveResponse = false) {
224
229
  return async (request, response) => {
225
- const result = await handler(createRouterContext({
230
+ const returned = await handler(createRouterContext({
226
231
  request,
227
232
  route: context.route,
228
233
  params: context.params,
229
234
  signal: getRequestSignal(request),
230
235
  }));
231
- if (result instanceof HttpResponseContext && result !== response) {
236
+ /* A web Response or a plain value becomes a response context, exactly
237
+ * as the router treats it (a plain value is a 200 JSON body). */
238
+ const result = preserveResponse ||
239
+ returned === undefined ||
240
+ returned === null ||
241
+ returned instanceof HttpResponseContext
242
+ ? returned
243
+ : await normalizeResponse(returned);
244
+ if (!preserveResponse &&
245
+ result instanceof HttpResponseContext &&
246
+ result !== response) {
232
247
  /*
233
248
  * Everything the handler put on its response must survive the merge.
234
249
  * Copying only status, headers and body dropped every cookie the
@@ -252,7 +267,7 @@ function toDispatchHandler(handler, context) {
252
267
  */
253
268
  function toRouteMiddleware(middleware, route, state) {
254
269
  return async (request, response, next) => {
255
- await middleware({
270
+ const result = await middleware({
256
271
  request,
257
272
  response,
258
273
  state,
@@ -262,6 +277,10 @@ function toRouteMiddleware(middleware, route, state) {
262
277
  await next();
263
278
  return response;
264
279
  });
280
+ /* A guard's refusal is written onto the dispatch response, not dropped. */
281
+ if (isGuardResponse(result)) {
282
+ applyGuardResponse(response, result);
283
+ }
265
284
  };
266
285
  }
267
286
  function normalizeMiddleware(middleware) {
@@ -5,14 +5,28 @@
5
5
  * case sensitivity, optional parameters, per-parameter regular expression
6
6
  * constraints, and trailing wildcards.
7
7
  */
8
- import { decodeRouteSegment, normalizePath, splitPath, } from "../core/util/httpRoute.util.js";
8
+ import { decodeRouteSegment, hasTrailingSlash, normalizePath, splitPath, } from "../core/util/httpRoute.util.js";
9
9
  export function matchCompiledRoute(route, path, caseSensitive) {
10
+ /*
11
+ * `strictTrailingSlash` used to be compiled and stored but never read, so a
12
+ * strict router still answered `/users/` with the `/users` route. The
13
+ * request path reaches this function with its trailing slash intact
14
+ * (`normalizeMatchPath`), so the two spellings can finally be told apart.
15
+ */
16
+ if (route.strictTrailingSlash &&
17
+ hasTrailingSlash(path) !== (route.expectsTrailingSlash ?? false)) {
18
+ return undefined;
19
+ }
10
20
  const routePath = normalizePath(path);
11
21
  const inputSegments = splitPath(routePath);
12
22
  const output = {};
13
23
  const routeSegments = route.segments;
14
24
  let inputIndex = 0;
15
- for (const segment of routeSegments) {
25
+ for (let segmentIndex = 0; segmentIndex < routeSegments.length; segmentIndex += 1) {
26
+ const segment = routeSegments[segmentIndex];
27
+ if (segment === undefined) {
28
+ continue;
29
+ }
16
30
  if (segment.type === "wildcard") {
17
31
  /*
18
32
  * Decode each segment separately and re-join. Decoding the joined tail
@@ -33,10 +47,18 @@ export function matchCompiledRoute(route, path, caseSensitive) {
33
47
  break;
34
48
  }
35
49
  const input = inputSegments[inputIndex];
36
- if (input === undefined &&
37
- segment.type === "parameter" &&
38
- segment.optional) {
39
- continue;
50
+ if (segment.type === "parameter" && segment.optional) {
51
+ /*
52
+ * An optional parameter only claims a segment when the segments that
53
+ * follow it still have enough input left. Without that check
54
+ * `/account/:id?/profile` swallowed `profile` as the id and then found
55
+ * nothing to match its literal tail.
56
+ */
57
+ const remainingInput = inputSegments.length - inputIndex;
58
+ if (input === undefined ||
59
+ remainingInput <= requiredSegments(routeSegments, segmentIndex + 1)) {
60
+ continue;
61
+ }
40
62
  }
41
63
  if (input === undefined) {
42
64
  return undefined;
@@ -69,4 +91,27 @@ export function matchCompiledRoute(route, path, caseSensitive) {
69
91
  }
70
92
  return Object.freeze(output);
71
93
  }
94
+ /**
95
+ * Counts the segments from `start` onwards that must consume input.
96
+ *
97
+ * Optional parameters and wildcards can match nothing, so they do not count.
98
+ *
99
+ * @param segments - The route's compiled segments.
100
+ * @param start - The index to count from.
101
+ * @returns The number of segments that require an input segment.
102
+ */
103
+ function requiredSegments(segments, start) {
104
+ let required = 0;
105
+ for (let index = start; index < segments.length; index += 1) {
106
+ const segment = segments[index];
107
+ if (segment === undefined || segment.type === "wildcard") {
108
+ continue;
109
+ }
110
+ if (segment.type === "parameter" && segment.optional) {
111
+ continue;
112
+ }
113
+ required += 1;
114
+ }
115
+ return required;
116
+ }
72
117
  //# sourceMappingURL=httpRoute.matcher.core.js.map
@@ -51,7 +51,6 @@ export declare class RouteMatcher {
51
51
  stats(): RouteMatcherStats;
52
52
  resetStats(): void;
53
53
  private collect;
54
- private matchFirstAllowed;
55
54
  }
56
55
  export declare function createRouteMatcher(router: HttpRouter, options?: RouteMatcherOptions): RouteMatcher;
57
56
  export declare function matchRoute(matcher: RouteMatcher, method: string, path: string): RouteMatcherResult | undefined;
@@ -5,7 +5,8 @@
5
5
  * pathname. Route registration lives in the router core; per-route segment
6
6
  * matching lives in `httpRoute.matcher.core.ts`.
7
7
  */
8
- import { normalizeMethod } from "../core/factory/httpRoute.factory.base.js";
8
+ import { createOptionsResponse, normalizeMethod, } from "../core/factory/httpRoute.factory.base.js";
9
+ import { normalizeMatchPath } from "../core/util/httpRoute.util.js";
9
10
  import { formatAllowHeader } from "../../httpMethods/http.methods.js";
10
11
  import { matchCompiledRoute } from "./httpRoute.matcher.core.js";
11
12
  /* -------------------------------------------------------------------------- */
@@ -31,14 +32,20 @@ export class RouteMatcher {
31
32
  match(request) {
32
33
  this.requests += 1;
33
34
  const method = normalizeMethod(request.method);
34
- const path = normalizeRequestPath(request.path);
35
- const direct = this.matchMethod(method, path);
35
+ /*
36
+ * The raw target is threaded through so a strict-trailing-slash route can
37
+ * still tell `/users` from `/users/`; only the reported path is
38
+ * normalized.
39
+ */
40
+ const target = request.path;
41
+ const path = normalizeRequestPath(target);
42
+ const direct = this.matchMethod(method, target);
36
43
  if (direct) {
37
44
  this.matches += 1;
38
45
  return direct;
39
46
  }
40
47
  if (method === "HEAD" && this.matcherOptions.allowHeadFallback) {
41
- const fallback = this.matchMethod("GET", path);
48
+ const fallback = this.matchMethod("GET", target);
42
49
  if (fallback) {
43
50
  this.matches += 1;
44
51
  return {
@@ -48,13 +55,25 @@ export class RouteMatcher {
48
55
  }
49
56
  }
50
57
  if (method === "OPTIONS" && this.matcherOptions.allowOptionsFallback) {
51
- const first = this.matchFirstAllowed(this.allowedMethods(path).methods, path);
52
- if (first) {
58
+ const allowed = this.allowedMethods(target);
59
+ if (allowed.allowed) {
53
60
  this.matches += 1;
54
- return {
55
- ...first,
61
+ /*
62
+ * The fallback must never carry an executable route. It used to
63
+ * answer with the first route registered on the path under ANY
64
+ * method, so `OPTIONS /accounts/42` ran the `DELETE` handler — past
65
+ * any CSRF guard that treats `OPTIONS` as safe. The synthetic route
66
+ * below owns no middleware and answers `204` with `Allow`, matching
67
+ * what `HttpRouter.match()` already does.
68
+ */
69
+ return Object.freeze({
70
+ route: createOptionsRoute(path, allowed),
71
+ params: Object.freeze({}),
72
+ method,
73
+ path,
74
+ score: 0,
56
75
  matchedBy: "options-fallback",
57
- };
76
+ });
58
77
  }
59
78
  }
60
79
  this.misses += 1;
@@ -63,11 +82,12 @@ export class RouteMatcher {
63
82
  matchMethod(method, path) {
64
83
  const normalizedMethod = normalizeMethod(method);
65
84
  const normalizedPath = normalizeRequestPath(path);
85
+ const matchPath = normalizeMatchPath(path);
66
86
  for (const route of this.router.compiled()) {
67
87
  if (!methodApplies(route.definition.method, normalizedMethod)) {
68
88
  continue;
69
89
  }
70
- const params = matchCompiledRoute(route, normalizedPath, this.matcherOptions.caseSensitive);
90
+ const params = matchCompiledRoute(route, matchPath, this.matcherOptions.caseSensitive);
71
91
  if (!params) {
72
92
  continue;
73
93
  }
@@ -80,23 +100,22 @@ export class RouteMatcher {
80
100
  /* ------------------------------------------------------------------------ */
81
101
  matchAll(request) {
82
102
  const method = normalizeMethod(request.method);
83
- const path = normalizeRequestPath(request.path);
84
- return this.collect(method, path);
103
+ return this.collect(method, request.path);
85
104
  }
86
105
  /* ------------------------------------------------------------------------ */
87
106
  /* Path Matching */
88
107
  /* ------------------------------------------------------------------------ */
89
108
  matchPath(path) {
90
- return this.collect("*", normalizeRequestPath(path));
109
+ return this.collect("*", path);
91
110
  }
92
111
  /* ------------------------------------------------------------------------ */
93
112
  /* Allowed Methods */
94
113
  /* ------------------------------------------------------------------------ */
95
114
  allowedMethods(path) {
96
- const normalizedPath = normalizeRequestPath(path);
115
+ const matchPath = normalizeMatchPath(path);
97
116
  const methods = new Set();
98
117
  for (const route of this.router.compiled()) {
99
- const params = matchCompiledRoute(route, normalizedPath, this.matcherOptions.caseSensitive);
118
+ const params = matchCompiledRoute(route, matchPath, this.matcherOptions.caseSensitive);
100
119
  if (params) {
101
120
  methods.add(route.definition.method);
102
121
  }
@@ -140,29 +159,22 @@ export class RouteMatcher {
140
159
  /* ------------------------------------------------------------------------ */
141
160
  collect(method, path) {
142
161
  const results = [];
162
+ const normalizedPath = normalizeRequestPath(path);
163
+ const matchPath = normalizeMatchPath(path);
143
164
  const seen = new Set();
144
165
  for (const route of this.router.compiled()) {
145
166
  if (!methodApplies(route.definition.method, method)) {
146
167
  continue;
147
168
  }
148
- const params = matchCompiledRoute(route, path, this.matcherOptions.caseSensitive);
169
+ const params = matchCompiledRoute(route, matchPath, this.matcherOptions.caseSensitive);
149
170
  if (!params || seen.has(route.definition.id)) {
150
171
  continue;
151
172
  }
152
173
  seen.add(route.definition.id);
153
- results.push(createResult(route, params, method, path));
174
+ results.push(createResult(route, params, method, normalizedPath));
154
175
  }
155
176
  return Object.freeze(results);
156
177
  }
157
- matchFirstAllowed(methods, path) {
158
- for (const method of methods) {
159
- const result = this.matchMethod(method, path);
160
- if (result) {
161
- return result;
162
- }
163
- }
164
- return undefined;
165
- }
166
178
  }
167
179
  /* -------------------------------------------------------------------------- */
168
180
  /* Factory */
@@ -200,6 +212,32 @@ function createResult(route, params, method, path) {
200
212
  matchedBy: route.definition.method === "*" ? "wildcard" : "exact",
201
213
  });
202
214
  }
215
+ /**
216
+ * Builds the synthetic route an `OPTIONS` fallback resolves to.
217
+ *
218
+ * It carries no middleware and a handler that only answers `204` with the
219
+ * path's `Allow` header, so an `OPTIONS` request can never reach a handler
220
+ * registered for another method.
221
+ *
222
+ * @param path - The normalized request path.
223
+ * @param allowed - The methods registered on that path.
224
+ * @returns The synthetic route definition.
225
+ */
226
+ function createOptionsRoute(path, allowed) {
227
+ const methods = allowed.methods.includes("OPTIONS")
228
+ ? allowed.methods
229
+ : [...allowed.methods, "OPTIONS"];
230
+ return Object.freeze({
231
+ id: "route:options-fallback",
232
+ method: "OPTIONS",
233
+ path,
234
+ name: undefined,
235
+ params: Object.freeze({}),
236
+ metadata: Object.freeze({}),
237
+ handler: () => createOptionsResponse(methods),
238
+ middleware: Object.freeze([]),
239
+ });
240
+ }
203
241
  /* -------------------------------------------------------------------------- */
204
242
  /* Method Utilities */
205
243
  /* -------------------------------------------------------------------------- */
@@ -20,6 +20,7 @@ export interface CompiledRoutePath {
20
20
  readonly segments: readonly CompiledSegment[];
21
21
  readonly score: number;
22
22
  readonly strictTrailingSlash: boolean;
23
+ readonly expectsTrailingSlash: boolean;
23
24
  }
24
25
  /**
25
26
  * Compiles a route path into its segments.
@@ -35,4 +36,19 @@ export declare function compileRoute(path: string, strictTrailingSlash: boolean)
35
36
  * Scores compiled segments by specificity.
36
37
  */
37
38
  export declare function scoreSegments(segments: readonly CompiledSegment[]): number;
39
+ /**
40
+ * Compares two compiled patterns by specificity, most specific first.
41
+ *
42
+ * Segments are compared left to right by kind (literal, then parameter, then
43
+ * wildcard), which is how a router is expected to rank patterns. Summing the
44
+ * kinds into one scalar — as this used to — let a longer but entirely
45
+ * parameterised pattern such as `/:p/:q/:r/:s` outrank a literal-anchored
46
+ * `/admin/*rest`, so a request to `/admin/a/b/c` bypassed the admin route and
47
+ * every guard registered on it.
48
+ *
49
+ * @param left - The first pattern's segments.
50
+ * @param right - The second pattern's segments.
51
+ * @returns A negative number when `left` is more specific.
52
+ */
53
+ export declare function compareSegmentSpecificity(left: readonly CompiledSegment[], right: readonly CompiledSegment[]): number;
38
54
  //# sourceMappingURL=httpRoute.pattern.parse.d.ts.map