@rhythmjs/router 0.0.11 → 0.0.12

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
@@ -1,17 +1,17 @@
1
1
  # @rhythmjs/router
2
2
 
3
- Web-standard HTTP routing on top of `@rhythmjs/rhythm`. `RhythmRouter` matches routes with [rou3](https://github.com/h3js/rou3), the router that powers h3 (a static segment always wins over a `:param` segment, regardless of registration order), supports prefixes and nested routers, and mounts flat into a parent `Rhythm` app via `.use(router.middleware())`, so an unmatched request correctly falls through to whatever's registered after it.
3
+ The HTTP layer of Rhythm, the Bun-native backend framework: web-standard (`Request`/`Response`) routing on top of the `@rhythmjs/rhythm` kernel, served on `Bun.serve`. `RhythmRouter` matches routes with [rou3](https://github.com/h3js/rou3), the router that powers h3 (a static segment always wins over a `:param` segment, regardless of registration order), supports prefixes and nested routers, and mounts flat into a parent `Rhythm` app via `.use(router.middleware())`, so an unmatched request correctly falls through to whatever's registered after it.
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`, `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.
8
8
 
9
9
  ## Example
10
10
 
11
11
  ```ts
12
12
  import { Rhythm } from "@rhythmjs/rhythm";
13
13
  import { RhythmRouter } from "@rhythmjs/router";
14
- import { serve } from "@rhythmjs/router/serve";
14
+ import { toFetchHandler } from "@rhythmjs/router/fetch";
15
15
  import type { RhythmHttpContext } from "@rhythmjs/router/context";
16
16
 
17
17
  const usersRouter = new RhythmRouter({ prefix: "/users" }).get("/:id", (ctx) => {
@@ -19,77 +19,80 @@ const usersRouter = new RhythmRouter({ prefix: "/users" }).get("/:id", (ctx) =>
19
19
  });
20
20
 
21
21
  const app = new Rhythm<RhythmHttpContext>().use(usersRouter.middleware());
22
- serve(app, { port: 3000 });
22
+ Bun.serve({ port: 3000, fetch: toFetchHandler(app) });
23
23
  ```
24
24
 
25
25
  A fuller runnable version, including nested prefixes and a fallback route, is at [`examples/router`](../../examples/router).
26
26
 
27
27
  ## Concepts
28
28
 
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.
31
- - **`ctx.params`** — captured `:name` path segments, added once a route matches.
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())`.
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.
31
+ - **`ctx.params`**: captured `:name` path segments, added once a route matches.
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())`.
35
35
 
36
36
  ## API
37
37
 
38
- - `new RhythmRouter(options?)` — `options.prefix`.
39
- - `.get/.post/.put/.patch/.delete(path, ...handlers)` — register a route; `path` may contain `:param` segments.
40
- - `.use(fn)` — plain middleware; it takes only functions, so a nested router mounts as `.use(child.middleware())`.
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 — give the child its full prefix.
42
- - `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
- - `toFetchHandler(app)` — bridges a `Rhythm` app to a Web-standard `(Request) => Promise<Response>` handler.
38
+ - `new RhythmRouter(options?)`: `options.prefix`.
39
+ - `.get/.post/.put/.patch/.delete(path, ...handlers)`: register a route; `path` may contain `:param` segments.
40
+ - `.use(fn)`: plain middleware; it takes only functions, so a nested router mounts as `.use(child.middleware())`.
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
+ - `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
+ - `toFetchHandler(app)`: bridges a `Rhythm` app to a Web-standard `(Request) => Promise<Response>` handler.
44
44
 
45
- ## Serving
45
+ ## Serving: your Bun.serve, no wrapper
46
46
 
47
- Two primitives turn an app into a server, both coupled to [Bun](https://bun.com) on purpose:
47
+ 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
48
 
49
- - **`serve(app, options)`** (`@rhythmjs/router/serve`) — starts the app on `Bun.serve` and returns Bun's `Server` (`server.port`, `server.url`, `server.publish`, `server.stop()`).
50
- - **`toFetchHandler(app)`** (`@rhythmjs/router/fetch`) — the raw `(Request) => Promise<Response>` handler, for composing and testing without a listener.
49
+ - **`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
51
 
52
- `serve()` passes the server fields through to `Bun.serve` — `port`, `hostname`, `unix`, `tls`, `reusePort`, `idleTimeout`, `development`, `maxRequestBodySize` (an over-limit body is rejected without crashing) — plus the extension points below. Every request gets a lazy `request.ip` (from `server.requestIP()`), the field `@rhythmjs/security`'s rate limit and `@rhythmjs/http`'s proxy key off. Errors thrown in the middleware chain are answered with `500` (or the error's own `status`) without crashing the process; override the mapping with `options.error`.
52
+ Everything wired, explicitly:
53
53
 
54
- ### Static assets
54
+ ```ts
55
+ import { toFetchHandler, errorToResponse } from "@rhythmjs/router/fetch";
55
56
 
56
- The `static` option serves files with `Bun.file` before the app runs; a request no folder answers falls through to your routes. It takes one folder config or an array — folders are probed in order, first match wins:
57
+ const handler = toFetchHandler(app);
57
58
 
58
- ```ts
59
- serve(app, {
59
+ const server = Bun.serve({
60
60
  port: 3000,
61
- static: [{ dir: "dist/client", maxAge: 31536000, immutable: true }, { dir: "public" }],
61
+ async fetch(request, srv) {
62
+ // Optional: expose the client address as request.ip, the field
63
+ // @rhythmjs/security's rate limit and @rhythmjs/http's proxy key off.
64
+ Object.defineProperty(request, "ip", {
65
+ configurable: true,
66
+ get: () => srv.requestIP(request)?.address,
67
+ });
68
+ try {
69
+ return await handler(request);
70
+ } catch (error) {
71
+ return errorToResponse(error);
72
+ }
73
+ },
62
74
  });
63
75
  ```
64
76
 
65
- Each entry (`StaticMiddlewareOptions`, also exported from `@rhythmjs/router/static`) takes `dir`, `prefix` (mount point, default the site root), `index` (default `index.html`, served for directory paths), `maxAge`/`immutable` cache control, and `ETag` revalidation with `304`s (`etag`, on by default). Content types come from `Bun.file`. Path traversal is normalized away. `middleware` you pass runs before and wraps the static handlers, so CORS or logging cover asset responses too.
66
-
67
- ### Extending: CORS, WebSockets, and similar
68
-
69
- - **`middleware`** — serve middlewares (`(request, next) => Response`) run around the whole app, the natural place for CORS, logging, or auth gates:
77
+ Since `Bun.serve` is yours, all of Bun's server options (`port`, `hostname`, `unix`, `tls`, `idleTimeout`, `maxRequestBodySize`, `reusePort`, `development`, …) and the `Server` itself (`server.url`, `server.publish`, `server.stop()`) are used directly; nothing is proxied or renamed.
70
78
 
71
- ```ts
72
- serve(app, {
73
- middleware: [
74
- async (request, next) => {
75
- if (request.method === "OPTIONS")
76
- return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });
77
- const response = await next();
78
- response.headers.set("access-control-allow-origin", "*");
79
- return response;
80
- },
81
- ],
82
- });
83
- ```
79
+ ### Static files
84
80
 
85
- - **`upgrade` + `websocket`** — the seams for Bun's native WebSockets, shaped for [`@rhythmjs/ws`](https://github.com/rhythmjs/ws). Requests with an `upgrade: websocket` header divert to `upgrade(request, server)` before the app runs (return a `Response` to reject, or `undefined` after `server.upgrade()`); `websocket` is Bun's behavior object, passed through:
81
+ Use Bun's built-in `routes`; there is nothing to import:
86
82
 
87
- ```ts
88
- import { websocket } from "@rhythmjs/ws";
83
+ ```ts
84
+ const server = Bun.serve({
85
+ routes: {
86
+ "/": new Response(Bun.file("public/index.html")), // one known file
87
+ "/static/*": { dir: "./public" }, // a whole folder
88
+ },
89
+ fetch: toFetchHandler(app), // everything else is the app
90
+ });
91
+ ```
89
92
 
90
- serve(app, { upgrade: ws.upgrade, websocket: websocket() });
91
- ```
93
+ 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.
92
94
 
93
- - **`error`** — replace the default error-to-response mapping.
95
+ > **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"))`.
96
+ ### WebSockets
94
97
 
95
- Anything else fetch-shaped composes with the raw primitive: `toFetchHandler(app)` from `@rhythmjs/router/fetch`.
98
+ [`@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/fetch.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import type { Rhythm } from "@rhythmjs/rhythm";
2
2
  import { type RhythmHttpContext } from "./context";
3
3
  export declare function toFetchHandler<TContext extends RhythmHttpContext, TProviders extends object = {}>(app: Rhythm<RhythmHttpContext, TContext, TProviders>): (request: Request) => Promise<Response>;
4
+ export declare function errorToResponse(error: unknown): Response;
package/dist/fetch.js CHANGED
@@ -1,8 +1,32 @@
1
1
  // @bun
2
2
  import {
3
- toFetchHandler2
4
- } from "./rhythm-router-88dtq5mx.js";
5
- import"./rhythm-router-yj4ffyyg.js";
3
+ createHttpContext2
4
+ } from "./rhythm-router-yj4ffyyg.js";
5
+
6
+ // src/fetch.ts
7
+ function toFetchHandler(app) {
8
+ const run = app.callback();
9
+ return async (request) => {
10
+ const ctx = await run(createHttpContext2(request));
11
+ const response = ctx.response;
12
+ return new Response(response.body, {
13
+ status: response.status,
14
+ statusText: response.statusText,
15
+ headers: response.headers
16
+ });
17
+ };
18
+ }
19
+ 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);
22
+ if (status >= 500)
23
+ console.error(error);
24
+ return new Response(message, {
25
+ status,
26
+ headers: { "content-type": "text/plain; charset=utf-8" }
27
+ });
28
+ }
6
29
  export {
7
- toFetchHandler2 as toFetchHandler
30
+ errorToResponse,
31
+ toFetchHandler
8
32
  };
package/dist/static.d.ts CHANGED
@@ -1,10 +1,13 @@
1
- import type { ServeMiddleware } from "./serve";
2
- export interface StaticMiddlewareOptions {
1
+ export interface ServeStaticOptions {
3
2
  dir: string;
3
+ /** URL mount point (default the site root). */
4
4
  prefix?: string;
5
+ /** Directory index filename served for trailing-slash requests (default "index.html"). */
5
6
  index?: string;
7
+ /** Emit `Cache-Control: max-age=<n>`, with ", immutable" appended when `immutable` is set. */
6
8
  maxAge?: number;
7
9
  immutable?: boolean;
8
- etag?: boolean;
9
10
  }
10
- export declare function staticMiddleware(options: StaticMiddlewareOptions): ServeMiddleware;
11
+ /** A Response for a hit, null for a miss — chain with `??` into your app handler. */
12
+ export type StaticHandler = (request: Request) => Promise<Response | null>;
13
+ export declare function serveStatic(options: ServeStaticOptions): StaticHandler;
package/dist/static.js CHANGED
@@ -1,7 +1,67 @@
1
1
  // @bun
2
- import {
3
- staticMiddleware2
4
- } from "./rhythm-router-n08cmxbj.js";
2
+ // src/static.ts
3
+ import { join, normalize, sep } from "path";
4
+ function normalizePrefix(prefix) {
5
+ const withLeading = prefix.startsWith("/") ? prefix : `/${prefix}`;
6
+ return withLeading.endsWith("/") ? withLeading.slice(0, -1) : withLeading;
7
+ }
8
+ function serveStatic(options) {
9
+ const prefix = normalizePrefix(options.prefix ?? "/");
10
+ const index = options.index ?? "index.html";
11
+ const cacheControl = options.maxAge === undefined ? undefined : `max-age=${options.maxAge}${options.immutable ? ", immutable" : ""}`;
12
+ return async (request) => {
13
+ if (request.method !== "GET" && request.method !== "HEAD")
14
+ return null;
15
+ let pathname;
16
+ try {
17
+ pathname = decodeURIComponent(new URL(request.url).pathname);
18
+ } catch {
19
+ return null;
20
+ }
21
+ if (prefix !== "" && pathname !== prefix && !pathname.startsWith(`${prefix}/`))
22
+ return null;
23
+ const relative = normalize(pathname.slice(prefix.length));
24
+ if (relative.includes("\x00") || relative === ".." || relative.startsWith(`..${sep}`))
25
+ return null;
26
+ const path = pathname.endsWith("/") ? join(options.dir, relative, index) : join(options.dir, relative);
27
+ const file = Bun.file(path);
28
+ if (!await file.exists())
29
+ return null;
30
+ const size = file.size;
31
+ const mtime = Math.floor(file.lastModified);
32
+ const etag = `W/"${size.toString(16)}-${mtime.toString(16)}"`;
33
+ const headers = new Headers({
34
+ etag,
35
+ "last-modified": new Date(mtime).toUTCString(),
36
+ "accept-ranges": "bytes"
37
+ });
38
+ if (cacheControl !== undefined)
39
+ headers.set("cache-control", cacheControl);
40
+ const ifNoneMatch = request.headers.get("if-none-match");
41
+ if (ifNoneMatch !== null) {
42
+ if (ifNoneMatch.split(/\s*,\s*/).includes(etag))
43
+ return new Response(null, { status: 304, headers });
44
+ } else {
45
+ const ifModifiedSince = request.headers.get("if-modified-since");
46
+ if (ifModifiedSince !== null && Date.parse(ifModifiedSince) >= mtime - mtime % 1000) {
47
+ return new Response(null, { status: 304, headers });
48
+ }
49
+ }
50
+ const range = request.method === "GET" ? request.headers.get("range") : null;
51
+ const match = range === null ? null : /^bytes=(\d*)-(\d*)$/.exec(range);
52
+ if (match !== null && (match[1] !== "" || match[2] !== "")) {
53
+ const start = match[1] === "" ? Math.max(size - Number(match[2]), 0) : Number(match[1]);
54
+ const end = match[1] !== "" && match[2] !== "" ? Math.min(Number(match[2]), size - 1) : size - 1;
55
+ if (start >= size || start > end) {
56
+ headers.set("content-range", `bytes */${size}`);
57
+ return new Response(null, { status: 416, headers });
58
+ }
59
+ headers.set("content-range", `bytes ${start}-${end}/${size}`);
60
+ return new Response(file.slice(start, end + 1), { status: 206, headers });
61
+ }
62
+ return new Response(file, { headers });
63
+ };
64
+ }
5
65
  export {
6
- staticMiddleware2 as staticMiddleware
66
+ serveStatic
7
67
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhythmjs/router",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
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",
@@ -27,14 +27,6 @@
27
27
  "types": "./dist/fetch.d.ts",
28
28
  "default": "./dist/fetch.js"
29
29
  },
30
- "./serve": {
31
- "types": "./dist/serve.d.ts",
32
- "default": "./dist/serve.js"
33
- },
34
- "./static": {
35
- "types": "./dist/static.d.ts",
36
- "default": "./dist/static.js"
37
- },
38
30
  "./adapters/context": {
39
31
  "types": "./dist/context.d.ts",
40
32
  "default": "./dist/context.js"
@@ -46,7 +38,7 @@
46
38
  },
47
39
  "dependencies": {
48
40
  "rou3": "^0.11.0",
49
- "@rhythmjs/rhythm": "0.0.11"
41
+ "@rhythmjs/rhythm": "0.0.12"
50
42
  },
51
43
  "devDependencies": {
52
44
  "@types/bun": "^1.2.0",
@@ -57,7 +49,7 @@
57
49
  "bun": ">=1.2.0"
58
50
  },
59
51
  "scripts": {
60
- "build": "bun build src/rhythm-router.ts src/context.ts src/fetch.ts src/serve.ts src/static.ts --outdir dist --root src --format esm --target bun --packages external --splitting && tsc -p tsconfig.build.json",
52
+ "build": "bun build src/rhythm-router.ts src/context.ts src/fetch.ts --outdir dist --root src --format esm --target bun --packages external --splitting && tsc -p tsconfig.build.json",
61
53
  "typecheck": "tsc --noEmit",
62
54
  "test": "bun test"
63
55
  }