xanrouter 0.0.0-stage → 0.1.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 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
- # Temporary Holding Version
1
+ # xanrouter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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>;
@@ -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
+ }
@@ -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,66 @@
1
1
  {
2
2
  "name": "xanrouter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.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
+ ],
24
+ "sideEffects": false,
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "scripts": {
29
+ "build": "rimraf -rf dist && tsc -p tsconfig.json",
30
+ "test": "tsx test/router.test.ts && tsx test/static.test.ts && tsx test/context.test.ts && tsx test/express.test.ts",
31
+ "typecheck": "tsc -p tsconfig.json --noEmit",
32
+ "prepublishOnly": "npm run build && npm test"
33
+ },
34
+ "keywords": [
35
+ "router",
36
+ "fetch",
37
+ "request",
38
+ "response",
39
+ "middleware",
40
+ "static",
41
+ "cookies",
42
+ "bun",
43
+ "deno",
44
+ "express"
45
+ ],
46
+ "author": "Naxrul Ahmed (https://github.com/devnax)",
47
+ "license": "MIT",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/devnax/xanrouter.git"
51
+ },
52
+ "bugs": {
53
+ "url": "https://github.com/devnax/xanrouter/issues"
54
+ },
55
+ "homepage": "https://github.com/devnax/xanrouter#readme",
56
+ "dependencies": {
57
+ "regexparam": "^3.0.0"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^22.0.0",
61
+ "express": "^5.0.0",
62
+ "rimraf": "^6.1.4",
63
+ "tsx": "^4.19.0",
64
+ "typescript": "^5.6.0"
65
+ }
66
+ }