@b9g/router 0.1.6 → 0.1.8

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 CHANGED
@@ -117,7 +117,7 @@ router.use(cacheMiddleware);
117
117
  - `GeneratorMiddleware` - Generator-based middleware type using `yield`
118
118
  - `FunctionMiddleware` - Simple function middleware type
119
119
  - `Middleware` - Union of GeneratorMiddleware | FunctionMiddleware
120
- - `HttpMethod` - HTTP method string literal type
120
+ - `HTTPMethod` - HTTP method string literal type
121
121
  - `RouteConfig` - Route configuration object
122
122
 
123
123
  ## API Reference
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@b9g/router",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Universal request router for ServiceWorker applications. Built on web standards with cache-aware routing and generator-based middleware.",
5
5
  "keywords": [
6
6
  "router",
@@ -13,6 +13,7 @@
13
13
  "shovel"
14
14
  ],
15
15
  "dependencies": {
16
+ "@b9g/http-errors": "^0.1.5",
16
17
  "@b9g/match-pattern": "^0.1.7"
17
18
  },
18
19
  "devDependencies": {
package/src/index.d.ts CHANGED
@@ -30,14 +30,14 @@ export type Handler = (request: Request, context: RouteContext) => Response | Pr
30
30
  * Generator middleware signature - uses yield for continuation
31
31
  * Provides clean syntax and eliminates control flow bugs
32
32
  */
33
- export type GeneratorMiddleware = (request: Request, context: RouteContext) => AsyncGenerator<Request, Response | null | undefined, Response>;
33
+ export type GeneratorMiddleware = (request: Request, context: RouteContext) => Generator<Request, Response | null | undefined, Response> | AsyncGenerator<Request, Response | null | undefined, Response>;
34
34
  /**
35
35
  * Function middleware signature - supports short-circuiting
36
36
  * Can modify request and context, and can return a Response to short-circuit
37
37
  * - Return Response: short-circuits, skipping remaining middleware and handler
38
38
  * - Return null/undefined: continues to next middleware (fallthrough)
39
39
  */
40
- export type FunctionMiddleware = (request: Request, context: RouteContext) => null | undefined | Response | Promise<null | undefined | Response>;
40
+ export type FunctionMiddleware = (request: Request, context: RouteContext) => Response | null | undefined | Promise<Response | null | undefined>;
41
41
  /**
42
42
  * Union type for all supported middleware types
43
43
  * Framework automatically detects type and executes appropriately
@@ -46,7 +46,7 @@ export type Middleware = GeneratorMiddleware | FunctionMiddleware;
46
46
  /**
47
47
  * HTTP methods supported by the router
48
48
  */
49
- export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
49
+ export type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
50
50
  /**
51
51
  * Route configuration options
52
52
  */
@@ -67,6 +67,8 @@ interface RouteEntry {
67
67
  */
68
68
  interface MiddlewareEntry {
69
69
  middleware: Middleware;
70
+ /** If set, middleware only runs for paths matching this prefix */
71
+ pathPrefix?: string;
70
72
  }
71
73
  /**
72
74
  * RouteBuilder provides a chainable API for defining routes with multiple HTTP methods
@@ -126,9 +128,9 @@ export declare class Router {
126
128
  */
127
129
  use(middleware: Middleware): void;
128
130
  /**
129
- * Register a handler for a specific pattern
131
+ * Register middleware that only applies to routes matching the path prefix
130
132
  */
131
- use(pattern: string, handler: Handler): void;
133
+ use(pathPrefix: string, middleware: Middleware): void;
132
134
  /**
133
135
  * Create a route builder for the given pattern
134
136
  * Returns a chainable interface for registering HTTP method handlers
@@ -144,7 +146,7 @@ export declare class Router {
144
146
  * Internal method called by RouteBuilder to register routes
145
147
  * Public for RouteBuilder access, but not intended for direct use
146
148
  */
147
- addRoute(method: HttpMethod, pattern: string, handler: Handler): void;
149
+ addRoute(method: HTTPMethod, pattern: string, handler: Handler): void;
148
150
  /**
149
151
  * Handle a request - main entrypoint for ServiceWorker usage
150
152
  * Returns a response or throws if no route matches
@@ -187,4 +189,28 @@ export declare class Router {
187
189
  compiled: boolean;
188
190
  };
189
191
  }
192
+ /**
193
+ * Mode for trailing slash normalization
194
+ * - "strip": Redirect /path/ → /path (removes trailing slash)
195
+ * - "add": Redirect /path → /path/ (adds trailing slash)
196
+ */
197
+ export type TrailingSlashMode = "strip" | "add";
198
+ /**
199
+ * Middleware that normalizes trailing slashes via 301 redirect
200
+ *
201
+ * @param mode - "strip" removes trailing slash, "add" adds trailing slash
202
+ * @returns Function middleware that redirects non-canonical URLs
203
+ *
204
+ * @example
205
+ * ```typescript
206
+ * import { Router, trailingSlash } from "@b9g/router";
207
+ *
208
+ * const router = new Router();
209
+ * router.use(trailingSlash("strip")); // Redirect /path/ → /path
210
+ *
211
+ * // Can also be scoped to specific paths
212
+ * router.use("/api", trailingSlash("strip"));
213
+ * ```
214
+ */
215
+ export declare function trailingSlash(mode: TrailingSlashMode): FunctionMiddleware;
190
216
  export {};
package/src/index.js CHANGED
@@ -5,6 +5,11 @@ import {
5
5
  isSimplePattern,
6
6
  compilePathname
7
7
  } from "@b9g/match-pattern";
8
+ import {
9
+ InternalServerError,
10
+ NotFound,
11
+ isHTTPError
12
+ } from "@b9g/http-errors";
8
13
  var RadixNode = class {
9
14
  children;
10
15
  // char -> RadixNode
@@ -116,12 +121,18 @@ var RadixTreeExecutor = class {
116
121
  }
117
122
  return null;
118
123
  }
119
- const handler = node.handlers.get(method);
124
+ let handler = node.handlers.get(method);
125
+ if (!handler && method === "HEAD") {
126
+ handler = node.handlers.get("GET");
127
+ }
120
128
  if (handler) {
121
129
  return { handler, params };
122
130
  }
123
131
  if (node.wildcardChild) {
124
- const wildcardHandler = node.wildcardChild.handlers.get(method);
132
+ let wildcardHandler = node.wildcardChild.handlers.get(method);
133
+ if (!wildcardHandler && method === "HEAD") {
134
+ wildcardHandler = node.wildcardChild.handlers.get("GET");
135
+ }
125
136
  if (wildcardHandler) {
126
137
  params["0"] = "";
127
138
  return { handler: wildcardHandler, params };
@@ -144,7 +155,8 @@ var RadixTreeExecutor = class {
144
155
  };
145
156
  }
146
157
  for (const route of this.#complexRoutes) {
147
- if (route.method !== method) {
158
+ const methodMatches = route.method === method || method === "HEAD" && route.method === "GET";
159
+ if (!methodMatches) {
148
160
  continue;
149
161
  }
150
162
  const match = pathname.match(route.compiled.regex);
@@ -268,7 +280,7 @@ var Router = class {
268
280
  );
269
281
  } else {
270
282
  const notFoundHandler = async () => {
271
- return new Response("Not Found", { status: 404 });
283
+ throw new NotFound();
272
284
  };
273
285
  const mutableRequest = this.#createMutableRequest(request);
274
286
  return await this.#executeMiddlewareStack(
@@ -286,28 +298,27 @@ var Router = class {
286
298
  };
287
299
  this.handler = this.#handlerImpl;
288
300
  }
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)) {
301
+ use(pathPrefixOrMiddleware, maybeMiddleware) {
302
+ if (typeof pathPrefixOrMiddleware === "string") {
303
+ const middleware = maybeMiddleware;
304
+ if (!this.#isValidMiddleware(middleware)) {
300
305
  throw new Error(
301
306
  "Invalid middleware type. Must be function or async generator function."
302
307
  );
303
308
  }
304
- this.#middlewares.push({ middleware: patternOrMiddleware });
305
- this.#dirty = true;
309
+ this.#middlewares.push({
310
+ middleware,
311
+ pathPrefix: pathPrefixOrMiddleware
312
+ });
306
313
  } else {
307
- throw new Error(
308
- "Invalid middleware type. Must be function or async generator function."
309
- );
314
+ if (!this.#isValidMiddleware(pathPrefixOrMiddleware)) {
315
+ throw new Error(
316
+ "Invalid middleware type. Must be function or async generator function."
317
+ );
318
+ }
319
+ this.#middlewares.push({ middleware: pathPrefixOrMiddleware });
310
320
  }
321
+ this.#dirty = true;
311
322
  }
312
323
  route(patternOrConfig) {
313
324
  if (typeof patternOrConfig === "string") {
@@ -354,21 +365,38 @@ var Router = class {
354
365
  handler = matchResult.handler;
355
366
  context = matchResult.context;
356
367
  } else {
357
- handler = async () => new Response("Not Found", { status: 404 });
368
+ handler = async () => {
369
+ throw new NotFound();
370
+ };
358
371
  context = { params: {} };
359
372
  }
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
- );
373
+ let response;
374
+ try {
375
+ response = await this.#executeMiddlewareStack(
376
+ this.#middlewares,
377
+ mutableRequest,
378
+ context,
379
+ handler,
380
+ originalURL,
381
+ this.#executor
382
+ // Pass executor for re-routing
383
+ );
384
+ } catch (error) {
385
+ if (!matchResult && isHTTPError(error) && error.status === 404) {
386
+ return null;
387
+ }
388
+ throw error;
389
+ }
369
390
  if (!matchResult && response?.status === 404) {
370
391
  return null;
371
392
  }
393
+ if (response && request.method.toUpperCase() === "HEAD") {
394
+ response = new Response(null, {
395
+ status: response.status,
396
+ statusText: response.statusText,
397
+ headers: response.headers
398
+ });
399
+ }
372
400
  return response;
373
401
  }
