cpeak 2.7.0 → 2.8.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.
package/lib/index.ts CHANGED
@@ -2,6 +2,8 @@ import http from "node:http";
2
2
  import fs from "node:fs/promises";
3
3
  import { createReadStream } from "node:fs";
4
4
  import { pipeline } from "node:stream/promises";
5
+
6
+ import type net from "node:net";
5
7
  import type { Readable } from "node:stream";
6
8
  import type { Buffer } from "node:buffer";
7
9
 
@@ -9,52 +11,25 @@ import {
9
11
  resolveCompressionOptions,
10
12
  compressAndSend
11
13
  } from "./internal/compression";
14
+ import { MIME_TYPES } from "./internal/mimeTypes";
15
+ import { Router } from "./internal/router";
16
+ import { frameworkError, ErrorCode } from "./internal/errors";
17
+
18
+ export { frameworkError, ErrorCode };
12
19
 
13
20
  import type {
14
21
  StringMap,
22
+ CpeakHttpServer,
15
23
  CpeakOptions,
16
24
  CpeakRequest,
17
25
  CpeakResponse,
18
26
  Middleware,
19
27
  RouteMiddleware,
20
- Handler,
21
- RoutesMap
28
+ Handler
22
29
  } from "./types";
23
30
 
24
31
  import type { ResolvedCompressionConfig } from "./internal/types";
25
32
 
