@b9g/router 0.1.5 → 0.1.6

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/src/index.js CHANGED
@@ -1,6 +1,651 @@
1
1
  /// <reference types="./index.d.ts" />
2
2
  // src/index.ts
3
- import { Router } from "./router.js";
3
+ import {
4
+ MatchPattern,
5
+ isSimplePattern,
6
+ compilePathname
7
+ } from "@b9g/match-pattern";
8
+ var RadixNode = class {
9
+ children;
10
+ // char -> RadixNode
11
+ handlers;
12
+ // method -> handler
13
+ paramName;
14
+ // param name if this is a :param segment
15
+ paramChild;
16
+ // child node for :param
17
+ wildcardChild;
18
+ // child node for * wildcard
19
+ constructor() {
20
+ this.children = /* @__PURE__ */ new Map();
21
+ this.handlers = /* @__PURE__ */ new Map();
22
+ this.paramName = null;
23
+ this.paramChild = null;
24
+ this.wildcardChild = null;
25
+ }
26
+ };
27
+ var RadixTreeExecutor = class {
28
+ #root;
29
+ #complexRoutes;
30
+ constructor(routes) {
31
+ this.#root = new RadixNode();
32
+ this.#complexRoutes = [];
33
+ for (const route of routes) {
34
+ const pathname = route.pattern.pathname;
35
+ if (isSimplePattern(pathname)) {
36
+ this.#addToTree(pathname, route.method, route.handler);
37
+ } else {
38
+ const compiled = compilePathname(pathname);
39
+ this.#complexRoutes.push({
40
+ compiled,
41
+ method: route.method,
42
+ handler: route.handler,
43
+ pattern: route.pattern
44
+ });
45
+ }
46
+ }
47
+ }
48
+ /**
49
+ * Add a simple pattern to the radix tree
50
+ */
51
+ #addToTree(pathname, method, handler) {
52
+ let node = this.#root;
53
+ let i = 0;
54
+ while (i < pathname.length) {
55
+ const char = pathname[i];
56
+ if (char === ":") {
57
+ const match = pathname.slice(i).match(/^:(\w+)/);
58
+ if (match) {
59
+ const paramName = match[1];
60
+ if (!node.paramChild) {
61
+ node.paramChild = new RadixNode();
62
+ node.paramChild.paramName = paramName;
63
+ }
64
+ node = node.paramChild;
65
+ i += match[0].length;
66
+ continue;
67
+ }
68
+ }
69
+ if (char === "*") {
70
+ if (!node.wildcardChild) {
71
+ node.wildcardChild = new RadixNode();
72
+ }
73
+ node = node.wildcardChild;
74
+ break;
75
+ }
76
+ if (!node.children.has(char)) {
77
+ node.children.set(char, new RadixNode());
78
+ }
79
+ node = node.children.get(char);
80
+ i++;
81
+ }
82
+ node.handlers.set(method, handler);
83
+ }
84
+ /**
85
+ * Match a pathname against the radix tree
86
+ */
87
+ #matchTree(pathname, method) {
88
+ const params = {};
89
+ let node = this.#root;
90
+ let i = 0;
91
+ while (i < pathname.length) {
92
+ const char = pathname[i];
93
+ if (node.children.has(char)) {
94
+ node = node.children.get(char);
95
+ i++;
96
+ continue;
97
+ }
98
+ if (node.paramChild) {
99
+ let j = i;
100
+ while (j < pathname.length && pathname[j] !== "/") {
101
+ j++;
102
+ }
103
+ const value = pathname.slice(i, j);
104
+ if (value) {
105
+ params[node.paramChild.paramName] = value;
106
+ node = node.paramChild;
107
+ i = j;
108
+ continue;
109
+ }
110
+ }
111
+ if (node.wildcardChild) {
112
+ const rest = pathname.slice(i);
113
+ params["0"] = rest;
114
+ node = node.wildcardChild;
115
+ break;
116
+ }
117
+ return null;
118
+ }
119
+ const handler = node.handlers.get(method);
120
+ if (handler) {
121
+ return { handler, params };
122
+ }
123
+ if (node.wildcardChild) {
124
+ const wildcardHandler = node.wildcardChild.handlers.get(method);
125
+ if (wildcardHandler) {
126
+ params["0"] = "";
127
+ return { handler: wildcardHandler, params };
128
+ }
129
+ }
130
+ return null;
131
+ }
132
+ /**
133
+ * Find the first route that matches the request
134
+ */
135
+ match(request) {
136
+ const url = new URL(request.url);
137
+ const method = request.method.toUpperCase();
138
+ const pathname = url.pathname;
139
+ const treeResult = this.#matchTree(pathname, method);
140
+ if (treeResult) {
141
+ return {
142
+ handler: treeResult.handler,
143
+ context: { params: treeResult.params }
144
+ };
145
+ }
146
+ for (const route of this.#complexRoutes) {
147
+ if (route.method !== method) {
148
+ continue;
149
+ }
150
+ const match = pathname.match(route.compiled.regex);
151
+ if (match) {
152
+ const params = {};
153
+ for (let i = 0; i < route.compiled.paramNames.length; i++) {
154
+ if (match[i + 1] !== void 0) {
155
+ params[route.compiled.paramNames[i]] = match[i + 1];
156
+ }
157
+ }
158
+ return {
159
+ handler: route.handler,
160
+ context: { params }
161
+ };
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+ };
167
+ var RouteBuilder = class {
168
+ #router;
169
+ #pattern;
170
+ constructor(router, pattern) {
171
+ this.#router = router;
172
+ this.#pattern = pattern;
173
+ }
174
+ /**
175
+ * Register a GET handler for this route pattern
176
+ */
177
+ get(handler) {
178
+ this.#router.addRoute("GET", this.#pattern, handler);
179
+ return this;
180
+ }
181
+ /**
182
+ * Register a POST handler for this route pattern
183
+ */
184
+ post(handler) {
185
+ this.#router.addRoute("POST", this.#pattern, handler);
186
+ return this;
187
+ }
188
+ /**
189
+ * Register a PUT handler for this route pattern
190
+ */
191
+ put(handler) {
192
+ this.#router.addRoute("PUT", this.#pattern, handler);
193
+ return this;
194
+ }
195
+ /**
196
+ * Register a DELETE handler for this route pattern
197
+ */
198
+ delete(handler) {
199
+ this.#router.addRoute("DELETE", this.#pattern, handler);
200
+ return this;
201
+ }
202
+ /**
203
+ * Register a PATCH handler for this route pattern
204
+ */
205
+ patch(handler) {
206
+ this.#router.addRoute("PATCH", this.#pattern, handler);
207
+ return this;
208
+ }
209
+ /**
210
+ * Register a HEAD handler for this route pattern
211
+ */
212
+ head(handler) {
213
+ this.#router.addRoute("HEAD", this.#pattern, handler);
214
+ return this;
215
+ }
216
+ /**
217
+ * Register an OPTIONS handler for this route pattern
218
+ */
219
+ options(handler) {
220
+ this.#router.addRoute("OPTIONS", this.#pattern, handler);
221
+ return this;
222
+ }
223
+ /**
224
+ * Register a handler for all HTTP methods on this route pattern
225
+ */
226
+ all(handler) {
227
+ const methods = [
228
+ "GET",
229
+ "POST",
230
+ "PUT",
231
+ "DELETE",
232
+ "PATCH",
233
+ "HEAD",
234
+ "OPTIONS"
235
+ ];
236
+ methods.forEach((method) => {
237
+ this.#router.addRoute(method, this.#pattern, handler);
238
+ });
239
+ return this;
240
+ }
241
+ };
242
+ var Router = class {
243
+ #routes;
244
+ #middlewares;
245
+ #executor;
246
+ #dirty;
247
+ constructor() {
248
+ this.#routes = [];
249
+ this.#middlewares = [];
250
+ this.#executor = null;
251
+ this.#dirty = false;
252
+ this.#handlerImpl = async (request) => {
253
+ try {
254
+ if (this.#dirty || !this.#executor) {
255
+ this.#executor = new RadixTreeExecutor(this.#routes);
256
+ this.#dirty = false;
257
+ }
258
+ const matchResult = this.#executor.match(request);
259
+ if (matchResult) {
260
+ const mutableRequest = this.#createMutableRequest(request);
261
+ return await this.#executeMiddlewareStack(
262
+ this.#middlewares,
263
+ mutableRequest,
264
+ matchResult.context,
265
+ matchResult.handler,
266
+ request.url,
267
+ this.#executor
268
+ );
269
+ } else {
270
+ const notFoundHandler = async () => {
271
+ return new Response("Not Found", { status: 404 });
272
+ };
273
+ const mutableRequest = this.#createMutableRequest(request);
274
+ return await this.#executeMiddlewareStack(
275
+ this.#middlewares,
276
+ mutableRequest,
277
+ { params: {} },
278
+ notFoundHandler,
279
+ request.url,
280
+ this.#executor
281
+ );
282
+ }
283
+ } catch (error) {
284
+ return this.#createErrorResponse(error);
285
+ }
286
+ };
287
+ this.handler = this.#handlerImpl;
288
+ }
289
+ use(patternOrMiddleware, handler) {
290
+ if (typeof patternOrMiddleware === "string" && handler) {
291
+ this.addRoute("GET", patternOrMiddleware, handler);
292
+ this.addRoute("POST", patternOrMiddleware, handler);
293
+ this.addRoute("PUT", patternOrMiddleware, handler);
294
+ this.addRoute("DELETE", patternOrMiddleware, handler);
295
+ this.addRoute("PATCH", patternOrMiddleware, handler);
296
+ this.addRoute("HEAD", patternOrMiddleware, handler);
297
+ this.addRoute("OPTIONS", patternOrMiddleware, handler);
298
+ } else if (typeof patternOrMiddleware === "function") {
299
+ if (!this.#isValidMiddleware(patternOrMiddleware)) {
300
+ throw new Error(
301
+ "Invalid middleware type. Must be function or async generator function."
302
+ );
303
+ }
304
+ this.#middlewares.push({ middleware: patternOrMiddleware });
305
+ this.#dirty = true;
306
+ } else {
307
+ throw new Error(
308
+ "Invalid middleware type. Must be function or async generator function."
309
+ );
310
+ }
311
+ }
312
+ route(patternOrConfig) {
313
+ if (typeof patternOrConfig === "string") {
314
+ return new RouteBuilder(this, patternOrConfig);
315
+ } else {
316
+ return new RouteBuilder(this, patternOrConfig.pattern);
317
+ }
318
+ }
319
+ /**
320
+ * Internal method called by RouteBuilder to register routes
321
+ * Public for RouteBuilder access, but not intended for direct use
322
+ */
323
+ addRoute(method, pattern, handler) {
324
+ const matchPattern = new MatchPattern(pattern);
325
+ this.#routes.push({
326
+ pattern: matchPattern,
327
+ method: method.toUpperCase(),
328
+ handler
329
+ });
330
+ this.#dirty = true;
331
+ }
332
+ /**
333
+ * Handle a request - main entrypoint for ServiceWorker usage
334
+ * Returns a response or throws if no route matches
335
+ */
336
+ handler;
337
+ #handlerImpl;
338
+ /**
339
+ * Match a request against registered routes and execute the handler chain
340
+ * Returns the response from the matched handler, or null if no route matches
341
+ * Note: Global middleware executes even if no route matches
342
+ */
343
+ async match(request) {
344
+ if (this.#dirty || !this.#executor) {
345
+ this.#executor = new RadixTreeExecutor(this.#routes);
346
+ this.#dirty = false;
347
+ }
348
+ const mutableRequest = this.#createMutableRequest(request);
349
+ const originalURL = mutableRequest.url;
350
+ let matchResult = this.#executor.match(request);
351
+ let handler;
352
+ let context;
353
+ if (matchResult) {
354
+ handler = matchResult.handler;
355
+ context = matchResult.context;
356
+ } else {
357
+ handler = async () => new Response("Not Found", { status: 404 });
358
+ context = { params: {} };
359
+ }
360
+ const response = await this.#executeMiddlewareStack(
361
+ this.#middlewares,
362
+ mutableRequest,
363
+ context,
364
+ handler,
365
+ originalURL,
366
+ this.#executor
367
+ // Pass executor for re-routing
368
+ );
369
+ if (!matchResult && response?.status === 404) {
370
+ return null;
371
+ }
372
+ return response;
373
+ }
374
+ /**
375
+ * Get registered routes for debugging/introspection
376
+ */
377
+ getRoutes() {
378
+ return [...this.#routes];
379
+ }
380
+ /**
381
+ * Get registered middleware for debugging/introspection
382
+ */
383
+ getMiddlewares() {
384
+ return [...this.#middlewares];
385
+ }
386
+ /**
387
+ * Mount a subrouter at a specific path prefix
388
+ * All routes from the subrouter will be prefixed with the mount path
389
+ *
390
+ * Example:
391
+ * const apiRouter = new Router();
392
+ * apiRouter.route('/users').get(getUsersHandler);
393
+ * apiRouter.route('/users/:id').get(getUserHandler);
394
+ *
395
+ * const mainRouter = new Router();
396
+ * mainRouter.mount('/api/v1', apiRouter);
397
+ * // Routes become: /api/v1/users, /api/v1/users/:id
398
+ */
399
+ mount(mountPath, subrouter) {
400
+ const normalizedMountPath = this.#normalizeMountPath(mountPath);
401
+ const subroutes = subrouter.getRoutes();
402
+ for (const subroute of subroutes) {
403
+ const mountedPattern = this.#combinePaths(
404
+ normalizedMountPath,
405
+ subroute.pattern.pathname
406
+ );
407
+ this.#routes.push({
408
+ pattern: new MatchPattern(mountedPattern),
409
+ method: subroute.method,
410
+ handler: subroute.handler
411
+ });
412
+ }
413
+ const submiddlewares = subrouter.getMiddlewares();
414
+ for (const submiddleware of submiddlewares) {
415
+ this.#middlewares.push(submiddleware);
416
+ }
417
+ this.#dirty = true;
418
+ }
419
+ /**
420
+ * Normalize mount path: ensure it starts with / and doesn't end with /
421
+ */
422
+ #normalizeMountPath(mountPath) {
423
+ if (!mountPath.startsWith("/")) {
424
+ mountPath = "/" + mountPath;
425
+ }
426
+ if (mountPath.endsWith("/") && mountPath.length > 1) {
427
+ mountPath = mountPath.slice(0, -1);
428
+ }
429
+ return mountPath;
430
+ }
431
+ /**
432
+ * Combine mount path with route pattern
433
+ */
434
+ #combinePaths(mountPath, routePattern) {
435
+ if (routePattern === "/") {
436
+ return mountPath;
437
+ }
438
+ if (!routePattern.startsWith("/")) {
439
+ routePattern = "/" + routePattern;
440
+ }
441
+ return mountPath + routePattern;
442
+ }
443
+ /**
444
+ * Validate that a function is valid middleware
445
+ */
446
+ #isValidMiddleware(middleware) {
447
+ const constructorName = middleware.constructor.name;
448
+ return constructorName === "AsyncGeneratorFunction" || constructorName === "AsyncFunction" || constructorName === "Function";
449
+ }
450
+ /**
451
+ * Detect if a function is a generator middleware
452
+ */
453
+ #isGeneratorMiddleware(middleware) {
454
+ return middleware.constructor.name === "AsyncGeneratorFunction";
455
+ }
456
+ /**
457
+ * Execute middleware stack with guaranteed execution using Rack-style LIFO order
458
+ */
459
+ async #executeMiddlewareStack(middlewares, request, context, handler, originalURL, executor) {
460
+ const runningGenerators = [];
461
+ let currentResponse = null;
462
+ for (let i = 0; i < middlewares.length; i++) {
463
+ const middleware = middlewares[i].middleware;
464
+ if (this.#isGeneratorMiddleware(middleware)) {
465
+ const generator = middleware(request, context);
466
+ const result = await generator.next();
467
+ if (result.done) {
468
+ if (result.value) {
469
+ currentResponse = result.value;
470
+ break;
471
+ }
472
+ } else {
473
+ runningGenerators.push({ generator, index: i });
474
+ }
475
+ } else {
476
+ const result = await middleware(
477
+ request,
478
+ context
479
+ );
480
+ if (result) {
481
+ currentResponse = result;
482
+ break;
483
+ }
484
+ }
485
+ }
486
+ if (!currentResponse) {
487
+ let finalHandler = handler;
488
+ let finalContext = context;
489
+ if (request.url !== originalURL && executor) {
490
+ const newMatchResult = executor.match(
491
+ new Request(request.url, {
492
+ method: request.method,
493
+ headers: request.headers,
494
+ body: request.body
495
+ })
496
+ );
497
+ if (newMatchResult) {
498
+ finalHandler = newMatchResult.handler;
499
+ finalContext = newMatchResult.context;
500
+ }
501
+ }
502
+ let handlerError = null;
503
+ try {
504
+ currentResponse = await finalHandler(request, finalContext);
505
+ } catch (error) {
506
+ handlerError = error;
507
+ }
508
+ if (handlerError) {
509
+ currentResponse = await this.#handleErrorThroughGenerators(
510
+ handlerError,
511
+ runningGenerators
512
+ );
513
+ }
514
+ }
515
+ if (request.url !== originalURL && currentResponse) {
516
+ currentResponse = this.#handleAutomaticRedirect(
517
+ originalURL,
518
+ request.url,
519
+ request.method
520
+ );
521
+ }
522
+ for (let i = runningGenerators.length - 1; i >= 0; i--) {
523
+ const { generator } = runningGenerators[i];
524
+ const result = await generator.next(currentResponse);
525
+ if (result.value) {
526
+ currentResponse = result.value;
527
+ }
528
+ }
529
+ return currentResponse;
530
+ }
531
+ /**
532
+ * Handle errors by trying generators in reverse order
533
+ */
534
+ async #handleErrorThroughGenerators(error, runningGenerators) {
535
+ for (let i = runningGenerators.length - 1; i >= 0; i--) {
536
+ const { generator } = runningGenerators[i];
537
+ try {
538
+ const result = await generator.throw(error);
539
+ if (result.value) {
540
+ runningGenerators.splice(i, 1);
541
+ return result.value;
542
+ }
543
+ } catch (generatorError) {
544
+ runningGenerators.splice(i, 1);
545
+ continue;
546
+ }
547
+ }
548
+ throw error;
549
+ }
550
+ /**
551
+ * Create a mutable request wrapper that allows URL modification
552
+ */
553
+ #createMutableRequest(request) {
554
+ return {
555
+ url: request.url,
556
+ method: request.method,
557
+ headers: new Headers(request.headers),
558
+ body: request.body,
559
+ bodyUsed: request.bodyUsed,
560
+ cache: request.cache,
561
+ credentials: request.credentials,
562
+ destination: request.destination,
563
+ integrity: request.integrity,
564
+ keepalive: request.keepalive,
565
+ mode: request.mode,
566
+ redirect: request.redirect,
567
+ referrer: request.referrer,
568
+ referrerPolicy: request.referrerPolicy,
569
+ signal: request.signal,
570
+ // Add all other Request methods
571
+ arrayBuffer: () => request.arrayBuffer(),
572
+ blob: () => request.blob(),
573
+ clone: () => request.clone(),
574
+ formData: () => request.formData(),
575
+ json: () => request.json(),
576
+ text: () => request.text()
577
+ };
578
+ }
579
+ /**
580
+ * Handle automatic redirects when URL is modified
581
+ */
582
+ #handleAutomaticRedirect(originalURL, newURL, method) {
583
+ const originalURLObj = new URL(originalURL);
584
+ const newURLObj = new URL(newURL);
585
+ if (originalURLObj.hostname !== newURLObj.hostname || originalURLObj.port !== newURLObj.port && originalURLObj.port !== "" && newURLObj.port !== "") {
586
+ throw new Error(
587
+ `Cross-origin redirect not allowed: ${originalURL} -> ${newURL}`
588
+ );
589
+ }
590
+ let status = 302;
591
+ if (originalURLObj.protocol !== newURLObj.protocol) {
592
+ status = 301;
593
+ } else if (method.toUpperCase() !== "GET") {
594
+ status = 307;
595
+ }
596
+ return new Response(null, {
597
+ status,
598
+ headers: {
599
+ Location: newURL
600
+ }
601
+ });
602
+ }
603
+ /**
604
+ * Get route statistics
605
+ */
606
+ getStats() {
607
+ return {
608
+ routeCount: this.#routes.length,
609
+ middlewareCount: this.#middlewares.length,
610
+ compiled: !this.#dirty && this.#executor !== null
611
+ };
612
+ }
613
+ /**
614
+ * Create an error response for unhandled errors
615
+ * In development mode, includes error details; in production, returns generic message
616
+ */
617
+ #createErrorResponse(error) {
618
+ const isDev = typeof import.meta !== "undefined" && import.meta.env?.MODE !== "production";
619
+ if (isDev) {
620
+ const html = `<!DOCTYPE html>
621
+ <html>
622
+ <head>
623
+ <title>500 Internal Server Error</title>
624
+ <style>
625
+ body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
626
+ h1 { color: #dc2626; }
627
+ pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; overflow-x: auto; border-radius: 4px; }
628
+ .message { font-size: 1.25rem; color: #374151; }
629
+ </style>
630
+ </head>
631
+ <body>
632
+ <h1>500 Internal Server Error</h1>
633
+ <p class="message">${escapeHtml(error.message)}</p>
634
+ <pre>${escapeHtml(error.stack || "No stack trace available")}</pre>
635
+ </body>
636
+ </html>`;
637
+ return new Response(html, {
638
+ status: 500,
639
+ headers: { "Content-Type": "text/html; charset=utf-8" }
640
+ });
641
+ } else {
642
+ return new Response("Internal Server Error", { status: 500 });
643
+ }
644
+ }
645
+ };
646
+ function escapeHtml(str) {
647
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
648
+ }
4
649
  export {
5
650
  Router
6
651
  };