@fishenv/http 0.0.2 → 0.0.3

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.
Files changed (78) hide show
  1. package/dist/contract/builder.d.ts +9 -4
  2. package/dist/contract/builder.js +2 -0
  3. package/dist/contract/index.d.ts +3 -1
  4. package/dist/contract/index.js +1 -0
  5. package/dist/contract/types.d.ts +9 -1
  6. package/dist/contract/ws.d.ts +178 -0
  7. package/dist/contract/ws.js +164 -0
  8. package/dist/core/compose.js +5 -0
  9. package/dist/core/error.d.ts +21 -0
  10. package/dist/core/error.js +50 -7
  11. package/dist/core/index.d.ts +7 -3
  12. package/dist/core/index.js +5 -1
  13. package/dist/core/matcher.js +2 -1
  14. package/dist/core/route-builder.d.ts +4 -4
  15. package/dist/core/router.d.ts +16 -2
  16. package/dist/core/router.js +45 -6
  17. package/dist/core/transport.d.ts +39 -0
  18. package/dist/core/transport.js +68 -0
  19. package/dist/core/types.d.ts +22 -3
  20. package/dist/core/upgrade.d.ts +85 -0
  21. package/dist/core/upgrade.js +110 -0
  22. package/dist/core/utils/url.d.ts +4 -1
  23. package/dist/core/utils/url.js +14 -3
  24. package/dist/sse/hub.js +15 -33
  25. package/dist/sse/types.d.ts +3 -5
  26. package/dist/static/index.d.ts +27 -0
  27. package/dist/static/index.js +25 -0
  28. package/dist/static/negotiate.d.ts +23 -0
  29. package/dist/static/negotiate.js +85 -0
  30. package/dist/static/options.d.ts +44 -0
  31. package/dist/static/options.js +190 -0
  32. package/dist/static/resolve.d.ts +56 -0
  33. package/dist/static/resolve.js +113 -0
  34. package/dist/static/response.d.ts +53 -0
  35. package/dist/static/response.js +146 -0
  36. package/dist/static/router.d.ts +59 -0
  37. package/dist/static/router.js +217 -0
  38. package/dist/static/rules.d.ts +21 -0
  39. package/dist/static/rules.js +49 -0
  40. package/dist/static/types.d.ts +76 -0
  41. package/dist/static/types.js +0 -0
  42. package/dist/ws/channels/channel.d.ts +175 -0
  43. package/dist/ws/channels/channel.js +125 -0
  44. package/dist/ws/channels/handler.d.ts +33 -0
  45. package/dist/ws/channels/handler.js +486 -0
  46. package/dist/ws/channels/protocol.d.ts +96 -0
  47. package/dist/ws/channels/protocol.js +94 -0
  48. package/dist/ws/channels/target.d.ts +36 -0
  49. package/dist/ws/channels/target.js +56 -0
  50. package/dist/ws/connection.d.ts +56 -0
  51. package/dist/ws/connection.js +172 -0
  52. package/dist/ws/helpers.d.ts +17 -0
  53. package/dist/ws/helpers.js +48 -0
  54. package/dist/ws/hub.d.ts +172 -0
  55. package/dist/ws/hub.js +489 -0
  56. package/dist/ws/index.d.ts +65 -0
  57. package/dist/ws/index.js +66 -0
  58. package/dist/ws/lifecycle.d.ts +21 -0
  59. package/dist/ws/lifecycle.js +97 -0
  60. package/dist/ws/limits.d.ts +32 -0
  61. package/dist/ws/limits.js +67 -0
  62. package/dist/ws/message.d.ts +19 -0
  63. package/dist/ws/message.js +58 -0
  64. package/dist/ws/origin.d.ts +40 -0
  65. package/dist/ws/origin.js +152 -0
  66. package/dist/ws/presence.d.ts +96 -0
  67. package/dist/ws/presence.js +420 -0
  68. package/dist/ws/route-builder.d.ts +136 -0
  69. package/dist/ws/route-builder.js +265 -0
  70. package/dist/ws/router.d.ts +21 -0
  71. package/dist/ws/router.js +36 -0
  72. package/dist/ws/tickets.d.ts +82 -0
  73. package/dist/ws/tickets.js +161 -0
  74. package/dist/ws/typed.d.ts +54 -0
  75. package/dist/ws/typed.js +104 -0
  76. package/dist/ws/types.d.ts +69 -0
  77. package/dist/ws/types.js +70 -0
  78. package/package.json +14 -2
