@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 +54 -51
- package/dist/fetch.d.ts +1 -0
- package/dist/fetch.js +28 -4
- package/dist/static.d.ts +7 -4
- package/dist/static.js +64 -4
- package/package.json +3 -11
package/README.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# @rhythmjs/router
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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 {
|
|
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(
|
|
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`)
|
|
30
|
-
- **Response helpers
|
|
31
|
-
- **`ctx.params
|
|
32
|
-
- **Nesting is `.use(child.middleware())
|
|
33
|
-
- **Registration order is execution order
|
|
34
|
-
- **A router is a controller, not a module
|
|
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?)
|
|
39
|
-
- `.get/.post/.put/.patch/.delete(path, ...handlers)
|
|
40
|
-
- `.use(fn)
|
|
41
|
-
- `.middleware()
|
|
42
|
-
- `ctx.json/.text/.html(body, status?)`, `ctx.error(status, message?)`, `ctx.redirect(url, status?)
|
|
43
|
-
- `toFetchHandler(app)
|
|
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
|
-
|
|
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
|
-
- **`
|
|
50
|
-
- **`
|
|
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
|
-
|
|
52
|
+
Everything wired, explicitly:
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
```ts
|
|
55
|
+
import { toFetchHandler, errorToResponse } from "@rhythmjs/router/fetch";
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
const handler = toFetchHandler(app);
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
serve(app, {
|
|
59
|
+
const server = Bun.serve({
|
|
60
60
|
port: 3000,
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
+
Use Bun's built-in `routes`; there is nothing to import:
|
|
86
82
|
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
} from "./rhythm-router-
|
|
5
|
-
|
|
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
|
-
|
|
30
|
+
errorToResponse,
|
|
31
|
+
toFetchHandler
|
|
8
32
|
};
|
package/dist/static.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
66
|
+
serveStatic
|
|
7
67
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhythmjs/router",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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
|
|
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
|
}
|