busyserver 1.0.0
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/LICENSE +21 -0
- package/README.md +144 -0
- package/bin/busyserver.js +246 -0
- package/package.json +44 -0
- package/src/config.js +60 -0
- package/src/dashboard.js +32 -0
- package/src/mock.js +45 -0
- package/src/proxy.js +46 -0
- package/src/server.js +734 -0
- package/src/templates.js +330 -0
- package/src/watcher.js +106 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
const os = require("os");
|
|
2
|
+
const http = require("http");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const { execFile } = require("child_process");
|
|
6
|
+
const { WebSocketServer } = require("ws");
|
|
7
|
+
const { startWatcher } = require("./watcher");
|
|
8
|
+
const zlib = require("zlib");
|
|
9
|
+
const qrcode = require("qrcode-terminal");
|
|
10
|
+
// ── Colors ──────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const c = process.stdout.isTTY
|
|
13
|
+
? {
|
|
14
|
+
r: "\x1b[0m",
|
|
15
|
+
dim: "\x1b[2m",
|
|
16
|
+
bold: "\x1b[1m",
|
|
17
|
+
green: "\x1b[32m",
|
|
18
|
+
cyan: "\x1b[36m",
|
|
19
|
+
yellow: "\x1b[33m",
|
|
20
|
+
red: "\x1b[31m",
|
|
21
|
+
magenta: "\x1b[35m",
|
|
22
|
+
}
|
|
23
|
+
: { r: "", dim: "", bold: "", green: "", cyan: "", yellow: "", red: "", magenta: "" };
|
|
24
|
+
|
|
25
|
+
// ── MIME Types ───────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const MIME_TYPES = {
|
|
28
|
+
".html": "text/html; charset=utf-8",
|
|
29
|
+
".htm": "text/html; charset=utf-8",
|
|
30
|
+
".css": "text/css; charset=utf-8",
|
|
31
|
+
".js": "text/javascript; charset=utf-8",
|
|
32
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
33
|
+
".cjs": "text/javascript; charset=utf-8",
|
|
34
|
+
".json": "application/json; charset=utf-8",
|
|
35
|
+
".xml": "application/xml; charset=utf-8",
|
|
36
|
+
".txt": "text/plain; charset=utf-8",
|
|
37
|
+
".md": "text/markdown; charset=utf-8",
|
|
38
|
+
".csv": "text/csv; charset=utf-8",
|
|
39
|
+
".png": "image/png",
|
|
40
|
+
".jpg": "image/jpeg",
|
|
41
|
+
".jpeg": "image/jpeg",
|
|
42
|
+
".gif": "image/gif",
|
|
43
|
+
".webp": "image/webp",
|
|
44
|
+
".avif": "image/avif",
|
|
45
|
+
".svg": "image/svg+xml",
|
|
46
|
+
".ico": "image/x-icon",
|
|
47
|
+
".mp3": "audio/mpeg",
|
|
48
|
+
".wav": "audio/wav",
|
|
49
|
+
".ogg": "audio/ogg",
|
|
50
|
+
".flac": "audio/flac",
|
|
51
|
+
".aac": "audio/aac",
|
|
52
|
+
".mp4": "video/mp4",
|
|
53
|
+
".webm": "video/webm",
|
|
54
|
+
".ogv": "video/ogg",
|
|
55
|
+
".woff": "font/woff",
|
|
56
|
+
".woff2": "font/woff2",
|
|
57
|
+
".ttf": "font/ttf",
|
|
58
|
+
".otf": "font/otf",
|
|
59
|
+
".eot": "application/vnd.ms-fontobject",
|
|
60
|
+
".wasm": "application/wasm",
|
|
61
|
+
".map": "application/json",
|
|
62
|
+
".pdf": "application/pdf",
|
|
63
|
+
".zip": "application/zip",
|
|
64
|
+
".gz": "application/gzip",
|
|
65
|
+
".tar": "application/x-tar",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ── Utilities ───────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function getContentType(filePath) {
|
|
71
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
72
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isHtmlType(contentType) {
|
|
76
|
+
return contentType.startsWith("text/html");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isCompressible(contentType) {
|
|
80
|
+
return /text|javascript|json|xml|svg/i.test(contentType);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
function getNetworkAddress() {
|
|
86
|
+
try {
|
|
87
|
+
const interfaces = os.networkInterfaces();
|
|
88
|
+
for (const name of Object.keys(interfaces)) {
|
|
89
|
+
for (const net of interfaces[name] || []) {
|
|
90
|
+
if (net.family === "IPv4" && !net.internal) {
|
|
91
|
+
return net.address;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Network info unavailable
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function makeEtag(stat) {
|
|
102
|
+
return `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function shortenPath(fullPath) {
|
|
106
|
+
const home = os.homedir();
|
|
107
|
+
if (fullPath.startsWith(home)) {
|
|
108
|
+
return "~" + fullPath.slice(home.length);
|
|
109
|
+
}
|
|
110
|
+
return fullPath;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatMs(ms) {
|
|
114
|
+
if (ms < 1000) return `${ms}ms`;
|
|
115
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── HTML Templates ──────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const { getErrorPage, getDirectoryListing, injectLiveReload } = require("./templates");
|
|
121
|
+
|
|
122
|
+
// ── Port Management ─────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function listenOnPort(server, port, host) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
let settled = false;
|
|
127
|
+
|
|
128
|
+
const onError = (error) => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
settled = true;
|
|
131
|
+
server.removeListener("listening", onListening);
|
|
132
|
+
reject(error);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const onListening = () => {
|
|
136
|
+
if (settled) return;
|
|
137
|
+
settled = true;
|
|
138
|
+
server.removeListener("error", onError);
|
|
139
|
+
resolve(server.address().port);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
server.once("error", onError);
|
|
143
|
+
server.once("listening", onListening);
|
|
144
|
+
server.listen(port, host);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Browser Opening ─────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function openBrowser(url) {
|
|
151
|
+
const isTermux = process.env.PREFIX && process.env.PREFIX.includes("com.termux");
|
|
152
|
+
let cmd, args;
|
|
153
|
+
|
|
154
|
+
if (isTermux) {
|
|
155
|
+
cmd = "termux-open-url";
|
|
156
|
+
args = [url];
|
|
157
|
+
} else if (process.platform === "darwin") {
|
|
158
|
+
cmd = "open";
|
|
159
|
+
args = [url];
|
|
160
|
+
} else if (process.platform === "win32") {
|
|
161
|
+
cmd = "cmd";
|
|
162
|
+
args = ["/c", "start", url];
|
|
163
|
+
} else {
|
|
164
|
+
cmd = "xdg-open";
|
|
165
|
+
args = [url];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const child = execFile(cmd, args, { stdio: "ignore" });
|
|
170
|
+
child.unref();
|
|
171
|
+
child.on("error", () => {
|
|
172
|
+
// Silently fail - URL is printed anyway
|
|
173
|
+
});
|
|
174
|
+
} catch {
|
|
175
|
+
// Browser opening unavailable
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Security ────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
function isPathSafe(filePath, root) {
|
|
182
|
+
// Must be within root
|
|
183
|
+
if (filePath !== root && !filePath.startsWith(root + path.sep)) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hasNullByte(str) {
|
|
190
|
+
return str.includes("\0");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function hasDotSegment(urlPath) {
|
|
194
|
+
// Check if any path segment starts with a dot (hidden files/dirs)
|
|
195
|
+
const segments = urlPath.split("/");
|
|
196
|
+
for (const seg of segments) {
|
|
197
|
+
if (seg.startsWith(".") && seg !== "." && seg !== "..") {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Request Logger ──────────────────────────────────
|
|
205
|
+
|
|
206
|
+
function logRequest(method, urlPath, statusCode, startTime, quiet) {
|
|
207
|
+
if (quiet) return;
|
|
208
|
+
|
|
209
|
+
const elapsed = Date.now() - startTime;
|
|
210
|
+
const methodStr = method.padEnd(5);
|
|
211
|
+
|
|
212
|
+
let statusColor = c.green;
|
|
213
|
+
if (statusCode >= 400) statusColor = c.yellow;
|
|
214
|
+
if (statusCode >= 500) statusColor = c.red;
|
|
215
|
+
|
|
216
|
+
const pathDisplay = urlPath.length > 40
|
|
217
|
+
? urlPath.slice(0, 37) + "..."
|
|
218
|
+
: urlPath;
|
|
219
|
+
|
|
220
|
+
console.log(
|
|
221
|
+
` ${c.dim}${methodStr}${c.r} ${pathDisplay.padEnd(42)} ${statusColor}${statusCode}${c.r} ${c.dim}${formatMs(elapsed)}${c.r}`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Main Server ─────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Start the BusyServer server.
|
|
229
|
+
*
|
|
230
|
+
* @param {Object} options
|
|
231
|
+
* @param {string} options.root - Directory to serve
|
|
232
|
+
* @param {number} options.port - Port number
|
|
233
|
+
* @param {string} options.host - Host to bind to
|
|
234
|
+
* @param {boolean} options.autoPort - Auto-find available port
|
|
235
|
+
* @param {boolean} options.reload - Enable live reload
|
|
236
|
+
* @param {boolean} options.watch - Enable file watching
|
|
237
|
+
* @param {boolean} options.open - Open browser
|
|
238
|
+
* @param {boolean} options.spa - SPA fallback mode
|
|
239
|
+
* @param {boolean} options.quiet - Suppress request logging
|
|
240
|
+
* @param {boolean} options.qr - Show QR code
|
|
241
|
+
* @param {string[]} options.ignore - Additional ignore patterns
|
|
242
|
+
*/
|
|
243
|
+
async function startBusyServer(options = {}) {
|
|
244
|
+
const root = options.root || process.cwd();
|
|
245
|
+
const port = options.port != null ? options.port : 3000;
|
|
246
|
+
const host = options.host || "0.0.0.0";
|
|
247
|
+
const autoPort = options.autoPort !== false;
|
|
248
|
+
const reloadEnabled = options.reload !== false;
|
|
249
|
+
const watchEnabled = options.watch !== false;
|
|
250
|
+
const spaMode = options.spa === true;
|
|
251
|
+
const quiet = options.quiet === true;
|
|
252
|
+
const openBrowserOnStart = options.open === true;
|
|
253
|
+
const showQr = options.qr === true;
|
|
254
|
+
const ignorePatterns = options.ignore || [];
|
|
255
|
+
|
|
256
|
+
// Read version
|
|
257
|
+
let version = "1.0.0";
|
|
258
|
+
try {
|
|
259
|
+
const pkg = require("../package.json");
|
|
260
|
+
version = pkg.version || version;
|
|
261
|
+
} catch {
|
|
262
|
+
// package.json not found
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Create HTTP Server ──
|
|
266
|
+
|
|
267
|
+
const handler = async (req, res) => {
|
|
268
|
+
const startTime = Date.now();
|
|
269
|
+
let statusCode = 200;
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
if (options.headers) {
|
|
273
|
+
for (const [key, val] of Object.entries(options.headers)) {
|
|
274
|
+
res.setHeader(key, val);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (req.url === "/__busyserver") {
|
|
279
|
+
const { generateDashboard } = require("./dashboard");
|
|
280
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
281
|
+
res.end(generateDashboard(options, wss, watcher));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const { handleProxy } = require("./proxy");
|
|
286
|
+
if (handleProxy(req, res, options)) return;
|
|
287
|
+
|
|
288
|
+
const { handleMock } = require("./mock");
|
|
289
|
+
if (handleMock(req, res, options)) return;
|
|
290
|
+
|
|
291
|
+
// Only support GET and HEAD
|
|
292
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
293
|
+
statusCode = 405;
|
|
294
|
+
res.writeHead(405, { "Content-Type": "text/html; charset=utf-8", "Allow": "GET, HEAD" });
|
|
295
|
+
res.end(getErrorPage(405, "Method Not Allowed", "Use GET or HEAD."));
|
|
296
|
+
logRequest(req.method, req.url, statusCode, startTime, quiet);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Parse URL
|
|
301
|
+
let urlPath;
|
|
302
|
+
try {
|
|
303
|
+
const rawPath = req.url.split("?")[0];
|
|
304
|
+
urlPath = decodeURIComponent(rawPath);
|
|
305
|
+
} catch {
|
|
306
|
+
statusCode = 400;
|
|
307
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
308
|
+
res.end(getErrorPage(400, "Bad Request", "Malformed URL."));
|
|
309
|
+
logRequest(req.method, req.url, statusCode, startTime, quiet);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Null byte check
|
|
314
|
+
if (hasNullByte(urlPath)) {
|
|
315
|
+
statusCode = 400;
|
|
316
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
317
|
+
res.end(getErrorPage(400, "Bad Request", "Null byte in URL."));
|
|
318
|
+
logRequest(req.method, urlPath, statusCode, startTime, quiet);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!urlPath.startsWith("/")) {
|
|
323
|
+
urlPath = "/" + urlPath;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Resolve file path
|
|
327
|
+
const filePath = path.resolve(root, "." + urlPath);
|
|
328
|
+
|
|
329
|
+
// Security: path traversal check
|
|
330
|
+
if (!isPathSafe(filePath, root)) {
|
|
331
|
+
statusCode = 403;
|
|
332
|
+
res.writeHead(403, {
|
|
333
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
334
|
+
"X-Content-Type-Options": "nosniff",
|
|
335
|
+
});
|
|
336
|
+
res.end(getErrorPage(403, "Access forbidden", urlPath));
|
|
337
|
+
logRequest(req.method, urlPath, statusCode, startTime, quiet);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Block dotfiles/dotdirs (but not root)
|
|
342
|
+
if (hasDotSegment(urlPath)) {
|
|
343
|
+
statusCode = 403;
|
|
344
|
+
res.writeHead(403, {
|
|
345
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
346
|
+
"X-Content-Type-Options": "nosniff",
|
|
347
|
+
});
|
|
348
|
+
res.end(getErrorPage(403, "Access forbidden", urlPath));
|
|
349
|
+
logRequest(req.method, urlPath, statusCode, startTime, quiet);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let stats;
|
|
354
|
+
try {
|
|
355
|
+
stats = await fs.promises.stat(filePath);
|
|
356
|
+
} catch {
|
|
357
|
+
// File not found - SPA fallback?
|
|
358
|
+
if (spaMode && !path.extname(urlPath)) {
|
|
359
|
+
const spaIndex = path.join(root, "index.html");
|
|
360
|
+
try {
|
|
361
|
+
const spaStats = await fs.promises.stat(spaIndex);
|
|
362
|
+
if (spaStats.isFile()) {
|
|
363
|
+
const data = await fs.promises.readFile(spaIndex, "utf-8");
|
|
364
|
+
const html = injectLiveReload(data, reloadEnabled);
|
|
365
|
+
res.writeHead(200, {
|
|
366
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
367
|
+
"Cache-Control": "no-cache",
|
|
368
|
+
"X-Content-Type-Options": "nosniff",
|
|
369
|
+
});
|
|
370
|
+
res.end(html);
|
|
371
|
+
logRequest(req.method, urlPath, 200, startTime, quiet);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
// SPA index.html not found either
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
statusCode = 404;
|
|
380
|
+
const body = injectLiveReload(getErrorPage(404, "File not found", urlPath), reloadEnabled);
|
|
381
|
+
res.writeHead(404, {
|
|
382
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
383
|
+
"X-Content-Type-Options": "nosniff",
|
|
384
|
+
});
|
|
385
|
+
res.end(body);
|
|
386
|
+
logRequest(req.method, urlPath, statusCode, startTime, quiet);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── Directory ──
|
|
391
|
+
|
|
392
|
+
if (stats.isDirectory()) {
|
|
393
|
+
// Redirect to trailing slash if missing
|
|
394
|
+
if (!urlPath.endsWith("/")) {
|
|
395
|
+
res.writeHead(301, { Location: urlPath + "/" });
|
|
396
|
+
res.end();
|
|
397
|
+
logRequest(req.method, urlPath, 301, startTime, quiet);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Try index.html
|
|
402
|
+
const indexPath = path.join(filePath, "index.html");
|
|
403
|
+
try {
|
|
404
|
+
const indexStats = await fs.promises.stat(indexPath);
|
|
405
|
+
if (indexStats.isFile()) {
|
|
406
|
+
const data = await fs.promises.readFile(indexPath, "utf-8");
|
|
407
|
+
const html = injectLiveReload(data, reloadEnabled);
|
|
408
|
+
res.writeHead(200, {
|
|
409
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
410
|
+
"Cache-Control": "no-cache",
|
|
411
|
+
"X-Content-Type-Options": "nosniff",
|
|
412
|
+
});
|
|
413
|
+
res.end(html);
|
|
414
|
+
logRequest(req.method, urlPath, 200, startTime, quiet);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
} catch {
|
|
418
|
+
// No index.html - show directory listing
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const listing = injectLiveReload(getDirectoryListing(filePath, urlPath), reloadEnabled);
|
|
422
|
+
res.writeHead(200, {
|
|
423
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
424
|
+
"Cache-Control": "no-cache",
|
|
425
|
+
"X-Content-Type-Options": "nosniff",
|
|
426
|
+
});
|
|
427
|
+
res.end(listing);
|
|
428
|
+
logRequest(req.method, urlPath, 200, startTime, quiet);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── File ──
|
|
433
|
+
|
|
434
|
+
const contentType = getContentType(filePath);
|
|
435
|
+
const etag = makeEtag(stats);
|
|
436
|
+
|
|
437
|
+
// 304 Not Modified
|
|
438
|
+
if (req.headers["if-none-match"] === etag) {
|
|
439
|
+
statusCode = 304;
|
|
440
|
+
res.writeHead(304);
|
|
441
|
+
res.end();
|
|
442
|
+
logRequest(req.method, urlPath, statusCode, startTime, quiet);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// HTML files: read into memory for live reload injection
|
|
447
|
+
if (isHtmlType(contentType)) {
|
|
448
|
+
const data = await fs.promises.readFile(filePath, "utf-8");
|
|
449
|
+
const html = injectLiveReload(data, reloadEnabled);
|
|
450
|
+
res.writeHead(200, {
|
|
451
|
+
"Content-Type": contentType,
|
|
452
|
+
"Cache-Control": "no-cache",
|
|
453
|
+
"ETag": etag,
|
|
454
|
+
"X-Content-Type-Options": "nosniff",
|
|
455
|
+
});
|
|
456
|
+
res.end(html);
|
|
457
|
+
logRequest(req.method, urlPath, 200, startTime, quiet);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// All other files: stream
|
|
462
|
+
const headers = {
|
|
463
|
+
"Content-Type": contentType,
|
|
464
|
+
"Cache-Control": "no-cache",
|
|
465
|
+
"ETag": etag,
|
|
466
|
+
"X-Content-Type-Options": "nosniff",
|
|
467
|
+
"Accept-Ranges": "bytes"
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// Range support
|
|
471
|
+
let start = 0;
|
|
472
|
+
let end = stats.size - 1;
|
|
473
|
+
let isRange = false;
|
|
474
|
+
|
|
475
|
+
if (req.headers.range) {
|
|
476
|
+
const parts = req.headers.range.replace(/bytes=/, "").split("-");
|
|
477
|
+
const partialStart = parts[0];
|
|
478
|
+
const partialEnd = parts[1];
|
|
479
|
+
|
|
480
|
+
start = parseInt(partialStart, 10);
|
|
481
|
+
end = partialEnd ? parseInt(partialEnd, 10) : stats.size - 1;
|
|
482
|
+
|
|
483
|
+
if (isNaN(start) || isNaN(end) || start > end || start >= stats.size) {
|
|
484
|
+
res.writeHead(416, { "Content-Range": `bytes */${stats.size}` });
|
|
485
|
+
res.end();
|
|
486
|
+
logRequest(req.method, urlPath, 416, startTime, quiet);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
isRange = true;
|
|
491
|
+
headers["Content-Range"] = `bytes ${start}-${end}/${stats.size}`;
|
|
492
|
+
headers["Content-Length"] = end - start + 1;
|
|
493
|
+
statusCode = 206;
|
|
494
|
+
} else {
|
|
495
|
+
headers["Content-Length"] = stats.size;
|
|
496
|
+
statusCode = 200;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Compression support
|
|
500
|
+
let compressStream = null;
|
|
501
|
+
if (!isRange && isCompressible(contentType) && options.compression !== false) {
|
|
502
|
+
const acceptEncoding = req.headers["accept-encoding"] || "";
|
|
503
|
+
if (acceptEncoding.match(/\bbr\b/)) {
|
|
504
|
+
headers["Content-Encoding"] = "br";
|
|
505
|
+
delete headers["Content-Length"]; // Chunked transfer
|
|
506
|
+
compressStream = zlib.createBrotliCompress();
|
|
507
|
+
} else if (acceptEncoding.match(/\bgzip\b/)) {
|
|
508
|
+
headers["Content-Encoding"] = "gzip";
|
|
509
|
+
delete headers["Content-Length"];
|
|
510
|
+
compressStream = zlib.createGzip();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (req.method === "HEAD") {
|
|
515
|
+
res.writeHead(statusCode, headers);
|
|
516
|
+
res.end();
|
|
517
|
+
logRequest(req.method, urlPath, statusCode, startTime, quiet);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
res.writeHead(statusCode, headers);
|
|
522
|
+
const stream = fs.createReadStream(filePath, { start, end });
|
|
523
|
+
|
|
524
|
+
stream.on("error", (err) => {
|
|
525
|
+
if (!res.headersSent) {
|
|
526
|
+
res.writeHead(500);
|
|
527
|
+
}
|
|
528
|
+
res.end(getErrorPage(500, "Internal Server Error", err.message));
|
|
529
|
+
logRequest(req.method, urlPath, 500, startTime, quiet);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
if (compressStream) {
|
|
533
|
+
stream.pipe(compressStream).pipe(res);
|
|
534
|
+
} else {
|
|
535
|
+
stream.pipe(res);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
stream.on("end", () => {
|
|
539
|
+
logRequest(req.method, urlPath, statusCode, startTime, quiet);
|
|
540
|
+
});
|
|
541
|
+
} catch (error) {
|
|
542
|
+
console.error(`${c.red}Server error:${c.r}`, error.message);
|
|
543
|
+
statusCode = 500;
|
|
544
|
+
if (!res.headersSent) {
|
|
545
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
546
|
+
}
|
|
547
|
+
if (!res.writableEnded) {
|
|
548
|
+
res.end("500 - Internal Server Error");
|
|
549
|
+
}
|
|
550
|
+
logRequest(req.method, req.url, statusCode, startTime, quiet);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
let server;
|
|
555
|
+
if (options.https) {
|
|
556
|
+
try {
|
|
557
|
+
const https = require("https");
|
|
558
|
+
const key = fs.readFileSync(path.join(process.cwd(), "key.pem"));
|
|
559
|
+
const cert = fs.readFileSync(path.join(process.cwd(), "cert.pem"));
|
|
560
|
+
server = https.createServer({ key, cert }, handler);
|
|
561
|
+
} catch (e) {
|
|
562
|
+
console.error(`${c.red}❌ HTTPS Error:${c.r} Could not read key.pem or cert.pem in current directory.`);
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
server = http.createServer(handler);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── Bind Port ──
|
|
570
|
+
|
|
571
|
+
let actualPort = port;
|
|
572
|
+
|
|
573
|
+
if (autoPort) {
|
|
574
|
+
let attempts = 0;
|
|
575
|
+
while (true) {
|
|
576
|
+
try {
|
|
577
|
+
actualPort = await listenOnPort(server, actualPort, host);
|
|
578
|
+
break;
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (error.code !== "EADDRINUSE") throw error;
|
|
581
|
+
if (!quiet) console.log(`${c.yellow}⚠️ Port ${actualPort} is busy, trying ${actualPort + 1}...${c.r}`);
|
|
582
|
+
actualPort++;
|
|
583
|
+
attempts++;
|
|
584
|
+
if (actualPort > 65535 || attempts > 100) {
|
|
585
|
+
console.error(`${c.red}❌ No available ports found.${c.r}`);
|
|
586
|
+
process.exit(1);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} else {
|
|
591
|
+
try {
|
|
592
|
+
actualPort = await listenOnPort(server, port, host);
|
|
593
|
+
} catch (error) {
|
|
594
|
+
if (error.code === "EADDRINUSE") {
|
|
595
|
+
console.error(`${c.red}❌ Port ${port} is already in use.${c.r}`);
|
|
596
|
+
process.exit(1);
|
|
597
|
+
}
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ── WebSocket ──
|
|
603
|
+
|
|
604
|
+
let wss = null;
|
|
605
|
+
if (reloadEnabled) {
|
|
606
|
+
wss = new WebSocketServer({ server, path: "/__busyserver_ws" });
|
|
607
|
+
wss.on("connection", (ws) => {
|
|
608
|
+
if (!quiet) {
|
|
609
|
+
console.log(` ${c.dim}🔌 Browser connected${c.r}`);
|
|
610
|
+
}
|
|
611
|
+
ws.on("error", () => {
|
|
612
|
+
// Ignore WebSocket errors from disconnected clients
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ── File Watcher ──
|
|
618
|
+
|
|
619
|
+
const watcher = await startWatcher(
|
|
620
|
+
root,
|
|
621
|
+
(change) => {
|
|
622
|
+
if (!quiet) {
|
|
623
|
+
console.log(` ${c.magenta}⚡ ${change.event}:${c.r} ${change.relativePath} ${c.dim}[${change.type}]${c.r}`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!wss) return;
|
|
627
|
+
|
|
628
|
+
for (const client of wss.clients) {
|
|
629
|
+
if (client.readyState === 1) {
|
|
630
|
+
client.send(JSON.stringify({
|
|
631
|
+
type: change.type,
|
|
632
|
+
file: change.relativePath,
|
|
633
|
+
}));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
enabled: watchEnabled,
|
|
639
|
+
ignore: ignorePatterns,
|
|
640
|
+
}
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
// ── Graceful Shutdown ──
|
|
644
|
+
|
|
645
|
+
let shuttingDown = false;
|
|
646
|
+
|
|
647
|
+
function shutdown() {
|
|
648
|
+
if (shuttingDown) return;
|
|
649
|
+
shuttingDown = true;
|
|
650
|
+
|
|
651
|
+
console.log(`\n${c.dim}Shutting down...${c.r}`);
|
|
652
|
+
|
|
653
|
+
// Close watcher
|
|
654
|
+
if (watcher) {
|
|
655
|
+
try { watcher.close(); } catch { /* ignore */ }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Close WebSocket connections
|
|
659
|
+
if (wss) {
|
|
660
|
+
for (const client of wss.clients) {
|
|
661
|
+
try { client.close(); } catch { /* ignore */ }
|
|
662
|
+
}
|
|
663
|
+
try { wss.close(); } catch { /* ignore */ }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Close HTTP server
|
|
667
|
+
server.close(() => {
|
|
668
|
+
process.exit(0);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Force exit after 3 seconds
|
|
672
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
process.on("SIGINT", shutdown);
|
|
676
|
+
process.on("SIGTERM", shutdown);
|
|
677
|
+
|
|
678
|
+
// ── Startup Banner ──
|
|
679
|
+
|
|
680
|
+
const networkAddress = getNetworkAddress();
|
|
681
|
+
const protocol = options.https ? "https" : "http";
|
|
682
|
+
const localUrl = `${protocol}://localhost:${actualPort}`;
|
|
683
|
+
const networkUrl = networkAddress ? `${protocol}://${networkAddress}:${actualPort}` : null;
|
|
684
|
+
|
|
685
|
+
if (!quiet) {
|
|
686
|
+
console.log("");
|
|
687
|
+
console.log(` ${c.bold}🚀 BusyServer v${version}${c.r}`);
|
|
688
|
+
console.log("");
|
|
689
|
+
console.log(` ${c.dim}Local:${c.r} ${c.cyan}${localUrl}${c.r}`);
|
|
690
|
+
|
|
691
|
+
if (networkUrl) {
|
|
692
|
+
console.log(` ${c.dim}Network:${c.r} ${c.cyan}${networkUrl}${c.r}`);
|
|
693
|
+
} else {
|
|
694
|
+
console.log(` ${c.dim}Network:${c.r} ${c.dim}Not available${c.r}`);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
console.log("");
|
|
698
|
+
console.log(` ${c.dim}Folder:${c.r} ${shortenPath(root)}`);
|
|
699
|
+
console.log(` ${c.dim}Live Reload:${c.r} ${reloadEnabled ? `${c.green}ON${c.r}` : `${c.dim}OFF${c.r}`}`);
|
|
700
|
+
console.log(` ${c.dim}Watching:${c.r} ${watchEnabled ? `${c.green}ON${c.r}` : `${c.dim}OFF${c.r}`}`);
|
|
701
|
+
|
|
702
|
+
if (spaMode) {
|
|
703
|
+
console.log(` ${c.dim}SPA Mode:${c.r} ${c.green}ON${c.r}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
console.log("");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ── QR Code ──
|
|
710
|
+
|
|
711
|
+
if (showQr && networkUrl && !quiet) {
|
|
712
|
+
try {
|
|
713
|
+
console.log(` ${c.dim}Network URL for mobile:${c.r}`);
|
|
714
|
+
qrcode.generate(networkUrl, { small: true }, function (qrcode) {
|
|
715
|
+
const indented = qrcode.split("\n").map(line => " " + line).join("\n");
|
|
716
|
+
console.log(indented);
|
|
717
|
+
});
|
|
718
|
+
console.log("");
|
|
719
|
+
} catch {
|
|
720
|
+
console.log(` ${c.bold}${networkUrl}${c.r}`);
|
|
721
|
+
console.log("");
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ── Open Browser ──
|
|
726
|
+
|
|
727
|
+
if (openBrowserOnStart) {
|
|
728
|
+
openBrowser(localUrl);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return { server, wss, watcher, port: actualPort, url: localUrl };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
module.exports = { startBusyServer };
|