@purecore/one-server-4-all 0.1.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.ts ADDED
@@ -0,0 +1,741 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import fs from "node:fs/promises";
4
+ import { createReadStream, existsSync, statSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { exec } from "node:child_process";
7
+ import os from "node:os";
8
+ import { Watcher } from "./watcher.js";
9
+ import { Config } from "./validator.js";
10
+ import { CertGenerator } from "./cert-generator.js";
11
+
12
+ // Script injetado no HTML para ouvir mudanças
13
+ const INJECTED_SCRIPT = `
14
+ <!-- Code injected by auto-server -->
15
+ <script>
16
+ (function() {
17
+ console.log('[hot-server] Connected to hot reload');
18
+ const evtSource = new EventSource('/_hot_server_sse');
19
+ evtSource.onmessage = function(event) {
20
+ try {
21
+ const data = JSON.parse(event.data);
22
+ if (data.type === 'css') {
23
+ console.log('[hot-server] CSS changed, injecting...');
24
+ injectCSS(data.file);
25
+ } else {
26
+ console.log('[hot-server] Reloading...');
27
+ window.location.reload();
28
+ }
29
+ } catch (e) {
30
+ // Fallback para compatibilidade
31
+ if (event.data === 'reload') {
32
+ console.log('[hot-server] Reloading...');
33
+ window.location.reload();
34
+ }
35
+ }
36
+ };
37
+
38
+ function injectCSS(filePath) {
39
+ // Remove timestamp do cache dos links CSS
40
+ const links = document.querySelectorAll('link[rel="stylesheet"]');
41
+ const timestamp = Date.now();
42
+
43
+ links.forEach(link => {
44
+ const href = link.getAttribute('href');
45
+ if (href && href.includes(filePath)) {
46
+ // Força reload do CSS adicionando/removendo timestamp
47
+ const newHref = href.split('?')[0] + '?v=' + timestamp;
48
+ link.setAttribute('href', newHref);
49
+ console.log('[hot-server] CSS injected:', filePath);
50
+ }
51
+ });
52
+ }
53
+
54
+ evtSource.onerror = function() {
55
+ console.log('[hot-server] Disconnected. Retrying...');
56
+ };
57
+ })();
58
+ </script>
59
+ `;
60
+
61
+ const MIME_TYPES: Record<string, string> = {
62
+ // Textos e documentos
63
+ ".html": "text/html",
64
+ ".htm": "text/html",
65
+ ".css": "text/css",
66
+ ".js": "text/javascript",
67
+ ".mjs": "text/javascript",
68
+ ".json": "application/json",
69
+ ".xml": "application/xml",
70
+ ".txt": "text/plain",
71
+ ".md": "text/markdown",
72
+
73
+ // Imagens
74
+ ".png": "image/png",
75
+ ".jpg": "image/jpeg",
76
+ ".jpeg": "image/jpeg",
77
+ ".gif": "image/gif",
78
+ ".svg": "image/svg+xml",
79
+ ".ico": "image/x-icon",
80
+ ".webp": "image/webp",
81
+ ".bmp": "image/bmp",
82
+ ".tiff": "image/tiff",
83
+ ".tif": "image/tiff",
84
+
85
+ // Vídeos
86
+ ".mp4": "video/mp4",
87
+ ".webm": "video/webm",
88
+ ".ogg": "video/ogg",
89
+ ".avi": "video/x-msvideo",
90
+ ".mov": "video/quicktime",
91
+ ".wmv": "video/x-ms-wmv",
92
+ ".flv": "video/x-flv",
93
+
94
+ // Áudios
95
+ ".mp3": "audio/mpeg",
96
+ ".wav": "audio/wav",
97
+ ".oga": "audio/ogg",
98
+ ".aac": "audio/aac",
99
+ ".m4a": "audio/m4a",
100
+ ".opus": "audio/opus",
101
+
102
+ // Fontes
103
+ ".woff": "font/woff",
104
+ ".woff2": "font/woff2",
105
+ ".ttf": "font/ttf",
106
+ ".otf": "font/otf",
107
+ ".eot": "application/vnd.ms-fontobject",
108
+
109
+ // Aplicações e manifestos
110
+ ".pdf": "application/pdf",
111
+ ".zip": "application/zip",
112
+ ".gzip": "application/gzip",
113
+ ".tar": "application/x-tar",
114
+ ".wasm": "application/wasm",
115
+ ".webmanifest": "application/manifest+json",
116
+
117
+ // Outros tipos comuns
118
+ ".csv": "text/csv",
119
+ ".tsv": "text/tab-separated-values",
120
+ ".yaml": "application/x-yaml",
121
+ ".yml": "application/x-yaml",
122
+ ".toml": "application/toml",
123
+ };
124
+
125
+ export class HotServer {
126
+ private clients: Set<http.ServerResponse> = new Set();
127
+ private server!: http.Server | https.Server;
128
+ private watcher: Watcher;
129
+ private isHttps: boolean = false;
130
+ private startTime: number = Date.now();
131
+
132
+ constructor(private config: Config) {
133
+ this.watcher = new Watcher(config.root);
134
+ this.isHttps = config.https === "true";
135
+ }
136
+
137
+ private async handleRequest(
138
+ req: http.IncomingMessage,
139
+ res: http.ServerResponse
140
+ ) {
141
+ // 1. Endpoint de Hot Reload (SSE)
142
+ if (req.url === "/_hot_server_sse") {
143
+ this.handleSSE(req, res);
144
+ return;
145
+ }
146
+
147
+ // 2. Servir Arquivos Estáticos
148
+ const urlRaw = req.url || "/";
149
+ const urlPathOnly = urlRaw.split("?")[0];
150
+
151
+ let filePath = path.join(
152
+ this.config.root,
153
+ urlPathOnly === "/" ? "index.html" : urlPathOnly
154
+ );
155
+
156
+ // Remove query params
157
+ filePath = filePath.split("?")[0];
158
+
159
+ // Se for diretório, tenta index.html
160
+ if (existsSync(filePath) && statSync(filePath).isDirectory()) {
161
+ const indexPath = path.join(filePath, "index.html");
162
+ if (existsSync(indexPath)) {
163
+ filePath = indexPath;
164
+ } else {
165
+ // Directory listing (UI preta com cards)
166
+ await this.serveDirectoryListing({
167
+ dirPath: filePath,
168
+ requestPath: urlPathOnly,
169
+ res,
170
+ });
171
+ return;
172
+ }
173
+ }
174
+
175
+ if (!existsSync(filePath)) {
176
+ // Fallback: se URL não tem extensão, tenta .html e /index.html
177
+ // Ex: /sobre -> /sobre.html, /sobre/index.html
178
+ const hasExt = path.extname(urlPathOnly) !== "";
179
+ if (!hasExt && urlPathOnly !== "/" && !urlPathOnly.endsWith("/")) {
180
+ const htmlCandidate = path.join(
181
+ this.config.root,
182
+ `${urlPathOnly}.html`
183
+ );
184
+ const dirIndexCandidate = path.join(
185
+ this.config.root,
186
+ urlPathOnly,
187
+ "index.html"
188
+ );
189
+ if (existsSync(htmlCandidate)) {
190
+ filePath = htmlCandidate;
191
+ } else if (existsSync(dirIndexCandidate)) {
192
+ filePath = dirIndexCandidate;
193
+ }
194
+ }
195
+
196
+ // Suporte SPA: se arquivo não existe, tenta servir index.html
197
+ if (this.config.spa === "true") {
198
+ const indexPath = path.join(this.config.root, "index.html");
199
+ if (existsSync(indexPath)) {
200
+ filePath = indexPath;
201
+ } else {
202
+ res.writeHead(404);
203
+ res.end(`File not found: ${req.url}`);
204
+ return;
205
+ }
206
+ } else {
207
+ res.writeHead(404);
208
+ res.end(`File not found: ${req.url}`);
209
+ return;
210
+ }
211
+ }
212
+
213
+ const ext = path.extname(filePath).toLowerCase();
214
+ const mimeType = MIME_TYPES[ext] || "application/octet-stream";
215
+
216
+ // Log detalhado de arquivos servidos
217
+ const relativePath = path.relative(this.config.root, filePath);
218
+ const fileSize = (await fs.stat(filePath)).size;
219
+ console.log(
220
+ `📄 Servindo: ${relativePath} (${this.formatBytes(
221
+ fileSize
222
+ )}) [${mimeType}]`
223
+ );
224
+
225
+ // 3. Injeção de Script (apenas HTML)
226
+ if (ext === ".html") {
227
+ try {
228
+ let content = await fs.readFile(filePath, "utf-8");
229
+
230
+ // Analisar e logar recursos do HTML
231
+ this.logHtmlResources(content, relativePath);
232
+
233
+ // Injeta antes de </body> ou no final se não tiver body
234
+ if (content.includes("</body>")) {
235
+ content = content.replace("</body>", `${INJECTED_SCRIPT}</body>`);
236
+ } else {
237
+ content += INJECTED_SCRIPT;
238
+ }
239
+
240
+ res.writeHead(200, {
241
+ "Content-Type": "text/html",
242
+ "Access-Control-Allow-Origin": "*",
243
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
244
+ "Access-Control-Allow-Headers": "Content-Type",
245
+ });
246
+ res.end(content);
247
+ console.log(`🌐 HTML injetado com hot-reload: ${relativePath}`);
248
+ } catch (err) {
249
+ res.writeHead(500);
250
+ res.end("Internal Server Error");
251
+ console.error(`❌ Erro ao servir HTML ${relativePath}:`, err);
252
+ }
253
+ } else {
254
+ // Stream para arquivos binários ou grandes
255
+ res.writeHead(200, {
256
+ "Content-Type": mimeType,
257
+ "Access-Control-Allow-Origin": "*",
258
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
259
+ "Access-Control-Allow-Headers": "Content-Type",
260
+ });
261
+ const stream = createReadStream(filePath);
262
+ stream.pipe(res);
263
+ }
264
+ }
265
+
266
+ private handleSSE(req: http.IncomingMessage, res: http.ServerResponse) {
267
+ res.writeHead(200, {
268
+ "Content-Type": "text/event-stream",
269
+ "Cache-Control": "no-cache",
270
+ Connection: "keep-alive",
271
+ "Access-Control-Allow-Origin": "*",
272
+ });
273
+
274
+ // Mantém conexão viva
275
+ res.write("data: connected\n\n");
276
+
277
+ this.clients.add(res);
278
+
279
+ req.on("close", () => {
280
+ this.clients.delete(res);
281
+ });
282
+ }
283
+
284
+ private notifyClients(changedFile?: string) {
285
+ if (this.clients.size === 0) return;
286
+
287
+ if (changedFile) {
288
+ const ext = path.extname(changedFile).toLowerCase();
289
+ if (ext === ".css") {
290
+ console.log(
291
+ `📡 Notificando ${this.clients.size} clientes sobre mudança CSS: ${changedFile}`
292
+ );
293
+ const data = JSON.stringify({ type: "css", file: changedFile });
294
+ for (const client of this.clients) {
295
+ client.write(`data: ${data}\n\n`);
296
+ }
297
+ return;
298
+ }
299
+ }
300
+
301
+ // Fallback para reload completo
302
+ console.log(
303
+ `📡 Notificando ${this.clients.size} clientes para recarregar...`
304
+ );
305
+ for (const client of this.clients) {
306
+ client.write("data: reload\n\n");
307
+ }
308
+ }
309
+
310
+ private openBrowser() {
311
+ const protocol = this.isHttps ? "https" : "http";
312
+ const url = `${protocol}://localhost:${this.config.port}`;
313
+ const start =
314
+ process.platform == "darwin"
315
+ ? "open"
316
+ : process.platform == "win32"
317
+ ? "start"
318
+ : "xdg-open";
319
+ exec(`${start} ${url}`);
320
+ }
321
+
322
+ public async start() {
323
+ // Inicia Watcher
324
+ this.watcher.on("change", (filePath: string) =>
325
+ this.notifyClients(filePath)
326
+ );
327
+ this.watcher.start();
328
+
329
+ // Criar servidor HTTP ou HTTPS
330
+ if (this.isHttps) {
331
+ const certs = await CertGenerator.getCertPaths();
332
+ if (!certs) {
333
+ console.log("🔐 Gerando certificados auto-assinados...");
334
+ await CertGenerator.generateCerts();
335
+ }
336
+ const certPaths = await CertGenerator.getCertPaths();
337
+ if (certPaths) {
338
+ const options = {
339
+ key: await fs.readFile(certPaths.keyPath),
340
+ cert: await fs.readFile(certPaths.certPath),
341
+ };
342
+ this.server = https.createServer(
343
+ options,
344
+ this.handleRequest.bind(this)
345
+ );
346
+ } else {
347
+ throw new Error("Não foi possível gerar certificados HTTPS");
348
+ }
349
+ } else {
350
+ this.server = http.createServer(this.handleRequest.bind(this));
351
+ }
352
+
353
+ const tryListen = (port: number) => {
354
+ const onError = (err: any) => {
355
+ if (err.code === "EADDRINUSE") {
356
+ console.log(
357
+ `\n \x1b[33mPort ${port} is in use, trying another one...\x1b[0m`
358
+ );
359
+ // Garante que limpamos listeners antigos antes de tentar de novo
360
+ this.server.removeListener("error", onError);
361
+ this.server.close();
362
+ tryListen(port + 1);
363
+ } else {
364
+ console.error("❌ Erro ao iniciar servidor:", err);
365
+ process.exit(1);
366
+ }
367
+ };
368
+
369
+ this.server.once("error", onError);
370
+
371
+ this.server.listen(port, () => {
372
+ this.server.removeListener("error", onError);
373
+ this.config.port = port;
374
+
375
+ const protocol = this.isHttps ? "https" : "http";
376
+ const readyTime = Date.now() - this.startTime;
377
+
378
+ const version = "0.2.0";
379
+
380
+ const cyan = (text: string) => `\x1b[36m${text}\x1b[0m`;
381
+ const green = (text: string) => `\x1b[32m${text}\x1b[0m`;
382
+ const bold = (text: string) => `\x1b[1m${text}\x1b[0m`;
383
+ const gray = (text: string) => `\x1b[90m${text}\x1b[0m`;
384
+
385
+ console.log(
386
+ `\n ${bold(cyan("HOT-SERVER"))} ${cyan("v" + version)} ${gray(
387
+ "ready in"
388
+ )} ${bold(green(readyTime + " ms"))}\n`
389
+ );
390
+ console.log(
391
+ ` ${green("➜")} ${bold("Local")}: ${cyan(
392
+ `${protocol}://localhost:${this.config.port}/`
393
+ )}`
394
+ );
395
+
396
+ const networks = this.getNetworkIPs();
397
+ networks.forEach((ip) => {
398
+ console.log(
399
+ ` ${green("➜")} ${bold("Network")}: ${cyan(
400
+ `${protocol}://${ip}:${this.config.port}/`
401
+ )}`
402
+ );
403
+ });
404
+
405
+ console.log(
406
+ ` ${green("➜")} ${gray("press")} ${bold("h + enter")} ${gray(
407
+ "to show help"
408
+ )}\n`
409
+ );
410
+
411
+ if (this.config.open === "true") {
412
+ this.openBrowser();
413
+ }
414
+ });
415
+ };
416
+
417
+ tryListen(this.config.port);
418
+ }
419
+
420
+ private formatBytes(bytes: number): string {
421
+ if (bytes === 0) return "0 B";
422
+ const k = 1024;
423
+ const sizes = ["B", "KB", "MB", "GB"];
424
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
425
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
426
+ }
427
+
428
+ private logHtmlResources(htmlContent: string, htmlPath: string) {
429
+ const resources: Array<{ type: "CSS" | "JS" | "IMG"; path: string }> = [];
430
+
431
+ // CSS links
432
+ const cssMatches = htmlContent.match(
433
+ /<link[^>]*href="([^"]*\.css[^"]*)"[^>]*>/gi
434
+ );
435
+ if (cssMatches) {
436
+ cssMatches.forEach((match) => {
437
+ const hrefMatch = match.match(/href="([^"]*\.css[^"]*)"/i);
438
+ if (hrefMatch) {
439
+ resources.push({ type: "CSS", path: hrefMatch[1] });
440
+ }
441
+ });
442
+ }
443
+
444
+ // JavaScript scripts
445
+ const jsMatches = htmlContent.match(
446
+ /<script[^>]*src="([^"]*\.js[^"]*)"[^>]*><\/script>/gi
447
+ );
448
+ if (jsMatches) {
449
+ jsMatches.forEach((match) => {
450
+ const srcMatch = match.match(/src="([^"]*\.js[^"]*)"/i);
451
+ if (srcMatch) {
452
+ resources.push({ type: "JS", path: srcMatch[1] });
453
+ }
454
+ });
455
+ }
456
+
457
+ // Images
458
+ const imgMatches = htmlContent.match(/<img[^>]*src="([^"]*)"[^>]*>/gi);
459
+ if (imgMatches) {
460
+ imgMatches.forEach((match) => {
461
+ const srcMatch = match.match(/src="([^"]*)"/i);
462
+ if (srcMatch) {
463
+ resources.push({ type: "IMG", path: srcMatch[1] });
464
+ }
465
+ });
466
+ }
467
+
468
+ if (resources.length > 0) {
469
+ console.log(`🔍 Recursos encontrados em ${htmlPath}:`);
470
+ resources.forEach((resource) => {
471
+ console.log(
472
+ ` ${
473
+ resource.type === "CSS"
474
+ ? "🎨"
475
+ : resource.type === "JS"
476
+ ? "📜"
477
+ : "🖼️"
478
+ } ${resource.path}`
479
+ );
480
+ });
481
+ }
482
+ }
483
+
484
+ private async serveDirectoryListing(params: {
485
+ dirPath: string;
486
+ requestPath: string;
487
+ res: http.ServerResponse;
488
+ }) {
489
+ const { dirPath, requestPath, res } = params;
490
+
491
+ // Normaliza paths para links
492
+ const basePath = requestPath.endsWith("/")
493
+ ? requestPath
494
+ : `${requestPath}/`;
495
+
496
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
497
+ const items = entries
498
+ .filter((e) => e.name !== ".DS_Store")
499
+ .sort((a, b) => {
500
+ // Pastas primeiro
501
+ if (a.isDirectory() && !b.isDirectory()) return -1;
502
+ if (!a.isDirectory() && b.isDirectory()) return 1;
503
+ return a.name.localeCompare(b.name);
504
+ })
505
+ .map((e) => {
506
+ const isDir = e.isDirectory();
507
+ const name = e.name;
508
+ const href = `${basePath}${encodeURIComponent(name)}${
509
+ isDir ? "/" : ""
510
+ }`;
511
+ const icon = isDir ? "📁" : this.getFileIconByName(name);
512
+ const type = isDir
513
+ ? "Pasta"
514
+ : path.extname(name).slice(1).toUpperCase() || "FILE";
515
+ return { name, href, icon, type, isDir };
516
+ });
517
+
518
+ const parentHref = basePath !== "/" ? "../" : null;
519
+
520
+ const html = `<!doctype html>
521
+ <html lang="pt-BR">
522
+ <head>
523
+ <meta charset="utf-8" />
524
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
525
+ <title>Index of ${this.escapeHtml(requestPath)}</title>
526
+ <style>
527
+ :root {
528
+ color-scheme: dark;
529
+ --bg: #000;
530
+ --card: #0b0b0b;
531
+ --border: rgba(255,255,255,.10);
532
+ --text: rgba(255,255,255,.92);
533
+ --muted: rgba(255,255,255,.62);
534
+ --hover: rgba(255,255,255,.06);
535
+ --shadow: 0 10px 30px rgba(0,0,0,.45);
536
+ }
537
+ * { box-sizing: border-box; }
538
+ body {
539
+ margin: 0;
540
+ font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
541
+ background: var(--bg);
542
+ color: var(--text);
543
+ }
544
+ .wrap {
545
+ max-width: 1100px;
546
+ margin: 0 auto;
547
+ padding: 20px;
548
+ }
549
+ header {
550
+ display: flex;
551
+ gap: 12px;
552
+ align-items: baseline;
553
+ justify-content: space-between;
554
+ margin-bottom: 16px;
555
+ }
556
+ h1 {
557
+ margin: 0;
558
+ font-size: 18px;
559
+ font-weight: 650;
560
+ letter-spacing: .2px;
561
+ }
562
+ .path {
563
+ color: var(--muted);
564
+ font-size: 13px;
565
+ overflow: hidden;
566
+ text-overflow: ellipsis;
567
+ white-space: nowrap;
568
+ max-width: 60vw;
569
+ }
570
+ .grid {
571
+ display: grid;
572
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
573
+ gap: 12px;
574
+ }
575
+ a.card {
576
+ display: flex;
577
+ gap: 12px;
578
+ align-items: center;
579
+ text-decoration: none;
580
+ color: inherit;
581
+ padding: 14px 14px;
582
+ border: 1px solid var(--border);
583
+ border-radius: 12px;
584
+ background: var(--card);
585
+ box-shadow: var(--shadow);
586
+ transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
587
+ will-change: transform;
588
+ }
589
+ a.card:hover {
590
+ transform: translateY(-2px);
591
+ border-color: rgba(255,255,255,.22);
592
+ background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
593
+ }
594
+ .icon {
595
+ width: 38px;
596
+ height: 38px;
597
+ display: grid;
598
+ place-items: center;
599
+ border-radius: 10px;
600
+ border: 1px solid var(--border);
601
+ background: rgba(255,255,255,.03);
602
+ flex: 0 0 auto;
603
+ font-size: 18px;
604
+ }
605
+ .meta { min-width: 0; }
606
+ .name {
607
+ font-size: 14px;
608
+ font-weight: 600;
609
+ line-height: 1.25;
610
+ overflow: hidden;
611
+ text-overflow: ellipsis;
612
+ white-space: nowrap;
613
+ max-width: 100%;
614
+ }
615
+ .type {
616
+ margin-top: 3px;
617
+ font-size: 12px;
618
+ color: var(--muted);
619
+ }
620
+ .back {
621
+ margin: 14px 0 0;
622
+ font-size: 13px;
623
+ color: var(--muted);
624
+ }
625
+ .back a { color: var(--text); text-decoration: none; border-bottom: 1px dotted rgba(255,255,255,.25); }
626
+ </style>
627
+ </head>
628
+ <body>
629
+ <div class="wrap">
630
+ <header>
631
+ <h1>Directory listing</h1>
632
+ <div class="path">${this.escapeHtml(requestPath)}</div>
633
+ </header>
634
+
635
+ <div class="grid">
636
+ ${
637
+ parentHref
638
+ ? `<a class="card" href="${parentHref}"><div class="icon">↩</div><div class="meta"><div class="name">..</div><div class="type">Voltar</div></div></a>`
639
+ : ""
640
+ }
641
+ ${items
642
+ .map((i) =>
643
+ `
644
+ <a class="card" href="${i.href}">
645
+ <div class="icon">${i.icon}</div>
646
+ <div class="meta">
647
+ <div class="name">${this.escapeHtml(i.name)}${
648
+ i.isDir ? "/" : ""
649
+ }</div>
650
+ <div class="type">${this.escapeHtml(i.type)}</div>
651
+ </div>
652
+ </a>
653
+ `.trim()
654
+ )
655
+ .join("")}
656
+ </div>
657
+
658
+ <div class="back">
659
+ Servido por <strong>purecore-hot-server</strong>
660
+ </div>
661
+ </div>
662
+ </body>
663
+ </html>`;
664
+
665
+ res.writeHead(200, {
666
+ "Content-Type": "text/html; charset=utf-8",
667
+ "Access-Control-Allow-Origin": "*",
668
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
669
+ "Access-Control-Allow-Headers": "Content-Type",
670
+ });
671
+ res.end(html);
672
+ console.log(
673
+ `📁 Directory listing: ${path.relative(this.config.root, dirPath)}`
674
+ );
675
+ }
676
+
677
+ private getFileIconByName(fileName: string): string {
678
+ const ext = path.extname(fileName).toLowerCase();
679
+ if (ext === ".html" || ext === ".htm") return "🌐";
680
+ if (ext === ".css") return "🎨";
681
+ if (ext === ".js" || ext === ".mjs" || ext === ".ts") return "📜";
682
+ if (ext === ".json" || ext === ".yaml" || ext === ".yml" || ext === ".toml")
683
+ return "🧩";
684
+ if (ext === ".md" || ext === ".txt") return "📝";
685
+ if (
686
+ [
687
+ ".png",
688
+ ".jpg",
689
+ ".jpeg",
690
+ ".gif",
691
+ ".svg",
692
+ ".webp",
693
+ ".bmp",
694
+ ".tif",
695
+ ".tiff",
696
+ ".ico",
697
+ ].includes(ext)
698
+ )
699
+ return "🖼️";
700
+ if (
701
+ [
702
+ ".mp4",
703
+ ".webm",
704
+ ".mov",
705
+ ".avi",
706
+ ".wmv",
707
+ ".flv",
708
+ ".mkv",
709
+ ".ogg",
710
+ ].includes(ext)
711
+ )
712
+ return "🎞️";
713
+ if ([".mp3", ".wav", ".m4a", ".aac", ".opus", ".oga"].includes(ext))
714
+ return "🎵";
715
+ if ([".zip", ".tar", ".gz", ".gzip"].includes(ext)) return "🗜️";
716
+ if (ext === ".pdf") return "📄";
717
+ return "📄";
718
+ }
719
+
720
+ private escapeHtml(input: string): string {
721
+ return input
722
+ .replaceAll("&", "&amp;")
723
+ .replaceAll("<", "&lt;")
724
+ .replaceAll(">", "&gt;")
725
+ .replaceAll('"', "&quot;")
726
+ .replaceAll("'", "&#039;");
727
+ }
728
+
729
+ private getNetworkIPs(): string[] {
730
+ const interfaces = os.networkInterfaces();
731
+ const ips: string[] = [];
732
+ for (const name of Object.keys(interfaces)) {
733
+ for (const iface of interfaces[name] || []) {
734
+ if (iface.family === "IPv4" && !iface.internal) {
735
+ ips.push(iface.address);
736
+ }
737
+ }
738
+ }
739
+ return ips;
740
+ }
741
+ }