374
402
  /**
@@ -412,7 +440,19 @@ var Router = class {
412
440
  }
413
441
  const submiddlewares = subrouter.getMiddlewares();
414
442
  for (const submiddleware of submiddlewares) {
415
- this.#middlewares.push(submiddleware);
443
+ let composedPrefix;
444
+ if (submiddleware.pathPrefix) {
445
+ composedPrefix = this.#combinePaths(
446
+ normalizedMountPath,
447
+ submiddleware.pathPrefix
448
+ );
449
+ } else {
450
+ composedPrefix = normalizedMountPath;
451
+ }
452
+ this.#middlewares.push({
453
+ middleware: submiddleware.middleware,
454
+ pathPrefix: composedPrefix
455
+ });
416
456
  }
417
457
  this.#dirty = true;
418
458
  }
@@ -445,13 +485,29 @@ var Router = class {
445
485
  */
446
486
  #isValidMiddleware(middleware) {
447
487
  const constructorName = middleware.constructor.name;
448
- return constructorName === "AsyncGeneratorFunction" || constructorName === "AsyncFunction" || constructorName === "Function";
488
+ return constructorName === "AsyncGeneratorFunction" || constructorName === "GeneratorFunction" || constructorName === "AsyncFunction" || constructorName === "Function";
449
489
  }
