-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.mjs
More file actions
1677 lines (1538 loc) · 60.3 KB
/
server.mjs
File metadata and controls
1677 lines (1538 loc) · 60.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { createServer } from 'node:http';
import { createReadStream } from 'node:fs';
import { readFile, readdir, stat, realpath } from 'node:fs/promises';
import { createInterface } from 'node:readline';
import { join, resolve, relative, extname, basename, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { Readable } from 'node:stream';
import { isIP } from 'node:net';
import chokidar from 'chokidar';
import jschardet from 'jschardet';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const distDir = join(__dirname, 'dist');
// --- Friendly error reporting ---
const ERROR_HINTS = {
EACCES: {
title: 'アクセス権限エラー (EACCES)',
meaning: 'ファイル・ディレクトリ、またはポートへのアクセスが許可されていません。',
causes: [
'1024 未満のポート (80, 443 など) を管理者権限なしで使おうとしている',
'対象ディレクトリやファイルに読み取り権限がない',
],
fixes: [
'--port 4000 のように 1024 以上のポート番号を指定してください',
'ls -la <ディレクトリ> で権限を確認してください (自分が読めるか)',
'別のディレクトリに移動するか、権限を付与してから再実行してください',
],
},
EADDRINUSE: {
title: 'ポートが既に使われています (EADDRINUSE)',
meaning: '指定したポートは他のプロセスが使用中です。',
causes: [
'別の docview / 開発サーバが同じポートで起動している',
'前回の起動プロセスが終了しきれていない',
],
fixes: [
'docview --port 4001 のように別のポートを指定してください',
'lsof -i :<port> (macOS/Linux) で使用中のプロセスを特定できます',
'Windows の場合: netstat -ano | findstr :<port>',
],
},
ENOENT: {
title: 'ファイル・ディレクトリが見つかりません (ENOENT)',
meaning: '指定したパスが存在しません。',
causes: ['パス名の打ち間違い', 'ファイルが移動・削除された'],
fixes: [
'ls で対象のパスが存在するか確認してください',
'相対パスは現在のディレクトリ (pwd) を基準にします',
'スペースを含むパスは引用符で囲ってください: docview "My Docs"',
],
},
EADDRNOTAVAIL: {
title: 'バインドできないアドレスです (EADDRNOTAVAIL)',
meaning: '指定したネットワークアドレスがこのマシンで利用できません。',
causes: ['ホスト名やIPが間違っている', 'ネットワーク設定の問題'],
fixes: ['localhost または 127.0.0.1 を使用してください'],
},
EMFILE: {
title: 'ファイルディスクリプタが不足しています (EMFILE)',
meaning: 'OS が許可するオープン可能なファイル数の上限に達しました。',
causes: ['非常に大量のファイルを監視しようとしている'],
fixes: [
'ulimit -n 10240 で上限を引き上げてください (macOS/Linux)',
'docview <ディレクトリ> でファイル数の少ないディレクトリを指定してください',
],
},
};
function searchUrl(query) {
return `https://www.google.com/search?q=${encodeURIComponent(query)}`;
}
function formatFriendlyError(err) {
const code = err && err.code;
const hint = code && ERROR_HINTS[code];
const lines = [];
lines.push('');
lines.push(' エラーが発生しました');
lines.push(' ───────────────────────────────');
if (hint) {
lines.push(` 種類: ${hint.title}`);
lines.push(` 説明: ${hint.meaning}`);
lines.push('');
lines.push(' 考えられる原因:');
hint.causes.forEach((c) => lines.push(` - ${c}`));
lines.push('');
lines.push(' 解決方法:');
hint.fixes.forEach((f) => lines.push(` - ${f}`));
} else {
lines.push(` ${err && err.message ? err.message : String(err)}`);
if (code) lines.push(` コード: ${code}`);
lines.push('');
lines.push(' このエラーの意味が分からない場合は、下の検索リンクを開いてみてください。');
}
lines.push('');
lines.push(' この問題の解決策を検索:');
lines.push(` ${searchUrl(`node.js ${code || (err && err.message) || 'error'}`)}`);
if (err && err.message) {
lines.push('');
lines.push(' 元のエラーメッセージ (そのまま検索にも使えます):');
lines.push(` ${err.message}`);
}
lines.push(' ───────────────────────────────');
lines.push('');
return lines.join('\n');
}
function reportAndExit(err, { exitCode = 1 } = {}) {
try {
process.stderr.write(formatFriendlyError(err));
} catch {
console.error(err);
}
process.exit(exitCode);
}
process.on('uncaughtException', (err) => reportAndExit(err));
process.on('unhandledRejection', (err) => reportAndExit(err instanceof Error ? err : new Error(String(err))));
// --- Encoding detection helpers ---
const ENCODING_MAP = {
'utf-8': 'utf-8',
'ascii': 'utf-8',
'utf8': 'utf-8',
'shift_jis': 'shift_jis',
'shiftjis': 'shift_jis',
'shift-jis': 'shift_jis',
'windows-31j': 'shift_jis',
'cp932': 'shift_jis',
'euc-jp': 'euc-jp',
'eucjp': 'euc-jp',
'iso-2022-jp': 'iso-2022-jp',
'euc-kr': 'euc-kr',
'big5': 'big5',
'gb2312': 'gbk',
'gb18030': 'gb18030',
'gbk': 'gbk',
'windows-1252': 'windows-1252',
'iso-8859-1': 'windows-1252',
'iso-8859-2': 'iso-8859-2',
'ibm866': 'ibm866',
'koi8-r': 'koi8-r',
};
/**
* Detect encoding from a buffer and return a TextDecoder-compatible label.
*/
function detectEncoding(buf) {
// BOM detection
if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) return 'utf-8';
if (buf[0] === 0xFF && buf[1] === 0xFE) return 'utf-16le';
if (buf[0] === 0xFE && buf[1] === 0xFF) return 'utf-16be';
const detected = jschardet.detect(buf);
if (!detected || !detected.encoding) return 'utf-8';
const key = detected.encoding.toLowerCase().replace(/[_\s]/g, match => match === '_' ? '_' : '-');
return ENCODING_MAP[key] || 'utf-8';
}
/**
* Read a file and decode to UTF-8 string, auto-detecting encoding.
*/
async function readFileText(filePath) {
const buf = await readFile(filePath);
const encoding = detectEncoding(buf);
if (encoding === 'utf-8') return buf.toString('utf-8');
const decoder = new TextDecoder(encoding);
return decoder.decode(buf);
}
/**
* Create a Readable stream of UTF-8 text from a file, auto-detecting encoding.
* For UTF-8 files, uses createReadStream directly.
* For non-UTF-8 files, reads the entire file, decodes, and wraps as a stream.
*/
async function createTextReadStream(filePath) {
// Sample first 4KB to detect encoding
const { open } = await import('node:fs/promises');
const fh = await open(filePath, 'r');
try {
const sample = Buffer.alloc(4096);
const { bytesRead } = await fh.read(sample, 0, 4096, 0);
const encoding = detectEncoding(sample.subarray(0, bytesRead));
if (encoding === 'utf-8') {
return createReadStream(filePath, { encoding: 'utf-8' });
}
// Non-UTF-8: read full file, decode, return as stream
const buf = await readFile(filePath);
const text = new TextDecoder(encoding).decode(buf);
return Readable.from([text]);
} finally {
await fh.close();
}
}
// Parse CLI args
const args = process.argv.slice(2);
let targetDir = process.cwd();
let initialFile = null;
let port = 4000;
let remoteEnabled = true;
let allowPrivateRemote = false;
let remoteMaxSize = 5 * 1024 * 1024; // 5 MB default
// Default: relaxed TLS so self-signed / corporate-MITM CAs don't block fetches.
// Users who want strict verification can opt in with --remote-strict-tls.
let remoteInsecureTls = true;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' || args[i] === '-p') {
const raw = args[++i];
// Strict integer match: reject "4e3", "1.5", "80abc", empty, etc.
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
reportAndExit(Object.assign(
new Error(`ポート番号が不正です: "${raw}" (1〜65535 の整数を指定してください)`),
{ code: 'EINVALIDPORT' },
));
}
const parsed = Number(raw);
if (parsed < 1 || parsed > 65535) {
reportAndExit(Object.assign(
new Error(`ポート番号が範囲外です: "${raw}" (1〜65535 の整数を指定してください)`),
{ code: 'EINVALIDPORT' },
));
}
port = parsed;
} else if (args[i] === '--no-remote') {
remoteEnabled = false;
} else if (args[i] === '--allow-private-remote') {
allowPrivateRemote = true;
} else if (args[i] === '--remote-max-size') {
const raw = args[++i];
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
reportAndExit(Object.assign(
new Error(`--remote-max-size の値が不正です: "${raw}" (バイト数を整数で指定してください)`),
{ code: 'EINVALIDREMOTESIZE' },
));
}
remoteMaxSize = Number(raw);
} else if (args[i] === '--remote-strict-tls') {
remoteInsecureTls = false;
} else if (args[i] === '--remote-insecure-tls') {
// No-op — insecure is already the default. Retained so old invocations
// don't fail.
remoteInsecureTls = true;
} else if (!args[i].startsWith('-')) {
const resolved = resolve(args[i]);
try {
const s = await stat(resolved);
if (s.isFile()) {
targetDir = dirname(resolved);
initialFile = basename(resolved);
} else {
targetDir = resolved;
}
} catch {
// Path doesn't exist yet — treat as directory
targetDir = resolved;
}
}
}
const SUPPORTED_EXTENSIONS = new Set([
// Markdown
'.md', '.markdown', '.mdx', '.txt',
// Data
'.json', '.jsonl', '.ndjson', '.yaml', '.yml', '.csv', '.tsv',
// Config
'.toml', '.ini', '.conf', '.env', '.cfg', '.properties',
// Logs
'.log',
// Images
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico',
// Videos (Phase 1 — see docs/design/video-support.md)
'.mp4', '.m4v', '.webm', '.ogv', '.mov',
]);
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico']);
const VIDEO_EXTENSIONS = new Set(['.mp4', '.m4v', '.webm', '.ogv', '.mov']);
const SEARCH_CONTEXT_LINES = 20;
// --- Remote URL fetching (Phase 3) ---
// Primary gate: URL path extension must be in SUPPORTED_EXTENSIONS.
// Secondary gate: Content-Type must be in REMOTE_MIME_ALLOW (used when URL has no / unsupported ext).
// Hard deny: Content-Type in REMOTE_MIME_DENY is rejected even if the extension is allowed.
const REMOTE_MIME_ALLOW = new Set([
'text/markdown', 'text/x-markdown', 'text/plain',
'application/json', 'application/yaml', 'application/x-yaml',
'text/yaml', 'text/x-yaml',
'text/csv', 'text/tab-separated-values',
'application/x-ndjson', 'application/jsonl',
'application/toml', 'text/x-toml', 'text/x-ini', 'text/x-properties',
'image/png', 'image/jpeg', 'image/gif', 'image/svg+xml',
'image/webp', 'image/bmp', 'image/x-icon', 'image/vnd.microsoft.icon',
]);
const REMOTE_MIME_DENY = new Set([
'text/html', 'application/xhtml+xml',
'text/javascript', 'application/javascript', 'application/x-javascript',
]);
function isPrivateIPv4(ip) {
const parts = ip.split('.').map(Number);
if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p) || p < 0 || p > 255)) return true;
const [a, b] = parts;
if (a === 0) return true; // "this network"
if (a === 10) return true; // RFC1918
if (a === 127) return true; // loopback
if (a === 169 && b === 254) return true; // link-local (AWS metadata etc.)
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
if (a === 192 && b === 168) return true; // RFC1918
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT RFC6598
if (a >= 224) return true; // multicast / reserved
return false;
}
function isPrivateIPv6(ip) {
const lower = ip.toLowerCase();
// Normalize IPv6 like ::ffff:127.0.0.1 to IPv4 form
const v4Mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4Mapped) return isPrivateIPv4(v4Mapped[1]);
if (lower === '::1' || lower === '::') return true;
if (lower.startsWith('fe80:') || lower.startsWith('fe80')) return true; // link-local
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // ULA
if (lower.startsWith('ff')) return true; // multicast
return false;
}
function isPrivateAddress(ip, family) {
if (family === 6) return isPrivateIPv6(ip);
return isPrivateIPv4(ip);
}
function normalizeContentType(ct) {
return (ct || '').split(';')[0].trim().toLowerCase();
}
/**
* Fetch a remote URL with SSRF and content-type guards.
* Re-resolves DNS on every hop (original + redirects) to prevent rebinding.
*
* Returns one of:
* { status: 200, body: Buffer, contentType, sourceUrl, lastModified }
* { status, error } // any error condition
*/
async function fetchRemoteUrl(rawUrl, config, hopsLeft = 3) {
const { maxSize, allowPrivate, insecureTls = false, timeoutMs = 5000 } = config;
let urlObj;
try { urlObj = new URL(rawUrl); } catch {
return { status: 400, error: 'Invalid URL' };
}
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
return { status: 400, error: `Unsupported protocol: ${urlObj.protocol}` };
}
const ext = extname(urlObj.pathname).toLowerCase();
const extAllowed = ext.length > 0 && SUPPORTED_EXTENSIONS.has(ext);
const hasExt = ext.length > 0;
if (hasExt && !extAllowed) {
return { status: 415, error: `Unsupported file extension: ${ext}` };
}
if (!urlObj.hostname) {
return { status: 400, error: 'URL has no hostname' };
}
// DNS lookup (re-done for every hop — guards against rebinding)
let address, family;
try {
const { lookup } = await import('node:dns/promises');
const result = await lookup(urlObj.hostname, { verbatim: true });
address = result?.address;
family = result?.family;
} catch (err) {
return { status: 502, error: `DNS lookup failed for ${urlObj.hostname}: ${err.message}` };
}
if (typeof address !== 'string' || !address) {
return { status: 502, error: `DNS lookup returned no address for ${urlObj.hostname}` };
}
const ipVersion = isIP(address);
if (ipVersion === 0) {
return { status: 502, error: `DNS lookup returned invalid IP for ${urlObj.hostname}: ${address}` };
}
// Trust isIP over the DNS-reported family — Node has surfaced family
// values outside {4, 6} in rare environments, which breaks net.connect.
const numericFamily = ipVersion === 6 ? 6 : 4;
if (!allowPrivate && isPrivateAddress(address, numericFamily)) {
return { status: 403, error: `Refusing to fetch private/loopback address (${address})` };
}
const lib = urlObj.protocol === 'https:' ? await import('node:https') : await import('node:http');
// Connect directly to the resolved IP so Node skips its own DNS lookup —
// this closes the rebinding window between our check and the connect call
// without needing a custom `lookup` hook (which has been unreliable across
// Node versions). The Host header keeps the origin server's virtual host
// routing intact, and servername drives TLS SNI.
const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80);
const hostHeader = urlObj.port ? `${urlObj.hostname}:${urlObj.port}` : urlObj.hostname;
const connectHost = numericFamily === 6 ? `[${address}]` : address;
return new Promise((resolve) => {
const reqOptions = {
method: 'GET',
host: connectHost,
path: urlObj.pathname + urlObj.search,
port,
headers: {
// Fresh request — no cookies / auth leakage
'Host': hostHeader,
'User-Agent': 'DocView-Remote/1.0',
'Accept': '*/*',
'Accept-Encoding': 'identity',
},
timeout: timeoutMs,
servername: urlObj.hostname, // TLS SNI
};
if (urlObj.protocol === 'https:' && insecureTls) {
// User opted into skipping cert verification — common when operating
// behind a corporate TLS-inspecting proxy. Scope is per-request only.
reqOptions.rejectUnauthorized = false;
}
const req = lib.request(reqOptions, (remoteRes) => {
const status = remoteRes.statusCode ?? 502;
// Manual redirect handling — re-validate at every hop
if (status >= 300 && status < 400 && remoteRes.headers.location) {
remoteRes.resume();
if (hopsLeft <= 0) {
resolve({ status: 502, error: 'Too many redirects' });
return;
}
let nextUrl;
try { nextUrl = new URL(remoteRes.headers.location, rawUrl).href; }
catch { resolve({ status: 502, error: 'Invalid redirect target' }); return; }
resolve(fetchRemoteUrl(nextUrl, config, hopsLeft - 1));
return;
}
if (status < 200 || status >= 300) {
remoteRes.resume();
resolve({ status: 502, error: `Remote returned ${status}` });
return;
}
const ct = normalizeContentType(remoteRes.headers['content-type']);
if (ct && REMOTE_MIME_DENY.has(ct)) {
remoteRes.resume();
resolve({ status: 415, error: `Content-Type ${ct} is not allowed` });
return;
}
// If no extension signal, require MIME allow-list match
if (!extAllowed) {
if (!ct || !REMOTE_MIME_ALLOW.has(ct)) {
remoteRes.resume();
resolve({ status: 415, error: ct ? `Content-Type ${ct} is not in the allow list` : 'Missing Content-Type' });
return;
}
}
const lengthHeader = remoteRes.headers['content-length'];
if (lengthHeader && Number(lengthHeader) > maxSize) {
remoteRes.resume();
resolve({ status: 413, error: `Remote content exceeds ${maxSize} bytes` });
return;
}
const chunks = [];
let total = 0;
remoteRes.on('data', (chunk) => {
total += chunk.length;
if (total > maxSize) {
remoteRes.destroy();
resolve({ status: 413, error: `Remote content exceeds ${maxSize} bytes` });
return;
}
chunks.push(chunk);
});
remoteRes.on('end', () => {
resolve({
status: 200,
body: Buffer.concat(chunks),
contentType: remoteRes.headers['content-type'] || 'application/octet-stream',
sourceUrl: urlObj.href,
lastModified: remoteRes.headers['last-modified'] || null,
});
});
remoteRes.on('error', (err) => resolve({ status: 502, error: `Stream error: ${err.message}` }));
});
req.on('timeout', () => { req.destroy(); resolve({ status: 504, error: 'Remote request timed out' }); });
req.on('error', (err) => resolve({ status: 502, error: `Request error: ${err.message}` }));
req.end();
});
}
const IMAGE_MIME = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
'.bmp': 'image/bmp', '.ico': 'image/x-icon',
};
const VIDEO_MIME = {
'.mp4': 'video/mp4',
'.m4v': 'video/mp4',
'.webm': 'video/webm',
'.ogv': 'video/ogg',
'.mov': 'video/quicktime',
};
/**
* Parse an HTTP Range header against a known total size.
*
* Supports:
* - bytes=START-END inclusive byte range
* - bytes=START- from START to end of file
* - bytes=-SUFFIX last SUFFIX bytes of file
*
* Returns { start, end } (both inclusive) or null when the range is malformed
* or unsatisfiable. Caller is responsible for emitting 416 vs 200 vs 206.
*/
function parseRange(header, total) {
const m = /^bytes=(\d*)-(\d*)$/.exec(header || '');
if (!m) return null;
const startStr = m[1];
const endStr = m[2];
let start = startStr === '' ? null : parseInt(startStr, 10);
let end = endStr === '' ? null : parseInt(endStr, 10);
if (start === null && end === null) return null;
if (start === null) {
// Suffix length form: bytes=-SUFFIX
if (end === 0) return null; // ambiguous — treat as unsatisfiable
start = Math.max(0, total - end);
end = total - 1;
}
if (end === null || end >= total) end = total - 1;
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
if (start < 0 || start > end || start >= total) return null;
return { start, end };
}
// --- Minimal ZIP writer (STORE method, no external deps) ---
// Produces a standards-compliant .zip archive in memory from a list of
// { name: string, data: Buffer } entries. Uses method=0 (stored) so there
// is no deflate cost; image files are already compressed.
const CRC32_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[i] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) {
c = (c >>> 8) ^ CRC32_TABLE[(c ^ buf[i]) & 0xFF];
}
return (c ^ 0xFFFFFFFF) >>> 0;
}
function buildZip(entries) {
const locals = [];
const centrals = [];
let offset = 0;
for (const entry of entries) {
const nameBuf = Buffer.from(entry.name, 'utf8');
const data = entry.data;
const crc = crc32(data);
const size = data.length;
const lfh = Buffer.alloc(30);
lfh.writeUInt32LE(0x04034b50, 0); // local file header signature
lfh.writeUInt16LE(20, 4); // version needed
lfh.writeUInt16LE(0x0800, 6); // general purpose flag (UTF-8 name)
lfh.writeUInt16LE(0, 8); // method = store
lfh.writeUInt16LE(0, 10); // mod time
lfh.writeUInt16LE(0x21, 12); // mod date (1980-01-01)
lfh.writeUInt32LE(crc, 14);
lfh.writeUInt32LE(size, 18); // compressed size
lfh.writeUInt32LE(size, 22); // uncompressed size
lfh.writeUInt16LE(nameBuf.length, 26);
lfh.writeUInt16LE(0, 28); // extra field length
locals.push(lfh, nameBuf, data);
const cdh = Buffer.alloc(46);
cdh.writeUInt32LE(0x02014b50, 0); // central dir header signature
cdh.writeUInt16LE(0x031E, 4); // version made by (UNIX + 3.0)
cdh.writeUInt16LE(20, 6); // version needed
cdh.writeUInt16LE(0x0800, 8); // general purpose flag
cdh.writeUInt16LE(0, 10); // method
cdh.writeUInt16LE(0, 12); // mod time
cdh.writeUInt16LE(0x21, 14); // mod date
cdh.writeUInt32LE(crc, 16);
cdh.writeUInt32LE(size, 20);
cdh.writeUInt32LE(size, 24);
cdh.writeUInt16LE(nameBuf.length, 28);
cdh.writeUInt16LE(0, 30); // extra length
cdh.writeUInt16LE(0, 32); // comment length
cdh.writeUInt16LE(0, 34); // disk number start
cdh.writeUInt16LE(0, 36); // internal attrs
cdh.writeUInt32LE(0, 38); // external attrs
cdh.writeUInt32LE(offset, 42); // LFH offset
centrals.push(cdh, nameBuf);
offset += lfh.length + nameBuf.length + data.length;
}
const centralSize = centrals.reduce((s, b) => s + b.length, 0);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0); // end of central dir signature
eocd.writeUInt16LE(0, 4); // disk number
eocd.writeUInt16LE(0, 6); // disk with central dir
eocd.writeUInt16LE(entries.length, 8); // entries on this disk
eocd.writeUInt16LE(entries.length, 10); // total entries
eocd.writeUInt32LE(centralSize, 12); // central dir size
eocd.writeUInt32LE(offset, 16); // central dir offset
eocd.writeUInt16LE(0, 20); // comment length
return Buffer.concat([...locals, ...centrals, eocd]);
}
// SSE clients
const sseClients = new Set();
// File tree builder
async function buildTree(dir, base = dir) {
const entries = await readdir(dir, { withFileTypes: true });
const items = [];
for (const entry of entries) {
if (entry.name === 'node_modules' || entry.name === '.git') continue;
// Skip hidden dirs but allow hidden files with supported extensions (e.g. .env)
if (entry.isDirectory() && entry.name.startsWith('.')) continue;
const fullPath = join(dir, entry.name);
const relPath = relative(base, fullPath);
if (entry.isDirectory()) {
const children = await buildTree(fullPath, base);
if (children.length > 0) {
items.push({ name: entry.name, path: relPath, type: 'dir', children });
}
} else if (SUPPORTED_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
items.push({ name: entry.name, path: relPath, type: 'file' });
}
}
// Sort: dirs first, then files, alphabetically
items.sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return items;
}
// Safe path resolution (prevent traversal + symlink bypass)
async function safePath(reqPath) {
const resolved = resolve(targetDir, reqPath);
// Check string prefix with trailing separator to prevent /docs-secret bypass
const safePrefix = targetDir.endsWith('/') ? targetDir : targetDir + '/';
if (resolved !== targetDir && !resolved.startsWith(safePrefix)) return null;
try {
// Resolve symlinks to real path and re-check
const real = await realpath(resolved);
const realBase = await realpath(targetDir);
const realPrefix = realBase.endsWith('/') ? realBase : realBase + '/';
if (real !== realBase && !real.startsWith(realPrefix)) return null;
return real;
} catch {
return null;
}
}
// MIME types for static files
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.ico': 'image/x-icon',
};
// Serve static file from dist
async function serveStatic(res, urlPath) {
let filePath = join(distDir, urlPath === '/' ? 'index.html' : urlPath);
try {
const s = await stat(filePath);
if (s.isDirectory()) filePath = join(filePath, 'index.html');
} catch {
// SPA fallback
filePath = join(distDir, 'index.html');
}
try {
const content = await readFile(filePath);
const ext = extname(filePath);
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
res.end(content);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found');
}
}
// --- Line-range reading helpers ---
/**
* Read a range of lines from a file using streams (never loads full file).
* Returns the requested lines and the total line count.
*/
async function readLineRange(filePath, offset, limit) {
const inputStream = await createTextReadStream(filePath);
return new Promise((resolve, reject) => {
const lines = [];
let lineNum = 0;
const rl = createInterface({
input: inputStream,
crlfDelay: Infinity,
});
rl.on('line', (line) => {
if (lineNum >= offset && lines.length < limit) {
lines.push(line);
}
lineNum++;
// Optimisation: once we have our lines, we still need total count,
// so we keep counting but skip storing.
});
rl.on('close', () => resolve({ lines, totalLines: lineNum }));
rl.on('error', reject);
});
}
/**
* Estimate total line count. For small files (< 1 MB) count exactly.
* For larger files, sample the first 8 KB and extrapolate.
*/
async function estimateLineCount(filePath, fileSize) {
const EXACT_THRESHOLD = 1024 * 1024; // 1 MB
if (fileSize <= EXACT_THRESHOLD) {
const inputStream = await createTextReadStream(filePath);
return new Promise((resolve, reject) => {
let count = 0;
const rl = createInterface({
input: inputStream,
crlfDelay: Infinity,
});
rl.on('line', () => count++);
rl.on('close', () => resolve(count));
rl.on('error', reject);
});
}
// Sample first 8 KB
const SAMPLE = 8192;
const buf = Buffer.alloc(SAMPLE);
const { open } = await import('node:fs/promises');
const fh = await open(filePath, 'r');
try {
const { bytesRead } = await fh.read(buf, 0, SAMPLE, 0);
const encoding = detectEncoding(buf.subarray(0, bytesRead));
const sample = encoding === 'utf-8'
? buf.toString('utf-8', 0, bytesRead)
: new TextDecoder(encoding).decode(buf.subarray(0, bytesRead));
const newlines = (sample.match(/\n/g) || []).length;
if (newlines === 0) return 1;
const avgLineBytes = bytesRead / newlines;
return Math.round(fileSize / avgLineBytes);
} finally {
await fh.close();
}
}
/**
* Search lines in a file matching a query string (case-insensitive).
* Streams through the file and collects matching lines with pagination.
* For CSV, always includes the header (line 0) in the response metadata.
*/
async function searchFileLines(filePath, query, offset, limit) {
const lowerQuery = query.toLowerCase();
const inputStream = await createTextReadStream(filePath);
return new Promise((resolve, reject) => {
const matches = [];
let lineNum = 0;
let totalMatches = 0;
let headerLine = null;
const rl = createInterface({
input: inputStream,
crlfDelay: Infinity,
});
rl.on('line', (line) => {
// Capture header (line 0) for CSV
if (lineNum === 0) {
headerLine = line;
}
if (line.toLowerCase().includes(lowerQuery)) {
if (totalMatches >= offset && matches.length < limit) {
matches.push({ lineNum, text: line });
}
totalMatches++;
}
lineNum++;
});
rl.on('close', () => resolve({ matches, totalMatches, totalLines: lineNum, headerLine }));
rl.on('error', reject);
});
}
// HTTP server
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${port}`);
// Security headers
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'");
// CORS restricted to same origin (no external access)
const origin = req.headers.origin;
if (origin && (origin === `http://localhost:${port}` || origin === `http://127.0.0.1:${port}`)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// API routes
if (url.pathname === '/api/tree') {
try {
const tree = await buildTree(targetDir);
const rootName = basename(targetDir);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ root: rootName, tree }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
}
return;
}
// File metadata (size, line count) — lightweight, no full read
if (url.pathname === '/api/file/meta') {
const filePath = url.searchParams.get('path');
if (!filePath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing path parameter' }));
return;
}
const resolved = await safePath(filePath);
if (!resolved) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Access denied' }));
return;
}
try {
const fileStat = await stat(resolved);
const ext = extname(resolved).toLowerCase();
const meta = {
size: fileStat.size,
mtime: fileStat.mtime.toISOString(),
ext,
};
// For text files > 0 bytes, estimate line count from a sample
if (!IMAGE_EXTENSIONS.has(ext) && fileStat.size > 0) {
meta.lines = await estimateLineCount(resolved, fileStat.size);
}
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(meta));
} catch {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File not found' }));
}
return;
}
if (url.pathname === '/api/file') {
const filePath = url.searchParams.get('path');
if (!filePath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing path parameter' }));
return;
}
const resolved = await safePath(filePath);
if (!resolved) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Access denied' }));
return;
}
// Line-range parameters (optional — omit for full file)
const offsetParam = url.searchParams.get('offset');
const limitParam = url.searchParams.get('limit');
const hasRange = offsetParam !== null || limitParam !== null;
try {
const fileStat = await stat(resolved);
const mtime = fileStat.mtime.toISOString();
const ext = extname(resolved).toLowerCase();
if (IMAGE_EXTENSIONS.has(ext)) {
const content = await readFile(resolved);
const mime = IMAGE_MIME[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'max-age=5', 'X-File-Mtime': mtime });
res.end(content);
} else if (VIDEO_EXTENSIONS.has(ext)) {
// Stream videos with HTTP Range support (206 Partial Content).
// <video> elements depend on this for seeking — without it, seek bars
// are non-functional and large files lock up the browser.
const total = fileStat.size;
const mime = VIDEO_MIME[ext] || 'application/octet-stream';
const rangeHeader = req.headers['range'];
const baseHeaders = {
'Content-Type': mime,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store',
'X-File-Mtime': mtime,
};
if (rangeHeader) {
const range = parseRange(rangeHeader, total);
if (!range) {
res.writeHead(416, {
...baseHeaders,
'Content-Range': `bytes */${total}`,
'Content-Length': '0',
});
res.end();
return;
}
const { start, end } = range;
const chunkLen = end - start + 1;
res.writeHead(206, {
...baseHeaders,
'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': String(chunkLen),
});
if (req.method === 'HEAD') {
res.end();
return;
}
const stream = createReadStream(resolved, { start, end });
stream.on('error', () => { try { res.destroy(); } catch { /* ignore */ } });
res.on('close', () => stream.destroy());
stream.pipe(res);
} else {
res.writeHead(200, {
...baseHeaders,
'Content-Length': String(total),
});
if (req.method === 'HEAD') {
res.end();
return;
}
const stream = createReadStream(resolved);
stream.on('error', () => { try { res.destroy(); } catch { /* ignore */ } });
res.on('close', () => stream.destroy());
stream.pipe(res);
}
} else if (hasRange) {
// Streaming line-range read — never loads the full file into memory
const offset = Math.max(0, parseInt(offsetParam || '0', 10) || 0);
const limit = Math.max(1, parseInt(limitParam || '1000', 10) || 1000);
const { lines, totalLines } = await readLineRange(resolved, offset, limit);
const headers = {
'Content-Type': 'text/plain; charset=utf-8',
'X-File-Mtime': mtime,
'X-Total-Lines': String(totalLines),
'X-Chunk-Offset': String(offset),
'X-Chunk-Limit': String(limit),
'X-Has-More': String(offset + lines.length < totalLines),
'Access-Control-Expose-Headers': 'X-File-Mtime, X-Total-Lines, X-Chunk-Offset, X-Chunk-Limit, X-Has-More',
};
res.writeHead(200, headers);
res.end(lines.join('\n'));
} else {
const content = await readFileText(resolved);
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'X-File-Mtime': mtime });
res.end(content);
}
} catch {