@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
@@ -1,5 +1,7 @@
1
1
  import { HTTP_HEADERS } from "../httpConstants/http.constants.js";
2
2
  import { InvalidJSONError } from "../httpErrors/httpError.helper.js";
3
+ import { parseQueryString as parseHardenedQueryString } from "../httpQuery/queryParse/index.js";
4
+ import { getClientIp, isTrustedPeer, } from "../httpTrustProxy/httpTrustProxy.helper.js";
3
5
  /* -------------------------------------------------------------------------- */
4
6
  /* Request Headers */
5
7
  /* -------------------------------------------------------------------------- */
@@ -72,7 +74,8 @@ export class NodeHTTPRequest {
72
74
  cachedJSON;
73
75
  jsonParsed = false;
74
76
  constructor(request, options = {}) {
75
- const protocol = getRequestProtocol(request);
77
+ const trustProxy = options.trustProxy ?? false;
78
+ const protocol = getRequestProtocol(request, trustProxy);
76
79
  const host = getRequestHost(request);
77
80
  const path = getRequestPath(request);
78
81
  this.method = normalizeHTTPMethod(request.method);
@@ -84,9 +87,9 @@ export class NodeHTTPRequest {
84
87
  this.params = options.params ?? {};
85
88
  this.protocol = protocol;
86
89
  this.hostname = getHostname(host);
87
- this.ip = options.ip ?? getRequestIP(request);
90
+ this.ip = options.ip ?? getRequestIP(request, trustProxy);
88
91
  this.ips = options.ips;
89
- this.secure = protocol === "https" || protocol === "wss";
92
+ this.secure = protocol === "https";
90
93
  this.rawBody = options.rawBody;
91
94
  this.body = options.body;
92
95
  this.signal = options.signal;
@@ -229,10 +232,41 @@ export function getHostname(host) {
229
232
  /* -------------------------------------------------------------------------- */
230
233
  /* Protocol */
231
234
  /* -------------------------------------------------------------------------- */
232
- export function getRequestProtocol(request) {
233
- const forwarded = request.headers[HTTP_HEADERS.X_FORWARDED_PROTO];
234
- if (typeof forwarded === "string") {
235
- return (forwarded.split(",", 1)[0] ?? "").trim().toLowerCase();
235
+ /** The only schemes a forwarded proto may name. */
236
+ const FORWARDED_PROTOCOLS = Object.freeze(["http", "https"]);
237
+ /**
238
+ * Presents an `IncomingMessage` in the shape `httpTrustProxy` works on, so
239
+ * this path and the Node adapter share one implementation of the hop logic.
240
+ */
241
+ function toProxyRequest(request) {
242
+ return {
243
+ headers: request.headers,
244
+ socket: { remoteAddress: request.socket?.remoteAddress },
245
+ };
246
+ }
247
+ /**
248
+ * Resolves the scheme the client used.
249
+ *
250
+ * `X-Forwarded-Proto` is written by whoever opened the socket, so it is read
251
+ * only when that peer is a configured trusted proxy, and only when it names
252
+ * `http` or `https` — a value such as `wss` is discarded rather than
253
+ * propagated. With the default `trustProxy` of `false` the socket's own TLS
254
+ * state is the only input.
255
+ *
256
+ * @param request - The incoming Node request.
257
+ * @param trustProxy - Which peers may speak through `X-Forwarded-Proto`.
258
+ * @returns `"https"` or `"http"`.
259
+ */
260
+ export function getRequestProtocol(request, trustProxy = false) {
261
+ if (isTrustedPeer(toProxyRequest(request), trustProxy)) {
262
+ const forwarded = request.headers[HTTP_HEADERS.X_FORWARDED_PROTO];
263
+ const value = Array.isArray(forwarded) ? forwarded[0] : forwarded;
264
+ if (typeof value === "string") {
265
+ const proto = (value.split(",", 1)[0] ?? "").trim().toLowerCase();
266
+ if (FORWARDED_PROTOCOLS.includes(proto)) {
267
+ return proto;
268
+ }
269
+ }
236
270
  }
237
271
  if ("encrypted" in request.socket &&
238
272
  request.socket.encrypted) {
@@ -243,43 +277,60 @@ export function getRequestProtocol(request) {
243
277
  /* -------------------------------------------------------------------------- */
244
278
  /* IP */
245
279
  /* -------------------------------------------------------------------------- */
246
- export function getRequestIP(request) {
247
- const forwarded = request.headers[HTTP_HEADERS.X_FORWARDED_FOR];
248
- if (typeof forwarded === "string") {
249
- const first = forwarded.split(",", 1)[0]?.trim();
250
- if (first) {
251
- return first;
252
- }
280
+ /**
281
+ * Resolves the client address.
282
+ *
283
+ * The socket peer is authoritative. `X-Forwarded-For` is consulted only when
284
+ * that peer is a configured trusted proxy, and the chain walk is delegated to
285
+ * `getClientIp` so there is a single implementation of the hop logic. With
286
+ * the default `trustProxy` of `false` the peer address is returned unchanged,
287
+ * which is what an allowlist, per-IP rate limit or audit trail keyed on
288
+ * `req.ip` needs.
289
+ *
290
+ * @param request - The incoming Node request.
291
+ * @param trustProxy - Which peers may speak through `X-Forwarded-For`.
292
+ * @returns The client address, or `undefined` when the socket has none.
293
+ */
294
+ export function getRequestIP(request, trustProxy = false) {
295
+ const proxyRequest = toProxyRequest(request);
296
+ const peer = proxyRequest.socket?.remoteAddress;
297
+ if (!isTrustedPeer(proxyRequest, trustProxy)) {
298
+ return peer ?? undefined;
253
299
  }
254
- return request.socket.remoteAddress ?? undefined;
300
+ return getClientIp(proxyRequest, trustProxy) ?? peer ?? undefined;
255
301
  }
256
302
  /* -------------------------------------------------------------------------- */
257
303
  /* Query */
258
304
  /* -------------------------------------------------------------------------- */
305
+ /**
306
+ * Parses the query component of a request-target into a flat record.
307
+ *
308
+ * Delegates to the hardened `httpQuery` parser, the same one behind the Node
309
+ * adapter's `request.query` and the router's `ctx.query`. This function used
310
+ * to carry its own loop that accumulated into an object literal and read
311
+ * `result[key]` without an own-property check, which had two consequences on
312
+ * fully attacker-controlled input:
313
+ *
314
+ * - `?__proto__=a&__proto__=b` assigned an array through the `__proto__`
315
+ * setter, so the returned query object's prototype became that array. The
316
+ * parameter vanished from its own keys while the object silently gained
317
+ * `length`, `map` and the rest of `Array.prototype`.
318
+ * - `?constructor=x` read the inherited `Object` constructor as the "existing"
319
+ * value and stored it in the result, handing a handler
320
+ * `query.constructor === [Function: Object], "x"]`.
321
+ *
322
+ * It also applied none of the four documented query limits, so a request with
323
+ * 50,000 parameters was parsed in full. Delegating fixes all three, and makes
324
+ * a limit breach throw {@link HTTPQueryLimitError} (414) as it already did on
325
+ * every other request path.
326
+ */
259
327
  export function parseQueryString(url) {
260
328
  const queryIndex = url.indexOf("?");
261
329
  if (queryIndex < 0) {
262
- return {};
263
- }
264
- const queryString = url.slice(queryIndex + 1);
265
- if (!queryString) {
266
- return {};
267
- }
268
- const searchParams = new URLSearchParams(queryString);
269
- const result = {};
270
- for (const [key, value] of searchParams.entries()) {
271
- const existing = result[key];
272
- if (existing === undefined) {
273
- result[key] = value;
274
- continue;
275
- }
276
- if (Array.isArray(existing)) {
277
- result[key] = [...existing, value];
278
- continue;
279
- }
280
- result[key] = [existing, value];
330
+ return parseHardenedQueryString(undefined);
281
331
  }
282
- return result;
332
+ const hashIndex = url.indexOf("#", queryIndex + 1);
333
+ return parseHardenedQueryString(url.slice(queryIndex + 1, hashIndex === -1 ? undefined : hashIndex));
283
334
  }
284
335
  /* -------------------------------------------------------------------------- */
285
336
  /* Method */
@@ -7,6 +7,7 @@
7
7
  * The context is intentionally framework-agnostic so adapters can populate it
8
8
  * from Node.js, Bun, Deno, or another HTTP runtime.
9
9
  */
10
+ import { AsyncLocalStorage } from "node:async_hooks";
10
11
  import { parseRequestTarget } from "./target/httpRequest.target.js";
11
12
  /* -------------------------------------------------------------------------- */
12
13
  /* Constants */
@@ -378,24 +379,16 @@ function validateHeaderValue(value) {
378
379
  throw new TypeError("HTTP header value cannot contain CR or LF characters.");
379
380
  }
380
381
  }
382
+ /**
383
+ * Builds the request-context store.
384
+ *
385
+ * `AsyncLocalStorage` used to be reached through `globalThis.require`, which
386
+ * does not exist in ESM under Node. The `typeof` guard turned that into a
387
+ * silent `undefined`, so `runWithRequestContext` merely called its callback
388
+ * and `getCurrentRequestContext` always returned `undefined`. It is imported
389
+ * statically now, as the rest of the package imports its Node built-ins.
390
+ */
381
391
  function createAsyncContextStorage() {
382
- /*
383
- * AsyncLocalStorage is intentionally loaded lazily so the HTTP package
384
- * remains usable in browser and non-Node runtimes.
385
- */
386
- try {
387
- const runtimeRequire = globalThis.require;
388
- if (typeof runtimeRequire !== "function") {
389
- return undefined;
390
- }
391
- const asyncHooks = runtimeRequire("node:async_hooks");
392
- if (!asyncHooks.AsyncLocalStorage) {
393
- return undefined;
394
- }
395
- return new asyncHooks.AsyncLocalStorage();
396
- }
397
- catch {
398
- return undefined;
399
- }
392
+ return new AsyncLocalStorage();
400
393
  }
401
394
  //# sourceMappingURL=httpRequest.context.js.map
@@ -17,8 +17,8 @@
17
17
  /**
18
18
  * Parses a request-target (origin-form, absolute-form or `*`) into a URL.
19
19
  *
20
- * An origin-form target is never parsed as an authority. Unparseable input
21
- * yields the root URL.
20
+ * An origin-form target is never parsed as an authority, and repeated slashes
21
+ * in the path are collapsed. Unparseable input yields the root URL.
22
22
  */
23
23
  export declare function parseRequestTarget(target: string): URL;
24
24
  /**
@@ -17,21 +17,39 @@
17
17
  const TARGET_BASE = "http://zudojs.invalid";
18
18
  const ABSOLUTE_FORM = /^https?:\/\//i;
19
19
  const ENCODED_DOT = /%2e/gi;
20
+ const REPEATED_SLASH = /\/{2,}/g;
21
+ /**
22
+ * Collapses repeated slashes in a parsed target's path.
23
+ *
24
+ * The router normalises `/{2,}` away before matching, while the request
25
+ * context kept them, so `//admin/secret` dispatched to the route registered
26
+ * at `/admin/secret` while a guard reading `request.path` saw a path that did
27
+ * not start with `/admin/`. Both sides parse here, so collapsing once here
28
+ * keeps them in agreement. The query and fragment are untouched.
29
+ */
30
+ function collapsePathSlashes(url) {
31
+ if (!url.pathname.includes("//")) {
32
+ return url;
33
+ }
34
+ const collapsed = new URL(url.href);
35
+ collapsed.pathname = url.pathname.replace(REPEATED_SLASH, "/");
36
+ return collapsed;
37
+ }
20
38
  /**
21
39
  * Parses a request-target (origin-form, absolute-form or `*`) into a URL.
22
40
  *
23
- * An origin-form target is never parsed as an authority. Unparseable input
24
- * yields the root URL.
41
+ * An origin-form target is never parsed as an authority, and repeated slashes
42
+ * in the path are collapsed. Unparseable input yields the root URL.
25
43
  */
26
44
  export function parseRequestTarget(target) {
27
45
  try {
28
46
  if (target.startsWith("/")) {
29
- return new URL(`${TARGET_BASE}${target}`);
47
+ return collapsePathSlashes(new URL(`${TARGET_BASE}${target}`));
30
48
  }
31
49
  if (ABSOLUTE_FORM.test(target)) {
32
- return new URL(target);
50
+ return collapsePathSlashes(new URL(target));
33
51
  }
34
- return new URL(`${TARGET_BASE}/${target === "*" ? "" : target}`);
52
+ return collapsePathSlashes(new URL(`${TARGET_BASE}/${target === "*" ? "" : target}`));
35
53
  }
36
54
  catch {
37
55
  return new URL(`${TARGET_BASE}/`);
@@ -9,7 +9,18 @@ import { type HttpResponseContext as ResponseContext } from "../../../httpRespon
9
9
  export declare function normalizeMethod(method: string): HttpMethod | "*";
10
10
  export declare function normalizeMethods(method: HttpMethod | readonly HttpMethod[] | "*"): readonly (HttpMethod | "*")[];
11
11
  export declare function isHttpMethod(value: string): value is HttpMethod;
12
- export declare function collectAllowedMethods(routes: readonly CompiledRoute[], path: string): HttpMethod[];
12
+ /**
13
+ * Collects the methods registered for a path.
14
+ *
15
+ * @param routes - The compiled routes to consider.
16
+ * @param path - The request path.
17
+ * @param caseSensitive - The router's case sensitivity. This used to be
18
+ * hardcoded to `false`, so a case-sensitive router advertised `Allow`
19
+ * methods belonging to a route that only differed by case — a method the
20
+ * client would then get a 404 from, and a disclosure of the other route.
21
+ * @returns The allowed methods, with `HEAD` implied by `GET`.
22
+ */
23
+ export declare function collectAllowedMethods(routes: readonly CompiledRoute[], path: string, caseSensitive?: boolean): HttpMethod[];
13
24
  /**
14
25
  * Extracts the monotonic registration sequence from a generated route id.
15
26
  *
@@ -39,6 +50,12 @@ export declare function defaultNotFoundHandler(context: HttpRouterRequestContext
39
50
  export declare function defaultMethodNotAllowedHandler(context: HttpRouterRequestContext, allowedMethods: readonly HttpMethod[]): ResponseContext;
40
51
  /**
41
52
  * Runs a matched route's middleware chain followed by its handler.
53
+ *
54
+ * Every result is folded into the ambient response context
55
+ * (`context.middleware.response`), which is the object route middleware
56
+ * writes to. Returning the handler's brand new response instead — as this
57
+ * used to — silently discarded every header, cookie and status a route
58
+ * middleware had set before calling `next()`.
42
59
  */
43
60
  export declare function executeRoute(route: MatchedRoute, context: HttpRouterContext): Promise<ResponseContext>;
44
61
  //# sourceMappingURL=httpRoute.factory.base.d.ts.map
@@ -36,10 +36,21 @@ export function isHttpMethod(value) {
36
36
  value === "CONNECT" ||
37
37
  value === "TRACE");
38
38
  }
39
- export function collectAllowedMethods(routes, path) {
39
+ /**
40
+ * Collects the methods registered for a path.
41
+ *
42
+ * @param routes - The compiled routes to consider.
43
+ * @param path - The request path.
44
+ * @param caseSensitive - The router's case sensitivity. This used to be
45
+ * hardcoded to `false`, so a case-sensitive router advertised `Allow`
46
+ * methods belonging to a route that only differed by case — a method the
47
+ * client would then get a 404 from, and a disclosure of the other route.
48
+ * @returns The allowed methods, with `HEAD` implied by `GET`.
49
+ */
50
+ export function collectAllowedMethods(routes, path, caseSensitive = false) {
40
51
  const methods = new Set();
41
52
  for (const route of routes) {
42
- if (!matchCompiledRoute(route, path, false)) {
53
+ if (!matchCompiledRoute(route, path, caseSensitive)) {
43
54
  continue;
44
55
  }
45
56
  if (isHttpMethod(route.definition.method)) {
@@ -153,11 +164,43 @@ export function defaultMethodNotAllowedHandler(context, allowedMethods) {
153
164
  /* -------------------------------------------------------------------------- */
154
165
  /* Route Execution */
155
166
  /* -------------------------------------------------------------------------- */
167
+ /**
168
+ * Merges one response context into another.
169
+ *
170
+ * Status, status text, headers, cookies, metadata and body are all carried
171
+ * over, so nothing a handler produced is lost.
172
+ *
173
+ * @param target - The response that stays authoritative.
174
+ * @param source - The response to fold into it.
175
+ * @returns The target response.
176
+ */
177
+ function mergeRouteResponse(target, source) {
178
+ if (source === target) {
179
+ return target;
180
+ }
181
+ target.setStatus(source.status, source.statusText);
182
+ target.headers_obj(source.headers);
183
+ for (const cookie of source.cookies) {
184
+ target.setCookie(cookie);
185
+ }
186
+ for (const [key, value] of Object.entries(source.metadata)) {
187
+ target.setMetadata(key, value);
188
+ }
189
+ target.setBody(source.body);
190
+ return target;
191
+ }
156
192
  /**
157
193
  * Runs a matched route's middleware chain followed by its handler.
194
+ *
195
+ * Every result is folded into the ambient response context
196
+ * (`context.middleware.response`), which is the object route middleware
197
+ * writes to. Returning the handler's brand new response instead — as this
198
+ * used to — silently discarded every header, cookie and status a route
199
+ * middleware had set before calling `next()`.
158
200
  */
159
201
  export async function executeRoute(route, context) {
160
202
  const layers = route.middleware;
203
+ const ambient = context.middleware.response;
161
204
  let invoked = -1;
162
205
  const run = async (index) => {
163
206
  if (index <= invoked) {
@@ -166,7 +209,7 @@ export async function executeRoute(route, context) {
166
209
  invoked = index;
167
210
  const layer = layers[index];
168
211
  if (layer === undefined) {
169
- return normalizeResponse(await route.handler(context));
212
+ return mergeRouteResponse(ambient, await normalizeResponse(await route.handler(context)));
170
213
  }
171
214
  let downstream;
172
215
  const result = await layer(context.middleware, async () => {
@@ -174,12 +217,12 @@ export async function executeRoute(route, context) {
174
217
  return downstream;
175
218
  });
176
219
  if (result instanceof HttpResponseContext) {
177
- return result;
220
+ return mergeRouteResponse(ambient, result);
178
221
  }
179
222
  if (typeof Response !== "undefined" && result instanceof Response) {
180
- return normalizeResponse(result);
223
+ return mergeRouteResponse(ambient, await normalizeResponse(result));
181
224
  }
182
- return downstream ?? context.middleware.response;
225
+ return downstream ?? ambient;
183
226
  };
184
227
  return run(0);
185
228
  }
@@ -4,7 +4,7 @@
4
4
  import { HttpRouter } from "../register/httpRouter.register.js";
5
5
  import { HttpRouterGroup } from "../group/httpRouterGroup.core.js";
6
6
  import { RouteConflictError, InvalidRoutePatternError, } from "../error/httpRouter.error.js";
7
- import { normalizePath } from "../util/httpRoute.util.js";
7
+ import { normalizeRoutePattern } from "../util/httpRoute.util.js";
8
8
  /**
9
9
  * Creates a new HTTP router instance.
10
10
  */
@@ -17,7 +17,7 @@ export function createRouter(options = {}) {
17
17
  export function createRoute(definition) {
18
18
  return {
19
19
  ...definition,
20
- path: normalizePath(definition.path),
20
+ path: normalizeRoutePattern(definition.path),
21
21
  middleware: Object.freeze([...(definition.middleware ?? [])]),
22
22
  metadata: Object.freeze({ ...(definition.metadata ?? {}) }),
23
23
  };
@@ -26,7 +26,7 @@ export function createRoute(definition) {
26
26
  * Builds a route path from a pattern and parameters.
27
27
  */
28
28
  export function buildRoutePath(pattern, params = {}) {
29
- const normalized = normalizePath(pattern);
29
+ const normalized = normalizeRoutePattern(pattern);
30
30
  return normalized
31
31
  .replace(/:([a-zA-Z_][a-zA-Z0-9_-]*)(\?)?/g, (_match, name, optional) => {
32
32
  const value = params[name];
@@ -4,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
@@ -24,8 +24,55 @@ export declare function parseUrl(value: string): URL;
24
24
  * for the same parameter.
25
25
  */
26
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
+ */
27
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;
28
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[];
29
76
  export declare function validateParameterName(name: string, path: string): void;
30
77
  export declare function decodeRouteValue(value: string): string;
31
78
  /**