@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 +1 -1
- package/package.json +2 -1
- package/src/index.d.ts +32 -6
- package/src/index.js +135 -70
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
|
-
- `
|
|
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.
|
|
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 |
|
|
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
|
|
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
|
|
131
|
+
* Register middleware that only applies to routes matching the path prefix
|
|
130
132
|
*/
|
|
131
|
-
use(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
290
|
-
if (typeof
|
|
291
|
-
|
|
292
|
-
this
|
|
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({
|
|
305
|
-
|
|
309
|
+
this.#middlewares.push({
|
|
310
|
+
middleware,
|
|
311
|
+
pathPrefix: pathPrefixOrMiddleware
|
|
312
|
+
});
|
|
306
313
|
} else {
|
|
307
|
-
|
|
308
|
-
|
|
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 () =>
|
|
368
|
+
handler = async () => {
|
|
369
|
+
throw new NotFound();
|
|
370
|
+
};
|
|
358
371
|
context = { params: {} };
|
|
359
372
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
*
|
|
684
|
+
* Uses HTTPError.toResponse() for consistent error formatting
|
|
616
685
|
*/
|
|
617
686
|
#createErrorResponse(error) {
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
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
|
|
647
|
-
return
|
|
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
|
};
|