@fishenv/http 0.0.0-stage → 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.
- package/dist/contract/builder.d.ts +87 -0
- package/dist/contract/builder.js +89 -0
- package/dist/contract/index.d.ts +4 -0
- package/dist/contract/index.js +2 -0
- package/dist/contract/types.d.ts +47 -0
- package/dist/contract/types.js +0 -0
- package/dist/contract/ws.d.ts +178 -0
- package/dist/contract/ws.js +164 -0
- package/dist/core/compose.d.ts +22 -0
- package/dist/core/compose.js +87 -0
- package/dist/core/cors.d.ts +38 -0
- package/dist/core/cors.js +96 -0
- package/dist/core/error.d.ts +73 -0
- package/dist/core/error.js +142 -0
- package/dist/core/index.d.ts +20 -0
- package/dist/core/index.js +23 -0
- package/dist/core/matcher.d.ts +17 -0
- package/dist/core/matcher.js +110 -0
- package/dist/core/request.d.ts +28 -0
- package/dist/core/request.js +106 -0
- package/dist/core/route-builder.d.ts +58 -0
- package/dist/core/route-builder.js +122 -0
- package/dist/core/router.d.ts +162 -0
- package/dist/core/router.js +327 -0
- package/dist/core/transport.d.ts +39 -0
- package/dist/core/transport.js +68 -0
- package/dist/core/types.d.ts +86 -0
- package/dist/core/types.js +0 -0
- package/dist/core/upgrade.d.ts +85 -0
- package/dist/core/upgrade.js +110 -0
- package/dist/core/utils/headers.d.ts +1 -0
- package/dist/core/utils/headers.js +2 -0
- package/dist/core/utils/url.d.ts +27 -0
- package/dist/core/utils/url.js +130 -0
- package/dist/core/validation.d.ts +13 -0
- package/dist/core/validation.js +24 -0
- package/dist/sse/connection.d.ts +30 -0
- package/dist/sse/connection.js +104 -0
- package/dist/sse/encode.d.ts +10 -0
- package/dist/sse/encode.js +27 -0
- package/dist/sse/hub.d.ts +33 -0
- package/dist/sse/hub.js +115 -0
- package/dist/sse/index.d.ts +38 -0
- package/dist/sse/index.js +40 -0
- package/dist/sse/response.d.ts +10 -0
- package/dist/sse/response.js +50 -0
- package/dist/sse/route-builder.d.ts +44 -0
- package/dist/sse/route-builder.js +65 -0
- package/dist/sse/router.d.ts +11 -0
- package/dist/sse/router.js +16 -0
- package/dist/sse/types.d.ts +43 -0
- package/dist/sse/types.js +4 -0
- package/dist/static/index.d.ts +27 -0
- package/dist/static/index.js +25 -0
- package/dist/static/negotiate.d.ts +23 -0
- package/dist/static/negotiate.js +85 -0
- package/dist/static/options.d.ts +44 -0
- package/dist/static/options.js +190 -0
- package/dist/static/resolve.d.ts +56 -0
- package/dist/static/resolve.js +113 -0
- package/dist/static/response.d.ts +53 -0
- package/dist/static/response.js +146 -0
- package/dist/static/router.d.ts +59 -0
- package/dist/static/router.js +217 -0
- package/dist/static/rules.d.ts +21 -0
- package/dist/static/rules.js +49 -0
- package/dist/static/types.d.ts +76 -0
- package/dist/static/types.js +0 -0
- package/dist/ws/channels/channel.d.ts +175 -0
- package/dist/ws/channels/channel.js +125 -0
- package/dist/ws/channels/handler.d.ts +33 -0
- package/dist/ws/channels/handler.js +486 -0
- package/dist/ws/channels/protocol.d.ts +96 -0
- package/dist/ws/channels/protocol.js +94 -0
- package/dist/ws/channels/target.d.ts +36 -0
- package/dist/ws/channels/target.js +56 -0
- package/dist/ws/connection.d.ts +56 -0
- package/dist/ws/connection.js +172 -0
- package/dist/ws/helpers.d.ts +17 -0
- package/dist/ws/helpers.js +48 -0
- package/dist/ws/hub.d.ts +172 -0
- package/dist/ws/hub.js +489 -0
- package/dist/ws/index.d.ts +65 -0
- package/dist/ws/index.js +66 -0
- package/dist/ws/lifecycle.d.ts +21 -0
- package/dist/ws/lifecycle.js +97 -0
- package/dist/ws/limits.d.ts +32 -0
- package/dist/ws/limits.js +67 -0
- package/dist/ws/message.d.ts +19 -0
- package/dist/ws/message.js +58 -0
- package/dist/ws/origin.d.ts +40 -0
- package/dist/ws/origin.js +152 -0
- package/dist/ws/presence.d.ts +96 -0
- package/dist/ws/presence.js +420 -0
- package/dist/ws/route-builder.d.ts +136 -0
- package/dist/ws/route-builder.js +265 -0
- package/dist/ws/router.d.ts +21 -0
- package/dist/ws/router.js +36 -0
- package/dist/ws/tickets.d.ts +82 -0
- package/dist/ws/tickets.js +161 -0
- package/dist/ws/typed.d.ts +54 -0
- package/dist/ws/typed.js +104 -0
- package/dist/ws/types.d.ts +69 -0
- package/dist/ws/types.js +70 -0
- package/package.json +54 -4
- package/README.md +0 -3
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isSafeSegment, isWithin, } from "./resolve.js";
|
|
4
|
+
import { applyRules, compileGlob, compileRules, matchesGlob, } from "./rules.js";
|
|
5
|
+
const RESPONSE_KEYS = [
|
|
6
|
+
"precompressed",
|
|
7
|
+
"etag",
|
|
8
|
+
"lastModified",
|
|
9
|
+
"cacheControl",
|
|
10
|
+
"headers",
|
|
11
|
+
"untrusted",
|
|
12
|
+
"setHeaders",
|
|
13
|
+
];
|
|
14
|
+
const STATIC_KEYS = new Set([
|
|
15
|
+
...RESPONSE_KEYS,
|
|
16
|
+
"root",
|
|
17
|
+
"index",
|
|
18
|
+
"extensions",
|
|
19
|
+
"dotfiles",
|
|
20
|
+
"allowDotfiles",
|
|
21
|
+
"symlinks",
|
|
22
|
+
"rules",
|
|
23
|
+
"exclude",
|
|
24
|
+
"fallback",
|
|
25
|
+
"cache",
|
|
26
|
+
]);
|
|
27
|
+
const ENCODINGS = ["br", "gzip"];
|
|
28
|
+
function fail(message) {
|
|
29
|
+
throw new Error(`static: ${message}`);
|
|
30
|
+
}
|
|
31
|
+
function checkKeys(opts, known, what) {
|
|
32
|
+
for (const key of Object.keys(opts)) {
|
|
33
|
+
if (!known.has(key))
|
|
34
|
+
fail(`unknown ${what} option "${key}"`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function stringArray(value, name) {
|
|
38
|
+
if (value === undefined)
|
|
39
|
+
return [];
|
|
40
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
41
|
+
fail(`"${name}" must be an array of strings`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
/** Validate and normalize the options shared with `serveFile()`. */
|
|
46
|
+
export function compileResponseOptions(opts) {
|
|
47
|
+
const precompressed = stringArray(opts.precompressed, "precompressed");
|
|
48
|
+
for (const enc of precompressed) {
|
|
49
|
+
if (!ENCODINGS.includes(enc)) {
|
|
50
|
+
fail(`unsupported precompressed encoding "${enc}" (use "br" | "gzip")`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (opts.setHeaders !== undefined && typeof opts.setHeaders !== "function") {
|
|
54
|
+
fail(`"setHeaders" must be a function`);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
precompressed: [...new Set(precompressed)],
|
|
58
|
+
etag: opts.etag ?? true,
|
|
59
|
+
lastModified: opts.lastModified ?? true,
|
|
60
|
+
cacheControl: opts.cacheControl ?? "no-cache",
|
|
61
|
+
headers: { ...opts.headers },
|
|
62
|
+
untrusted: opts.untrusted ?? false,
|
|
63
|
+
setHeaders: opts.setHeaders,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function compileServeFileOptions(opts) {
|
|
67
|
+
checkKeys(opts, new Set(RESPONSE_KEYS), "serveFile");
|
|
68
|
+
return compileResponseOptions(opts);
|
|
69
|
+
}
|
|
70
|
+
/** True when `segments` may be served at all (dotfiles, deny, exclude). */
|
|
71
|
+
export function isServable(cfg, segments) {
|
|
72
|
+
const rel = segments.join("/");
|
|
73
|
+
if (cfg.exclude.some((re) => matchesGlob(re, rel)))
|
|
74
|
+
return false;
|
|
75
|
+
return passesFilePolicy(cfg, segments);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Dotfile and deny rules. Unlike `exclude`, which carves out URL space,
|
|
79
|
+
* these protect files, so they are also applied to the canonical on-disk
|
|
80
|
+
* name of whatever a request resolves to.
|
|
81
|
+
*/
|
|
82
|
+
export function passesFilePolicy(cfg, segments) {
|
|
83
|
+
if (cfg.dotfiles === "hide") {
|
|
84
|
+
for (const segment of segments) {
|
|
85
|
+
if (segment.startsWith(".") &&
|
|
86
|
+
!cfg.allowDotfiles.has(segment.toLowerCase())) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return !applyRules(cfg.rules, segments.join("/")).deny;
|
|
92
|
+
}
|
|
93
|
+
/** Validate options and resolve the root. Throws on any misconfiguration. */
|
|
94
|
+
export function compileStaticOptions(opts) {
|
|
95
|
+
if (typeof opts !== "object" || opts === null)
|
|
96
|
+
fail("options are required");
|
|
97
|
+
checkKeys(opts, STATIC_KEYS, "staticFiles");
|
|
98
|
+
if (typeof opts.root !== "string" || opts.root === "") {
|
|
99
|
+
fail(`"root" is required`);
|
|
100
|
+
}
|
|
101
|
+
let root;
|
|
102
|
+
try {
|
|
103
|
+
root = realpathSync(path.resolve(opts.root));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
fail(`root "${opts.root}" does not exist`);
|
|
107
|
+
}
|
|
108
|
+
if (!statSync(root).isDirectory()) {
|
|
109
|
+
fail(`root "${opts.root}" is not a directory`);
|
|
110
|
+
}
|
|
111
|
+
if (path.parse(root).root === root) {
|
|
112
|
+
fail("refusing to serve the filesystem root");
|
|
113
|
+
}
|
|
114
|
+
const untrusted = opts.untrusted ?? false;
|
|
115
|
+
const dotfiles = opts.dotfiles ?? "hide";
|
|
116
|
+
if (dotfiles !== "hide" && dotfiles !== "allow") {
|
|
117
|
+
fail(`"dotfiles" must be "hide" or "allow"`);
|
|
118
|
+
}
|
|
119
|
+
if (untrusted && dotfiles === "allow") {
|
|
120
|
+
fail(`"dotfiles: allow" cannot be combined with "untrusted"`);
|
|
121
|
+
}
|
|
122
|
+
const symlinks = opts.symlinks ?? (untrusted ? "deny" : "within-root");
|
|
123
|
+
if (!["within-root", "deny", "follow"].includes(symlinks)) {
|
|
124
|
+
fail(`"symlinks" must be "within-root", "deny" or "follow"`);
|
|
125
|
+
}
|
|
126
|
+
const windows = process.platform === "win32";
|
|
127
|
+
const index = stringArray(opts.index ?? ["index.html"], "index");
|
|
128
|
+
for (const name of index) {
|
|
129
|
+
if (!isSafeSegment(name, windows))
|
|
130
|
+
fail(`invalid index name "${name}"`);
|
|
131
|
+
}
|
|
132
|
+
const extensions = stringArray(opts.extensions, "extensions").map((e) => e.replace(/^\./, ""));
|
|
133
|
+
for (const ext of extensions) {
|
|
134
|
+
if (!isSafeSegment(`x.${ext}`, windows) || ext === "") {
|
|
135
|
+
fail(`invalid extension "${ext}"`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
let cache;
|
|
139
|
+
if (opts.cache) {
|
|
140
|
+
const c = opts.cache === true ? {} : opts.cache;
|
|
141
|
+
checkKeys(c, new Set(["ttlMs", "maxEntries"]), "cache");
|
|
142
|
+
cache = { ttlMs: c.ttlMs ?? 5_000, maxEntries: c.maxEntries ?? 10_000 };
|
|
143
|
+
if (!(cache.ttlMs > 0) || !(cache.maxEntries >= 1)) {
|
|
144
|
+
fail(`"cache.ttlMs" and "cache.maxEntries" must be positive`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const cfg = {
|
|
148
|
+
...compileResponseOptions(opts),
|
|
149
|
+
root,
|
|
150
|
+
index,
|
|
151
|
+
extensions,
|
|
152
|
+
dotfiles,
|
|
153
|
+
allowDotfiles: new Set(stringArray(opts.allowDotfiles, "allowDotfiles").map((s) => s.toLowerCase())),
|
|
154
|
+
symlinks,
|
|
155
|
+
rules: compileRules(opts.rules ?? []),
|
|
156
|
+
exclude: stringArray(opts.exclude, "exclude").map(compileGlob),
|
|
157
|
+
cache,
|
|
158
|
+
windows,
|
|
159
|
+
allowCanonical: (segments) => passesFilePolicy(cfg, segments),
|
|
160
|
+
};
|
|
161
|
+
if (opts.fallback !== undefined) {
|
|
162
|
+
cfg.fallback = compileFallback(cfg, opts.fallback);
|
|
163
|
+
}
|
|
164
|
+
return cfg;
|
|
165
|
+
}
|
|
166
|
+
/** The fallback must be a servable regular file inside the root. */
|
|
167
|
+
function compileFallback(cfg, fallback) {
|
|
168
|
+
if (typeof fallback !== "string")
|
|
169
|
+
fail(`"fallback" must be a string`);
|
|
170
|
+
const segments = fallback.replace(/^\/+/, "").split("/");
|
|
171
|
+
if (!segments.every((s) => isSafeSegment(s, cfg.windows))) {
|
|
172
|
+
fail(`invalid fallback path "${fallback}"`);
|
|
173
|
+
}
|
|
174
|
+
if (!isServable(cfg, segments)) {
|
|
175
|
+
fail(`fallback "${fallback}" is hidden by dotfiles, rules or exclude`);
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const real = realpathSync(path.join(cfg.root, ...segments));
|
|
179
|
+
const canonical = path.relative(cfg.root, real).split(path.sep);
|
|
180
|
+
if (isWithin(cfg.root, real) &&
|
|
181
|
+
passesFilePolicy(cfg, canonical) &&
|
|
182
|
+
statSync(real).isFile()) {
|
|
183
|
+
return segments;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// fall through
|
|
188
|
+
}
|
|
189
|
+
return fail(`fallback "${fallback}" is not a file inside the root`);
|
|
190
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request path → file on disk. Every step either continues or rejects; see
|
|
3
|
+
* `.plan/12-static-files.md` ("Request → file pipeline") for the reasoning
|
|
4
|
+
* behind each check.
|
|
5
|
+
*/
|
|
6
|
+
import type { Stats } from "node:fs";
|
|
7
|
+
import { lstat, realpath, stat } from "node:fs/promises";
|
|
8
|
+
/**
|
|
9
|
+
* Filesystem calls used by the resolver. Exported so tests can observe how
|
|
10
|
+
* many syscalls a lookup performs; not part of the public API.
|
|
11
|
+
*/
|
|
12
|
+
export declare const fsOps: {
|
|
13
|
+
lstat: typeof lstat;
|
|
14
|
+
realpath: typeof realpath;
|
|
15
|
+
stat: typeof stat;
|
|
16
|
+
};
|
|
17
|
+
export interface ParsedPath {
|
|
18
|
+
/** Decoded, validated segments. Empty for the mount root. */
|
|
19
|
+
segments: string[];
|
|
20
|
+
/** The request ended in `/` (a directory request). */
|
|
21
|
+
trailingSlash: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Marker for `%` sequences that fail to decode (→ 400). */
|
|
24
|
+
export declare const BAD_ENCODING: unique symbol;
|
|
25
|
+
/** Validate one decoded segment (step 3). */
|
|
26
|
+
export declare function isSafeSegment(segment: string, windows: boolean): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Steps 1–3: split the raw (percent-encoded) remainder on `/`, then decode
|
|
29
|
+
* each segment exactly once and validate it. Splitting before decoding is
|
|
30
|
+
* what turns `..%2F..%2Fetc` into a single (rejected) segment.
|
|
31
|
+
*
|
|
32
|
+
* Returns `null` for a path that can never be served (→ 404).
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseRequestPath(raw: string | undefined, windows?: boolean): ParsedPath | typeof BAD_ENCODING | null;
|
|
35
|
+
/** Lexical containment: `candidate` is `root` or strictly below it. */
|
|
36
|
+
export declare function isWithin(root: string, candidate: string): boolean;
|
|
37
|
+
export type SymlinkPolicy = "within-root" | "deny" | "follow";
|
|
38
|
+
export interface Located {
|
|
39
|
+
/** Path to open: the resolved real path, except under `"follow"`. */
|
|
40
|
+
abs: string;
|
|
41
|
+
stat: Stats;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Policy check on the canonical (on-disk) segments of what was resolved.
|
|
45
|
+
* The request may have named it through an alias — an in-root symlink, a
|
|
46
|
+
* Windows 8.3 short name, other case on a case-insensitive disk, another
|
|
47
|
+
* Unicode normalization — so dotfile and deny rules must also hold here.
|
|
48
|
+
*/
|
|
49
|
+
export type CanonicalCheck = (segments: string[]) => boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Steps 6–8: map validated segments to a regular file or directory inside
|
|
52
|
+
* `root` (which must already be a real path). Anything else — missing,
|
|
53
|
+
* escaping, a forbidden symlink, a FIFO/socket/device, a canonical name
|
|
54
|
+
* that `allow` rejects — is `null`.
|
|
55
|
+
*/
|
|
56
|
+
export declare function locate(root: string, segments: string[], symlinks: SymlinkPolicy, allow?: CanonicalCheck): Promise<Located | null>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request path → file on disk. Every step either continues or rejects; see
|
|
3
|
+
* `.plan/12-static-files.md` ("Request → file pipeline") for the reasoning
|
|
4
|
+
* behind each check.
|
|
5
|
+
*/
|
|
6
|
+
import { lstat, realpath, stat } from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
/**
|
|
9
|
+
* Filesystem calls used by the resolver. Exported so tests can observe how
|
|
10
|
+
* many syscalls a lookup performs; not part of the public API.
|
|
11
|
+
*/
|
|
12
|
+
export const fsOps = { lstat, realpath, stat };
|
|
13
|
+
/** Marker for `%` sequences that fail to decode (→ 400). */
|
|
14
|
+
export const BAD_ENCODING = Symbol("bad-encoding");
|
|
15
|
+
// C0 controls and DEL.
|
|
16
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point
|
|
17
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
18
|
+
// Characters Windows refuses in names, plus `:` (alternate data streams).
|
|
19
|
+
const WINDOWS_FORBIDDEN = /[<>:"|?*]/;
|
|
20
|
+
const WINDOWS_DEVICE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
|
21
|
+
/** Validate one decoded segment (step 3). */
|
|
22
|
+
export function isSafeSegment(segment, windows) {
|
|
23
|
+
if (segment === "" || segment === "." || segment === "..")
|
|
24
|
+
return false;
|
|
25
|
+
if (segment.includes("/") || segment.includes("\\"))
|
|
26
|
+
return false;
|
|
27
|
+
if (CONTROL_CHARS.test(segment))
|
|
28
|
+
return false;
|
|
29
|
+
if (windows) {
|
|
30
|
+
if (WINDOWS_FORBIDDEN.test(segment))
|
|
31
|
+
return false;
|
|
32
|
+
if (/[. ]$/.test(segment))
|
|
33
|
+
return false;
|
|
34
|
+
if (WINDOWS_DEVICE.test(segment))
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Steps 1–3: split the raw (percent-encoded) remainder on `/`, then decode
|
|
41
|
+
* each segment exactly once and validate it. Splitting before decoding is
|
|
42
|
+
* what turns `..%2F..%2Fetc` into a single (rejected) segment.
|
|
43
|
+
*
|
|
44
|
+
* Returns `null` for a path that can never be served (→ 404).
|
|
45
|
+
*/
|
|
46
|
+
export function parseRequestPath(raw, windows = process.platform === "win32") {
|
|
47
|
+
if (raw === undefined || raw === "") {
|
|
48
|
+
// `/mount` (no slash) vs `/mount/`: the rest param is absent vs empty.
|
|
49
|
+
return { segments: [], trailingSlash: raw === "" };
|
|
50
|
+
}
|
|
51
|
+
const parts = raw.split("/");
|
|
52
|
+
const trailingSlash = parts[parts.length - 1] === "";
|
|
53
|
+
if (trailingSlash)
|
|
54
|
+
parts.pop();
|
|
55
|
+
const segments = [];
|
|
56
|
+
for (const part of parts) {
|
|
57
|
+
let decoded;
|
|
58
|
+
try {
|
|
59
|
+
decoded = decodeURIComponent(part);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return BAD_ENCODING;
|
|
63
|
+
}
|
|
64
|
+
if (!isSafeSegment(decoded, windows))
|
|
65
|
+
return null;
|
|
66
|
+
segments.push(decoded);
|
|
67
|
+
}
|
|
68
|
+
return { segments, trailingSlash };
|
|
69
|
+
}
|
|
70
|
+
/** Lexical containment: `candidate` is `root` or strictly below it. */
|
|
71
|
+
export function isWithin(root, candidate) {
|
|
72
|
+
return candidate === root || candidate.startsWith(root + path.sep);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Steps 6–8: map validated segments to a regular file or directory inside
|
|
76
|
+
* `root` (which must already be a real path). Anything else — missing,
|
|
77
|
+
* escaping, a forbidden symlink, a FIFO/socket/device, a canonical name
|
|
78
|
+
* that `allow` rejects — is `null`.
|
|
79
|
+
*/
|
|
80
|
+
export async function locate(root, segments, symlinks, allow) {
|
|
81
|
+
const candidate = path.join(root, ...segments);
|
|
82
|
+
if (!isWithin(root, candidate))
|
|
83
|
+
return null;
|
|
84
|
+
try {
|
|
85
|
+
if (symlinks === "deny") {
|
|
86
|
+
let current = root;
|
|
87
|
+
for (const segment of segments) {
|
|
88
|
+
current = path.join(current, segment);
|
|
89
|
+
if ((await fsOps.lstat(current)).isSymbolicLink())
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const real = await fsOps.realpath(candidate);
|
|
94
|
+
const inside = isWithin(root, real);
|
|
95
|
+
if (!inside && symlinks !== "follow")
|
|
96
|
+
return null;
|
|
97
|
+
// Under "follow" a target outside the root was explicitly allowed;
|
|
98
|
+
// there is no in-root name to check rules against.
|
|
99
|
+
if (inside && allow && !allow(canonicalSegments(root, real)))
|
|
100
|
+
return null;
|
|
101
|
+
const abs = symlinks === "follow" ? candidate : real;
|
|
102
|
+
const st = await fsOps.stat(abs);
|
|
103
|
+
if (!st.isFile() && !st.isDirectory())
|
|
104
|
+
return null;
|
|
105
|
+
return { abs, stat: st };
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function canonicalSegments(root, real) {
|
|
112
|
+
return path.relative(root, real).split(path.sep).filter(Boolean);
|
|
113
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ResponseConfig } from "./options.ts";
|
|
2
|
+
import type { StaticEncoding, StaticResponseOptions } from "./types.ts";
|
|
3
|
+
/** A file ready to be served, with its fresh `stat`. */
|
|
4
|
+
export interface ServableFile {
|
|
5
|
+
/** Path handed to `setHeaders` (relative for `staticFiles`). */
|
|
6
|
+
path: string;
|
|
7
|
+
/** Original file; decides `Content-Type` even when a variant is sent. */
|
|
8
|
+
contentTypeFrom: string;
|
|
9
|
+
/** File actually sent (original or `.br` / `.gz` sibling). */
|
|
10
|
+
abs: string;
|
|
11
|
+
size: number;
|
|
12
|
+
mtimeMs: number;
|
|
13
|
+
encoding?: StaticEncoding;
|
|
14
|
+
}
|
|
15
|
+
/** Per-response extras from `staticFiles` (rules, SPA fallback). */
|
|
16
|
+
export interface ResponseExtras {
|
|
17
|
+
cacheControl?: string;
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
/** SPA shell: force `no-cache` over options and rules. */
|
|
20
|
+
forceNoCache?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Build the response for a resolved file: headers, conditionals, body. */
|
|
23
|
+
export declare function buildFileResponse(req: Request, file: ServableFile, cfg: ResponseConfig, extras?: ResponseExtras): Response;
|
|
24
|
+
/** Stat a candidate and return it only if it is a regular file. */
|
|
25
|
+
export declare function statFile(abs: string): Promise<{
|
|
26
|
+
size: number;
|
|
27
|
+
mtimeMs: number;
|
|
28
|
+
} | null>;
|
|
29
|
+
/**
|
|
30
|
+
* Serve one known file from a handler, with the same headers, validators
|
|
31
|
+
* and pre-compressed negotiation as `staticFiles()`.
|
|
32
|
+
*
|
|
33
|
+
* `absPath` is trusted: never build it from request input — use
|
|
34
|
+
* `staticFiles()` for that. Throws `NotFoundError` when it is not a file.
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* api.get("/report").handle(({ req }) => serveFile(req, reportPath));
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare function serveFile(req: Request, absPath: string, opts?: StaticResponseOptions): Promise<Response>;
|
|
41
|
+
/**
|
|
42
|
+
* Choose an encoding among candidate sibling paths, stat the chosen file
|
|
43
|
+
* fresh, and fall back to the original when a variant has vanished.
|
|
44
|
+
*/
|
|
45
|
+
export declare function serveWithVariants(req: Request, cfg: ResponseConfig, target: {
|
|
46
|
+
path: string;
|
|
47
|
+
abs: string;
|
|
48
|
+
stat: {
|
|
49
|
+
size: number;
|
|
50
|
+
mtimeMs: number;
|
|
51
|
+
};
|
|
52
|
+
variants: Partial<Record<StaticEncoding, string>>;
|
|
53
|
+
}, extras?: ResponseExtras): Promise<Response>;
|
|
@@ -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 {};
|