@@ -0,0 +1,146 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { NotFoundError } from "../core/index.js";
3
+ import { chooseEncoding, isNotModified, makeEtag } from "./negotiate.js";
4
+ import { compileServeFileOptions } from "./options.js";
5
+ /** Types that cannot run script, so `untrusted` may show them inline. */
6
+ function isPassiveType(contentType) {
7
+ const type = contentType.split(";")[0].trim().toLowerCase();
8
+ if (type.startsWith("image/"))
9
+ return type !== "image/svg+xml";
10
+ return (type.startsWith("audio/") ||
11
+ type.startsWith("video/") ||
12
+ type === "text/plain" ||
13
+ type === "application/pdf");
14
+ }
15
+ // Headers a 304 keeps (RFC 9110 §15.4.5).
16
+ const NOT_MODIFIED_HEADERS = [
17
+ "cache-control",
18
+ "content-location",
19
+ "etag",
20
+ "expires",
21
+ "last-modified",
22
+ "vary",
23
+ ];
24
+ /** Build the response for a resolved file: headers, conditionals, body. */
25
+ export function buildFileResponse(req, file, cfg, extras = {}) {
26
+ const contentType = Bun.file(file.contentTypeFrom).type;
27
+ const etag = cfg.etag
28
+ ? makeEtag(file.size, file.mtimeMs, file.encoding)
29
+ : undefined;
30
+ const headers = new Headers({
31
+ "Content-Type": contentType,
32
+ "X-Content-Type-Options": "nosniff",
33
+ "Cache-Control": cfg.cacheControl,
34
+ });
35
+ if (cfg.precompressed.length > 0)
36
+ headers.set("Vary", "Accept-Encoding");
37
+ if (etag)
38
+ headers.set("ETag", etag);
39
+ if (cfg.lastModified) {
40
+ headers.set("Last-Modified", new Date(file.mtimeMs).toUTCString());
41
+ }
42
+ if (file.encoding)
43
+ headers.set("Content-Encoding", file.encoding);
44
+ // Bun adds it itself to 206 responses; setting it too would duplicate it.
45
+ if (!req.headers.has("range"))
46
+ headers.set("Accept-Ranges", "bytes");
47
+ for (const [k, v] of Object.entries(cfg.headers))
48
+ headers.set(k, v);
49
+ if (extras.cacheControl)
50
+ headers.set("Cache-Control", extras.cacheControl);
51
+ for (const [k, v] of Object.entries(extras.headers ?? {}))
52
+ headers.set(k, v);
53
+ if (cfg.untrusted) {
54
+ headers.set("Content-Security-Policy", "sandbox; default-src 'none'");
55
+ if (!isPassiveType(contentType)) {
56
+ headers.set("Content-Disposition", "attachment");
57
+ }
58
+ }
59
+ if (extras.forceNoCache)
60
+ headers.set("Cache-Control", "no-cache");
61
+ cfg.setHeaders?.({
62
+ path: file.path,
63
+ size: file.size,
64
+ mtime: new Date(file.mtimeMs),
65
+ encoding: file.encoding,
66
+ }, headers);
67
+ if (isNotModified(req, etag, cfg.lastModified ? file.mtimeMs : undefined)) {
68
+ const kept = new Headers();
69
+ for (const name of NOT_MODIFIED_HEADERS) {
70
+ const value = headers.get(name);
71
+ if (value !== null)
72
+ kept.set(name, value);
73
+ }
74
+ return new Response(null, { status: 304, headers: kept });
75
+ }
76
+ // Bun applies Range / If-Range to file bodies and sends via sendfile.
77
+ return new Response(Bun.file(file.abs), { headers });
78
+ }
79
+ /** Stat a candidate and return it only if it is a regular file. */
80
+ export async function statFile(abs) {
81
+ try {
82
+ const st = await stat(abs);
83
+ return st.isFile() ? { size: st.size, mtimeMs: st.mtimeMs } : null;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /**
90
+ * Serve one known file from a handler, with the same headers, validators
91
+ * and pre-compressed negotiation as `staticFiles()`.
92
+ *
93
+ * `absPath` is trusted: never build it from request input — use
94
+ * `staticFiles()` for that. Throws `NotFoundError` when it is not a file.
95
+ *
96
+ * ```ts
97
+ * api.get("/report").handle(({ req }) => serveFile(req, reportPath));
98
+ * ```
99
+ */
100
+ export async function serveFile(req, absPath, opts = {}) {
101
+ const cfg = compileServeFileOptions(opts);
102
+ const original = await statFile(absPath);
103
+ if (!original)
104
+ throw new NotFoundError();
105
+ const variants = {};
106
+ for (const enc of cfg.precompressed) {
107
+ variants[enc] = `${absPath}${enc === "br" ? ".br" : ".gz"}`;
108
+ }
109
+ return serveWithVariants(req, cfg, {
110
+ path: absPath,
111
+ abs: absPath,
112
+ stat: original,
113
+ variants,
114
+ });
115
+ }
116
+ /**
117
+ * Choose an encoding among candidate sibling paths, stat the chosen file
118
+ * fresh, and fall back to the original when a variant has vanished.
119
+ */
120
+ export async function serveWithVariants(req, cfg, target, extras) {
121
+ const acceptEncoding = req.headers.get("accept-encoding");
122
+ let available = cfg.precompressed.filter((enc) => target.variants[enc]);
123
+ while (available.length > 0) {
124
+ const encoding = chooseEncoding(acceptEncoding, available);
125
+ if (!encoding)
126
+ break;
127
+ const abs = target.variants[encoding];
128
+ const st = await statFile(abs);
129
+ if (st) {
130
+ return buildFileResponse(req, {
131
+ path: target.path,
132
+ contentTypeFrom: target.abs,
133
+ abs,
134
+ ...st,
135
+ encoding,
136
+ }, cfg, extras);
137
+ }
138
+ available = available.filter((e) => e !== encoding);
139
+ }
140
+ return buildFileResponse(req, {
141
+ path: target.path,
142
+ contentTypeFrom: target.abs,
143
+ abs: target.abs,
144
+ ...target.stat,
145
+ }, cfg, extras);
146
+ }
@@ -0,0 +1,59 @@
1
+ /** `staticFiles()`, `spa()` and the `withStatic()` mixin. */
2
+ import { type Router } from "../core/index.ts";
3
+ import type { StaticEncoding, StaticOptions } from "./types.ts";
4
+ /** Outcome of mapping a validated request path to the filesystem. */
5
+ type Lookup = {
6
+ kind: "file";
7
+ /** Relative path of the file actually served (may be an index). */
8
+ rel: string;
9
+ abs: string;
10
+ variants: Partial<Record<StaticEncoding, string>>;
11
+ } | {
12
+ kind: "redirect";
13
+ } | {
14
+ kind: "missing";
15
+ };
16
+ /** A router serving a directory; `invalidate()` clears the lookup cache. */
17
+ export type StaticRouter = Router<Record<never, never>, Record<never, never>> & {
18
+ invalidate(relPath?: string): void;
19
+ };
20
+ /**
21
+ * Serve a directory as a mountable router:
22
+ *
23
+ * ```ts
24
+ * app.add("/assets", staticFiles({ root: "./public" }));
25
+ * ```
26
+ *
27
+ * Paths are decoded and validated per segment, kept inside the (real) root,
28
+ * and dotfiles are hidden by default. Every rejection is a plain 404
29
+ * (`NotFoundError`), except undecodable `%` sequences (`BadRequestError`).
30
+ * Throws at creation on any misconfiguration.
31
+ */
32
+ export declare function staticFiles(opts: StaticOptions): StaticRouter;
33
+ /**
34
+ * Single-page app: `staticFiles()` with `index.html` as both the directory
35
+ * index and the fallback for unknown, extension-less HTML requests.
36
+ *
37
+ * ```ts
38
+ * app.add(api).add(spa({ root: "./dist", exclude: ["api/**"] }));
39
+ * ```
40
+ */
41
+ export declare function spa(opts: Omit<StaticOptions, "fallback"> & {
42
+ fallback?: string;
43
+ }): StaticRouter;
44
+ /** Mixin sugar for `router.add(prefix, staticFiles(opts))`. */
45
+ export declare function withStatic<R extends Router<any, any>>(router: R, prefix: string, opts: StaticOptions): R;
46
+ /** Bounded LRU of lookups. Misses expire sooner than hits. Internal; exported for tests. */
47
+ export declare class LookupCache {
48
+ #private;
49
+ constructor(opts: {
50
+ ttlMs: number;
51
+ maxEntries: number;
52
+ });
53
+ get size(): number;
54
+ get(key: string): Lookup | undefined;
55
+ set(key: string, lookup: Lookup): void;
56
+ delete(key: string): void;
57
+ invalidate(relPath?: string): void;
58
+ }
59
+ export {};
@@ -0,0 +1,217 @@
1
+ /** `staticFiles()`, `spa()` and the `withStatic()` mixin. */
2
+ import path from "node:path";
3
+ import { BadRequestError, NotFoundError, r, } from "../core/index.js";
4
+ import { acceptsHtml } from "./negotiate.js";
5
+ import { compileStaticOptions, isServable, } from "./options.js";
6
+ import { BAD_ENCODING, locate, parseRequestPath } from "./resolve.js";
7
+ import { serveWithVariants, statFile, } from "./response.js";
8
+ import { applyRules } from "./rules.js";
9
+ /**
10
+ * Serve a directory as a mountable router:
11
+ *
12
+ * ```ts
13
+ * app.add("/assets", staticFiles({ root: "./public" }));
14
+ * ```
15
+ *
16
+ * Paths are decoded and validated per segment, kept inside the (real) root,
17
+ * and dotfiles are hidden by default. Every rejection is a plain 404
18
+ * (`NotFoundError`), except undecodable `%` sequences (`BadRequestError`).
19
+ * Throws at creation on any misconfiguration.
20
+ */
21
+ export function staticFiles(opts) {
22
+ const cfg = compileStaticOptions(opts);
23
+ const cache = cfg.cache ? new LookupCache(cfg.cache) : null;
24
+ const router = r();
25
+ router.get("/:path*").handle(async ({ req, path: params }) => {
26
+ const raw = params.path;
27
+ const parsed = parseRequestPath(raw, cfg.windows);
28
+ if (parsed === BAD_ENCODING)
29
+ throw new BadRequestError();
30
+ if (parsed === null || !isServable(cfg, parsed.segments)) {
31
+ throw new NotFoundError();
32
+ }
33
+ const key = parsed.segments.join("/") + (parsed.trailingSlash ? "/" : "");
34
+ const found = await resolveFresh(cfg, cache, key, parsed);
35
+ if (found.kind === "file")
36
+ return serveLookup(req, cfg, found, found.stat);
37
+ if (found.kind === "redirect")
38
+ return redirectToDirectory(req);
39
+ if (cfg.fallback && isFallbackEligible(req, parsed.segments)) {
40
+ const shell = await withStat(await lookup(cfg, cfg.fallback, false));
41
+ if (shell?.kind === "file") {
42
+ return serveLookup(req, cfg, shell, shell.stat, { forceNoCache: true });
43
+ }
44
+ }
45
+ throw new NotFoundError();
46
+ });
47
+ return Object.defineProperty(router, "invalidate", {
48
+ value: (relPath) => cache?.invalidate(relPath),
49
+ enumerable: false,
50
+ configurable: true,
51
+ });
52
+ }
53
+ /**
54
+ * Single-page app: `staticFiles()` with `index.html` as both the directory
55
+ * index and the fallback for unknown, extension-less HTML requests.
56
+ *
57
+ * ```ts
58
+ * app.add(api).add(spa({ root: "./dist", exclude: ["api/**"] }));
59
+ * ```
60
+ */
61
+ export function spa(opts) {
62
+ return staticFiles({
63
+ index: ["index.html"],
64
+ fallback: "index.html",
65
+ ...opts,
66
+ });
67
+ }
68
+ /** Mixin sugar for `router.add(prefix, staticFiles(opts))`. */
69
+ // biome-ignore lint/suspicious/noExplicitAny: accepts routers of any context
70
+ export function withStatic(router, prefix, opts) {
71
+ router.add(prefix, staticFiles(opts));
72
+ return router;
73
+ }
74
+ /** Attach a fresh stat to a file lookup; `null` if it is no longer a file. */
75
+ async function withStat(found) {
76
+ if (found.kind !== "file")
77
+ return found;
78
+ const stat = await statFile(found.abs);
79
+ return stat ? { ...found, stat } : null;
80
+ }
81
+ /**
82
+ * Look up through the cache, but never build a response from a cached file
83
+ * without a fresh stat: a vanished or replaced entry is resolved again.
84
+ */
85
+ async function resolveFresh(cfg, cache, key, parsed) {
86
+ const cached = cache?.get(key);
87
+ if (cached) {
88
+ const fresh = await withStat(cached);
89
+ if (fresh)
90
+ return fresh;
91
+ cache?.delete(key);
92
+ }
93
+ const found = await lookup(cfg, parsed.segments, parsed.trailingSlash);
94
+ const fresh = await withStat(found);
95
+ if (fresh) {
96
+ cache?.set(key, found);
97
+ return fresh;
98
+ }
99
+ return { kind: "missing" };
100
+ }
101
+ function serveLookup(req, cfg, found, stat, extras = {}) {
102
+ const rules = applyRules(cfg.rules, found.rel);
103
+ return serveWithVariants(req, cfg, { path: found.rel, abs: found.abs, stat, variants: found.variants }, { cacheControl: rules.cacheControl, headers: rules.headers, ...extras });
104
+ }
105
+ /** Steps 6–10: file, directory index, redirect, extension fallback. */
106
+ async function lookup(cfg, segments, trailingSlash) {
107
+ const target = await locate(cfg.root, segments, cfg.symlinks, cfg.allowCanonical);
108
+ if (target?.stat.isDirectory()) {
109
+ if (cfg.index.length === 0)
110
+ return { kind: "missing" };
111
+ if (!trailingSlash)
112
+ return { kind: "redirect" };
113
+ for (const name of cfg.index) {
114
+ const found = await findFile(cfg, [...segments, name]);
115
+ if (found)
116
+ return found;
117
+ }
118
+ return { kind: "missing" };
119
+ }
120
+ if (target && !trailingSlash)
121
+ return withVariants(cfg, segments, target.abs);
122
+ if (!trailingSlash && segments.length > 0) {
123
+ const last = segments[segments.length - 1];
124
+ for (const ext of cfg.extensions) {
125
+ const found = await findFile(cfg, [
126
+ ...segments.slice(0, -1),
127
+ `${last}.${ext}`,
128
+ ]);
129
+ if (found)
130
+ return found;
131
+ }
132
+ }
133
+ return { kind: "missing" };
134
+ }
135
+ /** A derived candidate (index, extension) is checked like a request path. */
136
+ async function findFile(cfg, segments) {
137
+ if (!isServable(cfg, segments))
138
+ return null;
139
+ const located = await locate(cfg.root, segments, cfg.symlinks, cfg.allowCanonical);
140
+ if (!located?.stat.isFile())
141
+ return null;
142
+ return withVariants(cfg, segments, located.abs);
143
+ }
144
+ async function withVariants(cfg, segments, abs) {
145
+ const variants = {};
146
+ const last = segments[segments.length - 1];
147
+ for (const enc of cfg.precompressed) {
148
+ const suffix = enc === "br" ? ".br" : ".gz";
149
+ const sibling = await locate(cfg.root, [...segments.slice(0, -1), last + suffix], cfg.symlinks, cfg.allowCanonical);
150
+ if (sibling?.stat.isFile())
151
+ variants[enc] = sibling.abs;
152
+ }
153
+ return { kind: "file", rel: segments.join("/"), abs, variants };
154
+ }
155
+ /**
156
+ * Redirect `/dir` to `/dir/` with a relative `Location` built from the last
157
+ * URL segment. `./` keeps a segment like `https:evil.com` from reading as an
158
+ * absolute URL; nothing here can produce `//host`.
159
+ */
160
+ function redirectToDirectory(req) {
161
+ const url = new URL(req.url);
162
+ const last = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
163
+ return new Response(null, {
164
+ status: 301,
165
+ headers: { Location: `./${last}/${url.search}` },
166
+ });
167
+ }
168
+ function isFallbackEligible(req, segments) {
169
+ const last = segments[segments.length - 1] ?? "";
170
+ if (path.extname(last) !== "")
171
+ return false;
172
+ return acceptsHtml(req.headers.get("accept"));
173
+ }
174
+ /** Bounded LRU of lookups. Misses expire sooner than hits. Internal; exported for tests. */
175
+ export class LookupCache {
176
+ #entries = new Map();
177
+ #ttlMs;
178
+ #maxEntries;
179
+ constructor(opts) {
180
+ this.#ttlMs = opts.ttlMs;
181
+ this.#maxEntries = opts.maxEntries;
182
+ }
183
+ get size() {
184
+ return this.#entries.size;
185
+ }
186
+ get(key) {
187
+ const entry = this.#entries.get(key);
188
+ if (!entry)
189
+ return undefined;
190
+ this.#entries.delete(key);
191
+ if (entry.expires <= Date.now())
192
+ return undefined;
193
+ this.#entries.set(key, entry); // most recently used goes last
194
+ return entry.lookup;
195
+ }
196
+ set(key, lookup) {
197
+ const ttl = lookup.kind === "missing" ? Math.min(this.#ttlMs, 1_000) : this.#ttlMs;
198
+ this.#entries.delete(key);
199
+ this.#entries.set(key, { lookup, expires: Date.now() + ttl });
200
+ while (this.#entries.size > this.#maxEntries) {
201
+ const oldest = this.#entries.keys().next().value;
202
+ this.#entries.delete(oldest);
203
+ }
204
+ }
205
+ delete(key) {
206
+ this.#entries.delete(key);
207
+ }
208
+ invalidate(relPath) {
209
+ if (relPath === undefined) {
210
+ this.#entries.clear();
211
+ return;
212
+ }
213
+ const key = relPath.replace(/^\/+/, "").replace(/\/+$/, "");
214
+ this.#entries.delete(key);
215
+ this.#entries.delete(`${key}/`);
216
+ }
217
+ }
@@ -0,0 +1,21 @@
1
+ import type { StaticRule } from "./types.ts";
2
+ export interface CompiledRule {
3
+ regex: RegExp;
4
+ rule: StaticRule;
5
+ }
6
+ export interface RuleResult {
7
+ deny: boolean;
8
+ cacheControl?: string;
9
+ headers: Record<string, string>;
10
+ }
11
+ /**
12
+ * Compile a route-style glob for matching relative file paths. Reuses the
13
+ * route compiler so there is one glob dialect and no user-supplied RegExp.
14
+ * Matching is case-insensitive so deny rules hold on case-insensitive disks.
15
+ */
16
+ export declare function compileGlob(pattern: string): RegExp;
17
+ /** True when `rel` (no leading slash) matches the compiled glob. */
18
+ export declare function matchesGlob(regex: RegExp, rel: string): boolean;
19
+ export declare function compileRules(rules: StaticRule[]): CompiledRule[];
20
+ /** Fold every matching rule in order; later rules win per field. */
21
+ export declare function applyRules(rules: CompiledRule[], rel: string): RuleResult;
@@ -0,0 +1,49 @@
1
+ import { buildRouteRegex } from "../core/index.js";
2
+ /**
3
+ * Compile a route-style glob for matching relative file paths. Reuses the
4
+ * route compiler so there is one glob dialect and no user-supplied RegExp.
5
+ * Matching is case-insensitive so deny rules hold on case-insensitive disks.
6
+ */
7
+ export function compileGlob(pattern) {
8
+ if (typeof pattern !== "string" || pattern.trim() === "") {
9
+ throw new Error("static: glob patterns must be non-empty strings");
10
+ }
11
+ const trimmed = pattern.replace(/^\/+/, "");
12
+ if (trimmed.split("/").some((seg) => seg.startsWith(":"))) {
13
+ throw new Error(`static: glob "${pattern}" has a ":param" segment; use * or ** instead`);
14
+ }
15
+ const { regex } = buildRouteRegex(`/${trimmed}`);
16
+ return new RegExp(regex.source, "i");
17
+ }
18
+ /** True when `rel` (no leading slash) matches the compiled glob. */
19
+ export function matchesGlob(regex, rel) {
20
+ return regex.test(`/${rel}`);
21
+ }
22
+ export function compileRules(rules) {
23
+ return rules.map((rule) => {
24
+ if (typeof rule !== "object" || rule === null) {
25
+ throw new Error("static: each rule must be an object");
26
+ }
27
+ for (const key of Object.keys(rule)) {
28
+ if (!["match", "cacheControl", "headers", "deny"].includes(key)) {
29
+ throw new Error(`static: unknown rule option "${key}"`);
30
+ }
31
+ }
32
+ return { regex: compileGlob(rule.match), rule };
33
+ });
34
+ }
35
+ /** Fold every matching rule in order; later rules win per field. */
36
+ export function applyRules(rules, rel) {
37
+ const result = { deny: false, headers: {} };
38
+ for (const { regex, rule } of rules) {
39
+ if (!matchesGlob(regex, rel))
40
+ continue;
41
+ if (rule.deny !== undefined)
42
+ result.deny = rule.deny;
43
+ if (rule.cacheControl !== undefined)
44
+ result.cacheControl = rule.cacheControl;
45
+ if (rule.headers)
46
+ Object.assign(result.headers, rule.headers);
47
+ }
48
+ return result;
49
+ }
@@ -0,0 +1,76 @@
1
+ /** Pre-compressed sibling encodings: `file.br` and `file.gz`. */
2
+ export type StaticEncoding = "br" | "gzip";
3
+ /** Per-path overrides, matched against the decoded relative path. */
4
+ export interface StaticRule {
5
+ /** Route-style glob (`*`, `**`, `{a,b}`), matched case-insensitively. */
6
+ match: string;
7
+ cacheControl?: string;
8
+ headers?: Record<string, string>;
9
+ /** Respond 404 as if the file did not exist. */
10
+ deny?: boolean;
11
+ }
12
+ /** What `setHeaders` learns about the file being served. */
13
+ export interface StaticFileInfo {
14
+ /** Relative `/`-joined path inside the root (absolute for `serveFile`). */
15
+ path: string;
16
+ size: number;
17
+ mtime: Date;
18
+ /** Set when a pre-compressed sibling is served. */
19
+ encoding?: StaticEncoding;
20
+ }
21
+ export type SetHeadersFn = (info: StaticFileInfo, headers: Headers) => void;
22
+ /** Response options shared by `staticFiles()` and `serveFile()`. */
23
+ export interface StaticResponseOptions {
24
+ /** Sibling encodings to look for, in server preference order. Default `[]`. */
25
+ precompressed?: StaticEncoding[];
26
+ /** Default `true`. */
27
+ etag?: boolean;
28
+ /** Default `true`. */
29
+ lastModified?: boolean;
30
+ /** Default `"no-cache"`. */
31
+ cacheControl?: string;
32
+ /** Added to every file response. */
33
+ headers?: Record<string, string>;
34
+ /**
35
+ * Serve files as untrusted (user uploads): CSP sandbox on every response and
36
+ * `Content-Disposition: attachment` for anything but passive media types.
37
+ */
38
+ untrusted?: boolean;
39
+ /** Runs last; may override any header. */
40
+ setHeaders?: SetHeadersFn;
41
+ }
42
+ export interface StaticCacheOptions {
43
+ /** Default 5000. */
44
+ ttlMs?: number;
45
+ /** Default 10000. */
46
+ maxEntries?: number;
47
+ }
48
+ export interface StaticOptions extends StaticResponseOptions {
49
+ /** Directory to serve. Resolved through symlinks once, at creation. */
50
+ root: string;
51
+ /** File names tried for directory requests. Default `["index.html"]`. */
52
+ index?: string[];
53
+ /** Extensions tried for extension-less misses, e.g. `["html"]`. Default `[]`. */
54
+ extensions?: string[];
55
+ /** `"hide"` (default) answers 404 for any path segment starting with `.`. */
56
+ dotfiles?: "hide" | "allow";
57
+ /** Dot-segments still served under `dotfiles: "hide"`, e.g. `[".well-known"]`. */
58
+ allowDotfiles?: string[];
59
+ /**
60
+ * `"within-root"` (default) follows links whose target stays inside the
61
+ * root; `"deny"` refuses any link; `"follow"` follows links anywhere.
62
+ * Defaults to `"deny"` when `untrusted` is set.
63
+ */
64
+ symlinks?: "within-root" | "deny" | "follow";
65
+ /** Ordered per-path overrides; later rules win per field. */
66
+ rules?: StaticRule[];
67
+ /** Globs this router never serves (and never falls back for). */
68
+ exclude?: string[];
69
+ /**
70
+ * File served for unknown extension-less paths when the client accepts
71
+ * HTML (SPA routing), e.g. `"index.html"`. Always sent `no-cache`.
72
+ */
73
+ fallback?: string;
74
+ /** Cache path resolution (not contents). `true` uses the defaults. */
75
+ cache?: boolean | StaticCacheOptions;
76
+ }
File without changes