@b9g/router 0.1.5 → 0.1.7

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