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/README.md +2 -0
- package/dist/agent-health.js +52 -0
- package/dist/duration.js +12 -0
- package/dist/hub.js +9 -0
- package/dist/launcher.js +19 -2
- package/dist/main.js +77 -34
- package/dist/mcp-skills-server.js +24 -0
- package/dist/persona.js +69 -14
- package/dist/quotes.js +41 -0
- package/dist/recipes.js +28 -1
- package/dist/room.js +136 -33
- package/dist/rows.js +19 -0
- package/dist/server.js +251 -27
- package/dist/update.js +32 -2
- package/dist/viewer.js +69 -2
- package/dist/ws.js +175 -0
- package/package.json +2 -1
- package/ui/app.css +341 -251
- package/ui/app.js +1283 -278
- package/ui/avatars.js +6 -4
- package/ui/components.css +140 -0
- package/ui/components.js +342 -0
- package/ui/icons.js +9 -0
- package/ui/index.html +58 -36
- package/ui/theme.css +450 -183
- package/ui/tokens.js +620 -0
- package/ui/ui.js +113 -0
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
|
|
65
|
+
const sockets = new Set();
|
|
66
|
+
const snapshot = () => ({ ...hub.snapshot(), version: { ...info, pid: process.pid } });
|
|
57
67
|
const broadcast = (event) => {
|
|
58
|
-
const
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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()] ??
|
|
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] ?? "";
|