@zudojs/http 1.2.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 (69) hide show
  1. package/dist/httpAdapter/http.adapters.d.ts +24 -3
  2. package/dist/httpAdapter/http.adapters.js +20 -11
  3. package/dist/httpAdapter/node/httpNode.request.js +1 -1
  4. package/dist/httpAgent/http.agent.d.ts +22 -3
  5. package/dist/httpAgent/http.agent.js +52 -14
  6. package/dist/httpCacheControl/httpCacheControl.freshness.d.ts +7 -1
  7. package/dist/httpCacheControl/httpCacheControl.freshness.js +30 -3
  8. package/dist/httpKeepAlive/httpKeepAlive.core.js +14 -2
  9. package/dist/httpMiddleware/builtin/logging/httpMiddleware.logging.d.ts +18 -0
  10. package/dist/httpMiddleware/builtin/logging/httpMiddleware.logging.js +17 -1
  11. package/dist/httpMiddleware/builtin/security/httpMiddleware.security.d.ts +11 -0
  12. package/dist/httpMiddleware/builtin/security/httpMiddleware.security.js +25 -12
  13. package/dist/httpNegotiation/httpNegotiation.core.d.ts +13 -0
  14. package/dist/httpNegotiation/httpNegotiation.core.js +60 -8
  15. package/dist/httpProxy/http.proxy.d.ts +9 -0
  16. package/dist/httpProxy/http.proxy.js +35 -3
  17. package/dist/httpQuery/index.d.ts +11 -2
  18. package/dist/httpQuery/index.js +11 -2
  19. package/dist/httpQuery/queryParse/index.d.ts +10 -0
  20. package/dist/httpQuery/queryParse/index.js +10 -0
  21. package/dist/httpQuery/queryParse/queryParse.flat.d.ts +13 -0
  22. package/dist/httpQuery/queryParse/queryParse.flat.js +44 -0
  23. package/dist/httpQuery/queryParse/queryParse.nested.d.ts +25 -0
  24. package/dist/httpQuery/queryParse/queryParse.nested.js +112 -0
  25. package/dist/httpQuery/queryParse/queryParse.tokenizer.d.ts +37 -0
  26. package/dist/httpQuery/queryParse/queryParse.tokenizer.js +95 -0
  27. package/dist/httpQuery/queryRequest/index.d.ts +9 -0
  28. package/dist/httpQuery/queryRequest/index.js +9 -0
  29. package/dist/httpQuery/queryRequest/query.request.d.ts +43 -0
  30. package/dist/httpQuery/queryRequest/query.request.js +96 -0
  31. package/dist/httpQuery/querySerialize/index.d.ts +10 -0
  32. package/dist/httpQuery/querySerialize/index.js +10 -0
  33. package/dist/httpQuery/querySerialize/query.util.d.ts +21 -0
  34. package/dist/httpQuery/querySerialize/query.util.js +67 -0
  35. package/dist/httpQuery/querySerialize/querySerialize.core.d.ts +12 -0
  36. package/dist/httpQuery/querySerialize/querySerialize.core.js +97 -0
  37. package/dist/httpQuery/queryTypes/index.d.ts +11 -0
  38. package/dist/httpQuery/queryTypes/index.js +9 -0
  39. package/dist/httpQuery/queryTypes/query.container.d.ts +16 -0
  40. package/dist/httpQuery/queryTypes/query.container.js +51 -0
  41. package/dist/httpQuery/queryTypes/query.limit.d.ts +25 -0
  42. package/dist/httpQuery/queryTypes/query.limit.js +32 -0
  43. package/dist/httpQuery/queryTypes/query.type.d.ts +62 -0
  44. package/dist/httpQuery/queryTypes/query.type.js +2 -0
  45. package/dist/httpRedirect/http.redirect.d.ts +6 -0
  46. package/dist/httpRedirect/http.redirect.js +53 -2
  47. package/dist/httpRequest/http.request.d.ts +61 -2
  48. package/dist/httpRequest/http.request.js +86 -35
  49. package/dist/httpRequest/httpRequest.context.js +11 -18
  50. package/dist/httpRequest/target/httpRequest.target.d.ts +2 -2
  51. package/dist/httpRequest/target/httpRequest.target.js +23 -5
  52. package/dist/httpRouter/core/factory/httpRoute.factory.base.d.ts +18 -1
  53. package/dist/httpRouter/core/factory/httpRoute.factory.base.js +49 -6
  54. package/dist/httpRouter/core/factory/httpRoute.factory.js +3 -3
  55. package/dist/httpRouter/core/register/httpRouter.register.js +16 -21
  56. package/dist/httpRouter/core/types/httpRouter.type.d.ts +6 -0
  57. package/dist/httpRouter/core/util/httpRoute.util.d.ts +47 -0
  58. package/dist/httpRouter/core/util/httpRoute.util.js +85 -4
  59. package/dist/httpRouter/dispatch/httpRoute.dispatcher.d.ts +11 -0
  60. package/dist/httpRouter/dispatch/httpRoute.dispatcher.js +5 -3
  61. package/dist/httpRouter/matching/httpRoute.matcher.core.js +51 -6
  62. package/dist/httpRouter/matching/httpRoute.matcher.d.ts +0 -1
  63. package/dist/httpRouter/matching/httpRoute.matcher.js +64 -26
  64. package/dist/httpRouter/pattern/httpRoute.pattern.parse.d.ts +16 -0
  65. package/dist/httpRouter/pattern/httpRoute.pattern.parse.js +40 -11
  66. package/dist/httpRouter/pattern/index.d.ts +1 -1
  67. package/dist/httpRouter/pattern/index.js +1 -1
  68. package/dist/httpSecurity/httpSecurity.validator.js +16 -7
  69. package/package.json +5 -5