450
490
  /**
451
491
  * Detect if a function is a generator middleware
452
492
  */
453
493
  #isGeneratorMiddleware(middleware) {
454
- return middleware.constructor.name === "AsyncGeneratorFunction";
494
+ const name = middleware.constructor.name;
495
+ return name === "GeneratorFunction" || name === "AsyncGeneratorFunction";
496
+ }
497
+ /**
498
+ * Check if a request pathname matches a middleware's path prefix
499
+ * Matches on segment boundaries: /admin matches /admin, /admin/, /admin/users
500
+ * but NOT /administrator
501
+ */
502
+ #matchesPathPrefix(pathname, pathPrefix) {
503
+ if (pathname === pathPrefix) {
504
+ return true;
505
+ }
506
+ if (pathname.startsWith(pathPrefix)) {
507
+ const nextChar = pathname[pathPrefix.length];
508
+ return nextChar === "/" || nextChar === void 0;
509
+ }
510
+ return false;
455
511
  }
456
512
  /**
457
513
  * Execute middleware stack with guaranteed execution using Rack-style LIFO order
@@ -459,8 +515,13 @@ var Router = class {
459
515
  async #executeMiddlewareStack(middlewares, request, context, handler, originalURL, executor) {
460
516
  const runningGenerators = [];
461
517
  let currentResponse = null;
518
+ const requestPathname = new URL(request.url).pathname;
462
519
  for (let i = 0; i < middlewares.length; i++) {
463
- const middleware = middlewares[i].middleware;
520
+ const entry = middlewares[i];
521
+ const middleware = entry.middleware;
522
+ if (entry.pathPrefix && !this.#matchesPathPrefix(requestPathname, entry.pathPrefix)) {
523
+ continue;
524
+ }
464
525
  if (this.#isGeneratorMiddleware(middleware)) {
465
526
  const generator = middleware(request, context);
466
527
  const result = await generator.next();
@@ -506,10 +567,18 @@ var Router = class {
506
567
  handlerError = error;
507
568
  }
508
569
  if (handlerError) {
509
- currentResponse = await this.#handleErrorThroughGenerators(
510
- handlerError,
511
- runningGenerators
512
- );
570
+ if (request.url !== originalURL) {
571
+ currentResponse = this.#handleAutomaticRedirect(
572
+ originalURL,
573
+ request.url,
574
+ request.method
575
+ );
576
+ } else {
577
+ currentResponse = await this.#handleErrorThroughGenerators(
578
+ handlerError,
579
+ runningGenerators
580
+ );
581
+ }
513
582
  }
514
583
  }
515
584
  if (request.url !== originalURL && currentResponse) {
@@ -522,7 +591,7 @@ var Router = class {
522
591
  for (let i = runningGenerators.length - 1; i >= 0; i--) {
523
592
  const { generator } = runningGenerators[i];
524
593
  const result = await generator.next(currentResponse);
525
- if (result.value) {
594
+ if (result.value && result.done) {
526
595
  currentResponse = result.value;
527
596
  }
528
597
  }
@@ -536,7 +605,7 @@ var Router = class {
536
605
  const { generator } = runningGenerators[i];
537
606
  try {
538
607
  const result = await generator.throw(error);
539
- if (result.value) {
608
+ if (result.done && result.value) {
540
609
  runningGenerators.splice(i, 1);
541
610
  return result.value;
542
611
  }
@@ -612,40 +681,36 @@ var Router = class {
612
681
  }
613
682
  /**
614
683
  * Create an error response for unhandled errors
615
- * In development mode, includes error details; in production, returns generic message
684
+ * Uses HTTPError.toResponse() for consistent error formatting
616
685
  */
617
686
  #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
- }
687
+ const httpError = isHTTPError(error) ? error : new InternalServerError(error.message, { cause: error });
688
+ const isDev = import.meta.env?.MODE !== "production";
689
+ return httpError.toResponse(isDev);
644
690
  }
645
691
  };
646
- function escapeHtml(str) {
647
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
692
+ function trailingSlash(mode) {
693
+ return (request, _context) => {
694
+ const url = new URL(request.url);
695
+ const pathname = url.pathname;
696
+ if (pathname === "/")
697
+ return;
698
+ let newPathname = null;
699
+ if (mode === "strip" && pathname.endsWith("/")) {
700
+ newPathname = pathname.slice(0, -1);
701
+ } else if (mode === "add" && !pathname.endsWith("/")) {
702
+ newPathname = pathname + "/";
703
+ }
704
+ if (newPathname) {
705
+ url.pathname = newPathname;
706
+ return new Response(null, {
707
+ status: 301,
708
+ headers: { Location: url.toString() }
709
+ });
710
+ }
711
+ };
648
712
  }
649
713
  export {
650
- Router
714
+ Router,
715
+ trailingSlash
651
716
  };