@dnax/serve 0.0.0-stage → 0.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 +81 -2
- package/index.ts +8 -0
- package/package.json +25 -4
- package/src/cli.ts +594 -0
- package/src/listing.ts +173 -0
- package/src/mime.ts +154 -0
- package/src/server.ts +861 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import type { Stats } from "node:fs";
|
|
4
|
+
import { createServer as createNetServer } from "node:net";
|
|
5
|
+
import { stat } from "node:fs/promises";
|
|
6
|
+
import { extname, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { isDotfilePath, renderDirectoryListing } from "./listing.ts";
|
|
8
|
+
import { lookupMime, parseMimeTypes, withCharset, type MimeOverrides } from "./mime.ts";
|
|
9
|
+
|
|
10
|
+
export interface ServeOptions {
|
|
11
|
+
/** Directory to serve. Defaults to the current directory. */
|
|
12
|
+
root?: string;
|
|
13
|
+
/** Port to listen on. Use 0 to scan from 8080 for a free port. */
|
|
14
|
+
port?: number;
|
|
15
|
+
/** Address to bind. Defaults to 0.0.0.0. */
|
|
16
|
+
address?: string;
|
|
17
|
+
/** Base path the server is mounted under. Defaults to "/". */
|
|
18
|
+
baseDir?: string;
|
|
19
|
+
/** Show directory listings. Defaults to true. */
|
|
20
|
+
showDir?: boolean;
|
|
21
|
+
/** Serve index.<ext> for directories. Defaults to true. */
|
|
22
|
+
autoIndex?: boolean;
|
|
23
|
+
/** Let -d override the magic 404.<ext> file. Defaults to false. */
|
|
24
|
+
dirOverrides404?: boolean;
|
|
25
|
+
/** Serve precompressed .gz files when accepted. */
|
|
26
|
+
gzip?: boolean;
|
|
27
|
+
/** Serve precompressed .br files when accepted. */
|
|
28
|
+
brotli?: boolean;
|
|
29
|
+
/** Default extension for extensionless requests. Defaults to "html". */
|
|
30
|
+
ext?: string | true;
|
|
31
|
+
/** Suppress request logs and startup output handled by the CLI. */
|
|
32
|
+
silent?: boolean;
|
|
33
|
+
/** Enable CORS, optionally with extra request headers. */
|
|
34
|
+
cors?: boolean | string;
|
|
35
|
+
/** Enable Cross-Origin-Opener-Policy. */
|
|
36
|
+
coop?: boolean;
|
|
37
|
+
/** Enable Private Network Access. */
|
|
38
|
+
privateNetworkAccess?: boolean;
|
|
39
|
+
/** Extra response headers, applied to every response. */
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
/** Cache-Control max-age in seconds. -1 disables caching. Defaults to 3600. */
|
|
42
|
+
cache?: number;
|
|
43
|
+
/** Idle connection timeout in seconds. 0 disables it. Defaults to 120. */
|
|
44
|
+
timeout?: number;
|
|
45
|
+
/** Use UTC dates in logs. */
|
|
46
|
+
utc?: boolean;
|
|
47
|
+
/** Include the client IP in logs. */
|
|
48
|
+
logIp?: boolean;
|
|
49
|
+
/** Proxy unresolved requests to this URL. */
|
|
50
|
+
proxy?: string;
|
|
51
|
+
/** Proxy every request to `proxy`. */
|
|
52
|
+
proxyAll?: boolean;
|
|
53
|
+
/** Basic auth username. */
|
|
54
|
+
username?: string;
|
|
55
|
+
/** Basic auth password. */
|
|
56
|
+
password?: string;
|
|
57
|
+
/** Enable TLS. */
|
|
58
|
+
ssl?: boolean;
|
|
59
|
+
/** TLS certificate path. Defaults to cert.pem. */
|
|
60
|
+
cert?: string;
|
|
61
|
+
/** TLS key path. Defaults to key.pem. */
|
|
62
|
+
key?: string;
|
|
63
|
+
/** TLS key passphrase. Defaults to NODE_HTTP_SERVER_SSL_PASSPHRASE. */
|
|
64
|
+
passphrase?: string;
|
|
65
|
+
/** Reply to /robots.txt. true serves "User-agent: *\nDisallow: /"; a string is served verbatim. */
|
|
66
|
+
robots?: boolean | string;
|
|
67
|
+
/** Allow serving dotfiles. Defaults to true. */
|
|
68
|
+
dotfiles?: boolean;
|
|
69
|
+
/** Path to a .types file (or inline JSON) with custom MIME mappings. */
|
|
70
|
+
mimetypes?: string;
|
|
71
|
+
/** Hide permission columns in directory listings. */
|
|
72
|
+
hidePermissions?: boolean;
|
|
73
|
+
/** Hosts allowed to access the server. Empty means all. */
|
|
74
|
+
allowedHosts?: string[];
|
|
75
|
+
/** Request log sink. Defaults to console.log. */
|
|
76
|
+
log?: (line: string) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ResolvedServeOptions {
|
|
80
|
+
root: string;
|
|
81
|
+
port: number;
|
|
82
|
+
address: string;
|
|
83
|
+
baseDir: string;
|
|
84
|
+
showDir: boolean;
|
|
85
|
+
autoIndex: boolean;
|
|
86
|
+
dirOverrides404: boolean;
|
|
87
|
+
gzip: boolean;
|
|
88
|
+
brotli: boolean;
|
|
89
|
+
ext: string;
|
|
90
|
+
silent: boolean;
|
|
91
|
+
cors: boolean;
|
|
92
|
+
corsHeaders?: string;
|
|
93
|
+
coop: boolean;
|
|
94
|
+
privateNetworkAccess: boolean;
|
|
95
|
+
headers: Record<string, string>;
|
|
96
|
+
cache: number;
|
|
97
|
+
timeout: number;
|
|
98
|
+
utc: boolean;
|
|
99
|
+
logIp: boolean;
|
|
100
|
+
proxy?: string;
|
|
101
|
+
proxyAll: boolean;
|
|
102
|
+
username?: string;
|
|
103
|
+
password?: string;
|
|
104
|
+
ssl: boolean;
|
|
105
|
+
cert: string;
|
|
106
|
+
key: string;
|
|
107
|
+
passphrase?: string;
|
|
108
|
+
robots: boolean | string;
|
|
109
|
+
dotfiles: boolean;
|
|
110
|
+
mimeTypes: MimeOverrides;
|
|
111
|
+
hidePermissions: boolean;
|
|
112
|
+
allowedHosts: string[];
|
|
113
|
+
log: (line: string) => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface StaticServer {
|
|
117
|
+
server: Bun.Server<undefined>;
|
|
118
|
+
options: ResolvedServeOptions;
|
|
119
|
+
port: number;
|
|
120
|
+
url: string;
|
|
121
|
+
stop(force?: boolean): void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type Resolution =
|
|
125
|
+
| { kind: "file"; path: string; stats: Stats; typePath: string; encoding?: "br" | "gzip" }
|
|
126
|
+
| { kind: "dir"; path: string; stats: Stats }
|
|
127
|
+
| { kind: "redirect"; location: string }
|
|
128
|
+
| { kind: "notFound"; magic: boolean };
|
|
129
|
+
|
|
130
|
+
function asBool(value: unknown, fallback: boolean): boolean {
|
|
131
|
+
if (value === undefined || value === null) return fallback;
|
|
132
|
+
if (typeof value === "string") return value !== "false";
|
|
133
|
+
return Boolean(value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeBaseDir(value: string): string {
|
|
137
|
+
let result = value.startsWith("/") ? value : `/${value}`;
|
|
138
|
+
if (!result.endsWith("/")) result += "/";
|
|
139
|
+
return result.replace(/\/+/g, "/");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeExt(value: ServeOptions["ext"]): string {
|
|
143
|
+
if (value === undefined || value === true) return "html";
|
|
144
|
+
return String(value).replace(/^\./, "");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function normalizeCache(value: ServeOptions["cache"]): number {
|
|
148
|
+
if (value === undefined) return 3600;
|
|
149
|
+
const parsed = Number(value);
|
|
150
|
+
return Number.isFinite(parsed) ? parsed : 3600;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeTimeout(value: ServeOptions["timeout"]): number {
|
|
154
|
+
if (value === undefined) return 120;
|
|
155
|
+
const parsed = Number(value);
|
|
156
|
+
return Number.isFinite(parsed) ? parsed : 120;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function resolveOptions(options: ServeOptions = {}): ResolvedServeOptions {
|
|
160
|
+
const root = resolve(options.root ?? ".");
|
|
161
|
+
const cors = Boolean(options.cors);
|
|
162
|
+
const corsHeaders = typeof options.cors === "string" ? options.cors : undefined;
|
|
163
|
+
|
|
164
|
+
let mimeTypes: MimeOverrides = {};
|
|
165
|
+
if (options.mimetypes) {
|
|
166
|
+
let input = options.mimetypes;
|
|
167
|
+
try {
|
|
168
|
+
const trimmed = input.trim();
|
|
169
|
+
if (!trimmed.startsWith("{")) input = readFileSync(input, "utf8");
|
|
170
|
+
mimeTypes = parseMimeTypes(input);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw new Error(`Could not read mimetypes file "${options.mimetypes}": ${(error as Error).message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let proxy = options.proxy;
|
|
177
|
+
if (proxy) {
|
|
178
|
+
try {
|
|
179
|
+
new URL(proxy);
|
|
180
|
+
} catch {
|
|
181
|
+
throw new Error(`Invalid proxy url: ${proxy}`);
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
proxy = undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const username = options.username;
|
|
188
|
+
const password = options.password;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
root,
|
|
192
|
+
port: options.port ?? 8080,
|
|
193
|
+
address: options.address ?? "0.0.0.0",
|
|
194
|
+
baseDir: normalizeBaseDir(options.baseDir ?? "/"),
|
|
195
|
+
showDir: asBool(options.showDir, true),
|
|
196
|
+
autoIndex: asBool(options.autoIndex, true),
|
|
197
|
+
dirOverrides404: asBool(options.dirOverrides404, false),
|
|
198
|
+
gzip: asBool(options.gzip, false),
|
|
199
|
+
brotli: asBool(options.brotli, false),
|
|
200
|
+
ext: normalizeExt(options.ext),
|
|
201
|
+
silent: asBool(options.silent, false),
|
|
202
|
+
cors,
|
|
203
|
+
...(corsHeaders ? { corsHeaders } : {}),
|
|
204
|
+
coop: asBool(options.coop, false),
|
|
205
|
+
privateNetworkAccess: asBool(options.privateNetworkAccess, false),
|
|
206
|
+
headers: options.headers ?? {},
|
|
207
|
+
cache: normalizeCache(options.cache),
|
|
208
|
+
timeout: normalizeTimeout(options.timeout),
|
|
209
|
+
utc: asBool(options.utc, false),
|
|
210
|
+
logIp: asBool(options.logIp, false),
|
|
211
|
+
...(proxy ? { proxy } : {}),
|
|
212
|
+
proxyAll: asBool(options.proxyAll, false),
|
|
213
|
+
...(username !== undefined ? { username } : {}),
|
|
214
|
+
...(password !== undefined ? { password } : {}),
|
|
215
|
+
ssl: asBool(options.ssl, false),
|
|
216
|
+
cert: options.cert ?? "cert.pem",
|
|
217
|
+
key: options.key ?? "key.pem",
|
|
218
|
+
...((options.passphrase ?? process.env.NODE_HTTP_SERVER_SSL_PASSPHRASE)
|
|
219
|
+
? { passphrase: options.passphrase ?? process.env.NODE_HTTP_SERVER_SSL_PASSPHRASE }
|
|
220
|
+
: {}),
|
|
221
|
+
robots: options.robots ?? false,
|
|
222
|
+
dotfiles: asBool(options.dotfiles, true),
|
|
223
|
+
mimeTypes,
|
|
224
|
+
hidePermissions: asBool(options.hidePermissions, false),
|
|
225
|
+
allowedHosts: (options.allowedHosts ?? []).map((host) => host.trim().toLowerCase()).filter(Boolean),
|
|
226
|
+
log: options.log ?? ((line: string) => console.log(line)),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function cacheControl(seconds: number): string {
|
|
231
|
+
return seconds === -1 ? "no-cache, no-store, must-revalidate" : `max-age=${seconds}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function weakEtag(stats: Stats): string {
|
|
235
|
+
return `W/"${stats.ino}-${stats.size}-${stats.mtime.toISOString()}"`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function baseHeaders(opts: ResolvedServeOptions): Headers {
|
|
239
|
+
const headers = new Headers();
|
|
240
|
+
headers.set("accept-ranges", "bytes");
|
|
241
|
+
|
|
242
|
+
if (opts.cors) {
|
|
243
|
+
headers.set("access-control-allow-origin", "*");
|
|
244
|
+
const allowHeaders = "Origin, X-Requested-With, Content-Type, Accept, Range";
|
|
245
|
+
headers.set(
|
|
246
|
+
"access-control-allow-headers",
|
|
247
|
+
opts.corsHeaders ? `${allowHeaders}, ${opts.corsHeaders}` : allowHeaders,
|
|
248
|
+
);
|
|
249
|
+
headers.set("access-control-allow-methods", "GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS");
|
|
250
|
+
}
|
|
251
|
+
if (opts.coop) headers.set("cross-origin-opener-policy", "same-origin");
|
|
252
|
+
if (opts.privateNetworkAccess) headers.set("access-control-allow-private-network", "true");
|
|
253
|
+
|
|
254
|
+
for (const [key, value] of Object.entries(opts.headers)) {
|
|
255
|
+
headers.set(key, value);
|
|
256
|
+
}
|
|
257
|
+
return headers;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function textResponse(
|
|
261
|
+
status: number,
|
|
262
|
+
body: string,
|
|
263
|
+
opts: ResolvedServeOptions,
|
|
264
|
+
extra?: Record<string, string>,
|
|
265
|
+
): Response {
|
|
266
|
+
const headers = baseHeaders(opts);
|
|
267
|
+
headers.set("content-type", "text/plain; charset=utf-8");
|
|
268
|
+
if (extra) {
|
|
269
|
+
for (const [key, value] of Object.entries(extra)) headers.set(key, value);
|
|
270
|
+
}
|
|
271
|
+
return new Response(body, { status, headers });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function escapeHtml(value: string): string {
|
|
275
|
+
return value
|
|
276
|
+
.replace(/&/g, "&")
|
|
277
|
+
.replace(/</g, "<")
|
|
278
|
+
.replace(/>/g, ">")
|
|
279
|
+
.replace(/"/g, """)
|
|
280
|
+
.replace(/'/g, "'");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function errorResponse(status: number, error: unknown, opts: ResolvedServeOptions): Response {
|
|
284
|
+
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
285
|
+
const headers = baseHeaders(opts);
|
|
286
|
+
headers.set("content-type", "text/html; charset=utf-8");
|
|
287
|
+
const body = `<!doctype html>
|
|
288
|
+
<html>
|
|
289
|
+
<head>
|
|
290
|
+
<meta charset="utf-8">
|
|
291
|
+
<title>${status} Internal Server Error</title>
|
|
292
|
+
</head>
|
|
293
|
+
<body>
|
|
294
|
+
<pre>${escapeHtml(message)}</pre>
|
|
295
|
+
</body>
|
|
296
|
+
</html>
|
|
297
|
+
`;
|
|
298
|
+
return new Response(body, { status, headers });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function statOrUndefined(path: string): Promise<Stats | undefined> {
|
|
302
|
+
try {
|
|
303
|
+
return await stat(path);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
306
|
+
if (code === "ENOENT" || code === "ENOTDIR") return undefined;
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function acceptsEncoding(req: Request, name: string): boolean {
|
|
312
|
+
const header = req.headers.get("accept-encoding");
|
|
313
|
+
if (!header) return false;
|
|
314
|
+
let wildcardQuality: number | undefined;
|
|
315
|
+
for (const part of header.split(",")) {
|
|
316
|
+
const [rawCoding, ...params] = part.trim().split(";");
|
|
317
|
+
const coding = rawCoding?.trim().toLowerCase();
|
|
318
|
+
if (!coding) continue;
|
|
319
|
+
let quality = 1;
|
|
320
|
+
for (const param of params) {
|
|
321
|
+
const match = /^\s*q\s*=\s*([0-9.]+)\s*$/i.exec(param);
|
|
322
|
+
if (match?.[1]) quality = Number(match[1]);
|
|
323
|
+
}
|
|
324
|
+
if (coding === name) return quality > 0;
|
|
325
|
+
if (coding === "*") wildcardQuality = quality;
|
|
326
|
+
}
|
|
327
|
+
return (wildcardQuality ?? 0) > 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function isGzipFile(path: string): Promise<boolean> {
|
|
331
|
+
try {
|
|
332
|
+
const head = new Uint8Array(await Bun.file(path).slice(0, 2).arrayBuffer());
|
|
333
|
+
return head.length === 2 && head[0] === 0x1f && head[1] === 0x8b;
|
|
334
|
+
} catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function findCompressed(
|
|
340
|
+
req: Request,
|
|
341
|
+
path: string,
|
|
342
|
+
opts: ResolvedServeOptions,
|
|
343
|
+
): Promise<Resolution | undefined> {
|
|
344
|
+
if (opts.brotli && acceptsEncoding(req, "br")) {
|
|
345
|
+
const candidate = `${path}.br`;
|
|
346
|
+
const stats = await statOrUndefined(candidate);
|
|
347
|
+
if (stats?.isFile()) {
|
|
348
|
+
return { kind: "file", path: candidate, stats, typePath: path, encoding: "br" };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (opts.gzip && acceptsEncoding(req, "gzip")) {
|
|
352
|
+
const candidate = `${path}.gz`;
|
|
353
|
+
const stats = await statOrUndefined(candidate);
|
|
354
|
+
if (stats?.isFile() && (await isGzipFile(candidate))) {
|
|
355
|
+
return { kind: "file", path: candidate, stats, typePath: path, encoding: "gzip" };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function resolvePath(
|
|
362
|
+
req: Request,
|
|
363
|
+
target: string,
|
|
364
|
+
pathname: string,
|
|
365
|
+
opts: ResolvedServeOptions,
|
|
366
|
+
): Promise<Resolution> {
|
|
367
|
+
const wantsDirectory = pathname.endsWith("/");
|
|
368
|
+
|
|
369
|
+
if (!wantsDirectory) {
|
|
370
|
+
const compressed = await findCompressed(req, target, opts);
|
|
371
|
+
if (compressed) return compressed;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let stats = await statOrUndefined(target);
|
|
375
|
+
|
|
376
|
+
if (stats?.isDirectory()) {
|
|
377
|
+
if (!wantsDirectory) {
|
|
378
|
+
return { kind: "redirect", location: `${pathname}/` };
|
|
379
|
+
}
|
|
380
|
+
if (opts.autoIndex) {
|
|
381
|
+
const indexFile = join(target, `index.${opts.ext}`);
|
|
382
|
+
const indexStats = await statOrUndefined(indexFile);
|
|
383
|
+
if (indexStats?.isFile()) {
|
|
384
|
+
return { kind: "file", path: indexFile, stats: indexStats, typePath: indexFile };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (opts.showDir) return { kind: "dir", path: target, stats };
|
|
388
|
+
return { kind: "notFound", magic: !opts.dirOverrides404 };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (stats?.isFile()) {
|
|
392
|
+
return { kind: "file", path: target, stats, typePath: target };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (opts.ext && !wantsDirectory && !extname(pathname)) {
|
|
396
|
+
const candidate = `${target}.${opts.ext}`;
|
|
397
|
+
stats = await statOrUndefined(candidate);
|
|
398
|
+
if (stats?.isFile()) {
|
|
399
|
+
const compressed = await findCompressed(req, candidate, opts);
|
|
400
|
+
if (compressed) return compressed;
|
|
401
|
+
return { kind: "file", path: candidate, stats, typePath: candidate };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return { kind: "notFound", magic: true };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function isNotModified(req: Request, etag: string, mtime: Date): boolean {
|
|
409
|
+
const ifNoneMatch = req.headers.get("if-none-match");
|
|
410
|
+
if (ifNoneMatch) {
|
|
411
|
+
if (ifNoneMatch.trim() === "*") return true;
|
|
412
|
+
const normalize = (value: string) => value.trim().replace(/^W\//, "");
|
|
413
|
+
const target = normalize(etag);
|
|
414
|
+
return ifNoneMatch.split(",").some((candidate) => normalize(candidate) === target);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const ifModifiedSince = req.headers.get("if-modified-since");
|
|
418
|
+
if (ifModifiedSince) {
|
|
419
|
+
const since = Date.parse(ifModifiedSince);
|
|
420
|
+
if (Number.isNaN(since)) return false;
|
|
421
|
+
return Math.floor(mtime.getTime() / 1000) <= Math.floor(since / 1000);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
interface ByteRange {
|
|
428
|
+
start: number;
|
|
429
|
+
end: number;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function parseRange(header: string | null, size: number): ByteRange | "invalid" | undefined {
|
|
433
|
+
if (!header) return undefined;
|
|
434
|
+
const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
|
|
435
|
+
if (!match) return "invalid";
|
|
436
|
+
|
|
437
|
+
const rawStart = match[1] ?? "";
|
|
438
|
+
const rawEnd = match[2] ?? "";
|
|
439
|
+
if (!rawStart && !rawEnd) return "invalid";
|
|
440
|
+
|
|
441
|
+
let start: number;
|
|
442
|
+
let end: number;
|
|
443
|
+
|
|
444
|
+
if (!rawStart) {
|
|
445
|
+
const suffix = Number(rawEnd);
|
|
446
|
+
if (!Number.isFinite(suffix) || suffix <= 0) return "invalid";
|
|
447
|
+
start = Math.max(size - suffix, 0);
|
|
448
|
+
end = size - 1;
|
|
449
|
+
} else {
|
|
450
|
+
start = Number(rawStart);
|
|
451
|
+
end = rawEnd ? Number(rawEnd) : size - 1;
|
|
452
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return "invalid";
|
|
453
|
+
end = Math.min(end, size - 1);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (start > end || start >= size || size === 0) return "invalid";
|
|
457
|
+
return { start, end };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function serveFile(
|
|
461
|
+
req: Request,
|
|
462
|
+
opts: ResolvedServeOptions,
|
|
463
|
+
filePath: string,
|
|
464
|
+
stats: Stats,
|
|
465
|
+
typePath: string,
|
|
466
|
+
encoding: "br" | "gzip" | undefined,
|
|
467
|
+
status = 200,
|
|
468
|
+
): Promise<Response> {
|
|
469
|
+
const headers = baseHeaders(opts);
|
|
470
|
+
const etag = weakEtag(stats);
|
|
471
|
+
headers.set("cache-control", cacheControl(opts.cache));
|
|
472
|
+
headers.set("last-modified", stats.mtime.toUTCString());
|
|
473
|
+
headers.set("etag", etag);
|
|
474
|
+
headers.set("content-type", withCharset(lookupMime(typePath, opts.mimeTypes)));
|
|
475
|
+
if (encoding) headers.set("content-encoding", encoding === "gzip" ? "gzip" : "br");
|
|
476
|
+
|
|
477
|
+
if (status === 200 && isNotModified(req, etag, stats.mtime)) {
|
|
478
|
+
return new Response(null, { status: 304, headers });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const range = status === 200 ? parseRange(req.headers.get("range"), stats.size) : undefined;
|
|
482
|
+
|
|
483
|
+
if (range === "invalid") {
|
|
484
|
+
return textResponse(416, "Requested range not satisfiable", opts, {
|
|
485
|
+
"content-range": `bytes */${stats.size}`,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (range) {
|
|
490
|
+
const length = range.end - range.start + 1;
|
|
491
|
+
headers.set("content-range", `bytes ${range.start}-${range.end}/${stats.size}`);
|
|
492
|
+
headers.set("content-length", String(length));
|
|
493
|
+
const body = req.method === "HEAD" ? null : Bun.file(filePath).slice(range.start, range.end + 1);
|
|
494
|
+
return new Response(body, { status: 206, headers });
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
headers.set("content-length", String(stats.size));
|
|
498
|
+
const body = req.method === "HEAD" ? null : Bun.file(filePath);
|
|
499
|
+
return new Response(body, { status, headers });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function serveDirectory(
|
|
503
|
+
req: Request,
|
|
504
|
+
opts: ResolvedServeOptions,
|
|
505
|
+
dir: string,
|
|
506
|
+
stats: Stats,
|
|
507
|
+
pathname: string,
|
|
508
|
+
): Promise<Response> {
|
|
509
|
+
const html = await renderDirectoryListing(dir, opts.root, pathname, req.headers.get("host") ?? "", {
|
|
510
|
+
hidePermissions: opts.hidePermissions,
|
|
511
|
+
dotfiles: opts.dotfiles,
|
|
512
|
+
});
|
|
513
|
+
const payload = Buffer.from(html);
|
|
514
|
+
const headers = baseHeaders(opts);
|
|
515
|
+
headers.set("content-type", "text/html; charset=utf-8");
|
|
516
|
+
headers.set("cache-control", cacheControl(opts.cache));
|
|
517
|
+
headers.set("last-modified", stats.mtime.toUTCString());
|
|
518
|
+
headers.set("etag", weakEtag(stats));
|
|
519
|
+
headers.set("content-length", String(payload.byteLength));
|
|
520
|
+
return new Response(req.method === "HEAD" ? null : payload, { headers });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function serveNotFound(
|
|
524
|
+
req: Request,
|
|
525
|
+
opts: ResolvedServeOptions,
|
|
526
|
+
allowMagic: boolean,
|
|
527
|
+
): Promise<Response> {
|
|
528
|
+
if (allowMagic) {
|
|
529
|
+
const magicPath = join(opts.root, opts.baseDir, `404.${opts.ext}`);
|
|
530
|
+
const stats = await statOrUndefined(magicPath);
|
|
531
|
+
if (stats?.isFile()) {
|
|
532
|
+
return serveFile(req, opts, magicPath, stats, magicPath, undefined, 404);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return textResponse(404, "File not found. :(", opts);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function decodeBasicAuth(header: string): string | undefined {
|
|
539
|
+
if (!header.toLowerCase().startsWith("basic ")) return undefined;
|
|
540
|
+
try {
|
|
541
|
+
return atob(header.slice(6).trim());
|
|
542
|
+
} catch {
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function authorized(req: Request, opts: ResolvedServeOptions): boolean {
|
|
548
|
+
const credentials = decodeBasicAuth(req.headers.get("authorization") ?? "");
|
|
549
|
+
if (credentials === undefined) return false;
|
|
550
|
+
const expected = `${opts.username ?? ""}:${opts.password ?? ""}`;
|
|
551
|
+
const receivedDigest = createHash("sha256").update(credentials).digest();
|
|
552
|
+
const expectedDigest = createHash("sha256").update(expected).digest();
|
|
553
|
+
return timingSafeEqual(receivedDigest, expectedDigest);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function hostAllowed(req: Request, opts: ResolvedServeOptions): boolean {
|
|
557
|
+
if (opts.allowedHosts.length === 0) return true;
|
|
558
|
+
const rawHost = (req.headers.get("host") ?? "").toLowerCase();
|
|
559
|
+
if (!rawHost) return false;
|
|
560
|
+
const hostname = rawHost.replace(/:\d+$/, "");
|
|
561
|
+
|
|
562
|
+
return opts.allowedHosts.some((pattern) => {
|
|
563
|
+
if (pattern.includes(":")) return pattern === rawHost;
|
|
564
|
+
if (pattern.startsWith("*.")) {
|
|
565
|
+
const suffix = pattern.slice(1);
|
|
566
|
+
return hostname === pattern.slice(2) || hostname.endsWith(suffix);
|
|
567
|
+
}
|
|
568
|
+
return hostname === pattern;
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function clientIp(req: Request, server: Bun.Server<undefined>): string {
|
|
573
|
+
return req.headers.get("x-forwarded-for") ?? server.requestIP(req)?.address ?? "-";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function proxyRequest(
|
|
577
|
+
req: Request,
|
|
578
|
+
opts: ResolvedServeOptions,
|
|
579
|
+
server: Bun.Server<undefined>,
|
|
580
|
+
): Promise<Response> {
|
|
581
|
+
const url = new URL(req.url);
|
|
582
|
+
if (!opts.proxy) return textResponse(502, "Proxy is not configured", opts);
|
|
583
|
+
const target = new URL(url.pathname + url.search, opts.proxy);
|
|
584
|
+
|
|
585
|
+
const headers = new Headers(req.headers);
|
|
586
|
+
for (const header of [
|
|
587
|
+
"host",
|
|
588
|
+
"connection",
|
|
589
|
+
"keep-alive",
|
|
590
|
+
"proxy-authenticate",
|
|
591
|
+
"proxy-authorization",
|
|
592
|
+
"te",
|
|
593
|
+
"trailer",
|
|
594
|
+
"transfer-encoding",
|
|
595
|
+
"upgrade",
|
|
596
|
+
"expect",
|
|
597
|
+
]) {
|
|
598
|
+
headers.delete(header);
|
|
599
|
+
}
|
|
600
|
+
headers.set("accept-encoding", "identity");
|
|
601
|
+
headers.set("x-forwarded-for", [req.headers.get("x-forwarded-for"), clientIp(req, server)].filter(Boolean).join(", "));
|
|
602
|
+
headers.set("x-forwarded-host", req.headers.get("host") ?? "");
|
|
603
|
+
headers.set("x-forwarded-proto", url.protocol.replace(/:$/, ""));
|
|
604
|
+
|
|
605
|
+
const init: RequestInit = { method: req.method, headers, redirect: "manual" };
|
|
606
|
+
if (req.method !== "GET" && req.method !== "HEAD" && req.body) {
|
|
607
|
+
init.body = req.body;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
let upstream: Response;
|
|
611
|
+
try {
|
|
612
|
+
upstream = await fetch(target, init);
|
|
613
|
+
} catch (error) {
|
|
614
|
+
return textResponse(502, `Proxy error: ${(error as Error).message}`, opts);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const responseHeaders = new Headers(upstream.headers);
|
|
618
|
+
responseHeaders.delete("content-length");
|
|
619
|
+
const body = req.method === "HEAD" ? null : upstream.body;
|
|
620
|
+
return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function notFoundOrProxy(
|
|
624
|
+
req: Request,
|
|
625
|
+
opts: ResolvedServeOptions,
|
|
626
|
+
server: Bun.Server<undefined>,
|
|
627
|
+
allowMagic: boolean,
|
|
628
|
+
): Promise<Response> {
|
|
629
|
+
if (opts.proxy) return proxyRequest(req, opts, server);
|
|
630
|
+
return serveNotFound(req, opts, allowMagic);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function handleRequest(
|
|
634
|
+
req: Request,
|
|
635
|
+
server: Bun.Server<undefined>,
|
|
636
|
+
opts: ResolvedServeOptions,
|
|
637
|
+
): Promise<Response> {
|
|
638
|
+
if ((opts.username !== undefined || opts.password !== undefined) && !authorized(req, opts)) {
|
|
639
|
+
return textResponse(401, "Access denied", opts, { "www-authenticate": 'Basic realm=""' });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (!hostAllowed(req, opts)) {
|
|
643
|
+
return textResponse(403, "ACCESS DENIED", opts);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (opts.cors || opts.privateNetworkAccess) {
|
|
647
|
+
if (req.method === "OPTIONS") {
|
|
648
|
+
return new Response(null, { status: 204, headers: baseHeaders(opts) });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
653
|
+
return textResponse(405, "", opts, { allow: "GET, HEAD" });
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const url = new URL(req.url);
|
|
657
|
+
|
|
658
|
+
if (opts.robots && url.pathname === "/robots.txt") {
|
|
659
|
+
const body = opts.robots === true ? "User-agent: *\nDisallow: /" : String(opts.robots);
|
|
660
|
+
const headers = baseHeaders(opts);
|
|
661
|
+
headers.set("content-type", "text/plain");
|
|
662
|
+
return new Response(body, { headers });
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (opts.proxyAll && opts.proxy) {
|
|
666
|
+
return proxyRequest(req, opts, server);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
let pathname: string;
|
|
670
|
+
try {
|
|
671
|
+
pathname = decodeURIComponent(url.pathname);
|
|
672
|
+
} catch {
|
|
673
|
+
return textResponse(400, "Malformed request.", opts);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
pathname = pathname.replace(/\\/g, "/");
|
|
677
|
+
if (pathname.includes("\0")) {
|
|
678
|
+
return textResponse(400, "Malformed request.", opts);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
let relativePath: string;
|
|
682
|
+
if (opts.baseDir === "/") {
|
|
683
|
+
relativePath = pathname;
|
|
684
|
+
} else {
|
|
685
|
+
const bare = opts.baseDir.slice(0, -1);
|
|
686
|
+
if (pathname === bare || pathname === opts.baseDir) {
|
|
687
|
+
relativePath = "/";
|
|
688
|
+
} else if (pathname.startsWith(opts.baseDir)) {
|
|
689
|
+
relativePath = `/${pathname.slice(opts.baseDir.length)}`;
|
|
690
|
+
} else {
|
|
691
|
+
return notFoundOrProxy(req, opts, server, true);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const target = resolve(opts.root, `.${relativePath}`);
|
|
696
|
+
const rootPrefix = opts.root.endsWith(sep) ? opts.root : `${opts.root}${sep}`;
|
|
697
|
+
if (target !== opts.root && !target.startsWith(rootPrefix)) {
|
|
698
|
+
return textResponse(403, "ACCESS DENIED", opts);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (!opts.dotfiles && isDotfilePath(relative(opts.root, target))) {
|
|
702
|
+
return notFoundOrProxy(req, opts, server, true);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const resolution = await resolvePath(req, target, pathname, opts);
|
|
706
|
+
|
|
707
|
+
switch (resolution.kind) {
|
|
708
|
+
case "file":
|
|
709
|
+
return serveFile(req, opts, resolution.path, resolution.stats, resolution.typePath, resolution.encoding);
|
|
710
|
+
case "dir":
|
|
711
|
+
return serveDirectory(req, opts, resolution.path, resolution.stats, pathname);
|
|
712
|
+
case "redirect": {
|
|
713
|
+
const headers = baseHeaders(opts);
|
|
714
|
+
headers.set("location", `${resolution.location}${url.search}`);
|
|
715
|
+
return new Response(null, { status: 302, headers });
|
|
716
|
+
}
|
|
717
|
+
case "notFound":
|
|
718
|
+
return notFoundOrProxy(req, opts, server, resolution.magic);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function createRequestHandler(
|
|
723
|
+
options: ResolvedServeOptions,
|
|
724
|
+
): (req: Request, server: Bun.Server<undefined>) => Promise<Response> {
|
|
725
|
+
return async (req, server) => {
|
|
726
|
+
try {
|
|
727
|
+
const response = await handleRequest(req, server, options);
|
|
728
|
+
logRequest(req, server, options);
|
|
729
|
+
return response;
|
|
730
|
+
} catch (error) {
|
|
731
|
+
if (!options.silent) {
|
|
732
|
+
options.log(`[error] ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
733
|
+
}
|
|
734
|
+
return errorResponse(500, error, options);
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function logRequest(req: Request, server: Bun.Server<undefined>, opts: ResolvedServeOptions): void {
|
|
740
|
+
if (opts.silent) return;
|
|
741
|
+
const stamp = opts.utc ? new Date().toUTCString() : new Date().toString();
|
|
742
|
+
const ip = opts.logIp ? `${clientIp(req, server)} ` : "";
|
|
743
|
+
const url = new URL(req.url);
|
|
744
|
+
opts.log(
|
|
745
|
+
`[${stamp}] ${ip}"${req.method} ${url.pathname}${url.search}" "${req.headers.get("user-agent") ?? ""}"`,
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function isAddressInUse(error: unknown): boolean {
|
|
750
|
+
const candidate = error as { code?: string; message?: string };
|
|
751
|
+
return candidate?.code === "EADDRINUSE" || /EADDRINUSE|address already in use/i.test(candidate?.message ?? "");
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Bun sets SO_REUSEPORT, so two Bun servers can bind the same port without an
|
|
756
|
+
* error. Probe with a plain TCP listener (which does not set SO_REUSEPORT) to
|
|
757
|
+
* reproduce http-server's "port in use" detection.
|
|
758
|
+
*/
|
|
759
|
+
function portAvailable(port: number, hostname: string): Promise<boolean> {
|
|
760
|
+
return new Promise((resolvePort) => {
|
|
761
|
+
const probe = createNetServer();
|
|
762
|
+
const finish = (available: boolean): void => {
|
|
763
|
+
probe.removeAllListeners();
|
|
764
|
+
if (probe.listening) probe.close(() => resolvePort(available));
|
|
765
|
+
else resolvePort(available);
|
|
766
|
+
};
|
|
767
|
+
probe.once("error", () => finish(false));
|
|
768
|
+
probe.once("listening", () => finish(true));
|
|
769
|
+
try {
|
|
770
|
+
probe.listen(port, hostname);
|
|
771
|
+
} catch {
|
|
772
|
+
finish(false);
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function* scannedPorts(start = 8080): Generator<number> {
|
|
778
|
+
for (let port = start; port <= 65535; port += 1) yield port;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function displayHost(address: string): string {
|
|
782
|
+
if (address === "0.0.0.0" || address === "::" || address === "") return "127.0.0.1";
|
|
783
|
+
return address;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export async function startStaticServer(options: ServeOptions = {}): Promise<StaticServer> {
|
|
787
|
+
const opts = resolveOptions(options);
|
|
788
|
+
|
|
789
|
+
if (opts.ssl) {
|
|
790
|
+
for (const [label, file] of [
|
|
791
|
+
["certificate", opts.cert],
|
|
792
|
+
["private key", opts.key],
|
|
793
|
+
] as const) {
|
|
794
|
+
try {
|
|
795
|
+
statSyncExists(file);
|
|
796
|
+
} catch {
|
|
797
|
+
throw new Error(`Could not find ${label} ${file}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const handler = createRequestHandler(opts);
|
|
803
|
+
const ports = opts.port === 0 ? scannedPorts() : [opts.port];
|
|
804
|
+
let server: Bun.Server<undefined> | undefined;
|
|
805
|
+
let lastError: unknown;
|
|
806
|
+
|
|
807
|
+
for (const port of ports) {
|
|
808
|
+
const available = await portAvailable(port, opts.address);
|
|
809
|
+
if (!available) {
|
|
810
|
+
if (opts.port === 0) continue;
|
|
811
|
+
throw new Error(`Port ${port} is already in use`);
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
server = Bun.serve({
|
|
815
|
+
port,
|
|
816
|
+
hostname: opts.address,
|
|
817
|
+
idleTimeout: opts.timeout === 0 ? 0 : Math.min(255, Math.max(1, opts.timeout)),
|
|
818
|
+
development: false,
|
|
819
|
+
...(opts.ssl
|
|
820
|
+
? {
|
|
821
|
+
tls: {
|
|
822
|
+
cert: Bun.file(opts.cert),
|
|
823
|
+
key: Bun.file(opts.key),
|
|
824
|
+
...(opts.passphrase ? { passphrase: opts.passphrase } : {}),
|
|
825
|
+
},
|
|
826
|
+
}
|
|
827
|
+
: {}),
|
|
828
|
+
fetch: (req, bunServer) => handler(req, bunServer),
|
|
829
|
+
});
|
|
830
|
+
break;
|
|
831
|
+
} catch (error) {
|
|
832
|
+
lastError = error;
|
|
833
|
+
if (opts.port === 0 && isAddressInUse(error)) continue;
|
|
834
|
+
if (isAddressInUse(error)) {
|
|
835
|
+
throw new Error(`Port ${port} is already in use`);
|
|
836
|
+
}
|
|
837
|
+
throw error;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (!server) {
|
|
842
|
+
throw new Error(`Could not find an open port: ${(lastError as Error)?.message ?? "unknown error"}`);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const protocol = opts.ssl ? "https" : "http";
|
|
846
|
+
const port = server.port ?? opts.port;
|
|
847
|
+
|
|
848
|
+
return {
|
|
849
|
+
server,
|
|
850
|
+
options: opts,
|
|
851
|
+
port,
|
|
852
|
+
url: `${protocol}://${displayHost(opts.address)}:${port}`,
|
|
853
|
+
stop(force = true) {
|
|
854
|
+
server?.stop(force);
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function statSyncExists(path: string): void {
|
|
860
|
+
statSync(path);
|
|
861
|
+
}
|