viberoom 0.5.9 → 0.6.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/dist/server.js CHANGED
@@ -1,16 +1,19 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { spawn } from "node:child_process";
3
3
  import { createServer } from "node:http";
4
- import { readFile, stat } from "node:fs/promises";
4
+ import { readFile, realpath, stat } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createRequire } from "node:module";
7
- import { dirname, join } from "node:path";
8
- import { existsSync as fileExists } from "node:fs";
7
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
8
+ import { createReadStream, existsSync as fileExists } from "node:fs";
9
+ import { createInterface } from "node:readline";
9
10
  import { classifyOpenTarget, describeOpen, detectEditor, editorCommand, isExecutablePath, openCommand } from "./open.js";
10
- import { parseCsv, viewerKind, VIEWER_MAX_BYTES } from "./viewer.js";
11
+ import { imageMediaType, languageOf, looksBinary, parseCsv, sliceLines, viewerKind, IMAGE_VIEW_MAX_BYTES, STREAM_MAX_BYTES, VIEWER_MAX_BYTES, WINDOW_MAX_LINES } from "./viewer.js";
11
12
  import { createFolder, homeFolder, listFolders, listRoots } from "./fsbrowse.js";
12
13
  import { contentTypeOf, isStoredFileName, IMAGE_MAX_BYTES, IMAGES_PER_MESSAGE } from "./files.js";
14
+ import { QUOTES_PER_MESSAGE } from "./quotes.js";
13
15
  import { commandTarget, parseRoomCommand } from "./commands.js";
16
+ import { acceptUpgrade } from "./ws.js";
14
17
  let editorFound;
