@fishenv/http 0.0.2 → 0.0.4
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 +9 -4
- package/dist/contract/builder.js +2 -0
- package/dist/contract/index.d.ts +3 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/types.d.ts +9 -1
- package/dist/contract/ws.d.ts +178 -0
- package/dist/contract/ws.js +164 -0
- package/dist/core/compose.js +5 -0
- package/dist/core/error.d.ts +21 -0
- package/dist/core/error.js +50 -7
- package/dist/core/index.d.ts +7 -3
- package/dist/core/index.js +5 -1
- package/dist/core/matcher.js +2 -1
- package/dist/core/route-builder.d.ts +4 -4
- package/dist/core/router.d.ts +16 -2
- package/dist/core/router.js +45 -6
- package/dist/core/transport.d.ts +39 -0
- package/dist/core/transport.js +68 -0
- package/dist/core/types.d.ts +22 -3
- package/dist/core/upgrade.d.ts +85 -0
- package/dist/core/upgrade.js +110 -0
- package/dist/core/utils/url.d.ts +4 -1
- package/dist/core/utils/url.js +14 -3
- package/dist/sse/hub.js +15 -33
- package/dist/sse/types.d.ts +3 -5
- 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 +44 -0
- package/dist/ws/channels/handler.js +517 -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 +190 -0
- package/dist/ws/hub.js +562 -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 +14 -2
package/dist/sse/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Shared SSE data types. Deliberately free of class references so every other
|
|
3
3
|
* module in this package can import from here without a cycle.
|
|
4
4
|
*/
|
|
5
|
+
import type { Transport, TransportEnvelope } from "../core/transport.ts";
|
|
5
6
|
export interface SseEventOptions {
|
|
6
7
|
/** `event:` field — the client-side listener name. Defaults to `message`. */
|
|
7
8
|
event?: string;
|
|
@@ -25,7 +26,7 @@ export interface SseConnectionOptions {
|
|
|
25
26
|
onOverflow?: SseOverflowPolicy;
|
|
26
27
|
}
|
|
27
28
|
/** Cross-process fan-out envelope. Must be JSON-serializable. */
|
|
28
|
-
export interface SseTransportMessage {
|
|
29
|
+
export interface SseTransportMessage extends TransportEnvelope {
|
|
29
30
|
/** `null` means broadcast to every connection. */
|
|
30
31
|
key: string | null;
|
|
31
32
|
data: unknown;
|
|
@@ -36,10 +37,7 @@ export interface SseTransportMessage {
|
|
|
36
37
|
origin: string;
|
|
37
38
|
}
|
|
38
39
|
/** Seam for multi-instance deployments (Redis pub/sub, NATS, Postgres LISTEN…). */
|
|
39
|
-
export
|
|
40
|
-
publish(message: SseTransportMessage): void | Promise<void>;
|
|
41
|
-
subscribe(onMessage: (message: SseTransportMessage) => void): () => void;
|
|
42
|
-
}
|
|
40
|
+
export type SseTransport = Transport<SseTransportMessage>;
|
|
43
41
|
export interface SseHubOptions {
|
|
44
42
|
transport?: SseTransport;
|
|
45
43
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static file serving for `@fishenv/http`.
|
|
3
|
+
*
|
|
4
|
+
* `staticFiles()` returns an ordinary router, so it is mounted, prefixed and
|
|
5
|
+
* wrapped in middleware like any other:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* app.add("/assets", staticFiles({ root: "./public" }));
|
|
9
|
+
* app.add(api).add(spa({ root: "./dist", exclude: ["api/**"] }));
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* Requests are decoded and validated per segment, confined to the real root
|
|
13
|
+
* (symlinks included), dotfiles are hidden and every rejection is a plain 404.
|
|
14
|
+
*
|
|
15
|
+
* Module layout:
|
|
16
|
+
* - `types.ts` — option and rule types
|
|
17
|
+
* - `options.ts` — validation and normalization at creation (throws)
|
|
18
|
+
* - `resolve.ts` — request path → file on disk (the security core)
|
|
19
|
+
* - `rules.ts` — route-style globs for rules and `exclude`
|
|
20
|
+
* - `negotiate.ts` — Accept-Encoding, Accept and conditional requests
|
|
21
|
+
* - `response.ts` — headers and body; `serveFile()`
|
|
22
|
+
* - `router.ts` — `staticFiles()`, `spa()`, `withStatic()`
|
|
23
|
+
*/
|
|
24
|
+
export { serveFile } from "./response.ts";
|
|
25
|
+
export type { StaticRouter } from "./router.ts";
|
|
26
|
+
export { spa, staticFiles, withStatic } from "./router.ts";
|
|
27
|
+
export type { SetHeadersFn, StaticCacheOptions, StaticEncoding, StaticFileInfo, StaticOptions, StaticResponseOptions, StaticRule, } from "./types.ts";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static file serving for `@fishenv/http`.
|
|
3
|
+
*
|
|
4
|
+
* `staticFiles()` returns an ordinary router, so it is mounted, prefixed and
|
|
5
|
+
* wrapped in middleware like any other:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* app.add("/assets", staticFiles({ root: "./public" }));
|
|
9
|
+
* app.add(api).add(spa({ root: "./dist", exclude: ["api/**"] }));
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* Requests are decoded and validated per segment, confined to the real root
|
|
13
|
+
* (symlinks included), dotfiles are hidden and every rejection is a plain 404.
|
|
14
|
+
*
|
|
15
|
+
* Module layout:
|
|
16
|
+
* - `types.ts` — option and rule types
|
|
17
|
+
* - `options.ts` — validation and normalization at creation (throws)
|
|
18
|
+
* - `resolve.ts` — request path → file on disk (the security core)
|
|
19
|
+
* - `rules.ts` — route-style globs for rules and `exclude`
|
|
20
|
+
* - `negotiate.ts` — Accept-Encoding, Accept and conditional requests
|
|
21
|
+
* - `response.ts` — headers and body; `serveFile()`
|
|
22
|
+
* - `router.ts` — `staticFiles()`, `spa()`, `withStatic()`
|
|
23
|
+
*/
|
|
24
|
+
export { serveFile } from "./response.js";
|
|
25
|
+
export { spa, staticFiles, withStatic } from "./router.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { StaticEncoding } from "./types.ts";
|
|
2
|
+
interface QItem {
|
|
3
|
+
value: string;
|
|
4
|
+
q: number;
|
|
5
|
+
}
|
|
6
|
+
/** Parse a `token;q=0.5, other` header into lowercase values with weights. */
|
|
7
|
+
export declare function parseQList(header: string): QItem[];
|
|
8
|
+
/**
|
|
9
|
+
* Pick the best available encoding for an `Accept-Encoding` header. Highest
|
|
10
|
+
* q wins; ties go to `available` order (server preference). `q=0` refuses a
|
|
11
|
+
* coding, `*` covers codings not listed. No header means identity only.
|
|
12
|
+
*/
|
|
13
|
+
export declare function chooseEncoding(header: string | null, available: StaticEncoding[]): StaticEncoding | undefined;
|
|
14
|
+
/** True when the client accepts an HTML document (used for SPA fallback). */
|
|
15
|
+
export declare function acceptsHtml(header: string | null): boolean;
|
|
16
|
+
/** Weak ETag from size + mtime (no inode), suffixed per encoding. */
|
|
17
|
+
export declare function makeEtag(size: number, mtimeMs: number, encoding?: StaticEncoding): string;
|
|
18
|
+
/**
|
|
19
|
+
* Evaluate `If-None-Match` (weak comparison, `*`, lists), falling back to
|
|
20
|
+
* `If-Modified-Since` only when `If-None-Match` is absent (RFC 9110 §13.2.2).
|
|
21
|
+
*/
|
|
22
|
+
export declare function isNotModified(req: Request, etag: string | undefined, mtimeMs: number | undefined): boolean;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Parse a `token;q=0.5, other` header into lowercase values with weights. */
|
|
2
|
+
export function parseQList(header) {
|
|
3
|
+
const items = [];
|
|
4
|
+
for (const part of header.split(",")) {
|
|
5
|
+
const [rawValue, ...params] = part.split(";");
|
|
6
|
+
const value = rawValue.trim().toLowerCase();
|
|
7
|
+
if (!value)
|
|
8
|
+
continue;
|
|
9
|
+
let q = 1;
|
|
10
|
+
for (const param of params) {
|
|
11
|
+
const [k, v] = param.split("=").map((s) => s.trim());
|
|
12
|
+
if (k.toLowerCase() === "q") {
|
|
13
|
+
const n = Number(v);
|
|
14
|
+
q = Number.isFinite(n) ? Math.min(Math.max(n, 0), 1) : 0;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
items.push({ value, q });
|
|
18
|
+
}
|
|
19
|
+
return items;
|
|
20
|
+
}
|
|
21
|
+
const CODING_NAMES = {
|
|
22
|
+
br: ["br"],
|
|
23
|
+
gzip: ["gzip", "x-gzip"],
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Pick the best available encoding for an `Accept-Encoding` header. Highest
|
|
27
|
+
* q wins; ties go to `available` order (server preference). `q=0` refuses a
|
|
28
|
+
* coding, `*` covers codings not listed. No header means identity only.
|
|
29
|
+
*/
|
|
30
|
+
export function chooseEncoding(header, available) {
|
|
31
|
+
if (!header || available.length === 0)
|
|
32
|
+
return undefined;
|
|
33
|
+
const items = parseQList(header);
|
|
34
|
+
const star = items.find((i) => i.value === "*")?.q;
|
|
35
|
+
let best;
|
|
36
|
+
let bestQ = 0;
|
|
37
|
+
for (const enc of available) {
|
|
38
|
+
const explicit = items.find((i) => CODING_NAMES[enc].includes(i.value));
|
|
39
|
+
const q = explicit?.q ?? star ?? 0;
|
|
40
|
+
if (q > bestQ) {
|
|
41
|
+
best = enc;
|
|
42
|
+
bestQ = q;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return best;
|
|
46
|
+
}
|
|
47
|
+
/** True when the client accepts an HTML document (used for SPA fallback). */
|
|
48
|
+
export function acceptsHtml(header) {
|
|
49
|
+
if (!header)
|
|
50
|
+
return true;
|
|
51
|
+
return parseQList(header).some((i) => i.q > 0 &&
|
|
52
|
+
(i.value === "text/html" || i.value === "text/*" || i.value === "*/*"));
|
|
53
|
+
}
|
|
54
|
+
/** Weak ETag from size + mtime (no inode), suffixed per encoding. */
|
|
55
|
+
export function makeEtag(size, mtimeMs, encoding) {
|
|
56
|
+
const suffix = encoding ? `-${encoding}` : "";
|
|
57
|
+
return `W/"${size.toString(16)}-${Math.floor(mtimeMs).toString(16)}${suffix}"`;
|
|
58
|
+
}
|
|
59
|
+
function opaqueTag(tag) {
|
|
60
|
+
return tag.trim().replace(/^W\//, "");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Evaluate `If-None-Match` (weak comparison, `*`, lists), falling back to
|
|
64
|
+
* `If-Modified-Since` only when `If-None-Match` is absent (RFC 9110 §13.2.2).
|
|
65
|
+
*/
|
|
66
|
+
export function isNotModified(req, etag, mtimeMs) {
|
|
67
|
+
const inm = req.headers.get("if-none-match");
|
|
68
|
+
if (inm !== null) {
|
|
69
|
+
if (!etag)
|
|
70
|
+
return false;
|
|
71
|
+
if (inm.trim() === "*")
|
|
72
|
+
return true;
|
|
73
|
+
const ours = opaqueTag(etag);
|
|
74
|
+
return inm.split(",").some((t) => opaqueTag(t) === ours);
|
|
75
|
+
}
|
|
76
|
+
const ims = req.headers.get("if-modified-since");
|
|
77
|
+
if (ims !== null && mtimeMs !== undefined) {
|
|
78
|
+
const since = Date.parse(ims);
|
|
79
|
+
if (Number.isNaN(since))
|
|
80
|
+
return false;
|
|
81
|
+
// HTTP dates have second precision.
|
|
82
|
+
return Math.floor(mtimeMs / 1000) * 1000 <= since;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type CanonicalCheck, type SymlinkPolicy } from "./resolve.ts";
|
|
2
|
+
import { type CompiledRule } from "./rules.ts";
|
|
3
|
+
import type { SetHeadersFn, StaticEncoding, StaticOptions, StaticResponseOptions } from "./types.ts";
|
|
4
|
+
/** Normalized response options. */
|
|
5
|
+
export interface ResponseConfig {
|
|
6
|
+
precompressed: StaticEncoding[];
|
|
7
|
+
etag: boolean;
|
|
8
|
+
lastModified: boolean;
|
|
9
|
+
cacheControl: string;
|
|
10
|
+
headers: Record<string, string>;
|
|
11
|
+
untrusted: boolean;
|
|
12
|
+
setHeaders?: SetHeadersFn;
|
|
13
|
+
}
|
|
14
|
+
export interface StaticConfig extends ResponseConfig {
|
|
15
|
+
root: string;
|
|
16
|
+
index: string[];
|
|
17
|
+
extensions: string[];
|
|
18
|
+
dotfiles: "hide" | "allow";
|
|
19
|
+
allowDotfiles: Set<string>;
|
|
20
|
+
symlinks: SymlinkPolicy;
|
|
21
|
+
rules: CompiledRule[];
|
|
22
|
+
exclude: RegExp[];
|
|
23
|
+
fallback?: string[];
|
|
24
|
+
cache?: {
|
|
25
|
+
ttlMs: number;
|
|
26
|
+
maxEntries: number;
|
|
27
|
+
};
|
|
28
|
+
windows: boolean;
|
|
29
|
+
/** `passesFilePolicy` bound to this config, for `locate()`. */
|
|
30
|
+
allowCanonical: CanonicalCheck;
|
|
31
|
+
}
|
|
32
|
+
/** Validate and normalize the options shared with `serveFile()`. */
|
|
33
|
+
export declare function compileResponseOptions(opts: StaticResponseOptions): ResponseConfig;
|
|
34
|
+
export declare function compileServeFileOptions(opts: StaticResponseOptions): ResponseConfig;
|
|
35
|
+
/** True when `segments` may be served at all (dotfiles, deny, exclude). */
|
|
36
|
+
export declare function isServable(cfg: StaticConfig, segments: string[]): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Dotfile and deny rules. Unlike `exclude`, which carves out URL space,
|
|
39
|
+
* these protect files, so they are also applied to the canonical on-disk
|
|
40
|
+
* name of whatever a request resolves to.
|
|
41
|
+
*/
|
|
42
|
+
export declare function passesFilePolicy(cfg: StaticConfig, segments: string[]): boolean;
|
|
43
|
+
/** Validate options and resolve the root. Throws on any misconfiguration. */
|
|
44
|
+
export declare function compileStaticOptions(opts: StaticOptions): StaticConfig;
|
|
@@ -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>;
|