xanrouter 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,13 +91,37 @@ route.use(async (next) => {
91
91
 
92
92
  If a middleware calls `next()` without returning it, the downstream response is still used.
93
93
 
94
+ ## Sub-routers
95
+
96
+ Pass another `Router` to `use`, or to any method, to mount it. The sub-router sees paths with the mount prefix removed.
97
+
98
+ ```ts
99
+ const api = Router();
100
+ api.use((next) => next()); // only runs for /api/...
101
+ api.get("/users/:id", () => Response.json({ id: route.context().params.id }));
102
+
103
+ const route = Router();
104
+ route.use("/api", api); // GET /api/users/42
105
+ route.use("/orgs/:org", membersRouter); // params from the prefix are available in the sub-router
106
+ route.post("/users", usersRouter); // only POST requests reach usersRouter
107
+ route.use(otherRouter); // mount at the root
108
+ ```
109
+
110
+ - If the sub-router doesn't handle the request, or its middleware calls `next()` past its last layer, routing continues with the parent's next routes.
111
+ - Sub-routers share the request's context, cookies and headers, so `route.context()` works in any of them.
112
+ - Inside a sub-router, `ctx.pathname` is relative to it and `ctx.basePath` is the prefix removed so far (full path = `basePath + pathname`; `ctx.request.url` is the original).
113
+ - A sub-router's `notFound` is ignored when it is mounted; the top-level router's `notFound` is used. `sub.fetch()` still uses its own when called directly.
114
+ - `route.static()` inside a sub-router is relative to its mount: `sub.static("./public", { prefix: "/assets" })` mounted at `/v1` serves `/v1/assets/...`.
115
+ - A router can't be mounted on itself.
116
+
94
117
  ## Context
95
118
 
96
119
  `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
120
 
98
121
  ```ts
99
122
  const ctx = route.context();
100
- ctx.pathname; // "/users/42"
123
+ ctx.pathname; // "/users/42" (relative to the router handling it)
124
+ ctx.basePath; // mount prefix removed so far, "" at the top level
101
125
  ctx.method; // "GET"
102
126
  ctx.params; // { id: "42" }
103
127
  ctx.query; // URLSearchParams
@@ -143,6 +167,26 @@ cookies.delete("old"); // sends an expired cookie (pass the same path/domain
143
167
 
144
168
  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
169
 
170
+ ### Cookie helpers
171
+
172
+ `Cookie.parse` and `Cookie.serialize` work on their own, with or without a router.
173
+
174
+ ```ts
175
+ import { Cookie } from "xanrouter";
176
+
177
+ Cookie.parse("theme=dark; sid=abc%20123");
178
+ // { theme: "dark", sid: "abc 123" }
179
+
180
+ Cookie.serialize("sid", "abc 123", { httpOnly: true, sameSite: "lax", maxAge: 3600 });
181
+ // "sid=abc%20123; Path=/; Max-Age=3600; HttpOnly; SameSite=Lax"
182
+
183
+ // e.g. in your own response
184
+ new Response("ok", { headers: { "Set-Cookie": Cookie.serialize("a", "1") } });
185
+ ```
186
+
187
+ - `parse(header)` accepts `null` or `undefined` (returns `{}`). Values are URL-decoded, and the first occurrence of a name wins.
188
+ - `serialize(name, value, options?)` takes the same options as `cookies.set()` and throws on an invalid name.
189
+
146
190
  ## Static files
147
191
 
148
192
  ```ts
@@ -152,19 +196,19 @@ route.static("./dist", { prefix: "/assets", maxAge: 31536000, immutable: true })
152
196
 
153
197
  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
198
 
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 |
199
+ | Option | Meaning | Default |
200
+ | ---------------------- | ------------------------------------- | -------------- |
201
+ | `prefix` | URL prefix to mount under | `"/"` |
202
+ | `index` | Directory index file, or `false` | `"index.html"` |
203
+ | `dotfiles` | Serve files/folders starting with `.` | `false` |
204
+ | `maxAge` | `Cache-Control: max-age` (seconds) | `0` |
205
+ | `sMaxAge` | `s-maxage` (CDNs) | none |
206
+ | `immutable` | add `immutable` | `false` |
207
+ | `visibility` | `"public"` or `"private"` | `"public"` |
208
+ | `noCache` | add `no-cache` | `false` |
209
+ | `noStore` | send only `no-store` | `false` |
210
+ | `mustRevalidate` | add `must-revalidate` | `false` |
211
+ | `staleWhileRevalidate` | `stale-while-revalidate` (seconds) | none |
168
212
 
169
213
  `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
214
 
@@ -173,7 +217,7 @@ When no file matches, the request continues to the next route. Directories serve
173
217
  ```ts
174
218
  import express from "express";
175
219
  import { Router } from "xanrouter";
176
- import { expressAdapter } from "xanrouter/express";
220
+ import expressAdapter from "xanrouter/express";
177
221
 
178
222
  const route = Router();
179
223
  route.get("/hello/:name", () => new Response("hi " + route.context().params.name));
@@ -193,9 +237,9 @@ app.listen(3000);
193
237
  ```ts
194
238
  Router(): Router
195
239
 
196
- route.use(handler)
197
- route.use(path, handler) // prefix match
198
- route.get | post | put | patch | delete | head | options | all (path, handler)
240
+ route.use(handlerOrRouter)
241
+ route.use(path, handlerOrRouter) // prefix match; a Router is mounted under the prefix
242
+ route.get | post | put | patch | delete | head | options | all (path, handlerOrRouter)
199
243
  route.static(dir, options?)
200
244
  route.notFound(handler)
201
245
  route.context(): Context
@@ -208,4 +252,4 @@ All registration methods return the router, so calls can be chained.
208
252
 
209
253
  ## License
210
254
 
211
- MIT
255
+ MIT
@@ -0,0 +1,24 @@
1
+ import type { CookieOptions, Cookies } from "./types.js";
2
+ export type CookieJar = Cookies & {
3
+ /** Set-Cookie header values queued during this request (internal). */
4
+ _out(): string[];
5
+ };
6
+ /**
7
+ * Parse a `Cookie` request header (`"a=1; b=x%20y"`) into a name -> value object.
8
+ * Values are URL-decoded; if decoding fails the raw value is kept. When a name appears
9
+ * twice, the first one wins. Accepts null/undefined and returns `{}`.
10
+ * (The result has no prototype, so a cookie named `__proto__` is harmless.)
11
+ */
12
+ declare function parse(header: string | null | undefined): Record<string, string>;
13
+ /**
14
+ * Build a `Set-Cookie` header value. The value is URL-encoded and `Path` defaults to "/".
15
+ * Throws a TypeError for an invalid cookie name, or a path/domain containing `;` or a line break.
16
+ */
17
+ declare function serialize(name: string, value: string, o?: CookieOptions): string;
18
+ /** Cookie helpers usable on their own, outside a router. */
19
+ export declare const Cookie: {
20
+ parse: typeof parse;
21
+ serialize: typeof serialize;
22
+ };
23
+ export declare function createCookies(header: string | null): CookieJar;
24
+ export {};
@@ -0,0 +1,85 @@
1
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
2
+ const UNSAFE_ATTR = /[;\r\n]/;
3
+ /**
4
+ * Parse a `Cookie` request header (`"a=1; b=x%20y"`) into a name -> value object.
5
+ * Values are URL-decoded; if decoding fails the raw value is kept. When a name appears
6
+ * twice, the first one wins. Accepts null/undefined and returns `{}`.
7
+ * (The result has no prototype, so a cookie named `__proto__` is harmless.)
8
+ */
9
+ function parse(header) {
10
+ const out = Object.create(null);
11
+ if (!header)
12
+ return out;
13
+ for (const part of header.split(";")) {
14
+ const eq = part.indexOf("=");
15
+ if (eq < 0)
16
+ continue;
17
+ const name = part.slice(0, eq).trim();
18
+ if (!name || name in out)
19
+ continue; // first occurrence wins
20
+ let v = part.slice(eq + 1).trim();
21
+ if (v.length > 1 && v.startsWith('"') && v.endsWith('"'))
22
+ v = v.slice(1, -1);
23
+ try {
24
+ out[name] = decodeURIComponent(v);
25
+ }
26
+ catch {
27
+ out[name] = v;
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+ /**
33
+ * Build a `Set-Cookie` header value. The value is URL-encoded and `Path` defaults to "/".
34
+ * Throws a TypeError for an invalid cookie name, or a path/domain containing `;` or a line break.
35
+ */
36
+ function serialize(name, value, o = {}) {
37
+ if (!TOKEN.test(name))
38
+ throw new TypeError(`Invalid cookie name: ${JSON.stringify(name)}`);
39
+ const path = o.path ?? "/";
40
+ if (UNSAFE_ATTR.test(path) || (o.domain && UNSAFE_ATTR.test(o.domain)))
41
+ throw new TypeError("Invalid cookie path/domain");
42
+ let c = `${name}=${encodeURIComponent(value)}; Path=${path}`;
43
+ if (o.domain)
44
+ c += `; Domain=${o.domain}`;
45
+ if (o.maxAge !== undefined)
46
+ c += `; Max-Age=${Math.floor(o.maxAge)}`;
47
+ if (o.expires)
48
+ c += `; Expires=${o.expires.toUTCString()}`;
49
+ if (o.httpOnly)
50
+ c += "; HttpOnly";
51
+ if (o.secure)
52
+ c += "; Secure";
53
+ if (o.sameSite)
54
+ c += `; SameSite=${o.sameSite[0].toUpperCase()}${o.sameSite.slice(1)}`;
55
+ if (o.partitioned)
56
+ c += "; Partitioned";
57
+ return c;
58
+ }
59
+ /** Cookie helpers usable on their own, outside a router. */
60
+ export const Cookie = { parse, serialize };
61
+ export function createCookies(header) {
62
+ let base;
63
+ const read = () => (base ??= parse(header));
64
+ const view = new Map(); // undefined = deleted this request
65
+ const out = new Map(); // name;domain;path -> Set-Cookie (later set replaces earlier)
66
+ const key = (n, o) => `${n};${o.domain ?? ""};${o.path ?? "/"}`;
67
+ const jar = {
68
+ get: (n) => (view.has(n) ? view.get(n) : read()[n]),
69
+ has: (n) => jar.get(n) !== undefined,
70
+ all: () => {
71
+ const o = { ...read() };
72
+ for (const [k, v] of view)
73
+ v === undefined ? delete o[k] : (o[k] = v);
74
+ return o;
75
+ },
76
+ set: (n, v, o = {}) => (out.set(key(n, o), serialize(n, v, o)),
77
+ view.set(n, v),
78
+ jar),
79
+ delete: (n, o = {}) => (out.set(key(n, o), serialize(n, "", { ...o, maxAge: 0, expires: new Date(0) })),
80
+ view.set(n, undefined),
81
+ jar),
82
+ _out: () => [...out.values()],
83
+ };
84
+ return jar;
85
+ }
package/dist/express.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import type { Router } from "./index.js";
1
+ import type { Router } from "./router.js";
2
2
  export interface AdapterOptions {
3
3
  /** On a 404 from the router, call next() so later Express handlers run. Default false. */
4
4
  fallthrough?: boolean;
5
5
  }
6
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>;
7
+ export default function expressAdapter(route: Router, { fallthrough }?: AdapterOptions): (req: any, res: any, next: (err?: unknown) => void) => Promise<void>;
package/dist/express.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Readable } from "node:stream";
2
2
  /** Express (4/5) middleware that serves a Router. */
3
- export function expressAdapter(route, { fallthrough = false } = {}) {
3
+ export default function expressAdapter(route, { fallthrough = false } = {}) {
4
4
  return async (req, res, next) => {
5
5
  try {
6
6
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
@@ -21,8 +21,10 @@ export function expressAdapter(route, { fallthrough = false } = {}) {
21
21
  if (fallthrough && response.status === 404)
22
22
  return next();
23
23
  res.status(response.status);
24
- response.headers.forEach((v, k) => { if (k !== "set-cookie")
25
- res.setHeader(k, v); });
24
+ response.headers.forEach((v, k) => {
25
+ if (k !== "set-cookie")
26
+ res.setHeader(k, v);
27
+ });
26
28
  const cookies = response.headers.getSetCookie();
27
29
  if (cookies.length)
28
30
  res.setHeader("set-cookie", cookies);
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Merge ctx.headers() and the cookies queued by ctx.cookies() into the final Response.
3
+ * Context headers win over same-named headers on the response.
4
+ */
5
+ export declare function mergeIntoResponse(res: Response, resHeaders: Headers, cookies: string[]): Response;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Merge ctx.headers() and the cookies queued by ctx.cookies() into the final Response.
3
+ * Context headers win over same-named headers on the response.
4
+ */
5
+ export function mergeIntoResponse(res, resHeaders, cookies) {
6
+ const extraCookies = resHeaders.getSetCookie();
7
+ let has = cookies.length > 0 || extraCookies.length > 0;
8
+ resHeaders.forEach((_v, k) => {
9
+ if (k !== "set-cookie")
10
+ has = true;
11
+ });
12
+ if (!has)
13
+ return res;
14
+ const apply = (h) => {
15
+ resHeaders.forEach((v, k) => {
16
+ if (k !== "set-cookie")
17
+ h.set(k, v);
18
+ });
19
+ for (const c of extraCookies)
20
+ h.append("set-cookie", c);
21
+ for (const c of cookies)
22
+ h.append("set-cookie", c);
23
+ };
24
+ try {
25
+ apply(res.headers);
26
+ return res;
27
+ }
28
+ catch {
29
+ // immutable headers (Response.redirect(), fetch() results): rebuild
30
+ const h = new Headers(res.headers);
31
+ apply(h);
32
+ return new Response(res.body, {
33
+ status: res.status,
34
+ statusText: res.statusText,
35
+ headers: h,
36
+ });
37
+ }
38
+ }
package/dist/index.d.ts CHANGED
@@ -1,83 +1,5 @@
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;
1
+ import { Router } from "./router.js";
2
+ export type * from "./types.js";
3
+ export { Router } from "./router.js";
4
+ export { Cookie } from "./cookies.js";
83
5
  export default Router;
package/dist/index.js CHANGED
@@ -1,314 +1,4 @@
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
- }
1
+ import { Router } from "./router.js";
2
+ export { Router } from "./router.js";
3
+ export { Cookie } from "./cookies.js";
314
4
  export default Router;
package/dist/mime.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare const MIME: Record<string, string>;
package/dist/mime.js ADDED
@@ -0,0 +1,30 @@
1
+ export const MIME = {
2
+ html: "text/html; charset=utf-8",
3
+ htm: "text/html; charset=utf-8",
4
+ css: "text/css; charset=utf-8",
5
+ js: "text/javascript; charset=utf-8",
6
+ mjs: "text/javascript; charset=utf-8",
7
+ json: "application/json; charset=utf-8",
8
+ map: "application/json; charset=utf-8",
9
+ txt: "text/plain; charset=utf-8",
10
+ csv: "text/csv; charset=utf-8",
11
+ xml: "application/xml; charset=utf-8",
12
+ svg: "image/svg+xml",
13
+ png: "image/png",
14
+ jpg: "image/jpeg",
15
+ jpeg: "image/jpeg",
16
+ gif: "image/gif",
17
+ webp: "image/webp",
18
+ avif: "image/avif",
19
+ ico: "image/x-icon",
20
+ woff: "font/woff",
21
+ woff2: "font/woff2",
22
+ ttf: "font/ttf",
23
+ otf: "font/otf",
24
+ mp4: "video/mp4",
25
+ webm: "video/webm",
26
+ mp3: "audio/mpeg",
27
+ wav: "audio/wav",
28
+ pdf: "application/pdf",
29
+ wasm: "application/wasm",
30
+ };
@@ -0,0 +1,23 @@
1
+ import type { Context, Handler, StaticOptions } from "./types.js";
2
+ export interface Router {
3
+ /** Middleware, or mount another Router at the root. */
4
+ use(handler: Handler | Router): Router;
5
+ /** Middleware for a path prefix, or mount a Router under it (prefix is stripped for the sub-router). */
6
+ use(path: string, handler: Handler | Router): Router;
7
+ get(path: string, handler: Handler | Router): Router;
8
+ post(path: string, handler: Handler | Router): Router;
9
+ put(path: string, handler: Handler | Router): Router;
10
+ patch(path: string, handler: Handler | Router): Router;
11
+ delete(path: string, handler: Handler | Router): Router;
12
+ head(path: string, handler: Handler | Router): Router;
13
+ options(path: string, handler: Handler | Router): Router;
14
+ all(path: string, handler: Handler | Router): Router;
15
+ /** Serve static files from a directory. Falls through to next() when no file matches. */
16
+ static(dir: string, options?: StaticOptions): Router;
17
+ notFound(handler: Handler): Router;
18
+ /** Context of the request currently being handled (safe under concurrency). */
19
+ context(): Context;
20
+ /** Handle a web Request; method and path are read from it. */
21
+ fetch(request: Request): Promise<Response>;
22
+ }
23
+ export declare function Router(): Router;
package/dist/router.js ADDED
@@ -0,0 +1,144 @@
1
+ import { parse } from "regexparam";
2
+ import { createCookies } from "./cookies.js";
3
+ import { mergeIntoResponse } from "./finalize.js";
4
+ import { createStatic } from "./static.js";
5
+ import { currentContext, point, release, runInContext } from "./store.js";
6
+ // Symbol.for so two copies of the package can still mount each other.
7
+ const DISPATCH = Symbol.for("xanrouter.dispatch");
8
+ const ANY = /^\/?.*$/;
9
+ export function Router() {
10
+ const layers = [];
11
+ let notFoundHandler = () => new Response("Not Found", { status: 404 });
12
+ const add = (method, path, handler, loose) => {
13
+ const mount = typeof handler === "function" ? undefined : handler?.[DISPATCH];
14
+ if (typeof handler !== "function" && typeof mount !== "function") {
15
+ throw new TypeError("Expected a handler function or a Router");
16
+ }
17
+ if (handler === router)
18
+ throw new Error("A router cannot be mounted on itself");
19
+ const h = typeof handler === "function" ? handler : undefined;
20
+ if (path === null) {
21
+ layers.push({ method, keys: [], pattern: ANY, handler: h, mount });
22
+ }
23
+ else {
24
+ // a mounted Router matches by prefix
25
+ const { keys, pattern } = parse(path, loose || !!mount);
26
+ layers.push({ method, keys, pattern, handler: h, mount });
27
+ }
28
+ };
29
+ const router = {};
30
+ router.use = ((a, b) => {
31
+ typeof a === "string"
32
+ ? add(null, a, b, true)
33
+ : add(null, null, a, true);
34
+ return router;
35
+ });
36
+ const verb = (m) => (path, handler) => {
37
+ add(m, path, handler, false);
38
+ return router;
39
+ };
40
+ router.get = verb("GET");
41
+ router.post = verb("POST");
42
+ router.put = verb("PUT");
43
+ router.patch = verb("PATCH");
44
+ router.delete = verb("DELETE");
45
+ router.head = verb("HEAD");
46
+ router.options = verb("OPTIONS");
47
+ router.all = (path, handler) => (add("*", path, handler, false), router);
48
+ router.static = (dir, options) => {
49
+ const { prefix, handler } = createStatic(dir, options);
50
+ add("GET", prefix || null, handler, true);
51
+ return router;
52
+ };
53
+ router.notFound = (h) => ((notFoundHandler = h), router);
54
+ router.context = currentContext;
55
+ // Runs this router's layers against `path`. Used by fetch() and when mounted in another router.
56
+ const dispatch = (ctx, path, base, baseParams, tail) => {
57
+ const run = async (i) => {
58
+ for (; i < layers.length; i++) {
59
+ const l = layers[i];
60
+ if (l.method &&
61
+ l.method !== "*" &&
62
+ l.method !== ctx.method &&
63
+ !(l.method === "GET" && ctx.method === "HEAD"))
64
+ continue;
65
+ const m = l.pattern.exec(path);
66
+ if (!m)
67
+ continue;
68
+ const params = { ...baseParams };
69
+ l.keys.forEach((k, idx) => {
70
+ if (m[idx + 1] !== undefined)
71
+ params[k] = decodeURIComponent(m[idx + 1]);
72
+ });
73
+ // (Re)point the context at this layer. Also restores it after downstream layers have run.
74
+ const enter = () => {
75
+ ctx.pathname = path;
76
+ ctx.basePath = base;
77
+ ctx.params = params;
78
+ point(ctx);
79
+ };
80
+ const idx = i;
81
+ let downstream;
82
+ const next = () => (downstream ??= run(idx + 1).finally(enter));
83
+ if (l.mount) {
84
+ const consumed = l.pattern === ANY ? 0 : m[0].length;
85
+ let rest = path.slice(consumed);
86
+ if (!rest.startsWith("/"))
87
+ rest = "/" + rest;
88
+ return l.mount(ctx, rest, base + path.slice(0, consumed), params, next);
89
+ }
90
+ enter();
91
+ const out = await l.handler(next);
92
+ if (out instanceof Response)
93
+ return out;
94
+ if (downstream)
95
+ return downstream; // middleware called next() without returning
96
+ return undefined; // handler ended chain without a response
97
+ }
98
+ return tail(); // nothing here handled it: continue in the parent router (or end)
99
+ };
100
+ return run(0);
101
+ };
102
+ router[DISPATCH] = dispatch;
103
+ router.fetch = async (request) => {
104
+ const url = new URL(request.url);
105
+ const method = request.method.toUpperCase();
106
+ const pathname = url.pathname;
107
+ const jar = createCookies(request.headers.get("cookie"));
108
+ const resHeaders = new Headers();
109
+ const ctx = {
110
+ pathname,
111
+ basePath: "",
112
+ method,
113
+ params: {},
114
+ request,
115
+ query: url.searchParams,
116
+ cookies: () => jar,
117
+ headers: () => resHeaders,
118
+ };
119
+ const finalize = (res) => mergeIntoResponse(res, resHeaders, jar._out());
120
+ try {
121
+ const exec = async () => {
122
+ const res = await dispatch(ctx, pathname, "", {}, async () => undefined);
123
+ if (res)
124
+ return finalize(res);
125
+ ctx.params = {};
126
+ ctx.pathname = pathname;
127
+ ctx.basePath = "";
128
+ point(ctx);
129
+ return finalize((await notFoundHandler(async () => undefined)));
130
+ };
131
+ try {
132
+ return await runInContext(ctx, exec);
133
+ }
134
+ finally {
135
+ release(ctx);
136
+ }
137
+ }
138
+ catch (err) {
139
+ console.error(err);
140
+ return new Response("Internal Server Error", { status: 500 });
141
+ }
142
+ };
143
+ return router;
144
+ }
@@ -0,0 +1,9 @@
1
+ import type { Handler, StaticOptions } from "./types.js";
2
+ /**
3
+ * Build a GET handler that serves files from `dir`.
4
+ * `prefix` is "" for the root, otherwise "/something" (no trailing slash).
5
+ */
6
+ export declare function createStatic(dir: string, options?: StaticOptions): {
7
+ prefix: string;
8
+ handler: Handler;
9
+ };
package/dist/static.js ADDED
@@ -0,0 +1,108 @@
1
+ import { MIME } from "./mime.js";
2
+ import { currentContext } from "./store.js";
3
+ // Lazy Node builtin loader: keeps the router importable where node: modules don't exist.
4
+ const nodeMod = async (name) => globalThis.process?.getBuiltinModule?.(name) ??
5
+ (await import(/* @vite-ignore */ /* webpackIgnore: true */ name));
6
+ function cacheControlHeader(options) {
7
+ const cc = [];
8
+ if (options.noStore)
9
+ cc.push("no-store");
10
+ else {
11
+ cc.push(options.visibility ?? "public");
12
+ if (options.noCache)
13
+ cc.push("no-cache");
14
+ cc.push(`max-age=${options.maxAge ?? 0}`);
15
+ if (options.sMaxAge !== undefined)
16
+ cc.push(`s-maxage=${options.sMaxAge}`);
17
+ if (options.staleWhileRevalidate !== undefined)
18
+ cc.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
19
+ if (options.mustRevalidate)
20
+ cc.push("must-revalidate");
21
+ if (options.immutable)
22
+ cc.push("immutable");
23
+ }
24
+ return cc.join(", ");
25
+ }
26
+ /**
27
+ * Build a GET handler that serves files from `dir`.
28
+ * `prefix` is "" for the root, otherwise "/something" (no trailing slash).
29
+ */
30
+ export function createStatic(dir, options = {}) {
31
+ const index = options.index === undefined ? "index.html" : options.index;
32
+ const cacheControl = cacheControlHeader(options);
33
+ let prefix = "/" + (options.prefix ?? "").replace(/^\/+|\/+$/g, "");
34
+ if (prefix === "/")
35
+ prefix = "";
36
+ const handler = async (next) => {
37
+ const { pathname, method, request } = currentContext(); // read before any await (fallback-safe)
38
+ const [fs, path] = await Promise.all([
39
+ nodeMod("node:fs"),
40
+ nodeMod("node:path"),
41
+ ]);
42
+ let rel;
43
+ try {
44
+ rel = decodeURIComponent(pathname.slice(prefix.length));
45
+ }
46
+ catch {
47
+ return next();
48
+ }
49
+ if (rel.includes("\0"))
50
+ return next();
51
+ if (!options.dotfiles &&
52
+ rel.split(/[\\/]/).some((seg) => seg.startsWith(".")))
53
+ return next();
54
+ const root = path.resolve(dir);
55
+ let file = path.resolve(root, "." + path.sep + rel);
56
+ if (file !== root && !file.startsWith(root + path.sep))
57
+ return next(); // path traversal guard
58
+ let st = await fs.promises.stat(file).catch(() => null);
59
+ if (st?.isDirectory()) {
60
+ if (!index)
61
+ return next();
62
+ file = path.join(file, index);
63
+ st = await fs.promises.stat(file).catch(() => null);
64
+ }
65
+ if (!st?.isFile())
66
+ return next();
67
+ const ext = path.extname(file).slice(1).toLowerCase();
68
+ const headers = new Headers({
69
+ "Content-Type": MIME[ext] ?? "application/octet-stream",
70
+ "Accept-Ranges": "bytes",
71
+ "Cache-Control": cacheControl,
72
+ "Last-Modified": st.mtime.toUTCString(),
73
+ ETag: `W/"${st.size.toString(16)}-${Math.floor(st.mtimeMs).toString(16)}"`,
74
+ });
75
+ if (request.headers.get("if-none-match") === headers.get("ETag"))
76
+ return new Response(null, { status: 304, headers });
77
+ let status = 200, start = 0, end = st.size - 1;
78
+ const range = request.headers.get("range");
79
+ if (range) {
80
+ const m = /^bytes=(\d*)-(\d*)$/.exec(range);
81
+ if (m && (m[1] || m[2])) {
82
+ if (m[1]) {
83
+ start = +m[1];
84
+ if (m[2])
85
+ end = Math.min(+m[2], end);
86
+ }
87
+ else {
88
+ start = Math.max(st.size - +m[2], 0);
89
+ }
90
+ if (start > end || start >= st.size) {
91
+ return new Response(null, {
92
+ status: 416,
93
+ headers: { "Content-Range": `bytes */${st.size}` },
94
+ });
95
+ }
96
+ status = 206;
97
+ headers.set("Content-Range", `bytes ${start}-${end}/${st.size}`);
98
+ }
99
+ }
100
+ headers.set("Content-Length", String(st.size === 0 ? 0 : end - start + 1));
101
+ if (method === "HEAD" || st.size === 0)
102
+ return new Response(null, { status, headers });
103
+ const { Readable } = await nodeMod("node:stream");
104
+ const body = Readable.toWeb(fs.createReadStream(file, { start, end }));
105
+ return new Response(body, { status, headers });
106
+ };
107
+ return { prefix, handler };
108
+ }
@@ -0,0 +1,9 @@
1
+ import type { Context } from "./types.js";
2
+ /** Run `fn` with `ctx` as the current request context. */
3
+ export declare function runInContext<R>(ctx: Context, fn: () => R): R;
4
+ /** Fallback mode only: point the global at this request. No-op when AsyncLocalStorage is available. */
5
+ export declare function point(ctx: Context): void;
6
+ /** Fallback mode only: don't leak a finished request. */
7
+ export declare function release(ctx: Context): void;
8
+ /** The context of the request currently being handled. Throws outside a request. */
9
+ export declare function currentContext(): Context;
package/dist/store.js ADDED
@@ -0,0 +1,33 @@
1
+ let store;
2
+ try {
3
+ const g = globalThis;
4
+ const mod = g.process?.getBuiltinModule?.("node:async_hooks") ??
5
+ (await import(
6
+ /* @vite-ignore */ /* webpackIgnore: true */ ["node:async", "hooks"].join("_")));
7
+ store = new mod.AsyncLocalStorage();
8
+ }
9
+ catch {
10
+ store = undefined;
11
+ }
12
+ let current;
13
+ /** Run `fn` with `ctx` as the current request context. */
14
+ export function runInContext(ctx, fn) {
15
+ return store ? store.run(ctx, fn) : fn();
16
+ }
17
+ /** Fallback mode only: point the global at this request. No-op when AsyncLocalStorage is available. */
18
+ export function point(ctx) {
19
+ if (!store)
20
+ current = ctx;
21
+ }
22
+ /** Fallback mode only: don't leak a finished request. */
23
+ export function release(ctx) {
24
+ if (current === ctx)
25
+ current = undefined;
26
+ }
27
+ /** The context of the request currently being handled. Throws outside a request. */
28
+ export function currentContext() {
29
+ const c = store ? store.getStore() : current;
30
+ if (!c)
31
+ throw new Error("route.context() called outside of a request");
32
+ return c;
33
+ }
@@ -0,0 +1,66 @@
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
+ /** Path as seen by the router handling the request: any mount prefix is removed. */
28
+ pathname: string;
29
+ /** Mount prefix removed so far ("" at the top level). Full path = basePath + pathname. */
30
+ basePath: string;
31
+ method: string;
32
+ params: Record<string, string>;
33
+ request: Request;
34
+ query: URLSearchParams;
35
+ /** Read request cookies and queue response cookies. */
36
+ cookies(): Cookies;
37
+ /** RESPONSE headers: merged into whatever Response the handler returns. (Request headers: ctx.request.headers) */
38
+ headers(): Headers;
39
+ }
40
+ type Result = Response | void | undefined | Promise<Response | void | undefined>;
41
+ export type Handler = (next: Next) => Result;
42
+ export interface StaticOptions {
43
+ /** URL prefix to mount under (default "/"). e.g. "/assets" */
44
+ prefix?: string;
45
+ /** Index file for directories (default "index.html"; false disables). */
46
+ index?: string | false;
47
+ /** Cache-Control max-age in seconds (default 0). */
48
+ maxAge?: number;
49
+ /** Cache-Control s-maxage in seconds (shared caches / CDNs). */
50
+ sMaxAge?: number;
51
+ /** Add "immutable": file never changes, skip revalidation (use with hashed filenames). */
52
+ immutable?: boolean;
53
+ /** "public" (default) or "private". */
54
+ visibility?: "public" | "private";
55
+ /** Add "no-cache": store, but revalidate every time. */
56
+ noCache?: boolean;
57
+ /** Send only "no-store": never cache. Overrides every other cache option. */
58
+ noStore?: boolean;
59
+ /** Add "must-revalidate". */
60
+ mustRevalidate?: boolean;
61
+ /** Add "stale-while-revalidate=<seconds>". */
62
+ staleWhileRevalidate?: number;
63
+ /** Serve files/folders starting with "." (default false). */
64
+ dotfiles?: boolean;
65
+ }
66
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xanrouter",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Tiny Request/Response router with middleware, cookies, headers, static files and a global context. Works on Node, Bun and Deno.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,18 +19,17 @@
19
19
  "files": [
20
20
  "dist",
21
21
  "README.md",
22
- "LICENSE",
23
- "package.json"
22
+ "LICENSE"
24
23
  ],
25
24
  "sideEffects": false,
26
25
  "engines": {
27
26
  "node": ">=18"
28
27
  },
29
28
  "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",
29
+ "build": "rimraf dist && tsc -p tsconfig.json",
30
+ "test": "tsx test/router.test.ts && tsx test/static.test.ts && tsx test/context.test.ts && tsx test/mount.test.ts && tsx test/cookie.test.ts && tsx test/express.test.ts",
32
31
  "typecheck": "tsc -p tsconfig.json --noEmit",
33
- "prepublishOnly": "npm run build"
32
+ "prepublishOnly": "npm run build && npm test"
34
33
  },
35
34
  "keywords": [
36
35
  "router",
@@ -60,7 +59,7 @@
60
59
  "devDependencies": {
61
60
  "@types/node": "^22.0.0",
62
61
  "express": "^5.0.0",
63
- "rimraf": "^6.1.4",
62
+ "rimraf": "^5.0.10",
64
63
  "tsx": "^4.19.0",
65
64
  "typescript": "^5.6.0"
66
65
  }