@zudojs/http 1.1.0 → 1.3.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 (137) hide show
  1. package/README.md +53 -3
  2. package/dist/httpAdapter/http.adapter.d.ts +34 -3
  3. package/dist/httpAdapter/http.adapter.js +22 -6
  4. package/dist/httpAdapter/http.adapters.d.ts +24 -3
  5. package/dist/httpAdapter/http.adapters.js +24 -45
  6. package/dist/httpAdapter/httpAdapter.errorResponse.d.ts +2 -2
  7. package/dist/httpAdapter/httpAdapter.errorResponse.js +26 -9
  8. package/dist/httpAdapter/httpAdapter.logger.d.ts +19 -0
  9. package/dist/httpAdapter/httpAdapter.logger.js +21 -0
  10. package/dist/httpAdapter/node/httpNode.adapter.d.ts +12 -1
  11. package/dist/httpAdapter/node/httpNode.adapter.js +28 -14
  12. package/dist/httpAdapter/node/httpNode.request.d.ts +8 -7
  13. package/dist/httpAdapter/node/httpNode.request.js +17 -27
  14. package/dist/httpAdapter/node/httpNode.server.d.ts +6 -0
  15. package/dist/httpAdapter/node/httpNode.server.js +6 -0
  16. package/dist/httpAdapter/node/httpNode.type.d.ts +2 -1
  17. package/dist/httpAgent/http.agent.d.ts +22 -3
  18. package/dist/httpAgent/http.agent.js +52 -14
  19. package/dist/httpCacheControl/httpCacheControl.freshness.d.ts +7 -1
  20. package/dist/httpCacheControl/httpCacheControl.freshness.js +30 -3
  21. package/dist/httpCookies/http.cookies.d.ts +29 -2
  22. package/dist/httpCookies/http.cookies.js +44 -24
  23. package/dist/httpCookies/httpCookies.defaults.d.ts +40 -0
  24. package/dist/httpCookies/httpCookies.defaults.js +42 -0
  25. package/dist/httpCookies/index.d.ts +1 -0
  26. package/dist/httpCookies/index.js +1 -0
  27. package/dist/httpKeepAlive/httpKeepAlive.core.js +14 -2
  28. package/dist/httpMiddleware/builtin/conditional/httpMiddleware.conditional.d.ts +22 -1
  29. package/dist/httpMiddleware/builtin/conditional/httpMiddleware.conditional.js +19 -4
  30. package/dist/httpMiddleware/builtin/conditional/index.d.ts +1 -0
  31. package/dist/httpMiddleware/builtin/cors/httpMiddleware.cors.d.ts +8 -0
  32. package/dist/httpMiddleware/builtin/cors/httpMiddleware.cors.js +25 -22
  33. package/dist/httpMiddleware/builtin/helpers/httpMiddleware.accessor.js +2 -6
  34. package/dist/httpMiddleware/builtin/helpers/httpMiddleware.media.d.ts +33 -0
  35. package/dist/httpMiddleware/builtin/helpers/httpMiddleware.media.js +59 -0
  36. package/dist/httpMiddleware/builtin/helpers/index.d.ts +1 -0
  37. package/dist/httpMiddleware/builtin/helpers/index.js +1 -0
  38. package/dist/httpMiddleware/builtin/image/httpMiddleware.image.d.ts +13 -0
  39. package/dist/httpMiddleware/builtin/image/httpMiddleware.image.js +31 -53
  40. package/dist/httpMiddleware/builtin/index.d.ts +1 -0
  41. package/dist/httpMiddleware/builtin/index.js +1 -0
  42. package/dist/httpMiddleware/builtin/logging/httpMiddleware.logging.d.ts +18 -0
  43. package/dist/httpMiddleware/builtin/logging/httpMiddleware.logging.js +17 -1
  44. package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.d.ts +47 -0
  45. package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.js +61 -0
  46. package/dist/httpMiddleware/builtin/rateLimit/index.d.ts +7 -0
  47. package/dist/httpMiddleware/builtin/rateLimit/index.js +7 -0
  48. package/dist/httpMiddleware/builtin/security/httpMiddleware.security.d.ts +11 -0
  49. package/dist/httpMiddleware/builtin/security/httpMiddleware.security.js +25 -12
  50. package/dist/httpMiddleware/builtin/static/httpMiddleware.static.js +18 -4
  51. package/dist/httpMiddleware/builtin/video/httpMiddleware.video.d.ts +19 -1
  52. package/dist/httpMiddleware/builtin/video/httpMiddleware.video.js +38 -59
  53. package/dist/httpMiddleware/builtin/video/httpMiddleware.video.options.d.ts +12 -0
  54. package/dist/httpMiddleware/builtin/video/httpMiddleware.video.options.js +31 -0
  55. package/dist/httpMiddleware/httpMiddleware.error.d.ts +5 -31
  56. package/dist/httpMiddleware/httpMiddleware.error.js +5 -53
  57. package/dist/httpNegotiation/httpNegotiation.core.d.ts +13 -0
  58. package/dist/httpNegotiation/httpNegotiation.core.js +60 -8
  59. package/dist/httpProxy/http.proxy.d.ts +16 -0
  60. package/dist/httpProxy/http.proxy.js +44 -3
  61. package/dist/httpProxy/httpProxy.pathGuard.d.ts +23 -0
  62. package/dist/httpProxy/httpProxy.pathGuard.js +43 -0
  63. package/dist/httpProxy/index.d.ts +1 -0
  64. package/dist/httpProxy/index.js +1 -0
  65. package/dist/httpQuery/index.d.ts +11 -2
  66. package/dist/httpQuery/index.js +11 -2
  67. package/dist/httpQuery/queryParse/index.d.ts +10 -0
  68. package/dist/httpQuery/queryParse/index.js +10 -0
  69. package/dist/httpQuery/queryParse/queryParse.flat.d.ts +13 -0
  70. package/dist/httpQuery/queryParse/queryParse.flat.js +44 -0
  71. package/dist/httpQuery/queryParse/queryParse.nested.d.ts +25 -0
  72. package/dist/httpQuery/queryParse/queryParse.nested.js +112 -0
  73. package/dist/httpQuery/queryParse/queryParse.tokenizer.d.ts +37 -0
  74. package/dist/httpQuery/queryParse/queryParse.tokenizer.js +95 -0
  75. package/dist/httpQuery/queryRequest/index.d.ts +9 -0
  76. package/dist/httpQuery/queryRequest/index.js +9 -0
  77. package/dist/httpQuery/queryRequest/query.request.d.ts +43 -0
  78. package/dist/httpQuery/queryRequest/query.request.js +96 -0
  79. package/dist/httpQuery/querySerialize/index.d.ts +10 -0
  80. package/dist/httpQuery/querySerialize/index.js +10 -0
  81. package/dist/httpQuery/querySerialize/query.util.d.ts +21 -0
  82. package/dist/httpQuery/querySerialize/query.util.js +67 -0
  83. package/dist/httpQuery/querySerialize/querySerialize.core.d.ts +12 -0
  84. package/dist/httpQuery/querySerialize/querySerialize.core.js +97 -0
  85. package/dist/httpQuery/queryTypes/index.d.ts +11 -0
  86. package/dist/httpQuery/queryTypes/index.js +9 -0
  87. package/dist/httpQuery/queryTypes/query.container.d.ts +16 -0
  88. package/dist/httpQuery/queryTypes/query.container.js +51 -0
  89. package/dist/httpQuery/queryTypes/query.limit.d.ts +25 -0
  90. package/dist/httpQuery/queryTypes/query.limit.js +32 -0
  91. package/dist/httpQuery/queryTypes/query.type.d.ts +62 -0
  92. package/dist/httpQuery/queryTypes/query.type.js +2 -0
  93. package/dist/httpRedirect/http.redirect.d.ts +6 -0
  94. package/dist/httpRedirect/http.redirect.js +53 -2
  95. package/dist/httpRequest/http.request.d.ts +61 -2
  96. package/dist/httpRequest/http.request.js +86 -35
  97. package/dist/httpRequest/httpRequest.context.d.ts +7 -0
  98. package/dist/httpRequest/httpRequest.context.js +26 -34
  99. package/dist/httpRequest/index.d.ts +1 -0
  100. package/dist/httpRequest/index.js +1 -0
  101. package/dist/httpRequest/target/httpRequest.target.d.ts +45 -0
  102. package/dist/httpRequest/target/httpRequest.target.js +111 -0
  103. package/dist/httpRequest/target/index.d.ts +9 -0
  104. package/dist/httpRequest/target/index.js +9 -0
  105. package/dist/httpResponse/httpResponse.helper.js +2 -1
  106. package/dist/httpRouter/core/factory/httpRoute.factory.base.d.ts +18 -1
  107. package/dist/httpRouter/core/factory/httpRoute.factory.base.js +49 -6
  108. package/dist/httpRouter/core/factory/httpRoute.factory.js +3 -3
  109. package/dist/httpRouter/core/register/httpRouter.register.js +16 -21
  110. package/dist/httpRouter/core/types/httpRouter.type.d.ts +6 -0
  111. package/dist/httpRouter/core/util/httpRoute.util.d.ts +61 -0
  112. package/dist/httpRouter/core/util/httpRoute.util.js +102 -17
  113. package/dist/httpRouter/dispatch/httpRoute.dispatcher.d.ts +11 -0
  114. package/dist/httpRouter/dispatch/httpRoute.dispatcher.js +5 -3
  115. package/dist/httpRouter/matching/httpRoute.matcher.core.js +51 -6
  116. package/dist/httpRouter/matching/httpRoute.matcher.d.ts +0 -1
  117. package/dist/httpRouter/matching/httpRoute.matcher.js +64 -26
  118. package/dist/httpRouter/pattern/httpRoute.pattern.parse.d.ts +16 -0
  119. package/dist/httpRouter/pattern/httpRoute.pattern.parse.js +40 -11
  120. package/dist/httpRouter/pattern/index.d.ts +1 -1
  121. package/dist/httpRouter/pattern/index.js +1 -1
  122. package/dist/httpSecurity/httpSecurity.config.d.ts +6 -0
  123. package/dist/httpSecurity/httpSecurity.config.js +1 -0
  124. package/dist/httpSecurity/httpSecurity.guard.d.ts +7 -9
  125. package/dist/httpSecurity/httpSecurity.guard.js +7 -14
  126. package/dist/httpSecurity/httpSecurity.nodeGuard.d.ts +48 -0
  127. package/dist/httpSecurity/httpSecurity.nodeGuard.js +32 -0
  128. package/dist/httpSecurity/httpSecurity.validator.js +20 -9
  129. package/dist/httpSecurity/index.d.ts +2 -0
  130. package/dist/httpSecurity/index.js +1 -0
  131. package/dist/httpServer/core/httpServer.core.d.ts +11 -0
  132. package/dist/httpServer/core/httpServer.core.js +30 -4
  133. package/dist/httpTrustProxy/httpTrustProxy.compilation.d.ts +5 -2
  134. package/dist/httpTrustProxy/httpTrustProxy.compilation.js +19 -2
  135. package/dist/httpTrustProxy/httpTrustProxy.type.d.ts +3 -1
  136. package/dist/index.js +0 -1
  137. package/package.json +5 -5
