@keyou007/agent-bot 0.1.17-alpha.2 → 0.1.17

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.
@@ -2,6 +2,10 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import http, { type IncomingMessage, type ServerResponse } from "node:http";
4
4
  import path from "node:path";
5
+ import hljs from "highlight.js/lib/common";
6
+ import dos from "highlight.js/lib/languages/dos";
7
+ import dockerfile from "highlight.js/lib/languages/dockerfile";
8
+ import powershell from "highlight.js/lib/languages/powershell";
5
9
  import {
6
10
  selectPreferredNetworkAddress,
7
11
  type NetworkConnectionKind,
@@ -9,9 +13,131 @@ import {
9
13
 
10
14
  const SECRET_FILE = "secret";
11
15
  const PORT_FILE = "port";
16
+ const SHORT_TOKEN_BYTES = 12;
17
+ const SHORT_TOKEN_LENGTH = 16;
12
18
  const MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024;
13
19
  const MAX_CONTENT_SAMPLE_BYTES = 64 * 1024;
20
+ const MAX_SYNTAX_HIGHLIGHT_BYTES = 512 * 1024;
14
21
  const DIRECTORY_PAGE_SIZE = 100;
22
+ hljs.registerLanguage("dos", dos);
23
+ hljs.registerLanguage("dockerfile", dockerfile);
24
+ hljs.registerLanguage("powershell", powershell);
25
+
26
+ const HIGHLIGHT_LANGUAGE_BY_EXTENSION: Readonly<Record<string, string>> = {
27
+ ".bash": "bash", ".bat": "dos", ".c": "c", ".cc": "cpp", ".cmd": "dos", ".cpp": "cpp",
28
+ ".cs": "csharp", ".css": "css", ".cxx": "cpp", ".diff": "diff", ".go": "go", ".h": "cpp",
29
+ ".hpp": "cpp", ".htm": "xml", ".html": "xml", ".ini": "ini", ".ipynb": "json", ".java": "java",
30
+ ".js": "javascript", ".json": "json", ".jsonl": "json", ".jsx": "javascript", ".kt": "kotlin",
31
+ ".kts": "kotlin", ".less": "less", ".lua": "lua", ".markdown": "markdown", ".md": "markdown",
32
+ ".mjs": "javascript", ".patch": "diff", ".php": "php", ".pl": "perl", ".ps1": "powershell",
33
+ ".py": "python", ".r": "r", ".rb": "ruby", ".rs": "rust", ".scss": "scss", ".sh": "bash",
34
+ ".sql": "sql", ".swift": "swift", ".toml": "ini", ".ts": "typescript", ".tsx": "typescript",
35
+ ".vue": "xml", ".xml": "xml", ".yaml": "yaml", ".yml": "yaml", ".zsh": "bash",
36
+ };
37
+ const HIGHLIGHT_LANGUAGE_BY_FILENAME: Readonly<Record<string, string>> = {
38
+ ".env": "ini",
39
+ ".gitignore": "ini",
40
+ "dockerfile": "dockerfile",
41
+ "makefile": "makefile",
42
+ };
43
+ const VIEWER_CLIENT_SCRIPT = `(() => {
44
+ const eventsUrl = document.body.dataset.eventsUrl;
45
+ const content = document.getElementById("viewer-content");
46
+ const metadata = document.getElementById("viewer-metadata");
47
+ const header = document.getElementById("viewer-header");
48
+ const title = document.getElementById("viewer-title");
49
+ if (!content || !metadata || !header || !title) return;
50
+ const filePath = title.dataset.filePath || title.textContent || "";
51
+
52
+ title.addEventListener("pointerdown", (event) => {
53
+ if (event.button !== 0) return;
54
+ event.preventDefault();
55
+ const selection = window.getSelection();
56
+ if (!selection) return;
57
+ const range = document.createRange();
58
+ range.selectNodeContents(title);
59
+ selection.removeAllRanges();
60
+ selection.addRange(range);
61
+ });
62
+
63
+ const updateHeaderOffset = () => {
64
+ const offset = Math.ceil(header.getBoundingClientRect().height) + 8;
65
+ document.documentElement.style.setProperty("--viewer-header-offset", offset + "px");
66
+ };
67
+ const highlightHashTarget = () => {
68
+ document.querySelector(".line.is-target-line")?.classList.remove("is-target-line");
69
+ let id;
70
+ try {
71
+ id = decodeURIComponent(window.location.hash.slice(1));
72
+ } catch {
73
+ title.textContent = filePath;
74
+ return null;
75
+ }
76
+ const lineNumber = /^L(\\d+)$/.exec(id)?.[1];
77
+ title.textContent = lineNumber ? filePath + ":" + lineNumber : filePath;
78
+ if (!lineNumber) return null;
79
+ const target = document.getElementById(id);
80
+ target?.classList.add("is-target-line");
81
+ return target;
82
+ };
83
+ const positionHashTarget = () => {
84
+ highlightHashTarget()?.scrollIntoView({ block: "start" });
85
+ };
86
+ updateHeaderOffset();
87
+ if (typeof ResizeObserver === "function") {
88
+ new ResizeObserver(updateHeaderOffset).observe(header);
89
+ }
90
+ window.addEventListener("hashchange", () => requestAnimationFrame(positionHashTarget));
91
+ requestAnimationFrame(positionHashTarget);
92
+ if (!eventsUrl || typeof EventSource !== "function") return;
93
+
94
+ const restoreScroll = (top, left, atBottom, codeScrollLeft) => {
95
+ requestAnimationFrame(() => {
96
+ const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
97
+ window.scrollTo(left, atBottom ? maxTop : Math.min(top, maxTop));
98
+ const code = content.querySelector(".code");
99
+ if (code) code.scrollLeft = codeScrollLeft;
100
+ });
101
+ };
102
+ const replaceContent = (nextContent, nextMetadata) => {
103
+ const top = window.scrollY;
104
+ const left = window.scrollX;
105
+ const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
106
+ const atBottom = maxTop - top <= 24;
107
+ const codeScrollLeft = content.querySelector(".code")?.scrollLeft ?? 0;
108
+ content.innerHTML = nextContent;
109
+ metadata.innerHTML = nextMetadata;
110
+ highlightHashTarget();
111
+ restoreScroll(top, left, atBottom, codeScrollLeft);
112
+ };
113
+
114
+ const source = new EventSource(eventsUrl);
115
+ source.addEventListener("update", (event) => {
116
+ try {
117
+ const update = JSON.parse(event.data);
118
+ if (typeof update.content === "string" && typeof update.metadata === "string") {
119
+ replaceContent(update.content, update.metadata);
120
+ }
121
+ } catch {
122
+ // A later valid event will repair the view.
123
+ }
124
+ });
125
+ source.addEventListener("unavailable", (event) => {
126
+ let message = "文件暂时无法读取,服务会继续等待它恢复。";
127
+ try {
128
+ const update = JSON.parse(event.data);
129
+ if (typeof update.message === "string") message = update.message;
130
+ } catch {
131
+ // Keep the fallback message.
132
+ }
133
+ const notice = document.createElement("div");
134
+ notice.className = "notice";
135
+ notice.textContent = message;
136
+ replaceContent(notice.outerHTML, metadata.innerHTML);
137
+ });
138
+ window.addEventListener("pagehide", () => source.close(), { once: true });
139
+ })();
140
+ `;
15
141
 
16
142
  type TextEncoding = "utf-8" | "utf-16le" | "utf-16be" | "gb18030";
17
143
 
@@ -28,6 +154,11 @@ interface DirectoryViewerEntry {
28
154
  kind: DirectoryViewerEntryKind;
29
155
  }
30
156
 
157
+ interface RenderedFileSnapshot {
158
+ content: string;
159
+ metadata: string[];
160
+ }
161
+
31
162
  export interface LocalFileViewerServerOptions {
32
163
  host: string;
33
164
  port: number;
@@ -138,8 +269,7 @@ export class LocalFileViewerServer {
138
269
  return undefined;
139
270
  }
140
271
 
141
- const token = Buffer.from(absolutePath, "utf8").toString("base64url");
142
- const url = this.createSignedUrl("view", token);
272
+ const url = this.createPreviewUrl(absolutePath);
143
273
  const line = parseLineReference(reference);
144
274
  if (line !== undefined) url.hash = `L${line}`;
145
275
  return url.toString();
@@ -154,15 +284,27 @@ export class LocalFileViewerServer {
154
284
 
155
285
  const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
156
286
  const route = routeWithinBasePath(requestUrl.pathname, this.basePath);
157
- const match = /^\/(view|raw)\/([A-Za-z0-9_-]+)$/u.exec(route);
158
- if (!match?.[1] || !match[2]) {
287
+ if (!route.startsWith("/")) {
288
+ this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
289
+ return;
290
+ }
291
+ if (route === "/assets/viewer.js") {
292
+ this.sendJavascript(response, request.method === "HEAD");
293
+ return;
294
+ }
295
+ const previewMatch = new RegExp(`^/preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
296
+ const legacyMatch = /^\/(view|raw)\/([A-Za-z0-9_-]+)$/u.exec(route);
297
+ const previewPath = previewMatch?.[1]
298
+ ? this.verifyPreviewToken(previewMatch[1], requestUrl.searchParams.get("path") ?? "")
299
+ : undefined;
300
+ const legacyPath = legacyMatch?.[2]
301
+ ? this.verifyLegacyToken(legacyMatch[2], requestUrl.searchParams.get("sig") ?? "")
302
+ : undefined;
303
+ const filePath = previewPath ?? legacyPath;
304
+ if (!previewMatch && !legacyMatch) {
159
305
  this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
160
306
  return;
161
307
  }
162
-
163
- const token = match[2];
164
- const signature = requestUrl.searchParams.get("sig") ?? "";
165
- const filePath = this.verifyToken(token, signature);
166
308
  if (!filePath) {
167
309
  this.sendHtml(response, 403, errorPage("文件链接无效", "签名校验失败,Agent Bot 已拒绝该请求。"), request.method === "HEAD");
168
310
  return;
@@ -177,7 +319,17 @@ export class LocalFileViewerServer {
177
319
  return;
178
320
  }
179
321
 
180
- if (match[1] === "raw") {
322
+ if (requestUrl.searchParams.get("events") === "1") {
323
+ if (!stat.isFile()) {
324
+ this.sendHtml(response, 400, errorPage("无法监听目录", "实时更新目前只适用于文件。"), request.method === "HEAD");
325
+ return;
326
+ }
327
+ this.serveFileEvents(request, response, filePath);
328
+ return;
329
+ }
330
+
331
+ const raw = legacyMatch?.[1] === "raw" || requestUrl.searchParams.get("raw") === "1";
332
+ if (raw) {
181
333
  if (stat.isDirectory()) {
182
334
  this.sendHtml(response, 400, errorPage("无法打开目录", "目录只能通过文件列表页面浏览。"), request.method === "HEAD");
183
335
  return;
@@ -187,12 +339,12 @@ export class LocalFileViewerServer {
187
339
  }
188
340
 
189
341
  const html = stat.isDirectory()
190
- ? this.renderDirectoryViewer(filePath, stat, token, parseDirectoryPage(requestUrl.searchParams.get("page")))
191
- : this.renderViewer(filePath, stat, token, signature);
342
+ ? this.renderDirectoryViewer(filePath, stat, parseDirectoryPage(requestUrl.searchParams.get("page")))
343
+ : this.renderViewer(filePath, stat);
192
344
  this.sendHtml(response, 200, html, request.method === "HEAD");
193
345
  }
194
346
 
195
- private verifyToken(token: string, signature: string): string | undefined {
347
+ private verifyLegacyToken(token: string, signature: string): string | undefined {
196
348
  if (!this.secret || !/^[a-f0-9]{64}$/u.test(signature)) return undefined;
197
349
  const expected = this.sign(token);
198
350
  const actualBuffer = Buffer.from(signature, "hex");
@@ -209,11 +361,22 @@ export class LocalFileViewerServer {
209
361
  return normalizeAbsolutePath(decoded);
210
362
  }
211
363
 
364
+ private verifyPreviewToken(token: string, rawPath: string): string | undefined {
365
+ const filePath = normalizeAbsolutePath(rawPath);
366
+ if (!filePath || !this.secret) return undefined;
367
+ const expected = this.createShortToken(filePath);
368
+ const actualBuffer = Buffer.from(token, "utf8");
369
+ const expectedBuffer = Buffer.from(expected, "utf8");
370
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
371
+ ? filePath
372
+ : undefined;
373
+ }
374
+
212
375
  private sign(token: string): string {
213
376
  return createHmac("sha256", this.secret!).update(token).digest("hex");
214
377
  }
215
378
 
216
- private createSignedUrl(route: "view" | "raw", token: string): URL {
379
+ private createLegacySignedUrl(route: "view" | "raw", token: string): URL {
217
380
  const url = new URL(this.address!.publicBaseUrl);
218
381
  url.pathname = `${this.basePath}/${route}/${token}`.replace(/\/{2,}/gu, "/");
219
382
  url.search = "";
@@ -222,20 +385,64 @@ export class LocalFileViewerServer {
222
385
  return url;
223
386
  }
224
387
 
225
- private renderViewer(filePath: string, stat: fs.Stats, token: string, signature: string): string {
388
+ private createPreviewUrl(filePath: string): URL {
389
+ const url = new URL(this.address!.publicBaseUrl);
390
+ url.pathname = `${this.basePath}/preview/${this.createShortToken(filePath)}`
391
+ .replace(/\/{2,}/gu, "/");
392
+ url.search = `?path=${encodeReadableQueryPath(filePath)}`;
393
+ url.hash = "";
394
+ return url;
395
+ }
396
+
397
+ private createShortToken(filePath: string): string {
398
+ return createHmac("sha256", this.secret!)
399
+ .update(filePath)
400
+ .digest()
401
+ .subarray(0, SHORT_TOKEN_BYTES)
402
+ .toString("base64url");
403
+ }
404
+
405
+ private renderViewer(filePath: string, stat: fs.Stats): string {
406
+ const fileName = path.basename(filePath);
407
+ const rawUrl = new URL(this.createFileUrl(filePath)!);
408
+ rawUrl.hash = "";
409
+ appendUrlQueryParameter(rawUrl, "raw", "1");
410
+ const downloadUrl = new URL(rawUrl);
411
+ appendUrlQueryParameter(downloadUrl, "download", "1");
412
+ const eventsUrl = new URL(this.createFileUrl(filePath)!);
413
+ eventsUrl.hash = "";
414
+ appendUrlQueryParameter(eventsUrl, "events", "1");
415
+ const snapshot = this.renderFileSnapshot(filePath, stat);
416
+
417
+ return renderViewerPage({
418
+ title: fileName,
419
+ filePath,
420
+ metadata: snapshot.metadata,
421
+ actions: [
422
+ { href: rawUrl.toString(), label: "打开原始文件" },
423
+ { href: downloadUrl.toString(), label: "下载" },
424
+ ],
425
+ content: snapshot.content,
426
+ liveUpdates: {
427
+ eventsUrl: eventsUrl.toString(),
428
+ scriptPath: `${this.basePath}/assets/viewer.js`.replace(/\/{2,}/gu, "/"),
429
+ },
430
+ });
431
+ }
432
+
433
+ private renderFileSnapshot(filePath: string, stat: fs.Stats): RenderedFileSnapshot {
226
434
  const fileName = path.basename(filePath);
227
435
  const extension = path.extname(filePath).toLowerCase();
228
436
  const classification = classifyFileContent(filePath, stat.size);
229
437
  const contentType = binaryContentTypeFor(extension);
230
- const rawUrl = this.createSignedUrl("raw", token);
231
- rawUrl.searchParams.set("sig", signature);
232
- const downloadUrl = new URL(rawUrl);
233
- downloadUrl.searchParams.set("download", "1");
438
+ const rawUrl = new URL(this.createFileUrl(filePath)!);
439
+ rawUrl.hash = "";
440
+ appendUrlQueryParameter(rawUrl, "raw", "1");
234
441
 
235
442
  let content: string;
236
443
  if (classification.kind === "text") {
237
444
  const preview = readTextPreview(filePath, stat.size, classification.encoding);
238
- content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${renderText(preview.text)}`;
445
+ content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${renderText(preview.text, filePath)}`;
239
446
  } else if (contentType.startsWith("image/")) {
240
447
  content = `<div class="media"><img src="${escapeAttribute(rawUrl.toString())}" alt="${escapeAttribute(fileName)}"></div>`;
241
448
  } else if (contentType === "application/pdf") {
@@ -247,20 +454,77 @@ export class LocalFileViewerServer {
247
454
  } else {
248
455
  content = '<div class="unsupported">该文件是二进制格式,无法直接预览。可以打开原始文件或下载到本地。</div>';
249
456
  }
250
-
251
- return renderViewerPage({
252
- title: fileName,
253
- filePath,
254
- metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
255
- actions: [
256
- { href: rawUrl.toString(), label: "打开原始文件" },
257
- { href: downloadUrl.toString(), label: "下载" },
258
- ],
457
+ return {
259
458
  content,
260
- });
459
+ metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
460
+ };
461
+ }
462
+
463
+ private serveFileEvents(request: IncomingMessage, response: ServerResponse, filePath: string): void {
464
+ setSecurityHeaders(response);
465
+ response.statusCode = 200;
466
+ response.setHeader("Cache-Control", "no-cache, no-store");
467
+ response.setHeader("Connection", "keep-alive");
468
+ response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
469
+ response.setHeader("X-Accel-Buffering", "no");
470
+ if (request.method === "HEAD") {
471
+ response.end();
472
+ return;
473
+ }
474
+ response.flushHeaders();
475
+ response.write("retry: 2000\n\n");
476
+
477
+ let closed = false;
478
+ let pendingUpdate: NodeJS.Timeout | undefined;
479
+ const sendSnapshot = () => {
480
+ if (closed || response.destroyed) return;
481
+ try {
482
+ const stat = fs.statSync(filePath);
483
+ if (!stat.isFile()) throw new Error("Path is no longer a file");
484
+ const snapshot = this.renderFileSnapshot(filePath, stat);
485
+ writeServerSentEvent(response, "update", JSON.stringify({
486
+ content: snapshot.content,
487
+ metadata: renderMetadata(snapshot.metadata),
488
+ }));
489
+ } catch {
490
+ writeServerSentEvent(response, "unavailable", JSON.stringify({
491
+ message: "文件可能已被移动、重命名或删除。",
492
+ }));
493
+ }
494
+ };
495
+ const scheduleSnapshot = () => {
496
+ if (pendingUpdate) clearTimeout(pendingUpdate);
497
+ pendingUpdate = setTimeout(sendSnapshot, 100);
498
+ pendingUpdate.unref();
499
+ };
500
+ const watcher = (current: fs.Stats, previous: fs.Stats) => {
501
+ if (
502
+ current.mtimeMs === previous.mtimeMs
503
+ && current.ctimeMs === previous.ctimeMs
504
+ && current.size === previous.size
505
+ && current.nlink === previous.nlink
506
+ ) return;
507
+ scheduleSnapshot();
508
+ };
509
+ fs.watchFile(filePath, { interval: 1_000, persistent: false }, watcher);
510
+ sendSnapshot();
511
+ const keepAlive = setInterval(() => {
512
+ if (!closed && !response.destroyed) response.write(": keep-alive\n\n");
513
+ }, 15_000);
514
+ keepAlive.unref();
515
+
516
+ const cleanup = () => {
517
+ if (closed) return;
518
+ closed = true;
519
+ if (pendingUpdate) clearTimeout(pendingUpdate);
520
+ clearInterval(keepAlive);
521
+ fs.unwatchFile(filePath, watcher);
522
+ };
523
+ request.once("aborted", cleanup);
524
+ response.once("close", cleanup);
261
525
  }
262
526
 
263
- private renderDirectoryViewer(directory: string, stat: fs.Stats, token: string, requestedPage: number): string {
527
+ private renderDirectoryViewer(directory: string, stat: fs.Stats, requestedPage: number): string {
264
528
  const entries = fs.readdirSync(directory, { withFileTypes: true })
265
529
  .flatMap((entry): DirectoryViewerEntry[] => {
266
530
  const entryPath = path.join(directory, entry.name);
@@ -287,8 +551,8 @@ export class LocalFileViewerServer {
287
551
  : '<div class="empty-directory">这个目录是空的。</div>';
288
552
  const pagination = totalPages > 1
289
553
  ? renderDirectoryPagination(page, totalPages, (targetPage) => {
290
- const url = this.createSignedUrl("view", token);
291
- url.searchParams.set("page", String(targetPage));
554
+ const url = new URL(this.createFileUrl(directory)!);
555
+ appendUrlQueryParameter(url, "page", String(targetPage));
292
556
  return url.toString();
293
557
  })
294
558
  : "";
@@ -355,6 +619,16 @@ export class LocalFileViewerServer {
355
619
  response.setHeader("Content-Length", body.length);
356
620
  response.end(headOnly ? undefined : body);
357
621
  }
622
+
623
+ private sendJavascript(response: ServerResponse, headOnly: boolean): void {
624
+ const body = Buffer.from(VIEWER_CLIENT_SCRIPT, "utf8");
625
+ setSecurityHeaders(response);
626
+ response.statusCode = 200;
627
+ response.setHeader("Cache-Control", "no-store");
628
+ response.setHeader("Content-Type", "text/javascript; charset=utf-8");
629
+ response.setHeader("Content-Length", body.length);
630
+ response.end(headOnly ? undefined : body);
631
+ }
358
632
  }
359
633
 
360
634
  function renderViewerPage(input: {
@@ -363,11 +637,18 @@ function renderViewerPage(input: {
363
637
  metadata: string[];
364
638
  actions: Array<{ href: string; label: string }>;
365
639
  content: string;
640
+ liveUpdates?: { eventsUrl: string; scriptPath: string };
366
641
  }): string {
367
- const metadata = input.metadata.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
642
+ const metadata = renderMetadata(input.metadata);
368
643
  const actions = input.actions.length > 0
369
644
  ? `<nav class="actions">${input.actions.map((action) => `<a href="${escapeAttribute(action.href)}">${escapeHtml(action.label)}</a>`).join("")}</nav>`
370
645
  : "";
646
+ const liveAttributes = input.liveUpdates
647
+ ? ` data-events-url="${escapeAttribute(input.liveUpdates.eventsUrl)}"`
648
+ : "";
649
+ const liveScript = input.liveUpdates
650
+ ? `<script src="${escapeAttribute(input.liveUpdates.scriptPath)}" defer></script>`
651
+ : "";
371
652
  return `<!doctype html>
372
653
  <html lang="zh-CN">
373
654
  <head>
@@ -375,26 +656,36 @@ function renderViewerPage(input: {
375
656
  <meta name="viewport" content="width=device-width, initial-scale=1">
376
657
  <title>${escapeHtml(input.title)} · Agent Bot</title>
377
658
  <style>
378
- :root { color-scheme: light dark; font-family: Inter, "Segoe UI", system-ui, sans-serif; }
659
+ :root { --viewer-header-offset: 56px; --viewer-code-font: "Cascadia Mono", "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", "Sarasa Mono SC", "Noto Sans Mono CJK SC", "Microsoft YaHei Mono", NSimSun, monospace; color-scheme: light dark; font-family: Inter, "Segoe UI", system-ui, sans-serif; }
379
660
  * { box-sizing: border-box; }
380
661
  body { margin: 0; background: #f5f7fa; color: #1f2329; }
381
- header { position: sticky; top: 0; z-index: 2; padding: 14px 22px; background: rgba(255,255,255,.96); border-bottom: 1px solid #dfe3e8; }
382
- h1 { margin: 0; font-size: 18px; font-weight: 650; overflow-wrap: anywhere; }
383
- .path { margin-top: 5px; color: #646a73; font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
384
- .meta { display: flex; gap: 12px; align-items: center; margin-top: 10px; color: #646a73; font-size: 12px; }
662
+ header { position: sticky; top: 0; z-index: 2; padding: 7px 12px; background: rgba(255,255,255,.96); border-bottom: 1px solid #dfe3e8; }
663
+ h1 { margin: 0; cursor: text; font: 600 13px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
664
+ .meta { display: flex; gap: 10px; align-items: center; min-height: 26px; margin-top: 3px; color: #646a73; font-size: 11px; }
665
+ .metadata-values { display: contents; }
385
666
  .actions { margin-left: auto; display: flex; gap: 8px; }
386
- .actions a { color: #1456f0; text-decoration: none; padding: 5px 9px; border: 1px solid #c9d0db; border-radius: 5px; background: #fff; }
387
- main { padding: 18px 22px 30px; }
388
- .notice, .unsupported { margin-bottom: 12px; padding: 12px 14px; border: 1px solid #f3cf8f; background: #fff7e6; border-radius: 6px; }
389
- .code { margin: 0; padding: 12px 0; overflow: auto; border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; color: #1f2329; font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size: 2; }
390
- .line { display: block; min-height: 1.55em; padding-right: 16px; }
391
- .line:target { background: #fff1b8; }
667
+ .actions a { color: #1456f0; text-decoration: none; padding: 3px 7px; border: 1px solid #c9d0db; border-radius: 4px; background: #fff; }
668
+ main { padding: 0; }
669
+ .notice, .unsupported { margin: 8px; padding: 10px 12px; border: 1px solid #f3cf8f; background: #fff7e6; border-radius: 5px; }
670
+ .code { margin: 0; padding: 6px 0; overflow: auto; border: 0; border-radius: 0; background: #fff; color: #1f2329; font-size: 13px; line-height: 1.5; tab-size: 2; }
671
+ .code, .code code, .code .line, .code .line * { font-family: var(--viewer-code-font) !important; font-variant-ligatures: none; font-feature-settings: "liga" 0, "calt" 0; }
672
+ .line { display: block; min-height: 1.55em; padding-right: 16px; scroll-margin-top: var(--viewer-header-offset); }
673
+ .line:target, .line.is-target-line { background: #fff1b8; box-shadow: inset 4px 0 #e6a700; }
392
674
  .line-number { display: inline-block; width: 4.5em; margin-right: 12px; padding-right: 12px; text-align: right; color: #8f959e; border-right: 1px solid #eceff3; text-decoration: none; user-select: none; }
675
+ .hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; }
676
+ .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #a626a4; }
677
+ .hljs-string, .hljs-regexp, .hljs-title, .hljs-name, .hljs-type, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition, .hljs-variable, .hljs-template-tag, .hljs-template-variable { color: #087f5b; }
678
+ .hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-params { color: #986801; }
679
+ .hljs-title.function_, .hljs-title.class_ { color: #005cc5; }
680
+ .hljs-deletion { color: #b31d28; background: #ffeef0; }
681
+ .hljs-addition { background: #e6ffed; }
682
+ .hljs-emphasis { font-style: italic; }
683
+ .hljs-strong { font-weight: 700; }
393
684
  .media { display: grid; place-items: center; min-height: 220px; }
394
- .media img, .media video { max-width: 100%; max-height: calc(100vh - 180px); }
685
+ .media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
395
686
  .media audio { width: min(720px, 100%); }
396
- .document { width: 100%; height: calc(100vh - 150px); border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; }
397
- .directory-list { overflow: hidden; border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; }
687
+ .document { width: 100%; height: calc(100vh - 70px); border: 0; border-radius: 0; background: #fff; }
688
+ .directory-list { overflow: hidden; border: 0; border-radius: 0; background: #fff; }
398
689
  .directory-entry { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 18px; align-items: center; min-height: 44px; padding: 8px 14px; color: inherit; text-decoration: none; border-bottom: 1px solid #eceff3; }
399
690
  .directory-entry:last-child { border-bottom: 0; }
400
691
  .directory-entry:hover { background: #f0f4ff; }
@@ -410,29 +701,40 @@ function renderViewerPage(input: {
410
701
  @media (prefers-color-scheme: dark) {
411
702
  body { background: #17181a; color: #e5e6eb; }
412
703
  header { background: rgba(32,33,36,.96); border-color: #3a3b3d; }
413
- .path, .meta, .pagination { color: #a6a9ad; }
704
+ .meta, .pagination { color: #a6a9ad; }
414
705
  .actions a, .pagination a { color: #8ab4ff; }
415
706
  .actions a { border-color: #55585c; background: #292a2d; }
416
707
  .code, .directory-list { background: #202124; color: #e8eaed; border-color: #3a3b3d; }
417
708
  .directory-entry { border-color: #3a3b3d; }
418
709
  .directory-entry:hover { background: #292f3d; }
419
710
  .line-number { color: #8b9098; border-color: #3a3b3d; }
420
- .line:target { background: #594c20; }
711
+ .line:target, .line.is-target-line { background: #594c20; box-shadow: inset 4px 0 #d29922; }
712
+ .hljs-comment, .hljs-quote { color: #8b949e; }
713
+ .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #ff7b72; }
714
+ .hljs-string, .hljs-regexp, .hljs-title, .hljs-name, .hljs-type, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition, .hljs-variable, .hljs-template-tag, .hljs-template-variable { color: #a5d6ff; }
715
+ .hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-params { color: #d2a8ff; }
716
+ .hljs-title.function_, .hljs-title.class_ { color: #d2a8ff; }
717
+ .hljs-deletion { color: #ffdcd7; background: #67060c; }
718
+ .hljs-addition { color: #aff5b4; background: #033a16; }
421
719
  .notice, .unsupported { color: #f5d58a; background: #3b321c; border-color: #705b28; }
422
720
  }
423
721
  </style>
424
722
  </head>
425
- <body>
426
- <header>
427
- <h1>${escapeHtml(input.title)}</h1>
428
- <div class="path">${escapeHtml(input.filePath)}</div>
429
- <div class="meta">${metadata}${actions}</div>
723
+ <body${liveAttributes}>
724
+ <header id="viewer-header">
725
+ <h1 id="viewer-title" data-file-path="${escapeAttribute(input.filePath)}">${escapeHtml(input.filePath)}</h1>
726
+ <div class="meta"><span class="metadata-values" id="viewer-metadata">${metadata}</span>${actions}</div>
430
727
  </header>
431
- <main>${input.content}</main>
728
+ <main id="viewer-content">${input.content}</main>
729
+ ${liveScript}
432
730
  </body>
433
731
  </html>`;
434
732
  }
435
733
 
734
+ function renderMetadata(metadata: string[]): string {
735
+ return metadata.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
736
+ }
737
+
436
738
  function directoryViewerEntryKind(filePath: string, stat: fs.Stats): DirectoryViewerEntryKind {
437
739
  if (stat.isDirectory()) return "directory";
438
740
  if (classifyFileContent(filePath, stat.size).kind === "text") return "text";
@@ -509,6 +811,17 @@ function normalizeAbsolutePath(filePath: string): string | undefined {
509
811
  return path.resolve(normalized);
510
812
  }
511
813
 
814
+ function encodeReadableQueryPath(filePath: string): string {
815
+ return encodeURIComponent(filePath.replaceAll("\\", "/"))
816
+ .replaceAll("%3A", ":")
817
+ .replaceAll("%2F", "/");
818
+ }
819
+
820
+ function appendUrlQueryParameter(url: URL, name: string, value: string): void {
821
+ const parameter = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
822
+ url.search = url.search ? `${url.search}&${parameter}` : `?${parameter}`;
823
+ }
824
+
512
825
  function normalizePublicBaseUrl(value: string): string {
513
826
  const url = new URL(value);
514
827
  if (url.protocol !== "http:" && url.protocol !== "https:") {
@@ -650,14 +963,62 @@ function hasTextLikeCharacters(value: string): boolean {
650
963
  return controls <= 2 || controls / Math.max(1, characters) <= 0.02;
651
964
  }
652
965
 
653
- function renderText(value: string): string {
654
- const lines = value.split(/\r?\n/u);
655
- return `<pre class="code"><code>${lines.map((line, index) => {
966
+ function renderText(value: string, filePath: string): string {
967
+ const normalized = value.replace(/\r\n?/gu, "\n");
968
+ const language = syntaxLanguageFor(filePath);
969
+ const lines = language && Buffer.byteLength(normalized, "utf8") <= MAX_SYNTAX_HIGHLIGHT_BYTES
970
+ ? highlightLines(normalized, language)
971
+ : normalized.split("\n").map(escapeHtml);
972
+ const languageClass = language ? ` class="language-${escapeAttribute(language)}"` : "";
973
+ return `<pre class="code hljs"><code${languageClass}>${lines.map((line, index) => {
656
974
  const lineNumber = index + 1;
657
- return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${escapeHtml(line)}</span>`;
975
+ return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${line}</span>`;
658
976
  }).join("")}</code></pre>`;
659
977
  }
660
978
 
979
+ function syntaxLanguageFor(filePath: string): string | undefined {
980
+ const fileName = path.basename(filePath).toLowerCase();
981
+ return HIGHLIGHT_LANGUAGE_BY_FILENAME[fileName]
982
+ ?? HIGHLIGHT_LANGUAGE_BY_EXTENSION[path.extname(fileName)];
983
+ }
984
+
985
+ function highlightLines(value: string, language: string): string[] {
986
+ try {
987
+ return splitHighlightedLines(hljs.highlight(value, { language, ignoreIllegals: true }).value);
988
+ } catch {
989
+ return value.split("\n").map(escapeHtml);
990
+ }
991
+ }
992
+
993
+ function splitHighlightedLines(value: string): string[] {
994
+ const lines: string[] = [];
995
+ const openTags: string[] = [];
996
+ const tokenPattern = /<span class="[^"]+">|<\/span>|\n/gu;
997
+ let current = "";
998
+ let offset = 0;
999
+ for (const match of value.matchAll(tokenPattern)) {
1000
+ const token = match[0];
1001
+ const index = match.index ?? offset;
1002
+ current += value.slice(offset, index);
1003
+ offset = index + token.length;
1004
+ if (token === "\n") {
1005
+ current += "</span>".repeat(openTags.length);
1006
+ lines.push(current);
1007
+ current = openTags.join("");
1008
+ } else if (token === "</span>") {
1009
+ current += token;
1010
+ openTags.pop();
1011
+ } else {
1012
+ current += token;
1013
+ openTags.push(token);
1014
+ }
1015
+ }
1016
+ current += value.slice(offset);
1017
+ current += "</span>".repeat(openTags.length);
1018
+ lines.push(current);
1019
+ return lines;
1020
+ }
1021
+
661
1022
  function contentTypeFor(extension: string): string {
662
1023
  const known: Record<string, string> = {
663
1024
  ".aac": "audio/aac", ".avi": "video/x-msvideo", ".bmp": "image/bmp", ".css": "text/css; charset=utf-8",
@@ -726,8 +1087,15 @@ function formatFileSize(size: number): string {
726
1087
  return `${(size / 1024 / 1024 / 1024).toFixed(1)} GiB`;
727
1088
  }
728
1089
 
1090
+ function writeServerSentEvent(response: ServerResponse, event: string, data: string): void {
1091
+ if (response.destroyed || response.writableEnded) return;
1092
+ response.write(`event: ${event}\n`);
1093
+ for (const line of data.split(/\r?\n/u)) response.write(`data: ${line}\n`);
1094
+ response.write("\n");
1095
+ }
1096
+
729
1097
  function setSecurityHeaders(response: ServerResponse): void {
730
- response.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self'; object-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
1098
+ response.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self'; object-src 'self'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
731
1099
  response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
732
1100
  response.setHeader("Referrer-Policy", "no-referrer");
733
1101
  response.setHeader("X-Content-Type-Options", "nosniff");