@rhythmjs/router 0.0.11 → 0.0.13
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 +55 -50
- package/dist/context.d.ts +1 -0
- package/dist/context.js +3 -1
- package/dist/fetch.d.ts +1 -0
- package/dist/fetch.js +31 -4
- package/dist/rhythm-router-48zw4cyy.js +73 -0
- package/dist/rhythm-router.d.ts +3 -3
- package/dist/rhythm-router.js +18 -5
- 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`, `derive`; `next()` takes no arguments, extend the context with `derive()`), 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,82 @@ 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)` (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
|
+
- **`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, 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 `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
|
-
-
|
|
43
|
-
- `
|
|
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
|
+
- `.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`.
|
|
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`).
|
|
44
|
+
- `toFetchHandler(app)`: bridges a `Rhythm` app to a Web-standard `(Request) => Promise<Response>` handler.
|
|
44
45
|
|
|
45
|
-
## Serving
|
|
46
|
+
## Serving: your Bun.serve, no wrapper
|
|
46
47
|
|
|
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
|
-
- **`
|
|
50
|
+
- **`toFetchHandler(app)`** (`@rhythmjs/router/fetch`): the app as a `(Request) => Promise<Response>` handler.
|
|
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
|
|
|
54
|
-
|
|
55
|
+
```ts
|
|
56
|
+
import { toFetchHandler, errorToResponse } from "@rhythmjs/router/fetch";
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
const handler = toFetchHandler(app);
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
serve(app, {
|
|
60
|
+
const server = Bun.serve({
|
|
60
61
|
port: 3000,
|
|
61
|
-
|
|
62
|
+
async fetch(request, srv) {
|
|
63
|
+
// Optional: expose the client address as request.ip, the field
|
|
64
|
+
// @rhythmjs/security's rate limit and @rhythmjs/http's proxy key off.
|
|
65
|
+
Object.defineProperty(request, "ip", {
|
|
66
|
+
configurable: true,
|
|
67
|
+
get: () => srv.requestIP(request)?.address,
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
return await handler(request);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return errorToResponse(error);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
62
75
|
});
|
|
63
76
|
```
|
|
64
77
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
### Extending: CORS, WebSockets, and similar
|
|
78
|
+
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.
|
|
68
79
|
|
|
69
|
-
|
|
80
|
+
### Static files
|
|
70
81
|
|
|
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
|
-
```
|
|
82
|
+
Use Bun's built-in `routes`; there is nothing to import:
|
|
84
83
|
|
|
85
|
-
|
|
84
|
+
```ts
|
|
85
|
+
const server = Bun.serve({
|
|
86
|
+
routes: {
|
|
87
|
+
"/": new Response(Bun.file("public/index.html")), // one known file
|
|
88
|
+
"/static/*": { dir: "./public" }, // a whole folder
|
|
89
|
+
},
|
|
90
|
+
fetch: toFetchHandler(app), // everything else is the app
|
|
91
|
+
});
|
|
92
|
+
```
|
|
86
93
|
|
|
87
|
-
|
|
88
|
-
import { websocket } from "@rhythmjs/ws";
|
|
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.
|
|
89
95
|
|
|
90
|
-
|
|
91
|
-
```
|
|
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"))`.
|
|
92
97
|
|
|
93
|
-
|
|
98
|
+
### WebSockets
|
|
94
99
|
|
|
95
|
-
|
|
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-
|
|
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,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,35 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
STATUS_TEXT2,
|
|
4
|
+
createHttpContext2
|
|
5
|
+
} from "./rhythm-router-48zw4cyy.js";
|
|
6
|
+
|
|
7
|
+
// src/fetch.ts
|
|
8
|
+
function toFetchHandler(app) {
|
|
9
|
+
const run = app.callback();
|
|
10
|
+
return async (request) => {
|
|
11
|
+
const ctx = await run(createHttpContext2(request));
|
|
12
|
+
const response = ctx.response;
|
|
13
|
+
return new Response(response.body, {
|
|
14
|
+
status: response.status,
|
|
15
|
+
statusText: response.statusText,
|
|
16
|
+
headers: response.headers
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function errorToResponse(error) {
|
|
21
|
+
const declared = error.status ?? error.statusCode;
|
|
22
|
+
const status = Number.isInteger(declared) && declared >= 400 && declared <= 599 ? declared : 500;
|
|
23
|
+
if (status >= 500)
|
|
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}`;
|
|
27
|
+
return new Response(message, {
|
|
28
|
+
status,
|
|
29
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
30
|
+
});
|
|
31
|
+
}
|
|
6
32
|
export {
|
|
7
|
-
|
|
33
|
+
errorToResponse,
|
|
34
|
+
toFetchHandler
|
|
8
35
|
};
|
|
@@ -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 };
|
package/dist/rhythm-router.d.ts
CHANGED
|
@@ -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<
|
|
37
|
+
middleware(): Middleware<TInput>;
|
|
38
38
|
}
|
|
39
39
|
export {};
|
package/dist/rhythm-router.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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)
|
|
@@ -16,7 +17,7 @@ class RhythmRouter {
|
|
|
16
17
|
constructor(options = {}) {
|
|
17
18
|
this.#options = options;
|
|
18
19
|
}
|
|
19
|
-
get #
|
|
20
|
+
get #prefixPath() {
|
|
20
21
|
return this.#options.prefix ?? "";
|
|
21
22
|
}
|
|
22
23
|
get entries() {
|
|
@@ -29,7 +30,7 @@ class RhythmRouter {
|
|
|
29
30
|
return this;
|
|
30
31
|
}
|
|
31
32
|
#route(method, path, handlers) {
|
|
32
|
-
this.#entries.push({ kind: "route", method, path: joinPath(this.#
|
|
33
|
+
this.#entries.push({ kind: "route", method, path: joinPath(this.#prefixPath, path), handlers });
|
|
33
34
|
return this;
|
|
34
35
|
}
|
|
35
36
|
get(path, ...handlers) {
|
|
@@ -58,12 +59,23 @@ class RhythmRouter {
|
|
|
58
59
|
await match.data({ ...ctx, params: match.params ?? {} }, next);
|
|
59
60
|
};
|
|
60
61
|
};
|
|
62
|
+
const trees = [];
|
|
63
|
+
const reaches = (ctx, from) => {
|
|
64
|
+
const method = ctx.request.method;
|
|
65
|
+
const pathname = new URL(ctx.request.url).pathname;
|
|
66
|
+
for (let t = from;t < trees.length; t++)
|
|
67
|
+
if (findRoute(trees[t], method, pathname))
|
|
68
|
+
return true;
|
|
69
|
+
return false;
|
|
70
|
+
};
|
|
61
71
|
const stack = [];
|
|
62
72
|
let i = 0;
|
|
63
73
|
while (i < this.#entries.length) {
|
|
64
74
|
const entry = this.#entries[i];
|
|
65
75
|
if (entry.kind === "middleware") {
|
|
66
|
-
|
|
76
|
+
const { fn } = entry;
|
|
77
|
+
const from = trees.length;
|
|
78
|
+
stack.push(sourceOf(fn) ? fn : (ctx, next) => reaches(ctx, from) ? fn(ctx, next) : next());
|
|
67
79
|
i++;
|
|
68
80
|
continue;
|
|
69
81
|
}
|
|
@@ -75,15 +87,16 @@ class RhythmRouter {
|
|
|
75
87
|
addRoute(tree, route.method, route.path, compose(route.handlers));
|
|
76
88
|
i++;
|
|
77
89
|
}
|
|
90
|
+
trees.push(tree);
|
|
78
91
|
stack.push(dispatchFor(tree));
|
|
79
92
|
}
|
|
80
93
|
return compose(stack);
|
|
81
94
|
}
|
|
82
95
|
middleware() {
|
|
83
96
|
const fn = this.#compile();
|
|
84
|
-
return async (ctx, next) => {
|
|
97
|
+
return withSource(async (ctx, next) => {
|
|
85
98
|
await fn(ctx, next);
|
|
86
|
-
};
|
|
99
|
+
}, this);
|
|
87
100
|
}
|
|
88
101
|
}
|
|
89
102
|
export {
|
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.13",
|
|
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.14"
|
|
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
|
}
|