@@ -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, 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 = [];
@@ -90,7 +90,7 @@ export class HttpRouter {
90
90
  remove(method, path) {
91
91
  const normalizedMethod = normalizeMethod(method);
92
92
  const index = this.routes.findIndex((route) => route.definition.method === normalizedMethod &&
93
- route.definition.path === normalizePath(path));
93
+ route.definition.path === normalizeRoutePattern(path));
94
94
  if (index === -1) {
95
95
  return false;
96
96
  }
@@ -122,11 +122,13 @@ export class HttpRouter {
122
122
  match(method, path) {
123
123
  const normalizedMethod = method.toUpperCase();
124
124
  const normalizedPath = normalizePath(path);
125
+ const matchPath = normalizeMatchPath(path);
125
126
  const candidates = this.sortedRoutes();
127
+ const allowedForPath = () => collectAllowedMethods(candidates, matchPath, this.routerOptions.caseSensitive);
126
128
  const allowed = new Set();
127
129
  let pathMatched = false;
128
130
  for (const route of candidates) {
129
- const params = matchCompiledRoute(route, normalizedPath, this.routerOptions.caseSensitive);
131
+ const params = matchCompiledRoute(route, matchPath, this.routerOptions.caseSensitive);
130
132
  if (!params) {
131
133
  continue;
132
134
  }
@@ -137,9 +139,7 @@ export class HttpRouter {
137
139
  matched: true,
138
140
  route: route.definition,
139
141
  params,
140
- allowedMethods: Object.freeze([
141
- ...collectAllowedMethods(candidates, normalizedPath),
142
- ]),
142
+ allowedMethods: Object.freeze([...allowedForPath()]),
143
143
  path: normalizedPath,
144
144
  method: normalizedMethod,
145
145
  };
@@ -153,16 +153,13 @@ export class HttpRouter {
153
153
  if (route.definition.method !== "GET") {
154
154
  continue;
155
155
  }
156
- const params = matchCompiledRoute(route, normalizedPath, this.routerOptions.caseSensitive);
156
+ const params = matchCompiledRoute(route, matchPath, this.routerOptions.caseSensitive);
157
157
  if (params) {
158
158
  return {
159
159
  matched: true,
160
160
  route: route.definition,
161
161
  params,
162
- allowedMethods: Object.freeze([
163
- ...collectAllowedMethods(candidates, normalizedPath),
164
- "HEAD",
165
- ]),
162
+ allowedMethods: Object.freeze([...allowedForPath(), "HEAD"]),
166
163
  path: normalizedPath,
167
164
  method: normalizedMethod,
168
165
  };
@@ -176,10 +173,7 @@ export class HttpRouter {
176
173
  matched: true,
177
174
  route: undefined,
178
175
  params: {},
179
- allowedMethods: Object.freeze([
180
- ...collectAllowedMethods(candidates, normalizedPath),
181
- "OPTIONS",
182
- ]),
176
+ allowedMethods: Object.freeze([...allowedForPath(), "OPTIONS"]),
183
177
  path: normalizedPath,
184
178
  method: normalizedMethod,
185
179
  };
@@ -261,11 +255,11 @@ export class HttpRouter {
261
255
  /* ------------------------------------------------------------------------ */
262
256
  register(method, path, handler, options) {
263
257
  const normalizedMethod = normalizeMethod(method);
264
- const normalizedPath = normalizePath(path);
258
+ const normalizedPath = normalizeRoutePattern(path);
265
259
  if (typeof handler !== "function") {
266
260
  throw new HttpRouterError("Route handler must be a function.");
267
261
  }
268
- const compiled = compileRoute(normalizedPath, this.routerOptions.strictTrailingSlash ||
262
+ const compiled = compileRoute(path, this.routerOptions.strictTrailingSlash ||
269
263
  options.strictTrailingSlash === true);
270
264
  const existing = this.routes.find((route) => route.definition.method === normalizedMethod &&
271
265
  route.definition.path === normalizedPath);
@@ -290,6 +284,7 @@ export class HttpRouter {
290
284
  segments: compiled.segments,
291
285
  score: compiled.score,
292
286
  strictTrailingSlash: compiled.strictTrailingSlash,
287
+ expectsTrailingSlash: compiled.expectsTrailingSlash,
293
288
  });
294
289
  return () => {
295
290
  this.remove(normalizedMethod, normalizedPath);
@@ -297,9 +292,9 @@ export class HttpRouter {
297
292
  }
298
293
  sortedRoutes() {
299
294
  return [...this.routes].sort((left, right) => {
300
- const score = right.score - left.score;
301
- if (score !== 0) {
302
- return score;
295
+ const specificity = compareSegmentSpecificity(left.segments, right.segments);
296
+ if (specificity !== 0) {
297
+ return specificity;
303
298
  }
304
299
  return (extractRouteSequence(left.definition.id) -
305
300
  extractRouteSequence(right.definition.id));
@@ -104,6 +104,12 @@ export interface CompiledRoute {
104
104
  readonly segments: readonly CompiledSegment[];
105
105
  readonly score: number;
106
106
  readonly strictTrailingSlash: boolean;
107
+ /**
108
+ * Whether the registered pattern ended with a slash.
109
+ *
110
+ * Only consulted when {@link CompiledRoute.strictTrailingSlash} is set.
111
+ */
112
+ readonly expectsTrailingSlash?: boolean;
107
113
  }
108
114
  export { HttpRouterError, RouteConflictError, } from "../error/httpRouter.error.js";
109
115
  //# sourceMappingURL=httpRouter.type.d.ts.map
@@ -8,10 +8,71 @@ 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
+ * Parses a request-target with the canonical parser shared by the request
13
+ * context and path-scoped middleware.
14
+ */
11
15
  export declare function parseUrl(value: string): URL;
16
+ /**
17
+ * Builds the router's query record.
18
+ *
19
+ * Uses the same parser as the Node adapter's `request.query`
20
+ * (`parseQueryString`): a repeated name is an array, the record has a `null`
21
+ * prototype, and `__proto__` / `constructor` / `prototype` are dropped. The
22
+ * two used to disagree (`ctx.query.role` an array, `request.getQuery("role")`
23
+ * the last value), which let middleware and handlers read different values
24
+ * for the same parameter.
25
+ */
12
26
  export declare function parseQuery(params: URLSearchParams): Readonly<Record<string, string | string[]>>;
27
+ /**
28
+ * Normalizes a **request** path.
29
+ *
30
+ * Strips the query string, forces a leading slash, collapses repeated
31
+ * slashes, and trims a trailing slash.
32
+ *
33
+ * This must not be used on a route *pattern*: a pattern may legitimately
34
+ * contain `?` (the optional-parameter marker), which this function treats as
35
+ * the start of a query string. Use {@link normalizeRoutePattern} for
36
+ * patterns.
37
+ */
13
38
  export declare function normalizePath(path: string): string;
39
+ /**
40
+ * Normalizes a request path while preserving a single trailing slash.
41
+ *
42
+ * The trailing slash is the only information a strict-trailing-slash route
43
+ * needs and {@link normalizePath} destroys it, so matching runs on this
44
+ * spelling instead.
45
+ *
46
+ * @param path - The raw request path, possibly with a query or fragment.
47
+ * @returns The normalized path, keeping one trailing slash if present.
48
+ */
49
+ export declare function normalizeMatchPath(path: string): string;
50
+ /**
51
+ * Normalizes a route **pattern**.
52
+ *
53
+ * Identical to {@link normalizePath} except that `?` is left alone, so the
54
+ * documented optional-parameter syntax (`/users/:id?`, `/files/{name?}`)
55
+ * survives registration.
56
+ *
57
+ * @param pattern - The raw route pattern.
58
+ * @returns The normalized pattern.
59
+ */
60
+ export declare function normalizeRoutePattern(pattern: string): string;
61
+ /**
62
+ * Reports whether a path carries a meaningful trailing slash.
63
+ *
64
+ * @param path - The path to inspect.
65
+ * @returns `true` when the path ends with `/` and is not the root path.
66
+ */
67
+ export declare function hasTrailingSlash(path: string): boolean;
14
68
  export declare function splitPath(path: string): string[];
69
+ /**
70
+ * Splits a route pattern into its segments, keeping `?` markers intact.
71
+ *
72
+ * @param pattern - The raw route pattern.
73
+ * @returns The pattern's non-empty segments.
74
+ */
75
+ export declare function splitRoutePattern(pattern: string): string[];
15
76
  export declare function validateParameterName(name: string, path: string): void;
16
77
  export declare function decodeRouteValue(value: string): string;
17
78
  /**
@@ -4,6 +4,8 @@
4
4
  * Request accessors, URL/query parsing, and path normalization shared by the
5
5
  * router core.
6
6
  */
7
+ import { parseRequestTarget } from "../../../httpRequest/target/httpRequest.target.js";
8
+ import { parseQueryString } from "../../../httpQuery/index.js";
7
9
  import { InvalidRoutePatternError } from "../error/httpRouter.error.js";
8
10
  export function getRequestMethod(request) {
9
11
  const value = request.method;
@@ -19,35 +21,105 @@ export function getRequestUrl(request) {
19
21
  export function getRequestSignal(request) {
20
22
  return request.signal;
21
23
  }
24
+ /**
25
+ * Parses a request-target with the canonical parser shared by the request
26
+ * context and path-scoped middleware.
27
+ */
22
28
  export function parseUrl(value) {
23
- try {
24
- return new URL(value, "http://zudojs.local");
25
- }
26
- catch {
27
- return new URL("/", "http://zudojs.local");
28
- }
29
+ return parseRequestTarget(value);
29
30
  }
31
+ /**
32
+ * Builds the router's query record.
33
+ *
34
+ * Uses the same parser as the Node adapter's `request.query`
35
+ * (`parseQueryString`): a repeated name is an array, the record has a `null`
36
+ * prototype, and `__proto__` / `constructor` / `prototype` are dropped. The
37
+ * two used to disagree (`ctx.query.role` an array, `request.getQuery("role")`
38
+ * the last value), which let middleware and handlers read different values
39
+ * for the same parameter.
40
+ */
30
41
  export function parseQuery(params) {
31
- const result = {};
32
- for (const key of new Set(Array.from(params.keys()))) {
33
- const values = params.getAll(key);
34
- result[key] = values.length > 1 ? values : (values[0] ?? "");
35
- }
36
- return Object.freeze({
37
- ...result,
38
- });
42
+ return Object.freeze(parseQueryString(params.toString()));
39
43
  }
40
44
  /* -------------------------------------------------------------------------- */
41
45
  /* Path Helpers */
42
46
  /* -------------------------------------------------------------------------- */
47
+ /**
48
+ * Normalizes a **request** path.
49
+ *
50
+ * Strips the query string, forces a leading slash, collapses repeated
51
+ * slashes, and trims a trailing slash.
52
+ *
53
+ * This must not be used on a route *pattern*: a pattern may legitimately
54
+ * contain `?` (the optional-parameter marker), which this function treats as
55
+ * the start of a query string. Use {@link normalizeRoutePattern} for
56
+ * patterns.
57
+ */
43
58
  export function normalizePath(path) {
44
59
  if (!path || path === "") {
45
60
  return "/";
46
61
  }
47
62
  const withoutQuery = path.split("?", 1)[0] ?? path;
48
- let normalized = withoutQuery.startsWith("/")
49
- ? withoutQuery
50
- : `/${withoutQuery}`;
63
+ return collapsePath(withoutQuery);
64
+ }
65
+ /**
66
+ * Normalizes a request path while preserving a single trailing slash.
67
+ *
68
+ * The trailing slash is the only information a strict-trailing-slash route
69
+ * needs and {@link normalizePath} destroys it, so matching runs on this
70
+ * spelling instead.
71
+ *
72
+ * @param path - The raw request path, possibly with a query or fragment.
73
+ * @returns The normalized path, keeping one trailing slash if present.
74
+ */
75
+ export function normalizeMatchPath(path) {
76
+ if (!path || path.trim() === "") {
77
+ return "/";
78
+ }
79
+ let target = path.trim();
80
+ if (target.startsWith("http://") || target.startsWith("https://")) {
81
+ try {
82
+ target = new URL(target).pathname;
83
+ }
84
+ catch {
85
+ /* Keep the original value if it is not a valid absolute URL. */
86
+ }
87
+ }
88
+ const withoutQuery = target.split("?", 1)[0] ?? target;
89
+ const withoutHash = withoutQuery.split("#", 1)[0] ?? withoutQuery;
90
+ const collapsed = collapsePath(withoutHash);
91
+ return hasTrailingSlash(withoutHash) && collapsed !== "/"
92
+ ? `${collapsed}/`
93
+ : collapsed;
94
+ }
95
+ /**
96
+ * Normalizes a route **pattern**.
97
+ *
98
+ * Identical to {@link normalizePath} except that `?` is left alone, so the
99
+ * documented optional-parameter syntax (`/users/:id?`, `/files/{name?}`)
100
+ * survives registration.
101
+ *
102
+ * @param pattern - The raw route pattern.
103
+ * @returns The normalized pattern.
104
+ */
105
+ export function normalizeRoutePattern(pattern) {
106
+ if (!pattern || pattern === "") {
107
+ return "/";
108
+ }
109
+ return collapsePath(pattern);
110
+ }
111
+ /**
112
+ * Reports whether a path carries a meaningful trailing slash.
113
+ *
114
+ * @param path - The path to inspect.
115
+ * @returns `true` when the path ends with `/` and is not the root path.
116
+ */
117
+ export function hasTrailingSlash(path) {
118
+ const trimmed = path.replace(/\/{2,}/g, "/");
119
+ return trimmed.length > 1 && trimmed.endsWith("/");
120
+ }
121
+ function collapsePath(value) {
122
+ let normalized = value.startsWith("/") ? value : `/${value}`;
51
123
  normalized = normalized.replace(/\/{2,}/g, "/");
52
124
  if (normalized.length > 1 && normalized.endsWith("/")) {
53
125
  normalized = normalized.slice(0, -1);
@@ -61,6 +133,19 @@ export function splitPath(path) {
61
133
  }
62
134
  return normalized.split("/").filter(Boolean);
63
135
  }
136
+ /**
137
+ * Splits a route pattern into its segments, keeping `?` markers intact.
138
+ *
139
+ * @param pattern - The raw route pattern.
140
+ * @returns The pattern's non-empty segments.
141
+ */
142
+ export function splitRoutePattern(pattern) {
143
+ const normalized = normalizeRoutePattern(pattern);
144
+ if (normalized === "/") {
145
+ return [];
146
+ }
147
+ return normalized.split("/").filter(Boolean);
148
+ }
64
149
  export function validateParameterName(name, path) {
65
150
  if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(name)) {
66
151
  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>;
@@ -104,7 +104,7 @@ export class RouteDispatcher {
104
104
  async execute(context) {
105
105
  const state = new RouterMiddlewareState();
106
106
  const middleware = context.route.middleware.map((layer) => toRouteMiddleware(layer, context.route, state));
107
- const handler = toDispatchHandler(context.route.handler, context);
107
+ const handler = toDispatchHandler(context.route.handler, context, this.options.preserveResponse === true);
108
108
  let index = -1;
109
109
  const dispatchNext = async (current) => {
110
110
  if (current <= index) {
@@ -220,7 +220,7 @@ function normalizeHandler(handler) {
220
220
  * A response context returned by the handler is merged into the dispatch
221
221
  * response so the dispatcher's response object stays authoritative.
222
222
  */
223
- function toDispatchHandler(handler, context) {
223
+ function toDispatchHandler(handler, context, preserveResponse = false) {
224
224
  return async (request, response) => {
225
225
  const result = await handler(createRouterContext({
226
226
  request,
@@ -228,7 +228,9 @@ function toDispatchHandler(handler, context) {
228
228
  params: context.params,
229
229
  signal: getRequestSignal(request),
230
230
  }));
231
- if (result instanceof HttpResponseContext && result !== response) {
231
+ if (!preserveResponse &&
232
+ result instanceof HttpResponseContext &&
233
+ result !== response) {
232
234
  /*
233
235
  * Everything the handler put on its response must survive the merge.
234
236
  * Copying only status, headers and body dropped every cookie the
@@ -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
  /* -------------------------------------------------------------------------- */