26
- // A utility function to create an error with a custom stack trace
27
- export function frameworkError(
28
- message: string,
29
- skipFn: Function,
30
- code?: string,
31
- status?: number
32
- ) {
33
- const err = new Error(message) as Error & {
34
- code?: string;
35
- cpeak_err?: boolean;
36
- };
37
- Error.captureStackTrace(err, skipFn);
38
-
39
- err.cpeak_err = true;
40
-
41
- if (code) err.code = code;
42
- if (status) (err as any).status = status;
43
-
44
- return err;
45
- }
46
-
47
- export enum ErrorCode {
48
- MISSING_MIME = "CPEAK_ERR_MISSING_MIME",
49
- FILE_NOT_FOUND = "CPEAK_ERR_FILE_NOT_FOUND",
50
- NOT_A_FILE = "CPEAK_ERR_NOT_A_FILE",
51
- SEND_FILE_FAIL = "CPEAK_ERR_SEND_FILE_FAIL",
52
- INVALID_JSON = "CPEAK_ERR_INVALID_JSON",
53
- PAYLOAD_TOO_LARGE = "CPEAK_ERR_PAYLOAD_TOO_LARGE",
54
- WEAK_SECRET = "CPEAK_ERR_WEAK_SECRET",
55
- COMPRESSION_NOT_ENABLED = "CPEAK_ERR_COMPRESSION_NOT_ENABLED"
56
- }
57
-
58
33
  export class CpeakIncomingMessage extends http.IncomingMessage {
59
34
  // We define body and params here for better V8 optimization (not changing the shape of the object at runtime)
60
35
  public body: any = undefined;
@@ -88,13 +63,18 @@ export class CpeakServerResponse extends http.ServerResponse<CpeakIncomingMessag
88
63
  _compression?: ResolvedCompressionConfig;
89
64
 
90
65
  // Send a file back to the client
91
- async sendFile(path: string, mime: string) {
66
+ async sendFile(path: string, mime?: string) {
92
67
  if (!mime) {
93
- throw frameworkError(
94
- 'MIME type is missing. Use res.sendFile(path, "mime-type").',
95
- this.sendFile,
96
- ErrorCode.MISSING_MIME
97
- );
68
+ const dotIndex = path.lastIndexOf(".");
69
+ const fileExtension = dotIndex >= 0 ? path.slice(dotIndex + 1) : "";
70
+ mime = MIME_TYPES[fileExtension];
71
+ if (!mime) {
72
+ throw frameworkError(
73
+ `MIME type is missing for "${path}". Pass it as the second argument or register the extension via cpeak({ mimeTypes: { ${fileExtension || "ext"}: "..." } }).`,
74
+ this.sendFile,
75
+ ErrorCode.MISSING_MIME
76
+ );
77
+ }
98
78
  }
99
79
 
100
80
  try {
@@ -163,14 +143,14 @@ export class CpeakServerResponse extends http.ServerResponse<CpeakIncomingMessag
163
143
 
164
144
  // Send a json data back to the client.
165
145
  // This is only good for bodies that their size is less than the highWaterMark value.
166
- // Branches into compressAndSend (async) when compression was enabled at cpeak() construction.
167
- json(data: any): void | Promise<void> {
146
+ json(data: any): Promise<void> {
168
147
  const body = JSON.stringify(data);
169
148
  if (this._compression) {
170
149
  return compressAndSend(this, "application/json", body, this._compression);
171
150
  }
172
151
  this.setHeader("Content-Type", "application/json");
173
152
  this.end(body);
153
+ return Promise.resolve();
174
154
  }
175
155
 
176
156
  // Explicit compression entry point. A developer can use this in any custom handler to compress arbitrary responses
@@ -191,10 +171,11 @@ export class CpeakServerResponse extends http.ServerResponse<CpeakIncomingMessag
191
171
  }
192
172
 
193
173
  export class Cpeak {
194
- #server: http.Server<typeof CpeakIncomingMessage, typeof CpeakServerResponse>;
195
- #routes: RoutesMap;
174
+ #server: CpeakHttpServer;
175
+ #router: Router;
196
176
  #middleware: Middleware[];
197
177
  #handleErr?: (err: unknown, req: CpeakRequest, res: CpeakResponse) => void;
178
+ #fallback?: Handler;
198
179
  #compression?: ResolvedCompressionConfig;
199
180
 
200
181
  constructor(options: CpeakOptions = {}) {
@@ -202,7 +183,7 @@ export class Cpeak {
202
183
  IncomingMessage: CpeakIncomingMessage,
203
184
  ServerResponse: CpeakServerResponse
204
185
  });
205
- this.#routes = {};
186
+ this.#router = new Router();
206
187
  this.#middleware = [];
207
188
 
208
189
  // Resolve compression options once at app startup.
@@ -210,6 +191,9 @@ export class Cpeak {
210
191
  this.#compression = resolveCompressionOptions(options.compression);
211
192
  }
212
193
 
194
+ // Merge developer-supplied mime types with the defaults once at startup
195
+ if (options.mimeTypes) Object.assign(MIME_TYPES, options.mimeTypes);
196
+
213
197
  this.#server.on(
214
198
  "request",
215
199
  async (req: CpeakRequest, res: CpeakResponse) => {
@@ -220,13 +204,33 @@ export class Cpeak {
220
204
  const urlWithoutQueries =
221
205
  qIndex === -1 ? req.url || "" : req.url?.substring(0, qIndex);
222
206
 
223
- const dispatchError = (error: unknown) => {
207
+ // Routes every error path through the registered handleErr. Awaits
208
+ // handleErr so its own async work (or a rejecting res.json under
209
+ // compression) is caught. If handleErr itself fails, we log and send a
210
+ // bare 500 so the client never gets a hung socket. Returns a Promise
211
+ // that never rejects to avoid unhandled promise rejections in case of errors in handleErr.
212
+ const dispatchError = async (error: unknown) => {
224
213
  if (res.headersSent) {
225
214
  req.socket?.destroy();
226
215
  return;
227
216
  }
228
217
  res.setHeader("Connection", "close");
229
- this.#handleErr?.(error, req, res);
218
+ try {
219
+ await this.#handleErr?.(error, req, res);
220
+ } catch (handlerFailure) {
221
+ console.error(
222
+ "[cpeak] handleErr failed while processing:",
223
+ error,
224
+ "\nReason:",
225
+ handlerFailure
226
+ );
227
+ if (!res.headersSent) {
228
+ try {
229
+ res.statusCode = 500;
230
+ res.end();
231
+ } catch {}
232
+ }
233
+ }
230
234
  };
231
235
 
232
236
  // Run all the specific middleware functions for that router only and then run the handler
@@ -240,29 +244,22 @@ export class Cpeak {
240
244
  // Our exit point...
241
245
  if (index === middleware.length) {
242
246
  // Call the route handler with the modified req and res objects.
243
- // Also handle the promise errors by passing them to the handleErr to save developers from having to manually wrap every handler in try catch.
247
+ // Also handle the promise errors by passing them to handleErr to save developers from having to manually wrap every handler in try/catch.
244
248
  try {
245
- await cb(req, res, dispatchError);
249
+ await cb(req, res);
246
250
  } catch (error) {
247
251
  dispatchError(error);
248
252
  }
249
253
  } else {
250
- // Handle the promise errors by passing them to the handleErr to save developers from having to manually wrap every handler middleware in try catch.
254
+ // Handle the promise errors by passing them to handleErr to save developers from having to manually wrap every route middleware in try/catch.
251
255
  try {
252
- await middleware[index](
253
- req,
254
- res,
255
- // The next function
256
- async (error) => {
257
- // this function only accepts an error argument to be more compatible with NPM modules that are built for express
258
- if (error) {
259
- return dispatchError(error);
260
- }
261
- await runHandler(req, res, middleware, cb, index + 1);
262
- },
263
- // Error handler for a route middleware
264
- dispatchError
265
- );
256
+ await middleware[index](req, res, async (error?: unknown) => {
257
+ // this function only accepts an error argument to be more compatible with NPM modules that are built for express
258
+ if (error) {
259
+ return dispatchError(error);
260
+ }
261
+ await runHandler(req, res, middleware, cb, index + 1);
262
+ });
266
263
  } catch (error) {
267
264
  dispatchError(error);
268
265
  }
@@ -278,32 +275,30 @@ export class Cpeak {
278
275
  ) => {
279
276
  // Our exit point...
280
277
  if (index === middleware.length) {
281
- const routes = this.#routes[req.method?.toLowerCase() || ""];
282
- if (routes && typeof routes[Symbol.iterator] === "function")
283
- for (const route of routes) {
284
- const match = urlWithoutQueries?.match(route.regex);
285
-
286
- if (match) {
287
- // Parse the URL path variables from the matched route (like /users/:id)
288
- const pathVariables = this.#extractPathVariables(
289
- route.path,
290
- match
291
- );
292
-
293
- // We will call this params to be more familiar with other node.js frameworks.
294
- req.params = pathVariables;
295
-
296
- return await runHandler(
297
- req,
298
- res,
299
- route.middleware,
300
- route.cb,
301
- 0
302
- );
303
- }
278
+ const method = req.method?.toLowerCase() || "";
279
+ const found = this.#router.find(method, urlWithoutQueries || "");
280
+
281
+ if (found) {
282
+ req.params = found.params;
283
+ return await runHandler(
284
+ req,
285
+ res,
286
+ found.middleware,
287
+ found.handler,
288
+ 0
289
+ );
290
+ }
291
+
292
+ // If a fallback handler is registered, run it before falling back to the default 404
293
+ if (this.#fallback) {
294
+ try {
295
+ return await this.#fallback(req, res);
296
+ } catch (error) {
297
+ return dispatchError(error);
304
298
  }
299
+ }
305
300
 
306
- // If the requested route dose not exist, return 404
301
+ // If the requested route dose not exist, and developer has not registered the fallback handler, return 404
307
302
  return res
308
303
  .status(404)
309
304
  .json({ error: `Cannot ${req.method} ${urlWithoutQueries}` });
@@ -327,8 +322,6 @@ export class Cpeak {
327
322
  }
328
323
 
329
324
  route(method: string, path: string, ...args: (RouteMiddleware | Handler)[]) {
330
- if (!this.#routes[method]) this.#routes[method] = [];
331
-
332
325
  // The last argument should always be our handler
333
326
  const cb = args.pop() as Handler;
334
327
 
@@ -339,8 +332,7 @@ export class Cpeak {
339
332
  // Rest will be our middleware functions
340
333
  const middleware = args.flat() as RouteMiddleware[];
341
334
 
342
- const regex = this.#pathToRegex(path);
343
- this.#routes[method].push({ path, regex, middleware, cb });
335
+ this.#router.add(method, path, middleware, cb);
344
336
  }
345
337
 
346
338
  beforeEach(cb: Middleware) {
@@ -351,8 +343,24 @@ export class Cpeak {
351
343
  this.#handleErr = cb;
352
344
  }
353
345
 
354
- listen(port: number, cb?: () => void) {
355
- return this.#server.listen(port, cb);
346
+ // This will handle any request that doesn't match any of the routes and middleware functions
347
+ fallback(cb: Handler) {
348
+ if (this.#fallback) {
349
+ throw frameworkError(
350
+ "Fallback handler is already registered. Only one fallback can be set per app.",
351
+ this.fallback,
352
+ ErrorCode.DUPLICATE_FALLBACK
353
+ );
354
+ }
355
+ this.#fallback = cb;
356
+ }
357
+
358
+ // The first 3 listens are just TS overloads for better type inference and editor autocompletion. The last one is the actual implementation.
359
+ listen(port: number, cb?: () => void): CpeakHttpServer;
360
+ listen(port: number, host: string, cb?: () => void): CpeakHttpServer;
361
+ listen(options: net.ListenOptions, cb?: () => void): CpeakHttpServer;
362
+ listen(...args: any[]) {
363
+ return this.#server.listen(...args);
356
364
  }
357
365
 
358
366
  address() {
@@ -360,29 +368,12 @@ export class Cpeak {
360
368
  }
361
369
 
362
370
  close(cb?: (err?: Error) => void) {
363
- this.#server.close(cb);
371
+ return this.#server.close(cb);
364
372
  }
365
373
 
366
- // ------------------------------
367
- // PRIVATE METHODS:
368
- // ------------------------------
369
- #pathToRegex(path: string) {
370
- const regexString =
371
- "^" + path.replace(/:\w+/g, "([^/]+)").replace(/\*/g, ".*") + "$";
372
-
373
- return new RegExp(regexString);
374
- }
375
-
376
- #extractPathVariables(path: string, match: RegExpMatchArray) {
377
- // Extract path url variable values from the matched route
378
- const paramNames = (path.match(/:\w+/g) || []).map((param) =>
379
- param.slice(1)
380
- );
381
- const params: StringMap = {};
382
- paramNames.forEach((name, index) => {
383
- params[name] = match[index + 1];
384
- });
385
- return params;
374
+ // A getter for developers who want to access the underlying http server instance for advanced use cases that aren't covered by Cpeak
375
+ get server() {
376
+ return this.#server;
386
377
  }
387
378
  }
388
379
 
@@ -409,15 +400,14 @@ export type {
409
400
  export type { CompressionOptions } from "./internal/types";
410
401
 
411
402
  export type {
403
+ CpeakHttpServer,
412
404
  CpeakOptions,
413
405
  CpeakRequest,
414
406
  CpeakResponse,
415
407
  Next,
416
- HandleErr,
417
408
  Middleware,
418
409
  RouteMiddleware,
419
- Handler,
420
- RoutesMap
410
+ Handler
421
411
  } from "./types";
422
412
 
423
413
  export default function cpeak(options?: CpeakOptions): Cpeak {
@@ -0,0 +1,35 @@
1
+ // A utility function to create an error with a custom stack trace
2
+ export function frameworkError(
3
+ message: string,
4
+ skipFn: Function,
5
+ code?: string,
6
+ status?: number
7
+ ) {
8
+ const err = new Error(message) as Error & {
9
+ code?: string;
10
+ cpeak_err?: boolean;
11
+ };
12
+ Error.captureStackTrace(err, skipFn);
13
+
14
+ err.cpeak_err = true;
15
+
16
+ if (code) err.code = code;
17
+ if (status) (err as any).status = status;
18
+
19
+ return err;
20
+ }
21
+
22
+ export enum ErrorCode {
23
+ MISSING_MIME = "CPEAK_ERR_MISSING_MIME",
24
+ FILE_NOT_FOUND = "CPEAK_ERR_FILE_NOT_FOUND",
25
+ NOT_A_FILE = "CPEAK_ERR_NOT_A_FILE",
26
+ SEND_FILE_FAIL = "CPEAK_ERR_SEND_FILE_FAIL",
27
+ INVALID_JSON = "CPEAK_ERR_INVALID_JSON",
28
+ PAYLOAD_TOO_LARGE = "CPEAK_ERR_PAYLOAD_TOO_LARGE",
29
+ WEAK_SECRET = "CPEAK_ERR_WEAK_SECRET",
30
+ COMPRESSION_NOT_ENABLED = "CPEAK_ERR_COMPRESSION_NOT_ENABLED",
31
+ // For router:
32
+ DUPLICATE_ROUTE = "CPEAK_ERR_DUPLICATE_ROUTE",
33
+ INVALID_ROUTE = "CPEAK_ERR_INVALID_ROUTE",
34
+ DUPLICATE_FALLBACK = "CPEAK_ERR_DUPLICATE_FALLBACK"
35
+ }
@@ -0,0 +1,22 @@
1
+ import type { StringMap } from "../types";
2
+
3
+ // Developers can expand this if needed in the cpeak() constructor
4
+ export const MIME_TYPES: StringMap = {
5
+ html: "text/html",
6
+ css: "text/css",
7
+ js: "application/javascript",
8
+ jpg: "image/jpeg",
9
+ jpeg: "image/jpeg",
10
+ png: "image/png",
11
+ svg: "image/svg+xml",
12
+ txt: "text/plain",
13
+ eot: "application/vnd.ms-fontobject",
14
+ otf: "font/otf",
15
+ ttf: "font/ttf",
16
+ woff: "font/woff",
17
+ woff2: "font/woff2",
18
+ gif: "image/gif",
19
+ ico: "image/x-icon",
20
+ json: "application/json",
21
+ webmanifest: "application/manifest+json"
22
+ };
@@ -0,0 +1,259 @@
1
+ import type { Handler, RouteMiddleware, StringMap } from "../types";
2
+ import { frameworkError, ErrorCode } from "./errors";
3
+
4
+ // A node in our radix tree. Each one can hold up to three kinds of children:
5
+ // an exact static segment, a single ":param" placeholder, or a tail "*"
6
+ // wildcard. The handler and middleware here belong to the route whose path
7
+ // ends at this node, if any.
8
+ //
9
+ // Param names are not stored on the tree edges. We capture values positionally
10
+ // as we walk, and zip them with the param names attached to whichever leaf we
11
+ // land on. That lets two routes share the same param slot in the tree even
12
+ // when they use different names, like "/:id/profile" and "/:username/settings".
13
+ interface RadixNode {
14
+ staticChildren: Map<string, RadixNode>;
15
+ paramChild?: RadixNode;
16
+ wildcardChild?: WildcardLeaf;
17
+ handler?: Handler;
18
+ middleware?: RouteMiddleware[];
19
+ // Names of params captured along the path to this leaf, in order. Only set
20
+ // on nodes that own a handler.
21
+ paramNames?: string[];
22
+ }
23
+
24
+ interface WildcardLeaf {
25
+ handler: Handler;
26
+ middleware: RouteMiddleware[];
27
+ // Names of params captured before reaching this wildcard, in order.
28
+ paramNames: string[];
29
+ }
30
+
31
+ export interface RouteMatch {
32
+ middleware: RouteMiddleware[];
33
+ handler: Handler;
34
+ params: StringMap;
35
+ }
36
+
37
+ function createNode(): RadixNode {
38
+ return { staticChildren: new Map() };
39
+ }
40
+
41
+ // We keep one radix tree per HTTP method so different methods can safely
42
+ // share a path shape. POST /comments/:pageId and PUT /comments/:id can
43
+ // coexist without conflict because they live in separate trees.
44
+ export class Router {
45
+ #treesByMethod: Map<string, RadixNode> = new Map();
46
+
47
+ add(
48
+ method: string,
49
+ path: string,
50
+ middleware: RouteMiddleware[],
51
+ handler: Handler
52
+ ) {
53
+ const methodKey = method.toLowerCase();
54
+ let root = this.#treesByMethod.get(methodKey);
55
+ if (!root) {
56
+ root = createNode();
57
+ this.#treesByMethod.set(methodKey, root);
58
+ }
59
+
60
+ const segments = splitPath(path);
61
+ const paramNames: string[] = [];
62
+ let currentNode = root;
63
+
64
+ for (let i = 0; i < segments.length; i++) {
65
+ const segment = segments[i];
66
+ const isLastSegment = i === segments.length - 1;
67
+
68
+ // Named wildcards like "*name" are not a thing here. Only a bare "*"
69
+ // is allowed, and only as the very last segment.
70
+ if (segment.length > 1 && segment.startsWith("*")) {
71
+ throw frameworkError(
72
+ `Invalid route "${path}": named wildcards (e.g. "*name") are not supported. Use a plain "*" at the end of the path.`,
73
+ this.add,
74
+ ErrorCode.INVALID_ROUTE
75
+ );
76
+ }
77
+
78
+ // A "*" segment installs a tail wildcard on the current node. After
79
+ // that there's nothing more to walk, so we register and bail out.
80
+ if (segment === "*") {
81
+ if (!isLastSegment) {
82
+ throw frameworkError(
83
+ `Invalid route "${path}": "*" is only allowed as the final path segment.`,
84
+ this.add,
85
+ ErrorCode.INVALID_ROUTE
86
+ );
87
+ }
88
+ if (currentNode.wildcardChild) {
89
+ throw frameworkError(
90
+ `Duplicate route: ${method.toUpperCase()} ${path}`,
91
+ this.add,
92
+ ErrorCode.DUPLICATE_ROUTE
93
+ );
94
+ }
95
+ currentNode.wildcardChild = { handler, middleware, paramNames };
96
+ return;
97
+ }
98
+
99
+ // A ":name" segment walks into the param branch at this depth, or
100
+ // creates one. The name is collected positionally and resolved later
101
+ // at the leaf, so two routes can disagree on the param name here as
102
+ // long as their paths diverge before the leaf.
103
+ if (segment.startsWith(":")) {
104
+ const paramName = segment.slice(1);
105
+ if (!paramName) {
106
+ throw frameworkError(
107
+ `Invalid route "${path}": empty parameter name.`,
108
+ this.add,
109
+ ErrorCode.INVALID_ROUTE
110
+ );
111
+ }
112
+ paramNames.push(paramName);
113
+ if (!currentNode.paramChild) {
114
+ currentNode.paramChild = createNode();
115
+ }
116
+ currentNode = currentNode.paramChild;
117
+ continue;
118
+ }
119
+
120
+ // Plain static segment. Walk into the existing child or create a new one.
121
+ let staticChild = currentNode.staticChildren.get(segment);
122
+ if (!staticChild) {
123
+ staticChild = createNode();
124
+ currentNode.staticChildren.set(segment, staticChild);
125
+ }
126
+ currentNode = staticChild;
127
+ }
128
+
129
+ // We have consumed every segment of the path. The terminal node is where
130
+ // the handler gets attached. If something is already attached here, the
131
+ // user registered this exact path twice.
132
+ if (currentNode.handler) {
133
+ throw frameworkError(
134
+ `Duplicate route: ${method.toUpperCase()} ${path}`,
135
+ this.add,
136
+ ErrorCode.DUPLICATE_ROUTE
137
+ );
138
+ }
139
+ currentNode.handler = handler;
140
+ currentNode.middleware = middleware;
141
+ currentNode.paramNames = paramNames;
142
+ }
143
+
144
+ find(method: string, path: string): RouteMatch | null {
145
+ const root = this.#treesByMethod.get(method.toLowerCase());
146
+ if (!root) return null;
147
+
148
+ const segments = splitPath(path);
149
+ return matchSegments(root, segments, 0, []);
150
+ }
151
+ }
152
+
153
+ // Walk the tree one segment at a time, always trying static before param
154
+ // before wildcard. That ordering is where our precedence rules come from:
155
+ // static beats param beats wildcard. Because each branch is tried in turn
156
+ // and recursion lets us unwind a failed path, the matcher also backtracks.
157
+ // If the static branch dead-ends deeper down, we come back up and try the
158
+ // param sibling with the same segment value.
159
+ //
160
+ // We collect captured param values positionally as we walk. The actual names
161
+ // get zipped in at the terminal leaf, using the paramNames stored alongside
162
+ // the handler. That way the same captured value can be called "id" on one
163
+ // route and "username" on another without the tree caring.
164
+ function matchSegments(
165
+ node: RadixNode,
166
+ segments: string[],
167
+ segmentIndex: number,
168
+ capturedValues: string[]
169
+ ): RouteMatch | null {
170
+ // Out of segments to walk. If this node has a handler, that's our match.
171
+ // Otherwise let a wildcard at this depth catch the empty remainder so
172
+ // routes like "/foo/*" still match a request to "/foo".
173
+ if (segmentIndex === segments.length) {
174
+ if (node.handler) {
175
+ return {
176
+ middleware: node.middleware!,
177
+ handler: node.handler,
178
+ params: zipParams(node.paramNames!, capturedValues)
179
+ };
180
+ }
181
+ if (node.wildcardChild) {
182
+ return {
183
+ middleware: node.wildcardChild.middleware,
184
+ handler: node.wildcardChild.handler,
185
+ params: zipParams(node.wildcardChild.paramNames, capturedValues)
186
+ };
187
+ }
188
+ return null;
189
+ }
190
+
191
+ const segment = segments[segmentIndex];
192
+
193
+ // Try the exact static child first. Exact matches always win.
194
+ const staticChild = node.staticChildren.get(segment);
195
+ if (staticChild) {
196
+ const foundMatch = matchSegments(
197
+ staticChild,
198
+ segments,
199
+ segmentIndex + 1,
200
+ capturedValues
201
+ );
202
+ if (foundMatch) return foundMatch;
203
+ }
204
+
205
+ // Then try the param branch. We push the captured value before recursing
206
+ // and pop it back off if the recursion fails, so any sibling branch (or the
207
+ // caller unwinding above us) sees a clean capture list.
208
+ if (node.paramChild) {
209
+ capturedValues.push(safeDecode(segment));
210
+ const foundMatch = matchSegments(
211
+ node.paramChild,
212
+ segments,
213
+ segmentIndex + 1,
214
+ capturedValues
215
+ );
216
+ if (foundMatch) return foundMatch;
217
+ capturedValues.pop();
218
+ }
219
+
220
+ // Last resort. A wildcard at this node swallows whatever segments remain.
221
+ if (node.wildcardChild) {
222
+ return {
223
+ middleware: node.wildcardChild.middleware,
224
+ handler: node.wildcardChild.handler,
225
+ params: zipParams(node.wildcardChild.paramNames, capturedValues)
226
+ };
227
+ }
228
+
229
+ return null;
230
+ }
231
+
232
+ function zipParams(names: string[], values: string[]): StringMap {
233
+ const params: StringMap = {};
234
+ for (let i = 0; i < names.length; i++) {
235
+ params[names[i]] = values[i];
236
+ }
237
+ return params;
238
+ }
239
+
240
+ // Decode a URL segment without ever throwing. Malformed percent encoding is
241
+ // rare but it does happen in the wild. Falling back to the raw segment keeps
242
+ // the request matchable instead of blowing up before the handler runs.
243
+ // Example: safeDecode("a%20b%2Fc") returns "a b/c", while safeDecode("a%ZZb") returns "a%ZZb".
244
+ function safeDecode(segment: string): string {
245
+ try {
246
+ return decodeURIComponent(segment);
247
+ } catch {
248
+ return segment;
249
+ }
250
+ }
251
+
252
+ // Split a URL path into segments with no leading slash. We treat "" and "/"
253
+ // the same way: zero segments, meaning the root of the tree.
254
+ // Example: "/a/b/c" becomes ["a", "b", "c"]
255
+ function splitPath(path: string): string[] {
256
+ if (path === "" || path === "/") return [];
257
+ const withoutLeadingSlash = path.startsWith("/") ? path.slice(1) : path;
258
+ return withoutLeadingSlash.split("/");
259
+ }