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/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 };