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/README.md +120 -72
- package/dist/index.d.ts +77 -75
- package/dist/index.js +307 -135
- package/dist/index.js.map +1 -1
- package/lib/index.ts +108 -118
- package/lib/internal/errors.ts +35 -0
- package/lib/internal/mimeTypes.ts +22 -0
- package/lib/internal/router.ts +259 -0
- package/lib/types.ts +18 -20
- package/lib/utils/render.ts +11 -6
- package/lib/utils/serveStatic.ts +29 -28
- package/package.json +1 -1
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
|
|
66
|
+
async sendFile(path: string, mime?: string) {
|
|
92
67
|
if (!mime) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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:
|
|
195
|
-
#
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
+
}
|