15
18
  function currentEditor() {
16
19
  if (editorFound === undefined)
@@ -19,12 +22,6 @@ function currentEditor() {
19
22
  }
20
23
  const STATIC_FILES = {
21
24
  "/": { file: "index.html", type: "text/html; charset=utf-8" },
22
- "/index.html": { file: "index.html", type: "text/html; charset=utf-8" },
23
- "/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" },
24
- "/avatars.js": { file: "avatars.js", type: "text/javascript; charset=utf-8" },
25
- "/icons.js": { file: "icons.js", type: "text/javascript; charset=utf-8" },
26
- "/theme.css": { file: "theme.css", type: "text/css; charset=utf-8" },
27
- "/app.css": { file: "app.css", type: "text/css; charset=utf-8" },
28
25
  "/styleguide": { file: "styleguide.html", type: "text/html; charset=utf-8" },
29
26
  "/manifest.json": { file: "manifest.json", type: "application/manifest+json; charset=utf-8" },
30
27
  "/icon.svg": { file: "icon.svg", type: "image/svg+xml", dir: "assets" },
@@ -39,9 +36,21 @@ const STATIC_FILES = {
39
36
  "/vendor-icons/copilot.svg": { file: "vendors/copilot.svg", type: "image/svg+xml", dir: "assets" },
40
37
  "/vendor/mermaid.min.js": { file: "mermaid/dist/mermaid.min.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
41
38
  "/vendor/marked.umd.js": { file: "marked/lib/marked.umd.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
39
+ "/vendor/prism.js": { file: "prismjs/prism.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
40
+ };
41
+ const PRISM_LANGUAGE = /^\/vendor\/prism-lang\/([a-z0-9-]{1,32})\.js$/;
42
+ const UI_FILE = /^\/([a-z0-9_-]+\.(js|css|html|svg|json|png|ico))$/i;
43
+ const UI_TYPES = {
44
+ js: "text/javascript; charset=utf-8",
45
+ css: "text/css; charset=utf-8",
46
+ html: "text/html; charset=utf-8",
47
+ svg: "image/svg+xml",
48
+ json: "application/json; charset=utf-8",
49
+ png: "image/png",
50
+ ico: "image/x-icon",
42
51
  };
43
52
  import { checkForUpdate, installUpdate, restartWithNewBuild, runsFromSourceCheckout } from "./update.js";
44
- export function startServer(hub, port, log, info, onShutdownRequest) {
53
+ export function startServer(hub, port, log, info, onShutdownRequest, onRestartRequest) {
45
54
  const uiDir = fileURLToPath(new URL("../ui/", import.meta.url));
46
55
  const assetsDir = fileURLToPath(new URL("../assets/", import.meta.url));
47
56
  const resolveModule = createRequire(import.meta.url).resolve;
@@ -53,16 +62,26 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
53
62
  return join(packageDir(entry.file.slice(0, slash)), entry.file.slice(slash + 1));
54
63
  };
55
64
  const clients = new Set();
56
- const snapshot = () => ({ ...hub.snapshot(), version: info });
65
+ const sockets = new Set();
66
+ const snapshot = () => ({ ...hub.snapshot(), version: { ...info, pid: process.pid } });
57
67
  const broadcast = (event) => {
58
- const payload = `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
68
+ const json = JSON.stringify(event);
69
+ const payload = `event: ${event.type}\ndata: ${json}\n\n`;
59
70
  for (const res of clients)
60
71
  res.write(payload);
72
+ for (const peer of sockets)
73
+ peer.send(json);
61
74
  };
62
75
  hub.on("event", broadcast);
63
76
  const heartbeat = setInterval(() => {
64
77
  for (const res of clients)
65
78
  res.write(": ping\n\n");
79
+ for (const peer of sockets) {
80
+ if (!peer.alive)
81
+ peer.close(1001, "no pong");
82
+ else
83
+ peer.ping();
84
+ }
66
85
  }, 20_000);
67
86
  const server = createServer(async (req, res) => {
68
87
  try {
@@ -77,6 +96,20 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
77
96
  res.end();
78
97
  }
79
98
  });
99
+ server.on("upgrade", (req, socket, head) => {
100
+ const path = new URL(req.url ?? "/", "http://localhost").pathname;
101
+ if (path !== "/ws") {
102
+ socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
103
+ socket.destroy();
104
+ return;
105
+ }
106
+ const peer = acceptUpgrade(req, socket, head);
107
+ if (!peer)
108
+ return;
109
+ sockets.add(peer);
110
+ peer.onClose(() => sockets.delete(peer));
111
+ peer.send(JSON.stringify({ type: "snapshot", snapshot: snapshot() }));
112
+ });
80
113
  async function handle(req, res) {
81
114
  const url = new URL(req.url ?? "/", "http://localhost");
82
115
  const path = url.pathname;
@@ -92,6 +125,18 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
92
125
  }
93
126
  return;
94
127
  }
128
+ const prismLanguage = req.method === "GET" && path.match(PRISM_LANGUAGE);
129
+ if (prismLanguage) {
130
+ try {
131
+ const body = await readFile(staticPath({ file: `prismjs/components/prism-${prismLanguage[1]}.min.js`, dir: "node_modules" }));
132
+ res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "public, max-age=3600" });
133
+ res.end(body);
134
+ }
135
+ catch {
136
+ sendJson(res, 404, { error: `no highlighting for "${prismLanguage[1]}"` });
137
+ }
138
+ return;
139
+ }
95
140
  if (req.method === "GET" && STATIC_FILES[path]) {
96
141
  const entry = STATIC_FILES[path];
97
142
  const body = await readFile(staticPath(entry));
@@ -99,6 +144,20 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
99
144
  res.end(body);
100
145
  return;
101
146
  }
147
+ const uiFile = req.method === "GET" && path.match(UI_FILE);
148
+ if (uiFile) {
149
+ let body;
150
+ try {
151
+ body = await readFile(`${uiDir}${uiFile[1]}`);
152
+ }
153
+ catch {
154
+ sendJson(res, 404, { error: `no such file: ${uiFile[1]}` });
155
+ return;
156
+ }
157
+ res.writeHead(200, { "Content-Type": UI_TYPES[uiFile[2].toLowerCase()], "Cache-Control": "no-cache" });
158
+ res.end(body);
159
+ return;
160
+ }
102
161
  if (req.method === "GET" && path === "/events") {
103
162
  res.writeHead(200, {
104
163
  "Content-Type": "text/event-stream",
@@ -130,13 +189,55 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
130
189
  }
131
190
  return;
132
191
  }
192
+ if (req.method === "GET" && path === "/api/resolve") {
193
+ const asked = url.searchParams.get("path") ?? "";
194
+ const inRoom = await resolveInRoom(hub, url.searchParams.get("room"), asked);
195
+ const target = inRoom ?? (classifyOpenTarget(asked)?.kind === "path" ? classifyOpenTarget(asked).value : null);
196
+ if (!target) {
197
+ sendJson(res, 404, { error: "not a file of this room's folder" });
198
+ return;
199
+ }
200
+ try {
201
+ const info = await stat(target);
202
+ sendJson(res, 200, { ok: true, path: target, kind: info.isFile() ? "file" : info.isDirectory() ? "dir" : "other" });
203
+ }
204
+ catch {
205
+ sendJson(res, 404, { error: `no such file or folder: ${target}` });
206
+ }
207
+ return;
208
+ }
209
+ if (req.method === "GET" && path === "/api/image") {
210
+ const asked = url.searchParams.get("path") ?? "";
211
+ const inRoom = await resolveInRoom(hub, url.searchParams.get("room"), asked);
212
+ const target = classifyOpenTarget(inRoom ?? asked);
213
+ if (!target || target.kind !== "path")
214
+ throw new Error("only absolute paths, or a path inside the room's folder, can be shown");
215
+ const media = imageMediaType(target.value);
216
+ if (!media)
217
+ throw new Error("not a picture the room can show");
218
+ let info;
219
+ try {
220
+ info = await stat(target.value);
221
+ }
222
+ catch {
223
+ sendJson(res, 404, { error: `no such file: ${target.value}` });
224
+ return;
225
+ }
226
+ if (!info.isFile())
227
+ throw new Error(`not a file: ${target.value}`);
228
+ if (info.size > IMAGE_VIEW_MAX_BYTES)
229
+ throw new Error(`too big to show here (${Math.round(info.size / 1024 / 1024)} MB); it opens with the default app`);
230
+ res.writeHead(200, { "Content-Type": media, "Content-Length": String(info.size), "Cache-Control": "no-cache" });
231
+ createReadStream(target.value).pipe(res);
232
+ return;
233
+ }
133
234
  if (req.method === "GET" && path === "/api/file") {
134
- const target = classifyOpenTarget(url.searchParams.get("path") ?? "");
235
+ const asked = url.searchParams.get("path") ?? "";
236
+ const relative = await resolveInRoom(hub, url.searchParams.get("room"), asked);
237
+ const target = classifyOpenTarget(relative ?? asked);
135
238
  if (!target || target.kind !== "path")
136
- throw new Error("only absolute paths can be viewed");
239
+ throw new Error("only absolute paths, or a path inside the room's folder, can be viewed");
137
240
  const kind = viewerKind(target.value);
138
- if (!kind)
139
- throw new Error("only Markdown and CSV files can be viewed in the room");
140
241
  let info;
141
242
  try {
142
243
  info = await stat(target.value);
@@ -147,10 +248,41 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
147
248
  }
148
249
  if (!info.isFile())
149
250
  throw new Error(`not a file: ${target.value}`);
150
- if (info.size > VIEWER_MAX_BYTES)
151
- throw new Error(`too big to view here (${Math.round(info.size / 1024)} kB); open it in an editor`);
152
- const text = await readFile(target.value, "utf8");
153
- sendJson(res, 200, kind === "csv" ? { ok: true, kind, path: target.value, rows: parseCsv(text) } : { ok: true, kind, path: target.value, text });
251
+ const asNumber = (name) => {
252
+ const raw = url.searchParams.get(name);
253
+ if (raw === null || raw === "")
254
+ return undefined;
255
+ const value = Number(raw);
256
+ if (!Number.isFinite(value))
257
+ throw new Error(`${name} must be a line number`);
258
+ return Math.max(1, Math.floor(value));
259
+ };
260
+ const from = asNumber("from");
261
+ const to = asNumber("to");
262
+ const language = languageOf(target.value);
263
+ if (info.size > VIEWER_MAX_BYTES) {
264
+ if (kind !== "text")
265
+ throw new Error(`too big to view here (${Math.round(info.size / 1024)} kB); open it in an editor`);
266
+ const window = await readLineWindow(target.value, from ?? 1, to);
267
+ if (window.binary)
268
+ throw new Error("not a text file; opening it with the default app instead");
269
+ sendJson(res, 200, { ok: true, kind, path: target.value, language, text: window.text, from: window.from, to: window.to, lines: window.lines, more: true, bytes: info.size });
270
+ return;
271
+ }
272
+ const buffer = await readFile(target.value);
273
+ if (looksBinary(buffer.subarray(0, 8192)))
274
+ throw new Error("not a text file; opening it with the default app instead");
275
+ const text = buffer.toString("utf8");
276
+ if (kind === "csv") {
277
+ sendJson(res, 200, { ok: true, kind, path: target.value, rows: parseCsv(text) });
278
+ return;
279
+ }
280
+ if (kind === "markdown" && from === undefined) {
281
+ sendJson(res, 200, { ok: true, kind, path: target.value, text, lines: text.split(/\r?\n/).length });
282
+ return;
283
+ }
284
+ const window = sliceLines(text, from, to);
285
+ sendJson(res, 200, { ok: true, kind, path: target.value, language, ...window, bytes: info.size });
154
286
  return;
155
287
  }
156
288
  const roomFile = req.method === "GET" && path.match(/^\/api\/rooms\/([^/]+)\/files\/([^/]+)$/);
@@ -202,7 +334,7 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
202
334
  return;
203
335
  }
204
336
  if (req.method === "GET" && path === "/api/version") {
205
- sendJson(res, 200, info);
337
+ sendJson(res, 200, { ...info, dataDir: hub.dataDir, pid: process.pid });
206
338
  return;
207
339
  }
208
340
  if (req.method === "GET" && path === "/api/skills") {
@@ -228,6 +360,18 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
228
360
  sendJson(res, 200, target.room.describeRoomForAgent(target.participantId));
229
361
  return;
230
362
  }
363
+ if (req.method === "GET" && path === "/api/mcp/message") {
364
+ const target = hub.resolveMcpToken(url.searchParams.get("token") ?? "");
365
+ if (!target) {
366
+ sendJson(res, 403, { error: "unknown skills token (the session it belonged to is gone)" });
367
+ return;
368
+ }
369
+ const seq = Number(url.searchParams.get("seq"));
370
+ if (!Number.isInteger(seq))
371
+ throw new Error("seq must be the message number, the N of #N");
372
+ sendJson(res, 200, target.room.readMessageForAgent(target.participantId, seq, Number(url.searchParams.get("around") ?? 0)));
373
+ return;
374
+ }
231
375
  if (req.method === "GET" && path === "/api/mcp/skill") {
232
376
  const target = hub.resolveMcpToken(url.searchParams.get("token") ?? "");
233
377
  if (!target) {
@@ -416,6 +560,16 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
416
560
  sendJson(res, 200, { ok: true });
417
561
  return;
418
562
  }
563
+ if (path === "/api/restart") {
564
+ if (!onRestartRequest) {
565
+ sendJson(res, 400, { error: "this hub cannot restart itself" });
566
+ return;
567
+ }
568
+ log.info("restart requested from the window");
569
+ sendJson(res, 200, { ok: true });
570
+ setTimeout(onRestartRequest, 50);
571
+ return;
572
+ }
419
573
  if (path === "/api/shutdown") {
420
574
  log.info("shutdown requested over the API");
421
575
  sendJson(res, 200, { ok: true });
@@ -465,7 +619,7 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
465
619
  sendJson(res, 200, { ok: true, command: command.name, participant: target.name });
466
620
  return;
467
621
  }
468
- const message = room.postHumanMessage(text, imageList(body.images));
622
+ const message = room.postHumanMessage(text, imageList(body.images), quoteList(body.quotes));
469
623
  sendJson(res, 200, { ok: true, id: message.id });
470
624
  }
471
625
  else if (action === "template-preview") {
@@ -554,8 +708,10 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
554
708
  const room = hub.getRoom(decodeURIComponent(participantAction[1]));
555
709
  const id = decodeURIComponent(participantAction[2]);
556
710
  const action = participantAction[3];
557
- if (action === "cancel")
558
- room.cancelTurn(id);
711
+ if (action === "cancel") {
712
+ sendJson(res, 200, { ok: true, stopped: room.cancelTurn(id) });
713
+ return;
714
+ }
559
715
  else if (action === "respawn") {
560
716
  const replay = body.replay === undefined || body.replay === null || body.replay === "" ? undefined : Number(body.replay);
561
717
  await room.respawnAgent(id, { memory: body.memory === true || body.memory === "true", replay: replay !== undefined && Number.isFinite(replay) ? Math.max(0, Math.min(500, Math.round(replay))) : undefined });
@@ -592,14 +748,18 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
592
748
  else if (action === "unmute")
593
749
  room.setMuted(id, false);
594
750
  else if (action === "persona") {
595
- room.updatePersona(id, {
751
+ const patch = {
596
752
  name: body.name === undefined ? undefined : String(body.name),
597
753
  tagline: body.tagline === undefined ? undefined : String(body.tagline),
598
754
  role: body.role === undefined ? undefined : String(body.role),
599
755
  avatar: body.avatar === undefined ? undefined : String(body.avatar),
600
756
  replyDelay: body.replyDelay === undefined ? undefined : body.replyDelay === null || body.replyDelay === "" ? null : Number(body.replyDelay),
601
757
  skills: stringList(body.skills),
602
- });
758
+ };
759
+ if (body.restart === true || body.restart === "true")
760
+ await room.restartWithPersona(id, patch);
761
+ else
762
+ room.updatePersona(id, patch);
603
763
  }
604
764
  else
605
765
  await room.setConfig(id, String(body.configId ?? ""), body.value);
@@ -629,6 +789,8 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
629
789
  clearInterval(heartbeat);
630
790
  for (const res of clients)
631
791
  res.end();
792
+ for (const peer of sockets)
793
+ peer.close(1001, "hub closing");
632
794
  server.close();
633
795
  },
634
796
  });
@@ -658,6 +820,68 @@ function imageList(value) {
658
820
  return { name: optionalString(image.name) ?? undefined, mimeType: String(image.mimeType ?? ""), data: String(image.data ?? ""), n: Number.isInteger(n) && n > 0 ? n : undefined };
659
821
  });
660
822
  }
823
+ function inside(child, root) {
824
+ const [c, r] = process.platform === "linux" ? [child, root] : [child.toLowerCase(), root.toLowerCase()];
825
+ return c === r || c.startsWith(r.endsWith(sep) ? r : r + sep);
826
+ }
827
+ async function resolveInRoom(hub, roomId, asked) {
828
+ const text = String(asked ?? "").trim();
829
+ if (!roomId || !text || isAbsolute(text) || /^[a-z]:[\\/]/i.test(text) || /^[a-z][a-z0-9+.-]*:/i.test(text) || text.startsWith("~"))
830
+ return null;
831
+ const room = hub.getRoom(roomId);
832
+ const dir = room?.dir;
833
+ if (!dir)
834
+ return null;
835
+ const full = resolve(dir, text);
836
+ const root = resolve(dir);
837
+ if (!inside(full, root))
838
+ return null;
839
+ try {
840
+ const realFull = await realpath(full);
841
+ const realRoot = await realpath(root);
842
+ return inside(realFull, realRoot) ? full : null;
843
+ }
844
+ catch {
845
+ return full;
846
+ }
847
+ }
848
+ async function readLineWindow(path, from, to) {
849
+ const end = Math.max(from, Math.min(to ?? from + WINDOW_MAX_LINES - 1, from + WINDOW_MAX_LINES - 1));
850
+ const stream = createReadStream(path, { encoding: "utf8", highWaterMark: 256 * 1024 });
851
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
852
+ const kept = [];
853
+ let n = 0;
854
+ let bytes = 0;
855
+ let binary = false;
856
+ try {
857
+ for await (const line of lines) {
858
+ n++;
859
+ bytes += line.length + 1;
860
+ if (n === 1 && looksBinary(Buffer.from(line.slice(0, 4096), "utf8"))) {
861
+ binary = true;
862
+ break;
863
+ }
864
+ if (n >= from && n <= end)
865
+ kept.push(line);
866
+ if (n >= end || bytes > STREAM_MAX_BYTES)
867
+ break;
868
+ }
869
+ }
870
+ finally {
871
+ lines.close();
872
+ stream.destroy();
873
+ }
874
+ return { text: kept.join("\n"), from, to: Math.min(end, n), lines: n, binary };
875
+ }
876
+ function quoteList(value) {
877
+ if (!Array.isArray(value))
878
+ return [];
879
+ return value.slice(0, QUOTES_PER_MESSAGE).map((entry) => {
880
+ const quote = (entry ?? {});
881
+ const n = Number(quote.n);
882
+ return { seq: Number(quote.seq), text: String(quote.text ?? ""), n: Number.isInteger(n) && n > 0 ? n : undefined };
883
+ });
884
+ }
661
885
  function sendJson(res, status, body) {
662
886
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
663
887
  res.end(JSON.stringify(body));
package/dist/update.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
- import { join } from "node:path";
3
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
+ import { join, relative } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  export const REGISTRY_URL = "https://registry.npmjs.org/viberoom/latest";
7
7
  export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -69,6 +69,36 @@ export function runsFromSourceCheckout(mainModuleUrl) {
69
69
  const path = decodeURIComponent(new URL(mainModuleUrl).pathname);
70
70
  return !/\/node_modules\/viberoom\//.test(path);
71
71
  }
72
+ export function newerSourceThanBuild(mainModuleUrl) {
73
+ if (!runsFromSourceCheckout(mainModuleUrl))
74
+ return null;
75
+ const root = fileURLToPath(new URL("../", mainModuleUrl));
76
+ const src = join(root, "src");
77
+ if (!existsSync(src))
78
+ return null;
79
+ let built;
80
+ try {
81
+ built = statSync(fileURLToPath(mainModuleUrl)).mtimeMs;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ let newest = null;
87
+ const walk = (dir) => {
88
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
89
+ const path = join(dir, entry.name);
90
+ if (entry.isDirectory())
91
+ walk(path);
92
+ else if (entry.name.endsWith(".ts")) {
93
+ const mtime = statSync(path).mtimeMs;
94
+ if (mtime > built + 1000 && (!newest || mtime > newest.mtime))
95
+ newest = { path, mtime };
96
+ }
97
+ }
98
+ };
99
+ walk(src);
100
+ return newest ? relative(root, newest.path).split("\\").join("/") : null;
101
+ }
72
102
  export function installCommandLine(version) {
73
103
  if (!parseVersion(version))
74
104
  throw new Error(`not a version: ${version}`);
package/dist/viewer.js CHANGED
@@ -1,9 +1,76 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
- import { extname } from "node:path";
2
+ import { extname, basename } from "node:path";
3
3
  export const VIEWER_MAX_BYTES = 2 * 1024 * 1024;
4
+ export const WINDOW_MAX_LINES = 500;
5
+ export const STREAM_MAX_BYTES = 64 * 1024 * 1024;
4
6
  const KINDS = { ".md": "markdown", ".markdown": "markdown", ".csv": "csv", ".tsv": "csv" };
5
7
  export function viewerKind(path) {
6
- return KINDS[extname(path).toLowerCase()] ?? null;
8
+ return KINDS[extname(path).toLowerCase()] ?? "text";
9
+ }
10
+ const LANGUAGES = {
11
+ ".ts": "typescript", ".mts": "typescript", ".cts": "typescript", ".tsx": "tsx",
12
+ ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".jsx": "jsx",
13
+ ".json": "json", ".jsonc": "json", ".json5": "json",
14
+ ".py": "python", ".rb": "ruby", ".php": "php", ".pl": "perl", ".lua": "lua", ".r": "r",
15
+ ".rs": "rust", ".go": "go", ".java": "java", ".kt": "kotlin", ".kts": "kotlin", ".swift": "swift",
16
+ ".c": "c", ".h": "c", ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", ".hpp": "cpp", ".hh": "cpp",
17
+ ".cs": "csharp", ".fs": "fsharp", ".scala": "scala", ".dart": "dart", ".ex": "elixir", ".exs": "elixir",
18
+ ".sh": "bash", ".bash": "bash", ".zsh": "bash", ".ps1": "powershell", ".psm1": "powershell", ".bat": "batch", ".cmd": "batch",
19
+ ".sql": "sql", ".graphql": "graphql", ".gql": "graphql", ".proto": "protobuf",
20
+ ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".ini": "ini", ".cfg": "ini", ".conf": "ini", ".env": "bash",
21
+ ".xml": "xml", ".html": "html", ".htm": "html", ".svg": "xml", ".vue": "html",
22
+ ".css": "css", ".scss": "scss", ".sass": "scss", ".less": "less",
23
+ ".diff": "diff", ".patch": "diff",
24
+ };
25
+ const NAMED_FILES = {
26
+ dockerfile: "docker",
27
+ makefile: "makefile",
28
+ ".gitignore": "bash",
29
+ ".npmrc": "ini",
30
+ ".editorconfig": "ini",
31
+ };
32
+ export function languageOf(path) {
33
+ const name = basename(path).toLowerCase();
34
+ if (NAMED_FILES[name])
35
+ return NAMED_FILES[name];
36
+ return LANGUAGES[extname(path).toLowerCase()] || null;
37
+ }
38
+ const IMAGE_MEDIA = {
39
+ ".png": "image/png",
40
+ ".jpg": "image/jpeg",
41
+ ".jpeg": "image/jpeg",
42
+ ".gif": "image/gif",
43
+ ".webp": "image/webp",
44
+ ".bmp": "image/bmp",
45
+ ".avif": "image/avif",
46
+ ".ico": "image/x-icon",
47
+ ".svg": "image/svg+xml",
48
+ };
49
+ export const IMAGE_VIEW_MAX_BYTES = 20 * 1024 * 1024;
50
+ export function imageMediaType(path) {
51
+ return IMAGE_MEDIA[extname(path).toLowerCase()] ?? null;
52
+ }
53
+ export function looksBinary(head) {
54
+ if (!head.length)
55
+ return false;
56
+ let control = 0;
57
+ for (const byte of head) {
58
+ if (byte === 0)
59
+ return true;
60
+ if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13 && byte !== 12)
61
+ control++;
62
+ }
63
+ return control / head.length > 0.1;
64
+ }
65
+ export function sliceLines(text, from, to) {
66
+ const all = text.split(/\r?\n/);
67
+ if (all.length && all[all.length - 1] === "")
68
+ all.pop();
69
+ const lines = all.length;
70
+ const start = Math.max(1, Math.min(Math.floor(from ?? 1) || 1, Math.max(1, lines)));
71
+ const wanted = Math.floor(to ?? start + WINDOW_MAX_LINES - 1) || start;
72
+ const end = Math.max(start, Math.min(wanted, lines, start + WINDOW_MAX_LINES - 1));
73
+ return { text: all.slice(start - 1, end).join("\n"), from: start, to: end, lines };
7
74
  }
8
75
  export function detectDelimiter(text) {
9
76
  const first = text.split(/\r?\n/, 1)[0] ?? "";