@b9g/router 0.1.6 → 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/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.7",
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",
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
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  isSimplePattern,
6
6
  compilePathname
7
7
  } from "@b9g/match-pattern";
8
+ import { InternalServerError, isHTTPError } from "@b9g/http-errors";
8
9
  var RadixNode = class {
9
10
  children;
10
11
  // char -> RadixNode
@@ -116,12 +117,18 @@ var RadixTreeExecutor = class {
116
117
  }
117
118
  return null;
118
119
  }
119
- const handler = node.handlers.get(method);
120
+ let handler = node.handlers.get(method);
121
+ if (!handler && method === "HEAD") {
122
+ handler = node.handlers.get("GET");
123
+ }
120
124
  if (handler) {
121
125
  return { handler, params };
122
126
  }
123
127
  if (node.wildcardChild) {
124
- const wildcardHandler = node.wildcardChild.handlers.get(method);
128
+ let wildcardHandler = node.wildcardChild.handlers.get(method);
129
+ if (!wildcardHandler && method === "HEAD") {
130
+ wildcardHandler = node.wildcardChild.handlers.get("GET");
131
+ }
125
132
  if (wildcardHandler) {
126
133
  params["0"] = "";
127
134
  return { handler: wildcardHandler, params };
@@ -144,7 +151,8 @@ var RadixTreeExecutor = class {
144
151
  };
145
152
  }
146
153
  for (const route of this.#complexRoutes) {
147
- if (route.method !== method) {
154
+ const methodMatches = route.method === method || method === "HEAD" && route.method === "GET";
155
+ if (!methodMatches) {
148
156
  continue;
149
157
  }
150
158
  const match = pathname.match(route.compiled.regex);
@@ -286,28 +294,27 @@ var Router = class {
286
294
  };
287
295
  this.handler = this.#handlerImpl;
288
296
  }
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)) {
297
+ use(pathPrefixOrMiddleware, maybeMiddleware) {
298
+ if (typeof pathPrefixOrMiddleware === "string") {
299
+ const middleware = maybeMiddleware;
300
+ if (!this.#isValidMiddleware(middleware)) {
300
301
  throw new Error(
301
302
  "Invalid middleware type. Must be function or async generator function."
302
303
  );
303
304
  }
304
- this.#middlewares.push({ middleware: patternOrMiddleware });
305
- this.#dirty = true;
305
+ this.#middlewares.push({
306
+ middleware,
307
+ pathPrefix: pathPrefixOrMiddleware
308
+ });
306
309
  } else {
307
- throw new Error(
308
- "Invalid middleware type. Must be function or async generator function."
309
- );
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 });
310
316
  }
317
+ this.#dirty = true;
311
318
  }
312
319
  route(patternOrConfig) {
313
320
  if (typeof patternOrConfig === "string") {
@@ -357,7 +364,7 @@ var Router = class {
357
364
  handler = async () => new Response("Not Found", { status: 404 });
358
365
  context = { params: {} };
359
366
  }
360
- const response = await this.#executeMiddlewareStack(
367
+ let response = await this.#executeMiddlewareStack(
361
368
  this.#middlewares,
362
369
  mutableRequest,
363
370
  context,
@@ -369,6 +376,13 @@ var Router = class {
369
376
  if (!matchResult && response?.status === 404) {
370
377
  return null;
371
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
+ }
372
386
  return response;
373
387
  }
374
388
  /**
@@ -412,7 +426,19 @@ var Router = class {
412
426
  }
413
427
  const submiddlewares = subrouter.getMiddlewares();
414
428
  for (const submiddleware of submiddlewares) {
415
- this.#middlewares.push(submiddleware);
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
+ });
416
442
  }
417
443
  this.#dirty = true;
418
444
  }
@@ -445,13 +471,29 @@ var Router = class {
445
471
  */
446
472
  #isValidMiddleware(middleware) {
447
473
  const constructorName = middleware.constructor.name;
448
- return constructorName === "AsyncGeneratorFunction" || constructorName === "AsyncFunction" || constructorName === "Function";
474
+ return constructorName === "AsyncGeneratorFunction" || constructorName === "GeneratorFunction" || constructorName === "AsyncFunction" || constructorName === "Function";
449
475
  }
450
476
  /**
451
477
  * Detect if a function is a generator middleware
452
478
  */
453
479
  #isGeneratorMiddleware(middleware) {
454
- return middleware.constructor.name === "AsyncGeneratorFunction";
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;
455
497
  }
456
498
  /**
457
499
  * Execute middleware stack with guaranteed execution using Rack-style LIFO order
@@ -459,8 +501,13 @@ var Router = class {
459
501
  async #executeMiddlewareStack(middlewares, request, context, handler, originalURL, executor) {
460
502
  const runningGenerators = [];
461
503
  let currentResponse = null;
504
+ const requestPathname = new URL(request.url).pathname;
462
505
  for (let i = 0; i < middlewares.length; i++) {
463
- const middleware = middlewares[i].middleware;
506
+ const entry = middlewares[i];
507
+ const middleware = entry.middleware;
508
+ if (entry.pathPrefix && !this.#matchesPathPrefix(requestPathname, entry.pathPrefix)) {
509
+ continue;
510
+ }
464
511
  if (this.#isGeneratorMiddleware(middleware)) {
465
512
  const generator = middleware(request, context);
466
513
  const result = await generator.next();
@@ -522,7 +569,7 @@ var Router = class {
522
569
  for (let i = runningGenerators.length - 1; i >= 0; i--) {
523
570
  const { generator } = runningGenerators[i];
524
571
  const result = await generator.next(currentResponse);
525
- if (result.value) {
572
+ if (result.value && result.done) {
526
573
  currentResponse = result.value;
527
574
  }
528
575
  }
@@ -536,7 +583,7 @@ var Router = class {
536
583
  const { generator } = runningGenerators[i];
537
584
  try {
538
585
  const result = await generator.throw(error);
539
- if (result.value) {
586
+ if (result.done && result.value) {
540
587
  runningGenerators.splice(i, 1);
541
588
  return result.value;
542
589
  }
@@ -612,40 +659,14 @@ var Router = class {
612
659
  }
613
660
  /**
614
661
  * Create an error response for unhandled errors
615
- * In development mode, includes error details; in production, returns generic message
662
+ * Uses HTTPError.toResponse() for consistent error formatting
616
663
  */
617
664
  #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
- }
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);
644
668
  }
645
669
  };
646
- function escapeHtml(str) {
647
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
648
- }
649
670
  export {
650
671
  Router
651
672
  };