xanrouter 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +210 -2
- package/dist/express.d.ts +7 -0
- package/dist/express.js +38 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +314 -0
- package/package.json +65 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Naxrul Ahmed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,211 @@
|
|
|
1
|
-
#
|
|
1
|
+
# xanrouter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tiny router for the web-standard `Request` / `Response` API. Middleware, params, cookies, response headers, static files and a request-scoped context, in one small file. Works on Node 18+, Bun and Deno.
|
|
4
|
+
|
|
5
|
+
- One dependency: [`regexparam`](https://github.com/lukeed/regexparam)
|
|
6
|
+
- Handlers take a single argument: `next`
|
|
7
|
+
- Request context from anywhere with `route.context()`
|
|
8
|
+
- Express adapter included (`xanrouter/express`)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm i xanrouter
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { Router } from "xanrouter";
|
|
20
|
+
|
|
21
|
+
const route = Router();
|
|
22
|
+
|
|
23
|
+
route.use((next) => {
|
|
24
|
+
// runs for every request
|
|
25
|
+
return next();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
route.use("/api", (next) => {
|
|
29
|
+
// runs for /api and everything under it
|
|
30
|
+
return next();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
route.get("/", () => new Response("Hello World"));
|
|
34
|
+
|
|
35
|
+
route.get("/users/:id", () => {
|
|
36
|
+
const { params } = route.context();
|
|
37
|
+
return Response.json({ id: params.id });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const res = await route.fetch(new Request("http://localhost/users/42"));
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Use as a server
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
export default route; // Bun, Cloudflare Workers, `deno serve`
|
|
47
|
+
Bun.serve(route); // Bun
|
|
48
|
+
Deno.serve((req) => route.fetch(req)); // Deno
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Routes
|
|
52
|
+
|
|
53
|
+
`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `all`.
|
|
54
|
+
`HEAD` requests fall through to `get` routes. Unmatched requests return `404`; override with `route.notFound(handler)`.
|
|
55
|
+
|
|
56
|
+
Patterns come from `regexparam`:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
route.get("/posts/:id?", ...) // optional param
|
|
60
|
+
route.get("/files/*", ...) // wildcard -> route.context().params["*"]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Params are URL-decoded.
|
|
64
|
+
|
|
65
|
+
## Middleware
|
|
66
|
+
|
|
67
|
+
A handler either returns a `Response` (stops the chain) or calls `next()`.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// run code before and after the route
|
|
71
|
+
route.use(async (next) => {
|
|
72
|
+
const t = Date.now();
|
|
73
|
+
const res = await next(); // Response, or undefined if no route matched
|
|
74
|
+
res?.headers.set("x-time", String(Date.now() - t));
|
|
75
|
+
return res;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// auth on a prefix
|
|
79
|
+
route.use("/admin", (next) => {
|
|
80
|
+
if (route.context().request.headers.get("authorization") !== "Bearer secret")
|
|
81
|
+
return new Response("Unauthorized", { status: 401 });
|
|
82
|
+
return next();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// error handling (uncaught errors otherwise become a plain 500)
|
|
86
|
+
route.use(async (next) => {
|
|
87
|
+
try { return await next(); }
|
|
88
|
+
catch (e) { return Response.json({ error: (e as Error).message }, { status: 500 }); }
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If a middleware calls `next()` without returning it, the downstream response is still used.
|
|
93
|
+
|
|
94
|
+
## Context
|
|
95
|
+
|
|
96
|
+
`route.context()` returns the context of the request being handled. It is correct across `await` and overlapping requests when `AsyncLocalStorage` is available (Node, Bun, Deno, Cloudflare Workers with `nodejs_compat`).
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const ctx = route.context();
|
|
100
|
+
ctx.pathname; // "/users/42"
|
|
101
|
+
ctx.method; // "GET"
|
|
102
|
+
ctx.params; // { id: "42" }
|
|
103
|
+
ctx.query; // URLSearchParams
|
|
104
|
+
ctx.request; // the original Request
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Calling it outside a request throws.
|
|
108
|
+
|
|
109
|
+
> **No `AsyncLocalStorage`?** The router falls back to a global "current request". It is only reliable before the first `await` in a handler, so read `route.context()` first and keep the result in a variable.
|
|
110
|
+
|
|
111
|
+
### Response headers
|
|
112
|
+
|
|
113
|
+
`ctx.headers()` is a standard `Headers` object for the **response**. It is merged into whatever `Response` your handler returns, and wins over a header with the same name on that response. (Request headers: `ctx.request.headers`.)
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
route.use((next) => {
|
|
117
|
+
route.context().headers().set("x-request-id", crypto.randomUUID());
|
|
118
|
+
return next();
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Cookies
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const cookies = route.context().cookies();
|
|
126
|
+
|
|
127
|
+
cookies.get("theme"); // from the request Cookie header
|
|
128
|
+
cookies.has("sid");
|
|
129
|
+
cookies.all(); // { theme: "dark", ... }
|
|
130
|
+
|
|
131
|
+
cookies.set("sid", "abc123", {
|
|
132
|
+
httpOnly: true,
|
|
133
|
+
secure: true,
|
|
134
|
+
sameSite: "lax", // "lax" | "strict" | "none"
|
|
135
|
+
maxAge: 3600, // seconds
|
|
136
|
+
// path (default "/"), domain, expires, partitioned
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
cookies.delete("old"); // sends an expired cookie (pass the same path/domain it was set with)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`get` / `has` / `all` reflect changes made earlier in the same request. Setting the same cookie twice sends only the last one. Invalid cookie names, or paths/domains containing `;` or line breaks, throw. `sameSite: "none"` is not checked for `secure`: browsers require it.
|
|
143
|
+
|
|
144
|
+
Headers and cookies are applied to your handler's response, a custom `notFound` response, and immutable responses such as `Response.redirect()`. A request that ends in an uncaught error gets a plain 500 without them.
|
|
145
|
+
|
|
146
|
+
## Static files
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
route.static("./public"); // serve ./public at /
|
|
150
|
+
route.static("./dist", { prefix: "/assets", maxAge: 31536000, immutable: true });
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
When no file matches, the request continues to the next route. Directories serve `index.html`. Supports `ETag` / `If-None-Match` (304), `Range` requests, `HEAD`, and about 30 common content types. Path traversal is blocked, and dotfiles are hidden.
|
|
154
|
+
|
|
155
|
+
| Option | Meaning | Default |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `prefix` | URL prefix to mount under | `"/"` |
|
|
158
|
+
| `index` | Directory index file, or `false` | `"index.html"` |
|
|
159
|
+
| `dotfiles` | Serve files/folders starting with `.` | `false` |
|
|
160
|
+
| `maxAge` | `Cache-Control: max-age` (seconds) | `0` |
|
|
161
|
+
| `sMaxAge` | `s-maxage` (CDNs) | none |
|
|
162
|
+
| `immutable` | add `immutable` | `false` |
|
|
163
|
+
| `visibility` | `"public"` or `"private"` | `"public"` |
|
|
164
|
+
| `noCache` | add `no-cache` | `false` |
|
|
165
|
+
| `noStore` | send only `no-store` | `false` |
|
|
166
|
+
| `mustRevalidate` | add `must-revalidate` | `false` |
|
|
167
|
+
| `staleWhileRevalidate` | `stale-while-revalidate` (seconds) | none |
|
|
168
|
+
|
|
169
|
+
`route.static` needs Node-style `fs` (Node, Bun, Deno). It is not available on Cloudflare Workers or in the browser. Symlinks inside the served folder are followed.
|
|
170
|
+
|
|
171
|
+
## Express
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import express from "express";
|
|
175
|
+
import { Router } from "xanrouter";
|
|
176
|
+
import { expressAdapter } from "xanrouter/express";
|
|
177
|
+
|
|
178
|
+
const route = Router();
|
|
179
|
+
route.get("/hello/:name", () => new Response("hi " + route.context().params.name));
|
|
180
|
+
|
|
181
|
+
const app = express();
|
|
182
|
+
app.use(expressAdapter(route, { fallthrough: true })); // 404s continue to later Express handlers
|
|
183
|
+
app.use((req, res) => res.status(404).send("express 404"));
|
|
184
|
+
app.listen(3000);
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- Don't put `express.json()` before the adapter; it would consume the request body first.
|
|
188
|
+
- Mounted under a prefix (`app.use("/api", ...)`), your routes must include the prefix: `route.get("/api/users", ...)`.
|
|
189
|
+
- Node 18+, Express 4 or 5.
|
|
190
|
+
|
|
191
|
+
## API
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
Router(): Router
|
|
195
|
+
|
|
196
|
+
route.use(handler)
|
|
197
|
+
route.use(path, handler) // prefix match
|
|
198
|
+
route.get | post | put | patch | delete | head | options | all (path, handler)
|
|
199
|
+
route.static(dir, options?)
|
|
200
|
+
route.notFound(handler)
|
|
201
|
+
route.context(): Context
|
|
202
|
+
route.fetch(request: Request): Promise<Response>
|
|
203
|
+
|
|
204
|
+
type Handler = (next: () => Promise<Response | undefined>) => Response | void | Promise<Response | void>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
All registration methods return the router, so calls can be chained.
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Router } from "./index.js";
|
|
2
|
+
export interface AdapterOptions {
|
|
3
|
+
/** On a 404 from the router, call next() so later Express handlers run. Default false. */
|
|
4
|
+
fallthrough?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/** Express (4/5) middleware that serves a Router. */
|
|
7
|
+
export declare function expressAdapter(route: Router, { fallthrough }?: AdapterOptions): (req: any, res: any, next: (err?: unknown) => void) => Promise<void>;
|
package/dist/express.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
/** Express (4/5) middleware that serves a Router. */
|
|
3
|
+
export function expressAdapter(route, { fallthrough = false } = {}) {
|
|
4
|
+
return async (req, res, next) => {
|
|
5
|
+
try {
|
|
6
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
7
|
+
const headers = new Headers();
|
|
8
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
9
|
+
if (Array.isArray(v))
|
|
10
|
+
v.forEach((x) => headers.append(k, x));
|
|
11
|
+
else if (v !== undefined)
|
|
12
|
+
headers.set(k, String(v));
|
|
13
|
+
}
|
|
14
|
+
const request = new Request(`${req.protocol}://${req.get("host")}${req.originalUrl}`, {
|
|
15
|
+
method: req.method,
|
|
16
|
+
headers,
|
|
17
|
+
body: hasBody ? Readable.toWeb(req) : undefined,
|
|
18
|
+
duplex: "half",
|
|
19
|
+
});
|
|
20
|
+
const response = await route.fetch(request);
|
|
21
|
+
if (fallthrough && response.status === 404)
|
|
22
|
+
return next();
|
|
23
|
+
res.status(response.status);
|
|
24
|
+
response.headers.forEach((v, k) => { if (k !== "set-cookie")
|
|
25
|
+
res.setHeader(k, v); });
|
|
26
|
+
const cookies = response.headers.getSetCookie();
|
|
27
|
+
if (cookies.length)
|
|
28
|
+
res.setHeader("set-cookie", cookies);
|
|
29
|
+
if (response.body)
|
|
30
|
+
Readable.fromWeb(response.body).pipe(res);
|
|
31
|
+
else
|
|
32
|
+
res.end();
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
next(err);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
2
|
+
export type Next = () => Promise<Response | undefined>;
|
|
3
|
+
export interface CookieOptions {
|
|
4
|
+
/** Default "/". */
|
|
5
|
+
path?: string;
|
|
6
|
+
domain?: string;
|
|
7
|
+
/** Seconds. */
|
|
8
|
+
maxAge?: number;
|
|
9
|
+
expires?: Date;
|
|
10
|
+
httpOnly?: boolean;
|
|
11
|
+
secure?: boolean;
|
|
12
|
+
sameSite?: "lax" | "strict" | "none";
|
|
13
|
+
partitioned?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface Cookies {
|
|
16
|
+
/** Request cookie, or the value set/deleted earlier in this request. */
|
|
17
|
+
get(name: string): string | undefined;
|
|
18
|
+
has(name: string): boolean;
|
|
19
|
+
/** All cookies as the client will see them after this response. */
|
|
20
|
+
all(): Record<string, string>;
|
|
21
|
+
/** Queue a Set-Cookie on the response. */
|
|
22
|
+
set(name: string, value: string, options?: CookieOptions): Cookies;
|
|
23
|
+
/** Queue an expiring Set-Cookie. Pass the same path/domain the cookie was set with. */
|
|
24
|
+
delete(name: string, options?: Pick<CookieOptions, "path" | "domain">): Cookies;
|
|
25
|
+
}
|
|
26
|
+
export interface Context {
|
|
27
|
+
pathname: string;
|
|
28
|
+
method: string;
|
|
29
|
+
params: Record<string, string>;
|
|
30
|
+
request: Request;
|
|
31
|
+
query: URLSearchParams;
|
|
32
|
+
/** Read request cookies and queue response cookies. */
|
|
33
|
+
cookies(): Cookies;
|
|
34
|
+
/** RESPONSE headers: merged into whatever Response the handler returns. (Request headers: ctx.request.headers) */
|
|
35
|
+
headers(): Headers;
|
|
36
|
+
}
|
|
37
|
+
type Result = Response | void | undefined | Promise<Response | void | undefined>;
|
|
38
|
+
export type Handler = (next: Next) => Result;
|
|
39
|
+
export interface StaticOptions {
|
|
40
|
+
/** URL prefix to mount under (default "/"). e.g. "/assets" */
|
|
41
|
+
prefix?: string;
|
|
42
|
+
/** Index file for directories (default "index.html"; false disables). */
|
|
43
|
+
index?: string | false;
|
|
44
|
+
/** Cache-Control max-age in seconds (default 0). */
|
|
45
|
+
maxAge?: number;
|
|
46
|
+
/** Cache-Control s-maxage in seconds (shared caches / CDNs). */
|
|
47
|
+
sMaxAge?: number;
|
|
48
|
+
/** Add "immutable": file never changes, skip revalidation (use with hashed filenames). */
|
|
49
|
+
immutable?: boolean;
|
|
50
|
+
/** "public" (default) or "private". */
|
|
51
|
+
visibility?: "public" | "private";
|
|
52
|
+
/** Add "no-cache": store, but revalidate every time. */
|
|
53
|
+
noCache?: boolean;
|
|
54
|
+
/** Send only "no-store": never cache. Overrides every other cache option. */
|
|
55
|
+
noStore?: boolean;
|
|
56
|
+
/** Add "must-revalidate". */
|
|
57
|
+
mustRevalidate?: boolean;
|
|
58
|
+
/** Add "stale-while-revalidate=<seconds>". */
|
|
59
|
+
staleWhileRevalidate?: number;
|
|
60
|
+
/** Serve files/folders starting with "." (default false). */
|
|
61
|
+
dotfiles?: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface Router {
|
|
64
|
+
use(handler: Handler): Router;
|
|
65
|
+
use(path: string, handler: Handler): Router;
|
|
66
|
+
get(path: string, handler: Handler): Router;
|
|
67
|
+
post(path: string, handler: Handler): Router;
|
|
68
|
+
put(path: string, handler: Handler): Router;
|
|
69
|
+
patch(path: string, handler: Handler): Router;
|
|
70
|
+
delete(path: string, handler: Handler): Router;
|
|
71
|
+
head(path: string, handler: Handler): Router;
|
|
72
|
+
options(path: string, handler: Handler): Router;
|
|
73
|
+
all(path: string, handler: Handler): Router;
|
|
74
|
+
/** Serve static files from a directory. Falls through to next() when no file matches. */
|
|
75
|
+
static(dir: string, options?: StaticOptions): Router;
|
|
76
|
+
notFound(handler: Handler): Router;
|
|
77
|
+
/** Context of the request currently being handled (safe under concurrency). */
|
|
78
|
+
context(): Context;
|
|
79
|
+
/** Handle a web Request; method and path are read from it. */
|
|
80
|
+
fetch(request: Request): Promise<Response>;
|
|
81
|
+
}
|
|
82
|
+
export declare function Router(): Router;
|
|
83
|
+
export default Router;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { parse } from "regexparam";
|
|
2
|
+
const ANY = /^\/?.*$/;
|
|
3
|
+
let store;
|
|
4
|
+
try {
|
|
5
|
+
const g = globalThis;
|
|
6
|
+
const mod = g.process?.getBuiltinModule?.("node:async_hooks") ??
|
|
7
|
+
(await import(/* @vite-ignore */ /* webpackIgnore: true */ ["node:async", "hooks"].join("_")));
|
|
8
|
+
store = new mod.AsyncLocalStorage();
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
store = undefined;
|
|
12
|
+
}
|
|
13
|
+
let current;
|
|
14
|
+
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
15
|
+
const UNSAFE_ATTR = /[;\r\n]/;
|
|
16
|
+
function parseCookies(header) {
|
|
17
|
+
const out = Object.create(null);
|
|
18
|
+
if (!header)
|
|
19
|
+
return out;
|
|
20
|
+
for (const part of header.split(";")) {
|
|
21
|
+
const eq = part.indexOf("=");
|
|
22
|
+
if (eq < 0)
|
|
23
|
+
continue;
|
|
24
|
+
const name = part.slice(0, eq).trim();
|
|
25
|
+
if (!name || name in out)
|
|
26
|
+
continue; // first occurrence wins
|
|
27
|
+
let v = part.slice(eq + 1).trim();
|
|
28
|
+
if (v.length > 1 && v.startsWith('"') && v.endsWith('"'))
|
|
29
|
+
v = v.slice(1, -1);
|
|
30
|
+
try {
|
|
31
|
+
out[name] = decodeURIComponent(v);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
out[name] = v;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
function serializeCookie(name, value, o) {
|
|
40
|
+
if (!TOKEN.test(name))
|
|
41
|
+
throw new TypeError(`Invalid cookie name: ${JSON.stringify(name)}`);
|
|
42
|
+
const path = o.path ?? "/";
|
|
43
|
+
if (UNSAFE_ATTR.test(path) || (o.domain && UNSAFE_ATTR.test(o.domain)))
|
|
44
|
+
throw new TypeError("Invalid cookie path/domain");
|
|
45
|
+
let c = `${name}=${encodeURIComponent(value)}; Path=${path}`;
|
|
46
|
+
if (o.domain)
|
|
47
|
+
c += `; Domain=${o.domain}`;
|
|
48
|
+
if (o.maxAge !== undefined)
|
|
49
|
+
c += `; Max-Age=${Math.floor(o.maxAge)}`;
|
|
50
|
+
if (o.expires)
|
|
51
|
+
c += `; Expires=${o.expires.toUTCString()}`;
|
|
52
|
+
if (o.httpOnly)
|
|
53
|
+
c += "; HttpOnly";
|
|
54
|
+
if (o.secure)
|
|
55
|
+
c += "; Secure";
|
|
56
|
+
if (o.sameSite)
|
|
57
|
+
c += `; SameSite=${o.sameSite[0].toUpperCase()}${o.sameSite.slice(1)}`;
|
|
58
|
+
if (o.partitioned)
|
|
59
|
+
c += "; Partitioned";
|
|
60
|
+
return c;
|
|
61
|
+
}
|
|
62
|
+
function createCookies(header) {
|
|
63
|
+
let base;
|
|
64
|
+
const read = () => (base ??= parseCookies(header));
|
|
65
|
+
const view = new Map(); // undefined = deleted this request
|
|
66
|
+
const out = new Map(); // name;domain;path -> Set-Cookie (later set replaces earlier)
|
|
67
|
+
const key = (n, o) => `${n};${o.domain ?? ""};${o.path ?? "/"}`;
|
|
68
|
+
const jar = {
|
|
69
|
+
get: (n) => (view.has(n) ? view.get(n) : read()[n]),
|
|
70
|
+
has: (n) => jar.get(n) !== undefined,
|
|
71
|
+
all: () => {
|
|
72
|
+
const o = { ...read() };
|
|
73
|
+
for (const [k, v] of view)
|
|
74
|
+
v === undefined ? delete o[k] : (o[k] = v);
|
|
75
|
+
return o;
|
|
76
|
+
},
|
|
77
|
+
set: (n, v, o = {}) => (out.set(key(n, o), serializeCookie(n, v, o)), view.set(n, v), jar),
|
|
78
|
+
delete: (n, o = {}) => (out.set(key(n, o), serializeCookie(n, "", { ...o, maxAge: 0, expires: new Date(0) })), view.set(n, undefined), jar),
|
|
79
|
+
_out: () => [...out.values()],
|
|
80
|
+
};
|
|
81
|
+
return jar;
|
|
82
|
+
}
|
|
83
|
+
const MIME = {
|
|
84
|
+
html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", css: "text/css; charset=utf-8",
|
|
85
|
+
js: "text/javascript; charset=utf-8", mjs: "text/javascript; charset=utf-8", json: "application/json; charset=utf-8",
|
|
86
|
+
map: "application/json; charset=utf-8", txt: "text/plain; charset=utf-8", csv: "text/csv; charset=utf-8",
|
|
87
|
+
xml: "application/xml; charset=utf-8", svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg",
|
|
88
|
+
jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", ico: "image/x-icon",
|
|
89
|
+
woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf", mp4: "video/mp4",
|
|
90
|
+
webm: "video/webm", mp3: "audio/mpeg", wav: "audio/wav", pdf: "application/pdf", wasm: "application/wasm",
|
|
91
|
+
};
|
|
92
|
+
// Lazy Node builtin loader: keeps the router importable where node: modules don't exist.
|
|
93
|
+
const nodeMod = async (name) => globalThis.process?.getBuiltinModule?.(name) ??
|
|
94
|
+
(await import(/* @vite-ignore */ /* webpackIgnore: true */ name));
|
|
95
|
+
export function Router() {
|
|
96
|
+
const layers = [];
|
|
97
|
+
let notFoundHandler = () => new Response("Not Found", { status: 404 });
|
|
98
|
+
const add = (method, path, handler, loose) => {
|
|
99
|
+
if (path === null) {
|
|
100
|
+
layers.push({ method, keys: [], pattern: ANY, handler });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const { keys, pattern } = parse(path, loose);
|
|
104
|
+
layers.push({ method, keys, pattern, handler });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const router = {};
|
|
108
|
+
router.use = ((a, b) => {
|
|
109
|
+
typeof a === "function" ? add(null, null, a, true) : add(null, a, b, true);
|
|
110
|
+
return router;
|
|
111
|
+
});
|
|
112
|
+
const verb = (m) => (path, handler) => {
|
|
113
|
+
add(m, path, handler, false);
|
|
114
|
+
return router;
|
|
115
|
+
};
|
|
116
|
+
router.get = verb("GET");
|
|
117
|
+
router.post = verb("POST");
|
|
118
|
+
router.put = verb("PUT");
|
|
119
|
+
router.patch = verb("PATCH");
|
|
120
|
+
router.delete = verb("DELETE");
|
|
121
|
+
router.head = verb("HEAD");
|
|
122
|
+
router.options = verb("OPTIONS");
|
|
123
|
+
router.all = (path, handler) => (add("*", path, handler, false), router);
|
|
124
|
+
router.static = (dir, options = {}) => {
|
|
125
|
+
const index = options.index === undefined ? "index.html" : options.index;
|
|
126
|
+
const cc = [];
|
|
127
|
+
if (options.noStore)
|
|
128
|
+
cc.push("no-store");
|
|
129
|
+
else {
|
|
130
|
+
cc.push(options.visibility ?? "public");
|
|
131
|
+
if (options.noCache)
|
|
132
|
+
cc.push("no-cache");
|
|
133
|
+
cc.push(`max-age=${options.maxAge ?? 0}`);
|
|
134
|
+
if (options.sMaxAge !== undefined)
|
|
135
|
+
cc.push(`s-maxage=${options.sMaxAge}`);
|
|
136
|
+
if (options.staleWhileRevalidate !== undefined)
|
|
137
|
+
cc.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
|
|
138
|
+
if (options.mustRevalidate)
|
|
139
|
+
cc.push("must-revalidate");
|
|
140
|
+
if (options.immutable)
|
|
141
|
+
cc.push("immutable");
|
|
142
|
+
}
|
|
143
|
+
const cacheControl = cc.join(", ");
|
|
144
|
+
let prefix = "/" + (options.prefix ?? "").replace(/^\/+|\/+$/g, "");
|
|
145
|
+
if (prefix === "/")
|
|
146
|
+
prefix = "";
|
|
147
|
+
add("GET", prefix || null, async (next) => {
|
|
148
|
+
const { pathname, method, request } = router.context(); // read before any await (fallback-safe)
|
|
149
|
+
const [fs, path] = await Promise.all([nodeMod("node:fs"), nodeMod("node:path")]);
|
|
150
|
+
let rel;
|
|
151
|
+
try {
|
|
152
|
+
rel = decodeURIComponent(pathname.slice(prefix.length));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return next();
|
|
156
|
+
}
|
|
157
|
+
if (rel.includes("\0"))
|
|
158
|
+
return next();
|
|
159
|
+
if (!options.dotfiles && rel.split(/[\\/]/).some((seg) => seg.startsWith(".")))
|
|
160
|
+
return next();
|
|
161
|
+
const root = path.resolve(dir);
|
|
162
|
+
let file = path.resolve(root, "." + path.sep + rel);
|
|
163
|
+
if (file !== root && !file.startsWith(root + path.sep))
|
|
164
|
+
return next(); // path traversal guard
|
|
165
|
+
let st = await fs.promises.stat(file).catch(() => null);
|
|
166
|
+
if (st?.isDirectory()) {
|
|
167
|
+
if (!index)
|
|
168
|
+
return next();
|
|
169
|
+
file = path.join(file, index);
|
|
170
|
+
st = await fs.promises.stat(file).catch(() => null);
|
|
171
|
+
}
|
|
172
|
+
if (!st?.isFile())
|
|
173
|
+
return next();
|
|
174
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
175
|
+
const headers = new Headers({
|
|
176
|
+
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
|
177
|
+
"Accept-Ranges": "bytes",
|
|
178
|
+
"Cache-Control": cacheControl,
|
|
179
|
+
"Last-Modified": st.mtime.toUTCString(),
|
|
180
|
+
ETag: `W/"${st.size.toString(16)}-${Math.floor(st.mtimeMs).toString(16)}"`,
|
|
181
|
+
});
|
|
182
|
+
if (request.headers.get("if-none-match") === headers.get("ETag"))
|
|
183
|
+
return new Response(null, { status: 304, headers });
|
|
184
|
+
let status = 200, start = 0, end = st.size - 1;
|
|
185
|
+
const range = request.headers.get("range");
|
|
186
|
+
if (range) {
|
|
187
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
188
|
+
if (m && (m[1] || m[2])) {
|
|
189
|
+
if (m[1]) {
|
|
190
|
+
start = +m[1];
|
|
191
|
+
if (m[2])
|
|
192
|
+
end = Math.min(+m[2], end);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
start = Math.max(st.size - +m[2], 0);
|
|
196
|
+
}
|
|
197
|
+
if (start > end || start >= st.size) {
|
|
198
|
+
return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${st.size}` } });
|
|
199
|
+
}
|
|
200
|
+
status = 206;
|
|
201
|
+
headers.set("Content-Range", `bytes ${start}-${end}/${st.size}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
headers.set("Content-Length", String(st.size === 0 ? 0 : end - start + 1));
|
|
205
|
+
if (method === "HEAD" || st.size === 0)
|
|
206
|
+
return new Response(null, { status, headers });
|
|
207
|
+
const { Readable } = await nodeMod("node:stream");
|
|
208
|
+
const body = Readable.toWeb(fs.createReadStream(file, { start, end }));
|
|
209
|
+
return new Response(body, { status, headers });
|
|
210
|
+
}, true);
|
|
211
|
+
return router;
|
|
212
|
+
};
|
|
213
|
+
router.notFound = (h) => ((notFoundHandler = h), router);
|
|
214
|
+
router.context = () => {
|
|
215
|
+
const c = store ? store.getStore() : current;
|
|
216
|
+
if (!c)
|
|
217
|
+
throw new Error("route.context() called outside of a request");
|
|
218
|
+
return c;
|
|
219
|
+
};
|
|
220
|
+
router.fetch = async (request) => {
|
|
221
|
+
const url = new URL(request.url);
|
|
222
|
+
const method = request.method.toUpperCase();
|
|
223
|
+
const pathname = url.pathname;
|
|
224
|
+
const jar = createCookies(request.headers.get("cookie"));
|
|
225
|
+
const resHeaders = new Headers();
|
|
226
|
+
const ctx = {
|
|
227
|
+
pathname, method, params: {}, request, query: url.searchParams,
|
|
228
|
+
cookies: () => jar,
|
|
229
|
+
headers: () => resHeaders,
|
|
230
|
+
};
|
|
231
|
+
// Merge ctx.headers() and ctx.cookies() changes into the final Response.
|
|
232
|
+
const finalize = (res) => {
|
|
233
|
+
const cookies = jar._out();
|
|
234
|
+
const extraCookies = resHeaders.getSetCookie();
|
|
235
|
+
let has = cookies.length > 0 || extraCookies.length > 0;
|
|
236
|
+
resHeaders.forEach((_v, k) => { if (k !== "set-cookie")
|
|
237
|
+
has = true; });
|
|
238
|
+
if (!has)
|
|
239
|
+
return res;
|
|
240
|
+
const apply = (h) => {
|
|
241
|
+
resHeaders.forEach((v, k) => { if (k !== "set-cookie")
|
|
242
|
+
h.set(k, v); });
|
|
243
|
+
for (const c of extraCookies)
|
|
244
|
+
h.append("set-cookie", c);
|
|
245
|
+
for (const c of cookies)
|
|
246
|
+
h.append("set-cookie", c);
|
|
247
|
+
};
|
|
248
|
+
try {
|
|
249
|
+
apply(res.headers);
|
|
250
|
+
return res;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// immutable headers (Response.redirect(), fetch() results): rebuild
|
|
254
|
+
const h = new Headers(res.headers);
|
|
255
|
+
apply(h);
|
|
256
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
const run = async (i) => {
|
|
260
|
+
for (; i < layers.length; i++) {
|
|
261
|
+
const l = layers[i];
|
|
262
|
+
if (l.method && l.method !== "*" && l.method !== method && !(l.method === "GET" && method === "HEAD"))
|
|
263
|
+
continue;
|
|
264
|
+
const m = l.pattern.exec(pathname);
|
|
265
|
+
if (!m)
|
|
266
|
+
continue;
|
|
267
|
+
const params = {};
|
|
268
|
+
l.keys.forEach((k, idx) => {
|
|
269
|
+
if (m[idx + 1] !== undefined)
|
|
270
|
+
params[k] = decodeURIComponent(m[idx + 1]);
|
|
271
|
+
});
|
|
272
|
+
ctx.params = params;
|
|
273
|
+
const idx = i;
|
|
274
|
+
let downstream;
|
|
275
|
+
const next = () => (downstream ??= run(idx + 1).then((r) => {
|
|
276
|
+
return r;
|
|
277
|
+
}));
|
|
278
|
+
if (!store)
|
|
279
|
+
current = ctx; // fallback mode: point global at this request
|
|
280
|
+
const out = await l.handler(next);
|
|
281
|
+
if (out instanceof Response)
|
|
282
|
+
return out;
|
|
283
|
+
if (downstream)
|
|
284
|
+
return downstream; // middleware called next() without returning
|
|
285
|
+
return undefined; // handler ended chain without a response
|
|
286
|
+
}
|
|
287
|
+
return undefined;
|
|
288
|
+
};
|
|
289
|
+
try {
|
|
290
|
+
const exec = async () => {
|
|
291
|
+
const res = await run(0);
|
|
292
|
+
if (res)
|
|
293
|
+
return finalize(res);
|
|
294
|
+
ctx.params = {};
|
|
295
|
+
if (!store)
|
|
296
|
+
current = ctx;
|
|
297
|
+
return finalize((await notFoundHandler(async () => undefined)));
|
|
298
|
+
};
|
|
299
|
+
try {
|
|
300
|
+
return await (store ? store.run(ctx, exec) : exec());
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
if (current === ctx)
|
|
304
|
+
current = undefined; // fallback mode: don't leak a finished request
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
console.error(err);
|
|
309
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
return router;
|
|
313
|
+
}
|
|
314
|
+
export default Router;
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xanrouter",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Tiny Request/Response router with middleware, cookies, headers, static files and a global context. Works on Node, Bun and Deno.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./express": {
|
|
14
|
+
"types": "./dist/express.d.ts",
|
|
15
|
+
"import": "./dist/express.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"package.json"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "rimraf -rf dist && tsc -p tsconfig.json",
|
|
31
|
+
"test": "tsx test/router.test.ts && tsx test/static.test.ts && tsx test/context.test.ts && tsx test/express.test.ts",
|
|
32
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
33
|
+
"prepublishOnly": "npm run build"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"router",
|
|
37
|
+
"fetch",
|
|
38
|
+
"request",
|
|
39
|
+
"response",
|
|
40
|
+
"middleware",
|
|
41
|
+
"static",
|
|
42
|
+
"cookies",
|
|
43
|
+
"bun",
|
|
44
|
+
"deno",
|
|
45
|
+
"express"
|
|
46
|
+
],
|
|
47
|
+
"author": "Naxrul Ahmed (https://github.com/devnax)",
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/devnax/xanrouter.git"
|
|
52
|
+
},
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/devnax/xanrouter/issues"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/devnax/xanrouter#readme",
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"regexparam": "^3.0.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/node": "^22.0.0",
|
|
62
|
+
"express": "^5.0.0",
|
|
63
|
+
"rimraf": "^6.1.4",
|
|
64
|
+
"tsx": "^4.19.0",
|
|
65
|
+
"typescript": "^5.6.0"
|
|
66
|
+
}
|
|
67
|
+
}
|