@@ -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;
@@ -44,14 +44,82 @@ export function parseQuery(params) {
44
44
  /* -------------------------------------------------------------------------- */
45
45
  /* Path Helpers */
46
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
+ */
47
58
  export function normalizePath(path) {
48
59
  if (!path || path === "") {
49
60
  return "/";
50
61
  }
51
62
  const withoutQuery = path.split("?", 1)[0] ?? path;
52
- let normalized = withoutQuery.startsWith("/")
53
- ? withoutQuery
54
- : `/${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}`;
55
123
  normalized = normalized.replace(/\/{2,}/g, "/");
56
124
  if (normalized.length > 1 && normalized.endsWith("/")) {
57
125
  normalized = normalized.slice(0, -1);
@@ -65,6 +133,19 @@ export function splitPath(path) {
65
133
  }
66
134
  return normalized.split("/").filter(Boolean);
67
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
+ }
68
149
  export function validateParameterName(name, path) {
69
150
  if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(name)) {
70
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
  /* -------------------------------------------------------------------------- */
@@ -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
@@ -13,7 +13,7 @@
13
13
  * - `/assets/*path` trailing wildcard
14
14
  */
15
15
  import { InvalidRoutePatternError } from "../core/error/httpRouter.error.js";
16
- import { splitPath, validateParameterName, } from "../core/util/httpRoute.util.js";
16
+ import { hasTrailingSlash, splitRoutePattern, validateParameterName, } from "../core/util/httpRoute.util.js";
17
17
  const SEGMENT_SCORE_LITERAL = 3;
18
18
  const SEGMENT_SCORE_PARAMETER = 2;
19
19
  const SEGMENT_SCORE_WILDCARD = 1;
@@ -22,7 +22,7 @@ const SEGMENT_SCORE_WILDCARD = 1;
22
22
  */
23
23
  export function compileRouteSegments(path) {
24
24
  const segments = [];
25
- const parts = splitPath(path);
25
+ const parts = splitRoutePattern(path);
26
26
  for (const part of parts) {
27
27
  if (part.startsWith(":")) {
28
28
  segments.push(parseParameter(part, path));
@@ -56,6 +56,7 @@ export function compileRoute(path, strictTrailingSlash) {
56
56
  segments,
57
57
  score: scoreSegments(segments),
58
58
  strictTrailingSlash,
59
+ expectsTrailingSlash: hasTrailingSlash(path),
59
60
  });
60
61
  }
61
62
  /**
@@ -64,18 +65,46 @@ export function compileRoute(path, strictTrailingSlash) {
64
65
  export function scoreSegments(segments) {
65
66
  let score = 0;
66
67
  for (const segment of segments) {
67
- if (segment.type === "literal") {
68
- score += SEGMENT_SCORE_LITERAL;
69
- }
70
- else if (segment.type === "parameter") {
71
- score += SEGMENT_SCORE_PARAMETER;
72
- }
73
- else {
74
- score += SEGMENT_SCORE_WILDCARD;
75
- }
68
+ score += segmentScore(segment);
76
69
  }
77
70
  return score;
78
71
  }
72
+ function segmentScore(segment) {
73
+ if (segment === undefined) {
74
+ return 0;
75
+ }
76
+ if (segment.type === "literal") {
77
+ return SEGMENT_SCORE_LITERAL;
78
+ }
79
+ if (segment.type === "parameter") {
80
+ return SEGMENT_SCORE_PARAMETER;
81
+ }
82
+ return SEGMENT_SCORE_WILDCARD;
83
+ }
84
+ /**
85
+ * Compares two compiled patterns by specificity, most specific first.
86
+ *
87
+ * Segments are compared left to right by kind (literal, then parameter, then
88
+ * wildcard), which is how a router is expected to rank patterns. Summing the
89
+ * kinds into one scalar — as this used to — let a longer but entirely
90
+ * parameterised pattern such as `/:p/:q/:r/:s` outrank a literal-anchored
91
+ * `/admin/*rest`, so a request to `/admin/a/b/c` bypassed the admin route and
92
+ * every guard registered on it.
93
+ *
94
+ * @param left - The first pattern's segments.
95
+ * @param right - The second pattern's segments.
96
+ * @returns A negative number when `left` is more specific.
97
+ */
98
+ export function compareSegmentSpecificity(left, right) {
99
+ const length = Math.max(left.length, right.length);
100
+ for (let index = 0; index < length; index += 1) {
101
+ const difference = segmentScore(right[index]) - segmentScore(left[index]);
102
+ if (difference !== 0) {
103
+ return difference;
104
+ }
105
+ }
106
+ return 0;
107
+ }
79
108
  function parseParameter(segment, path) {
80
109
  let value = segment.slice(1);
81
110
  let optional = false;
@@ -14,6 +14,6 @@ export { parseSegments } from "./core/httpPattern.segmentParsing.js";
14
14
  export { buildRegex } from "./core/httpPattern.regexBuilding.js";
15
15
  export { testRoutePattern, matchRoutePattern, matchRoutePatterns, testRoutePatterns, } from "./core/httpPattern.matching.js";
16
16
  export { createRoutePattern, createStrictRoutePattern, createCaseInsensitiveRoutePattern, } from "./httpPattern.factory.js";
17
- export { compileRoute, compileRouteSegments, scoreSegments, } from "./httpRoute.pattern.parse.js";
17
+ export { compileRoute, compileRouteSegments, scoreSegments, compareSegmentSpecificity, } from "./httpRoute.pattern.parse.js";
18
18
  export type { CompiledRoutePath } from "./httpRoute.pattern.parse.js";
19
19
  //# sourceMappingURL=index.d.ts.map
@@ -13,5 +13,5 @@ export { parseSegments } from "./core/httpPattern.segmentParsing.js";
13
13
  export { buildRegex } from "./core/httpPattern.regexBuilding.js";
14
14
  export { testRoutePattern, matchRoutePattern, matchRoutePatterns, testRoutePatterns, } from "./core/httpPattern.matching.js";
15
15
  export { createRoutePattern, createStrictRoutePattern, createCaseInsensitiveRoutePattern, } from "./httpPattern.factory.js";
16
- export { compileRoute, compileRouteSegments, scoreSegments, } from "./httpRoute.pattern.parse.js";
16
+ export { compileRoute, compileRouteSegments, scoreSegments, compareSegmentSpecificity, } from "./httpRoute.pattern.parse.js";
17
17
  //# sourceMappingURL=index.js.map
@@ -20,14 +20,23 @@ export function validateHeaders(headers, config) {
20
20
  if (entries.length > cfg.maxHeaders) {
21
21
  errors.push(`Too many headers: ${entries.length} > ${cfg.maxHeaders}`);
22
22
  }
23
+ /*
24
+ * `IncomingMessage.headers` returns an array for `set-cookie` and for any
25
+ * header a non-Node caller supplies as a list. Checking only `typeof value
26
+ * === "string"` let every one of those values past the size cap and the
27
+ * CRLF filter while the guard still reported `allowed: true`.
28
+ */
23
29
  for (const [key, value] of entries) {
24
- if (typeof value === "string" && value.length > cfg.maxHeaderValueSize) {
25
- errors.push(`Header "${key}" value too large: ${value.length} > ${cfg.maxHeaderValueSize}`);
26
- }
27
- if (cfg.enableCrlfProtection &&
28
- typeof value === "string" &&
29
- /[\r\n]/.test(value)) {
30
- errors.push(`Header "${key}" contains CRLF characters`);
30
+ for (const element of Array.isArray(value) ? value : [value]) {
31
+ if (typeof element !== "string") {
32
+ continue;
33
+ }
34
+ if (element.length > cfg.maxHeaderValueSize) {
35
+ errors.push(`Header "${key}" value too large: ${element.length} > ${cfg.maxHeaderValueSize}`);
36
+ }
37
+ if (cfg.enableCrlfProtection && /[\r\n]/.test(element)) {
38
+ errors.push(`Header "${key}" contains CRLF characters`);
39
+ }
31
40
  }
32
41
  }
33
42
  return { valid: errors.length === 0, errors };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/http",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "HTTP primitives, request handling, routing, middleware, and server infrastructure for Zudojs applications.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -24,10 +24,10 @@
24
24
  "!dist/.tsbuildinfo"
25
25
  ],
26
26
  "dependencies": {
27
- "@zudojs/crypto": "1.2.0",
28
- "@zudojs/errors": "1.1.0",
29
- "@zudojs/logger": "1.2.0",
30
- "@zudojs/security": "1.1.0"
27
+ "@zudojs/crypto": "1.3.0",
28
+ "@zudojs/errors": "1.2.0",
29
+ "@zudojs/logger": "1.3.0",
30
+ "@zudojs/security": "1.2.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^26.4.1",