@b9g/router 0.1.7 → 0.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@b9g/router",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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,10 +13,11 @@
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": {
19
- "@b9g/libuild": "^0.1.11",
20
+ "@b9g/libuild": "^0.1.18",
20
21
  "bun-types": "latest"
21
22
  },
22
23
  "type": "module",
package/src/index.d.ts CHANGED
@@ -189,4 +189,28 @@ export declare class Router {
189
189
  compiled: boolean;
190
190
  };
191
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;
192
216
  export {};
package/src/index.js CHANGED
@@ -5,7 +5,11 @@ import {
5
5
  isSimplePattern,
6
6
  compilePathname
7
7
  } from "@b9g/match-pattern";
8
- import { InternalServerError, isHTTPError } from "@b9g/http-errors";
8
+ import {
9
+ InternalServerError,
10
+ NotFound,
11
+ isHTTPError
12
+ } from "@b9g/http-errors";
9
13
  var RadixNode = class {
10
14
  children;
11
15
  // char -> RadixNode
@@ -265,27 +269,21 @@ var Router = class {
265
269
  }
266
270
  const matchResult = this.#executor.match(request);
267
271
  if (matchResult) {
268
- const mutableRequest = this.#createMutableRequest(request);
269
272
  return await this.#executeMiddlewareStack(
270
273
  this.#middlewares,
271
- mutableRequest,
274
+ request,
272
275
  matchResult.context,
273
- matchResult.handler,
274
- request.url,
275
- this.#executor
276
+ matchResult.handler
276
277
  );
277
278
  } else {
278
279
  const notFoundHandler = async () => {
279
- return new Response("Not Found", { status: 404 });
280
+ throw new NotFound();
280
281
  };
281
- const mutableRequest = this.#createMutableRequest(request);
282
282
  return await this.#executeMiddlewareStack(
283
283
  this.#middlewares,
284
- mutableRequest,
284
+ request,
285
285
  { params: {} },
286
- notFoundHandler,
287
- request.url,
288
- this.#executor
286
+ notFoundHandler
289
287
  );
290
288
  }
291
289
  } catch (error) {
@@ -352,8 +350,6 @@ var Router = class {
352
350
  this.#executor = new RadixTreeExecutor(this.#routes);
353
351
  this.#dirty = false;
354
352
  }
355
- const mutableRequest = this.#createMutableRequest(request);
356
- const originalURL = mutableRequest.url;
357
353
  let matchResult = this.#executor.match(request);
358
354
  let handler;
359
355
  let context;
@@ -361,18 +357,25 @@ var Router = class {
361
357
  handler = matchResult.handler;
362
358
  context = matchResult.context;
363
359
  } else {
364
- handler = async () => new Response("Not Found", { status: 404 });
360
+ handler = async () => {
361
+ throw new NotFound();
362
+ };
365
363
  context = { params: {} };
366
364
  }
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
- );
365
+ let response;
366
+ try {
367
+ response = await this.#executeMiddlewareStack(
368
+ this.#middlewares,
369
+ request,
370
+ context,
371
+ handler
372
+ );
373
+ } catch (error) {
374
+ if (!matchResult && isHTTPError(error) && error.status === 404) {
375
+ return null;
376
+ }
377
+ throw error;
378
+ }
376
379
  if (!matchResult && response?.status === 404) {
377
380
  return null;
378
381
  }
@@ -498,7 +501,7 @@ var Router = class {
498
501
  /**
499
502
  * Execute middleware stack with guaranteed execution using Rack-style LIFO order
500
503
  */
501
- async #executeMiddlewareStack(middlewares, request, context, handler, originalURL, executor) {
504
+ async #executeMiddlewareStack(middlewares, request, context, handler) {
502
505
  const runningGenerators = [];
503
506
  let currentResponse = null;
504
507
  const requestPathname = new URL(request.url).pathname;
@@ -531,24 +534,9 @@ var Router = class {
531
534
  }
532
535
  }
533
536
  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
537
  let handlerError = null;
550
538
  try {
551
- currentResponse = await finalHandler(request, finalContext);
539
+ currentResponse = await handler(request, context);
552
540
  } catch (error) {
553
541
  handlerError = error;
554
542
  }
@@ -559,13 +547,6 @@ var Router = class {
559
547
  );
560
548
  }
561
549
  }
562
- if (request.url !== originalURL && currentResponse) {
563
- currentResponse = this.#handleAutomaticRedirect(
564
- originalURL,
565
- request.url,
566
- request.method
567
- );
568
- }
569
550
  for (let i = runningGenerators.length - 1; i >= 0; i--) {
570
551
  const { generator } = runningGenerators[i];
571
552
  const result = await generator.next(currentResponse);
@@ -594,59 +575,6 @@ var Router = class {
594
575
  }
595
576
  throw error;
596
577
  }
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
578
  /**
651
579
  * Get route statistics
652
580
  */
@@ -667,6 +595,28 @@ var Router = class {
667
595
  return httpError.toResponse(isDev);
668
596
  }
669
597
  };
598
+ function trailingSlash(mode) {
599
+ return (request, _context) => {
600
+ const url = new URL(request.url);
601
+ const pathname = url.pathname;
602
+ if (pathname === "/")
603
+ return;
604
+ let newPathname = null;
605
+ if (mode === "strip" && pathname.endsWith("/")) {
606
+ newPathname = pathname.slice(0, -1);
607
+ } else if (mode === "add" && !pathname.endsWith("/")) {
608
+ newPathname = pathname + "/";
609
+ }
610
+ if (newPathname) {
611
+ url.pathname = newPathname;
612
+ return new Response(null, {
613
+ status: 301,
614
+ headers: { Location: url.toString() }
615
+ });
616
+ }
617
+ };
618
+ }
670
619
  export {
671
- Router
620
+ Router,
621
+ trailingSlash
672
622
  };