@rhythmjs/router 0.0.12 → 0.0.14

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
@@ -4,7 +4,7 @@ The HTTP layer of Rhythm, the Bun-native backend framework: web-standard (`Reque
4
4
 
5
5
  Route patterns follow rou3's conventions: `:name` params (`:name?` optional, `:id(\\d+)` regex-constrained), `*` for one unnamed segment (captured as `params["0"]`), and `**` for the rest of the path (`params._`, or `params.name` with `**:name`). Param values are the raw path segments, undecoded.
6
6
 
7
- `RhythmRouter` is not an app and does not extend `Rhythm`; it is a controller that compiles routes and middleware down to a single middleware (`.middleware()`). It shares the core middleware contract (`compose`, `Middleware`, `next(extra)`), but has no `provide()` or `register()`, and it can't be served on its own: a `Rhythm` app is always the host that owns the lifecycle and the adapters.
7
+ `RhythmRouter` is not an app and does not extend `Rhythm`; it is a controller that compiles routes and middleware down to a single middleware (`.middleware()`). It shares the core middleware contract (`compose`, `Middleware`, `derive`; `next()` takes no arguments, extend the context with `derive()`), but has no a startup `context` or `register()`, and it can't be served on its own: a `Rhythm` app is always the host that owns the lifecycle and the adapters.
8
8
 
9
9
  ## Example
10
10
 
@@ -27,11 +27,11 @@ A fuller runnable version, including nested prefixes and a fallback route, is at
27
27
  ## Concepts
28
28
 
29
29
  - **`ctx.response`** is a plain mutable object (`status`, `statusText`, `headers`, `body`): set it directly rather than constructing a `Response` yourself. The adapter converts it to a real `Response` at the end.
30
- - **Response helpers**: `ctx.json(data, status?)`, `ctx.text(body, status?)`, `ctx.html(body, status?)`, `ctx.error(status, message?)`, and `ctx.redirect(url, status = 302)` set the content type, body, and status on `ctx.response` in one call. `error()` defaults the message from the status code (`ctx.error(404)` → `"Not Found"`). They're sugar over `ctx.response`, so mixing both styles is fine, and later writes win.
30
+ - **Response helpers**: `ctx.json(data, status?)`, `ctx.text(body, status?)`, `ctx.html(body, status?)`, `ctx.error(status, message?)`, and `ctx.redirect(url, status = 302)` (status must be 301, 302, 303, 307 or 308; the URL is used as given, so never pass a user-supplied `next`/`returnTo` value without checking it against an allowlist or requiring a same-origin path) set the content type, body, and status on `ctx.response` in one call. `error()` defaults the message from the status code (`ctx.error(404)` → `"Not Found"`). They're sugar over `ctx.response`, so mixing both styles is fine, and later writes win.
31
31
  - **`ctx.params`**: captured `:name` path segments, added once a route matches.
32
32
  - **Nesting is `.use(child.middleware())`**: a router mounts into another router (or into the app) as a compiled middleware. The mount is opaque, so the parent's prefix is **not** applied to the child's routes: the child carries its own absolute prefix (`new RhythmRouter({ prefix: "/api/users" })`). On a miss the child falls through to `next()`, so the parent's later middleware and routes still run, and the child keeps working standalone.
33
- - **Registration order is execution order**: a `.use()` middleware wraps only the routes registered after it; routes registered before it are untouched, and a matched route that doesn't call `next()` returns without reaching anything registered later. Consecutive routes share one rou3 lookup; an unmatched request falls through, entry by entry, to the outer `next()`.
34
- - **A router is a controller, not a module**: it has no `provide()` or `register()`, and it cannot be `register()`ed into a `Rhythm` app either; `register()` composes `Rhythm` modules only. A router mounts into an app exactly one way: koa-style, via `.use(router.middleware())`.
33
+ - **Registration order is execution order**: a `.use()` middleware wraps only the routes registered after it, and runs only when one of them matches the request's method and path (so a guard in a `/projects` router never answers `/docs`); a mounted router (`.use(child.middleware())`) always runs; routes registered before it are untouched, and a matched route that doesn't call `next()` returns without reaching anything registered later. Consecutive routes share one rou3 lookup; an unmatched request falls through, entry by entry, to the outer `next()`.
34
+ - **A router is a controller, not a module**: it has no a startup `context` or `register()`, and it cannot be `register()`ed into a `Rhythm` app either; `register()` composes `Rhythm` modules only. A router mounts into an app exactly one way: koa-style, via `.use(router.middleware())`.
35
35
 
36
36
  ## API
37
37
 
@@ -39,6 +39,7 @@ A fuller runnable version, including nested prefixes and a fallback route, is at
39
39
  - `.get/.post/.put/.patch/.delete(path, ...handlers)`: register a route; `path` may contain `:param` segments.
40
40
  - `.use(fn)`: plain middleware; it takes only functions, so a nested router mounts as `.use(child.middleware())`.
41
41
  - `.middleware()`: this router compiled to a plain middleware: the one form that mounts anywhere, into a `Rhythm` app or into another router. Because the compiled form is opaque, the mounting router's prefix is not applied to it, so give the child its full prefix.
42
+ - `.entries`: a read-only snapshot of registered middlewares and routes, in order. `.middleware()` is tagged with the router as its source, so a parent module lists it in `sources`.
42
43
  - `ctx.json/.text/.html(body, status?)`, `ctx.error(status, message?)`, `ctx.redirect(url, status?)`: response helpers built into the context (`createHttpContext` in `@rhythmjs/router/context`).
43
44
  - `toFetchHandler(app)`: bridges a `Rhythm` app to a Web-standard `(Request) => Promise<Response>` handler.
44
45
 
@@ -47,7 +48,7 @@ A fuller runnable version, including nested prefixes and a fallback route, is at
47
48
  There is no `serve()` helper and no static-file helper. You write `Bun.serve` in your own `main.ts`, and the package gives you exactly two plain pieces for its `fetch`:
48
49
 
49
50
  - **`toFetchHandler(app)`** (`@rhythmjs/router/fetch`): the app as a `(Request) => Promise<Response>` handler.
50
- - **`errorToResponse(error)`** (`@rhythmjs/router/fetch`): maps a thrown error to a Response: the error's own `status`/`statusCode` when set, else a logged `500`.
51
+ - **`errorToResponse(error)`** (`@rhythmjs/router/fetch`): maps a thrown error to a Response: the error's own `status`/`statusCode` when it is an integer in 400-599 (its message is the body, unless the error sets `expose: false`, which sends the generic status text instead), else a logged `500`.
51
52
 
52
53
  Everything wired, explicitly:
53
54
 
@@ -93,6 +94,7 @@ const server = Bun.serve({
93
94
  Directory routes (`{ dir }`, path must end in `/*`) come with content types, `Last-Modified` + weak `ETag` with `304` revalidation, `Range` requests, `index.html` for trailing-slash requests (and a `301` to add the slash), and `404` for missing or non-canonical (traversal) paths.
94
95
 
95
96
  > **Warning: never mount a directory at `"/*"`.** A directory route answers its own `404`s: with `"/*": { dir }`, every URL that isn't a file dies there and your app's `fetch` never runs. Keep folders on dedicated prefixes (`/static/*`, `/assets/*`) and let `fetch` stay the app's. For root-level files (favicon, robots.txt), map each one explicitly: `"/favicon.svg": new Response(Bun.file("public/favicon.svg"))`.
97
+
96
98
  ### WebSockets
97
99
 
98
100
  [`@rhythmjs/ws`](https://github.com/rhythmjs/ws) plugs into the same hand-wired `fetch`: its `upgrade()` returns `null` synchronously for non-websocket requests, so it composes as `ws.upgrade(request, srv) ?? handler(request)`, with `websocket: ws.websocket` on the same `Bun.serve` call.
package/dist/context.d.ts CHANGED
@@ -14,5 +14,6 @@ export interface RhythmHttpContext {
14
14
  error(status: number, message?: string): void;
15
15
  redirect(url: string, status?: number): void;
16
16
  }
17
+ export declare const STATUS_TEXT: Record<number, string>;
17
18
  export declare function createHttpContext(request: Request): RhythmHttpContext;
18
19
  export declare function toResponse(response: RhythmResponse): Response;
package/dist/context.js CHANGED
@@ -1,11 +1,13 @@
1
1
  // @bun
2
2
  import {
3
3
  RhythmResponse2,
4
+ STATUS_TEXT2,
4
5
  createHttpContext2,
5
6
  toResponse2
6
- } from "./rhythm-router-yj4ffyyg.js";
7
+ } from "./rhythm-router-48zw4cyy.js";
7
8
  export {
8
9
  RhythmResponse2 as RhythmResponse,
10
+ STATUS_TEXT2 as STATUS_TEXT,
9
11
  createHttpContext2 as createHttpContext,
10
12
  toResponse2 as toResponse
11
13
  };
package/dist/fetch.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import type { Rhythm } from "@rhythmjs/rhythm";
2
2
  import { type RhythmHttpContext } from "./context";
3
- export declare function toFetchHandler<TContext extends RhythmHttpContext, TProviders extends object = {}>(app: Rhythm<RhythmHttpContext, TContext, TProviders>): (request: Request) => Promise<Response>;
3
+ export declare function toFetchHandler<TContext extends RhythmHttpContext>(app: Rhythm<RhythmHttpContext, any, TContext>): (request: Request) => Promise<Response>;
4
4
  export declare function errorToResponse(error: unknown): Response;
package/dist/fetch.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // @bun
2
2
  import {
3
+ STATUS_TEXT2,
3
4
  createHttpContext2
4
- } from "./rhythm-router-yj4ffyyg.js";
5
+ } from "./rhythm-router-48zw4cyy.js";
5
6
 
6
7
  // src/fetch.ts
7
8
  function toFetchHandler(app) {
@@ -17,10 +18,12 @@ function toFetchHandler(app) {
17
18
  };
18
19
  }
19
20
  function errorToResponse(error) {
20
- const status = error.status ?? error.statusCode ?? 500;
21
- const message = status >= 500 ? "Internal Server Error" : error instanceof Error ? error.message : String(error);
21
+ const declared = error.status ?? error.statusCode;
22
+ const status = Number.isInteger(declared) && declared >= 400 && declared <= 599 ? declared : 500;
22
23
  if (status >= 500)
23
24
  console.error(error);
25
+ const expose = status < 500 && error.expose !== false;
26
+ const message = expose ? error instanceof Error ? error.message : String(error) : status >= 500 ? "Internal Server Error" : STATUS_TEXT2[status] ?? `Error ${status}`;
24
27
  return new Response(message, {
25
28
  status,
26
29
  headers: { "content-type": "text/plain; charset=utf-8" }
@@ -0,0 +1,73 @@
1
+ // @bun
2
+ // src/context.ts
3
+ class RhythmResponse2 {
4
+ status = 200;
5
+ statusText = undefined;
6
+ headers = new Headers;
7
+ body = null;
8
+ }
9
+ var REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
10
+ var STATUS_TEXT2 = {
11
+ 400: "Bad Request",
12
+ 401: "Unauthorized",
13
+ 403: "Forbidden",
14
+ 404: "Not Found",
15
+ 405: "Method Not Allowed",
16
+ 409: "Conflict",
17
+ 410: "Gone",
18
+ 413: "Payload Too Large",
19
+ 415: "Unsupported Media Type",
20
+ 422: "Unprocessable Entity",
21
+ 429: "Too Many Requests",
22
+ 500: "Internal Server Error",
23
+ 501: "Not Implemented",
24
+ 502: "Bad Gateway",
25
+ 503: "Service Unavailable",
26
+ 504: "Gateway Timeout"
27
+ };
28
+ function createHttpContext2(request) {
29
+ const response = new RhythmResponse2;
30
+ return {
31
+ request,
32
+ response,
33
+ json(data, status) {
34
+ if (status !== undefined)
35
+ response.status = status;
36
+ response.headers.set("content-type", "application/json; charset=utf-8");
37
+ response.body = JSON.stringify(data);
38
+ },
39
+ text(body, status) {
40
+ if (status !== undefined)
41
+ response.status = status;
42
+ response.headers.set("content-type", "text/plain; charset=utf-8");
43
+ response.body = body;
44
+ },
45
+ html(body, status) {
46
+ if (status !== undefined)
47
+ response.status = status;
48
+ response.headers.set("content-type", "text/html; charset=utf-8");
49
+ response.body = body;
50
+ },
51
+ error(status, message) {
52
+ response.status = status;
53
+ response.headers.set("content-type", "text/plain; charset=utf-8");
54
+ response.body = message ?? STATUS_TEXT2[status] ?? `Error ${status}`;
55
+ },
56
+ redirect(url, status = 302) {
57
+ if (!REDIRECT_STATUSES.has(status))
58
+ throw new RangeError(`redirect status must be 301, 302, 303, 307 or 308, got ${status}`);
59
+ response.status = status;
60
+ response.headers.set("location", url);
61
+ response.body = null;
62
+ }
63
+ };
64
+ }
65
+ function toResponse2(response) {
66
+ return new Response(response.body, {
67
+ status: response.status,
68
+ statusText: response.statusText,
69
+ headers: response.headers
70
+ });
71
+ }
72
+
73
+ export { RhythmResponse2, STATUS_TEXT2, createHttpContext2, toResponse2 };
@@ -18,11 +18,11 @@ export type RouterEntry = {
18
18
  };
19
19
  type RouteHandler<TContext> = Middleware<TContext & RhythmRouterContext>;
20
20
  export declare function joinPath(prefix: string, path: string): string;
21
- export declare class RhythmRouter<TContext extends RhythmHttpContext = RhythmHttpContext> {
21
+ export declare class RhythmRouter<TContext extends RhythmHttpContext = RhythmHttpContext, TInput extends RhythmHttpContext = TContext> {
22
22
  #private;
23
23
  constructor(options?: RhythmRouterOptions);
24
24
  get entries(): readonly RouterEntry[];
25
- use<TExtra extends object>(fn: DeriveMiddleware<TContext, TExtra>): RhythmRouter<TContext & TExtra>;
25
+ use<TExtra extends object>(fn: DeriveMiddleware<TContext, TExtra>): RhythmRouter<TContext & TExtra, TInput>;
26
26
  use(fn: Middleware<TContext>): this;
27
27
  get<TExtra extends object>(path: string, middleware: DeriveMiddleware<TContext & RhythmRouterContext, TExtra>, ...handlers: RouteHandler<TContext & TExtra>[]): this;
28
28
  get(path: string, ...handlers: RouteHandler<TContext>[]): this;
@@ -34,6 +34,6 @@ export declare class RhythmRouter<TContext extends RhythmHttpContext = RhythmHtt
34
34
  patch(path: string, ...handlers: RouteHandler<TContext>[]): this;
35
35
  delete<TExtra extends object>(path: string, middleware: DeriveMiddleware<TContext & RhythmRouterContext, TExtra>, ...handlers: RouteHandler<TContext & TExtra>[]): this;
36
36
  delete(path: string, ...handlers: RouteHandler<TContext>[]): this;
37
- middleware(): Middleware<TContext>;
37
+ middleware(): Middleware<TInput>;
38
38
  }
39
39
  export {};
@@ -1,14 +1,31 @@
1
1
  // @bun
2
2
  // src/rhythm-router.ts
3
3
  import { compose } from "@rhythmjs/rhythm/compose";
4
+ import { sourceOf, withSource } from "@rhythmjs/rhythm/source";
4
5
  import { addRoute, createRouter, findRoute } from "rou3";
5
6
  function joinPath(prefix, path) {
6
7
  if (!prefix)
7
8
  return path;
9
+ if (path === "/" || path === "")
10
+ return prefix;
8
11
  const trimmedPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
9
12
  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
10
13
  return `${trimmedPrefix}${normalizedPath}`;
11
14
  }
15
+ function mountedRoutes(router, tree, seen = new Set) {
16
+ if (seen.has(router))
17
+ return;
18
+ seen.add(router);
19
+ for (const entry of router.entries) {
20
+ if (entry.kind === "route")
21
+ addRoute(tree, entry.method, entry.path, true);
22
+ else {
23
+ const child = sourceOf(entry.fn);
24
+ if (child instanceof RhythmRouter)
25
+ mountedRoutes(child, tree, seen);
26
+ }
27
+ }
28
+ }
12
29
 
13
30
  class RhythmRouter {
14
31
  #options;
@@ -16,7 +33,7 @@ class RhythmRouter {
16
33
  constructor(options = {}) {
17
34
  this.#options = options;
18
35
  }
19
- get #prefix() {
36
+ get #prefixPath() {
20
37
  return this.#options.prefix ?? "";
21
38
  }
22
39
  get entries() {
@@ -29,7 +46,7 @@ class RhythmRouter {
29
46
  return this;
30
47
  }
31
48
  #route(method, path, handlers) {
32
- this.#entries.push({ kind: "route", method, path: joinPath(this.#prefix, path), handlers });
49
+ this.#entries.push({ kind: "route", method, path: joinPath(this.#prefixPath, path), handlers });
33
50
  return this;
34
51
  }
35
52
  get(path, ...handlers) {
@@ -58,12 +75,33 @@ class RhythmRouter {
58
75
  await match.data({ ...ctx, params: match.params ?? {} }, next);
59
76
  };
60
77
  };
78
+ const trees = [];
79
+ const reaches = (ctx, from) => {
80
+ const method = ctx.request.method;
81
+ const pathname = new URL(ctx.request.url).pathname;
82
+ for (let t = from;t < trees.length; t++)
83
+ if (findRoute(trees[t], method, pathname))
84
+ return true;
85
+ return false;
86
+ };
61
87
  const stack = [];
62
88
  let i = 0;
63
89
  while (i < this.#entries.length) {
64
90
  const entry = this.#entries[i];
65
91
  if (entry.kind === "middleware") {
66
- stack.push(entry.fn);
92
+ const { fn } = entry;
93
+ const source = sourceOf(fn);
94
+ if (source) {
95
+ if (source instanceof RhythmRouter) {
96
+ const tree = createRouter();
97
+ mountedRoutes(source, tree);
98
+ trees.push(tree);
99
+ }
100
+ stack.push(fn);
101
+ } else {
102
+ const from = trees.length;
103
+ stack.push((ctx, next) => reaches(ctx, from) ? fn(ctx, next) : next());
104
+ }
67
105
  i++;
68
106
  continue;
69
107
  }
@@ -75,15 +113,16 @@ class RhythmRouter {
75
113
  addRoute(tree, route.method, route.path, compose(route.handlers));
76
114
  i++;
77
115
  }
116
+ trees.push(tree);
78
117
  stack.push(dispatchFor(tree));
79
118
  }
80
119
  return compose(stack);
81
120
  }
82
121
  middleware() {
83
122
  const fn = this.#compile();
84
- return async (ctx, next) => {
123
+ return withSource(async (ctx, next) => {
85
124
  await fn(ctx, next);
86
- };
125
+ }, this);
87
126
  }
88
127
  }
89
128
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhythmjs/router",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "Bun-native HTTP router for the Rhythm middleware kernel, matching with rou3 and serving with Bun.serve.",
5
5
  "homepage": "https://rhythm.js.org/router",
6
6
  "license": "ISC",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "rou3": "^0.11.0",
41
- "@rhythmjs/rhythm": "0.0.12"
41
+ "@rhythmjs/rhythm": "0.0.15"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/bun": "^1.2.0",