@bivy/bivy 0.17.3-staging.1 → 0.17.3-staging.3
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/apps/gateway.js +80 -7
- package/dist/apps/listeners.js +115 -0
- package/dist/apps/preview-shell.js +39 -2
- package/dist/apps/registry.js +47 -4
- package/dist/apps/service.js +18 -1
- package/dist/controllers/app-commands.js +15 -5
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +2 -0
- package/dist/server.js +2 -0
- package/package.json +1 -1
package/dist/apps/gateway.js
CHANGED
|
@@ -15,7 +15,25 @@ const MIME = {
|
|
|
15
15
|
const COOKIE = "__Host-bivy-preview";
|
|
16
16
|
const OPEN_PATH = "/__bivy/open";
|
|
17
17
|
const REDEEM_PATH = "/__bivy/redeem";
|
|
18
|
+
const REVISION_PATH = "/__bivy/revision";
|
|
18
19
|
const HOUR = 60 * 60_000;
|
|
20
|
+
/** Marks gateway-generated "server not answering" responses, so recovery
|
|
21
|
+
* polling can tell them apart from an app's own 502s. */
|
|
22
|
+
const UPSTREAM_DOWN = "x-bivy-upstream-down";
|
|
23
|
+
/** A top-level or framed page load. Browsers without Fetch Metadata still ask for HTML. */
|
|
24
|
+
function isPageLoad(req) {
|
|
25
|
+
const dest = req.headers["sec-fetch-dest"];
|
|
26
|
+
return req.method === "GET" && (dest ? ["document", "iframe"].includes(dest) : /text\/html/.test(req.headers.accept ?? ""));
|
|
27
|
+
}
|
|
28
|
+
/** Served on the app's origin in place of a blank frame. It reloads itself once
|
|
29
|
+
* the server answers, and tells a framing shell so it can offer next steps. */
|
|
30
|
+
function upstreamDownPage(nonce, port, shellOrigin) {
|
|
31
|
+
return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Waiting for the app server</title>
|
|
32
|
+
<style nonce="${nonce}">:root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;box-sizing:border-box;text-align:center}main{max-width:32rem}h1{font-size:1.25rem;margin:0 0 8px}p{margin:0;opacity:.75;line-height:1.5}</style>
|
|
33
|
+
<main><h1>Nothing is answering on port ${port}</h1><p role="status">The app server stopped or hasn’t started yet. This page reloads by itself when it’s back.</p></main>
|
|
34
|
+
<script nonce="${nonce}">const tell=state=>{if(parent!==window)parent.postMessage({type:'bivy:upstream',state,port:${port}},${JSON.stringify(shellOrigin)});};tell('down');
|
|
35
|
+
const poll=async()=>{try{const r=await fetch(location.href,{method:'HEAD',cache:'no-store'});if(!r.headers.has('${UPSTREAM_DOWN}')){tell('up');location.reload();return;}}catch{}setTimeout(poll,2000);};setTimeout(poll,2000);</script></html>`;
|
|
36
|
+
}
|
|
19
37
|
/** Copied links are reusable until revoked, capped so a forgotten one lapses. */
|
|
20
38
|
export const SHARE_TTL = 24 * HOUR;
|
|
21
39
|
const HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
|
|
@@ -176,7 +194,7 @@ export class AppGateway {
|
|
|
176
194
|
const origin = this.shellOrigin(id);
|
|
177
195
|
const nonce = randomBytes(16).toString("hex");
|
|
178
196
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
179
|
-
res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; connect-src 'self'; frame-src ${this.origin(id)}; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`);
|
|
197
|
+
res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; connect-src 'self' ${this.origin(id)}; frame-src ${this.origin(id)}; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`);
|
|
180
198
|
if (req.method === "GET" && this.styles.has(req.url ?? "")) {
|
|
181
199
|
res.setHeader("Content-Type", "text/css; charset=utf-8");
|
|
182
200
|
res.end(this.styles.get(req.url));
|
|
@@ -297,6 +315,13 @@ export class AppGateway {
|
|
|
297
315
|
return;
|
|
298
316
|
}
|
|
299
317
|
this.track(id, req.socket, expires);
|
|
318
|
+
if (req.url.startsWith(`${REVISION_PATH}?`) && req.method === "GET") {
|
|
319
|
+
this.revision(req, res, entry);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// Remember the framed page so a turn reload lands where the user was.
|
|
323
|
+
if (req.method === "GET" && req.headers["sec-fetch-dest"] === "iframe" && req.url.length <= 2048)
|
|
324
|
+
entry.lastPath = req.url;
|
|
300
325
|
if (entry.target.kind === "static") {
|
|
301
326
|
if (!["GET", "HEAD"].includes(req.method ?? "")) {
|
|
302
327
|
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
@@ -314,7 +339,17 @@ export class AppGateway {
|
|
|
314
339
|
}
|
|
315
340
|
if (file.endsWith("/"))
|
|
316
341
|
file += "index.html";
|
|
317
|
-
|
|
342
|
+
let data = entry.target.files.get(file);
|
|
343
|
+
let status = 200;
|
|
344
|
+
// Client-side routes: an extensionless page load falls back like common
|
|
345
|
+
// static hosts do — to 404.html (status 404) when present, else index.html.
|
|
346
|
+
if (!data && !path.extname(file) && isPageLoad(req)) {
|
|
347
|
+
const notFound = entry.target.files.get("/404.html");
|
|
348
|
+
file = notFound ? "/404.html" : "/index.html";
|
|
349
|
+
data = notFound ?? entry.target.files.get(file);
|
|
350
|
+
if (notFound)
|
|
351
|
+
status = 404;
|
|
352
|
+
}
|
|
318
353
|
if (!data) {
|
|
319
354
|
res.writeHead(404);
|
|
320
355
|
res.end("File not found.");
|
|
@@ -322,7 +357,7 @@ export class AppGateway {
|
|
|
322
357
|
}
|
|
323
358
|
const headers = responseHeaders({}, this.shellOrigin(id));
|
|
324
359
|
headers["content-type"] = MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
|
|
325
|
-
res.writeHead(
|
|
360
|
+
res.writeHead(status, { ...headers, "content-length": data.length });
|
|
326
361
|
res.end(req.method === "HEAD" ? undefined : data);
|
|
327
362
|
return;
|
|
328
363
|
}
|
|
@@ -331,17 +366,54 @@ export class AppGateway {
|
|
|
331
366
|
res.end();
|
|
332
367
|
return;
|
|
333
368
|
}
|
|
334
|
-
const
|
|
369
|
+
const port = entry.target.port;
|
|
370
|
+
const upstream = http.request({ hostname: "127.0.0.1", port, path: req.url, method: req.method, headers: upstreamHeaders(req, this.origin(id)) }, (response) => {
|
|
335
371
|
res.writeHead(response.statusCode ?? 502, responseHeaders(response.headers, this.shellOrigin(id)));
|
|
336
372
|
response.on("error", () => res.destroy());
|
|
337
373
|
response.pipe(res);
|
|
338
374
|
});
|
|
339
375
|
upstream.setTimeout(60_000, () => upstream.destroy(new Error("Preview timed out")));
|
|
340
|
-
upstream.on("error", () => {
|
|
341
|
-
|
|
376
|
+
upstream.on("error", () => {
|
|
377
|
+
if (res.headersSent) {
|
|
378
|
+
res.destroy();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (!isPageLoad(req)) {
|
|
382
|
+
res.writeHead(502, { "content-type": "text/plain", [UPSTREAM_DOWN]: "1" });
|
|
383
|
+
res.end("App server unavailable. Start it on the registered port, then reload.");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const nonce = randomBytes(16).toString("hex");
|
|
387
|
+
res.writeHead(502, { "content-type": "text/html; charset=utf-8", [UPSTREAM_DOWN]: "1", "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${this.shellOrigin(id)}; base-uri 'none'` });
|
|
388
|
+
res.end(upstreamDownPage(nonce, port, this.shellOrigin(id)));
|
|
389
|
+
});
|
|
342
390
|
res.on("close", () => upstream.destroy());
|
|
343
391
|
req.pipe(upstream);
|
|
344
392
|
}
|
|
393
|
+
/** Long poll from the trusted shell (same-site, credentialed CORS): answers
|
|
394
|
+
* when the view's revision differs from `after`, or after 25 seconds. */
|
|
395
|
+
revision(req, res, entry) {
|
|
396
|
+
const id = entry.view.id;
|
|
397
|
+
const after = Number(new URL(req.url, "http://gateway").searchParams.get("after"));
|
|
398
|
+
let timer;
|
|
399
|
+
const onRevision = (viewId) => { if (viewId === id)
|
|
400
|
+
send(); };
|
|
401
|
+
const cleanup = () => { clearTimeout(timer); this.registry.off("revision", onRevision); };
|
|
402
|
+
const send = () => {
|
|
403
|
+
cleanup();
|
|
404
|
+
if (res.writableEnded || res.destroyed)
|
|
405
|
+
return;
|
|
406
|
+
res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": this.shellOrigin(id), "access-control-allow-credentials": "true", vary: "Origin" });
|
|
407
|
+
res.end(JSON.stringify({ revision: entry.revision, path: entry.lastPath ?? "/" }));
|
|
408
|
+
};
|
|
409
|
+
if (entry.revision !== after) {
|
|
410
|
+
send();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
this.registry.on("revision", onRevision);
|
|
414
|
+
timer = setTimeout(send, 25_000);
|
|
415
|
+
res.on("close", cleanup);
|
|
416
|
+
}
|
|
345
417
|
upgrade(req, socket, head) {
|
|
346
418
|
const entry = this.entry(req);
|
|
347
419
|
const expires = entry && this.authorize(req, entry.view.id);
|
|
@@ -350,7 +422,8 @@ export class AppGateway {
|
|
|
350
422
|
return;
|
|
351
423
|
}
|
|
352
424
|
this.track(entry.view.id, socket, expires);
|
|
353
|
-
const
|
|
425
|
+
const port = entry.target.port;
|
|
426
|
+
const upstream = http.request({ hostname: "127.0.0.1", port, path: req.url, headers: { ...upstreamHeaders(req, this.origin(entry.view.id)), connection: "Upgrade", upgrade: "websocket" } });
|
|
354
427
|
upstream.setTimeout(10_000, () => upstream.destroy());
|
|
355
428
|
upstream.on("upgrade", (response, peer, upstreamHead) => {
|
|
356
429
|
peer.setTimeout(0);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
const execFileP = promisify(execFile);
|
|
8
|
+
/** Loopback or wildcard TCP listeners whose process runs inside a workspace.
|
|
9
|
+
* Agent-neutral by design: every framework and agent starts servers the same
|
|
10
|
+
* way, so the process table is the one hook that works for all of them. A
|
|
11
|
+
* listener is only an offer — access still needs a user's tap and a grant. */
|
|
12
|
+
export async function scanListeners(workspace, platform = process.platform) {
|
|
13
|
+
let root;
|
|
14
|
+
try {
|
|
15
|
+
root = await fs.realpath(workspace);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const found = platform === "linux" ? await scanProc(root) : platform === "darwin" ? await scanLsof(root) : [];
|
|
21
|
+
const byPort = new Map();
|
|
22
|
+
for (const offer of found)
|
|
23
|
+
if (offer.pid !== process.pid && offer.port >= 1024 && !byPort.has(offer.port))
|
|
24
|
+
byPort.set(offer.port, offer);
|
|
25
|
+
return [...byPort.values()].sort((a, b) => a.port - b.port);
|
|
26
|
+
}
|
|
27
|
+
const inside = (root, dir) => dir === root || dir.startsWith(root + path.sep);
|
|
28
|
+
/** `local_address` in /proc/net/tcp{,6}: hex address in little-endian 32-bit words. */
|
|
29
|
+
export function listenPort(line) {
|
|
30
|
+
const cols = line.trim().split(/\s+/);
|
|
31
|
+
if (cols.length < 10 || cols[3] !== "0A")
|
|
32
|
+
return undefined; // 0A = LISTEN
|
|
33
|
+
const [address, portHex] = cols[1].split(":");
|
|
34
|
+
const words = address.match(/.{8}/g) ?? [];
|
|
35
|
+
const last = words[words.length - 1] ?? "";
|
|
36
|
+
const zero = /^0+$/.test(address);
|
|
37
|
+
const v4Loopback = last.endsWith("7F") && (words.length === 1 || words.slice(0, 3).join("") === "0000000000000000FFFF0000");
|
|
38
|
+
const v6Loopback = address === "00000000000000000000000001000000";
|
|
39
|
+
if (!zero && !v4Loopback && !v6Loopback)
|
|
40
|
+
return undefined;
|
|
41
|
+
return { inode: cols[9], port: parseInt(portHex, 16) };
|
|
42
|
+
}
|
|
43
|
+
async function scanProc(root) {
|
|
44
|
+
const sockets = new Map();
|
|
45
|
+
for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
46
|
+
const text = await fs.readFile(file, "utf8").catch(() => "");
|
|
47
|
+
for (const line of text.split("\n").slice(1)) {
|
|
48
|
+
const hit = listenPort(line);
|
|
49
|
+
if (hit && hit.inode !== "0")
|
|
50
|
+
sockets.set(hit.inode, hit.port);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!sockets.size)
|
|
54
|
+
return [];
|
|
55
|
+
const offers = [];
|
|
56
|
+
const pids = (await fs.readdir("/proc").catch(() => [])).filter((name) => /^\d+$/.test(name));
|
|
57
|
+
// cwd first: other users' processes fail here, and only workspace processes
|
|
58
|
+
// pay for an fd walk.
|
|
59
|
+
await Promise.all(pids.map(async (pid) => {
|
|
60
|
+
const cwd = await fs.readlink(`/proc/${pid}/cwd`).catch(() => "");
|
|
61
|
+
if (!cwd || !inside(root, cwd))
|
|
62
|
+
return;
|
|
63
|
+
const fds = await fs.readdir(`/proc/${pid}/fd`).catch(() => []);
|
|
64
|
+
const ports = new Set();
|
|
65
|
+
for (const fd of fds) {
|
|
66
|
+
const target = await fs.readlink(`/proc/${pid}/fd/${fd}`).catch(() => "");
|
|
67
|
+
const inode = /^socket:\[(\d+)\]$/.exec(target)?.[1];
|
|
68
|
+
const port = inode ? sockets.get(inode) : undefined;
|
|
69
|
+
if (port)
|
|
70
|
+
ports.add(port);
|
|
71
|
+
}
|
|
72
|
+
if (!ports.size)
|
|
73
|
+
return;
|
|
74
|
+
const argv = (await fs.readFile(`/proc/${pid}/cmdline`, "utf8").catch(() => "")).split("\0").filter(Boolean);
|
|
75
|
+
for (const port of ports)
|
|
76
|
+
offers.push({ port, pid: Number(pid), command: commandLabel(argv) });
|
|
77
|
+
}));
|
|
78
|
+
return offers;
|
|
79
|
+
}
|
|
80
|
+
/** `lsof -F` field output: p<pid>, c<command>, n<name> records. */
|
|
81
|
+
export function parseLsof(stdout) {
|
|
82
|
+
const records = [];
|
|
83
|
+
for (const line of stdout.split("\n")) {
|
|
84
|
+
if (line.startsWith("p"))
|
|
85
|
+
records.push({ pid: Number(line.slice(1)), values: [] });
|
|
86
|
+
else if (line.startsWith("n") && records.length)
|
|
87
|
+
records[records.length - 1].values.push(line.slice(1));
|
|
88
|
+
}
|
|
89
|
+
return records;
|
|
90
|
+
}
|
|
91
|
+
async function scanLsof(root) {
|
|
92
|
+
const run = (args) => execFileP("lsof", args, { timeout: 4000, maxBuffer: 4 * 1024 * 1024 }).then((r) => r.stdout, (e) => e.stdout ?? "");
|
|
93
|
+
const listening = parseLsof(await run(["-nP", "-iTCP", "-sTCP:LISTEN", "-Fpn"]));
|
|
94
|
+
if (!listening.length)
|
|
95
|
+
return [];
|
|
96
|
+
const cwds = new Map(parseLsof(await run(["-a", "-d", "cwd", "-Fpn", "-p", listening.map((r) => r.pid).join(",")])).map((r) => [r.pid, r.values[0] ?? ""]));
|
|
97
|
+
const offers = [];
|
|
98
|
+
for (const { pid, values } of listening) {
|
|
99
|
+
const cwd = await fs.realpath(cwds.get(pid) ?? "").catch(() => "");
|
|
100
|
+
if (!cwd || !inside(root, cwd))
|
|
101
|
+
continue;
|
|
102
|
+
const args = (await execFileP("ps", ["-p", String(pid), "-o", "args="], { timeout: 4000 }).then((r) => r.stdout, () => "")).trim();
|
|
103
|
+
for (const name of values) {
|
|
104
|
+
const match = /^(\*|127\.[\d.]+|\[::1\]|\[::\]|localhost):(\d+)$/.exec(name);
|
|
105
|
+
if (match)
|
|
106
|
+
offers.push({ port: Number(match[2]), pid, command: commandLabel(args.split(/\s+/)) });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return offers;
|
|
110
|
+
}
|
|
111
|
+
/** "node /x/node_modules/.bin/vite --port 5173" → "node vite --port 5173". */
|
|
112
|
+
function commandLabel(argv) {
|
|
113
|
+
const label = argv.slice(0, 4).map((arg) => arg.startsWith("/") || arg.startsWith(".") ? path.basename(arg) : arg).join(" ");
|
|
114
|
+
return label.length > 60 ? `${label.slice(0, 59)}…` : label || "server";
|
|
115
|
+
}
|
|
@@ -10,12 +10,15 @@ html,body { margin:0; width:100%; height:100%; }
|
|
|
10
10
|
body { display:flex; flex-direction:column; background:var(--bg); color:var(--ink); font-family:var(--font-sans); }
|
|
11
11
|
nav { display:flex; align-items:center; gap:var(--space-2); padding:var(--space-2) var(--space-3); border-bottom:thin solid var(--line); background:var(--surface); flex-shrink:0; }
|
|
12
12
|
nav .btn { min-height:var(--space-7); flex-shrink:0; }
|
|
13
|
+
#stamp { font-size:var(--text-xs); white-space:nowrap; }
|
|
13
14
|
#title { flex:1; min-width:0; font-size:var(--text-sm); font-weight:var(--weight-semibold); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
15
|
+
#down { flex-shrink:0; }
|
|
14
16
|
#status { padding:var(--space-4); margin:0; font-size:var(--text-sm); }
|
|
15
17
|
iframe { flex:1; min-height:0; width:100%; border:0; background:var(--bg); }
|
|
16
18
|
[hidden] { display:none !important; }
|
|
17
19
|
</style></head><body>
|
|
18
|
-
<nav aria-label="Bivy preview controls"><button class="btn ghost" id="back">‹ Back to chat</button><span id="title">App preview</span><button class="btn ghost" id="reload" disabled>Reload</button></nav>
|
|
20
|
+
<nav aria-label="Bivy preview controls"><button class="btn ghost" id="back">‹ Back to chat</button><span id="title">App preview</span><span id="stamp" class="muted" role="status"></span><button class="btn ghost" id="reload" disabled>Reload</button></nav>
|
|
21
|
+
<div class="banner" data-tone="warn" id="down" hidden><span class="banner-text" id="down-text" role="status"></span><span class="banner-actions"><button class="btn sm" id="ask" hidden>Ask agent to fix</button></span></div>
|
|
19
22
|
<p id="status" role="status">Opening app…</p>
|
|
20
23
|
<iframe id="app" title="App preview" hidden sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-popups" referrerpolicy="no-referrer"></iframe>
|
|
21
24
|
<script nonce="${nonce}">
|
|
@@ -34,10 +37,44 @@ function show(data,launch){
|
|
|
34
37
|
frame.src=data.origin+(launch?'/__bivy/open#'+launch:'/');
|
|
35
38
|
reload.disabled=false;
|
|
36
39
|
status.textContent='Loading app…';
|
|
37
|
-
frame.onload=()=>{status.hidden=true;};
|
|
40
|
+
frame.onload=()=>{status.hidden=true;if(revision===null){revision=-1;watch();}else if(wake)wake();};
|
|
38
41
|
// Store navigation metadata only, never tickets or cookies.
|
|
39
42
|
try{sessionStorage.setItem(storageKey,JSON.stringify(data));}catch{}
|
|
40
43
|
}
|
|
44
|
+
// Reload when an agent turn changes files, returning to the last framed page.
|
|
45
|
+
// Until the frame has redeemed its access cookie, polls fail; back off, and
|
|
46
|
+
// let the next frame load retry at once.
|
|
47
|
+
let revision=null,retry=0,wake=null;
|
|
48
|
+
async function watch(){
|
|
49
|
+
wake=null;
|
|
50
|
+
try{
|
|
51
|
+
const r=await fetch(metadata.origin+'/__bivy/revision?after='+revision,{credentials:'include',cache:'no-store'});
|
|
52
|
+
if(!r.ok)throw Error();
|
|
53
|
+
const d=await r.json();
|
|
54
|
+
if(revision>=0&&d.revision!==revision){
|
|
55
|
+
const path=typeof d.path==='string'&&d.path.startsWith('/')&&!d.path.startsWith('//')?d.path:'/';
|
|
56
|
+
frame.src=metadata.origin+path;
|
|
57
|
+
document.getElementById('stamp').textContent='Updated after the agent’s turn';
|
|
58
|
+
}
|
|
59
|
+
revision=d.revision;retry=0;setTimeout(watch,0);
|
|
60
|
+
}catch{retry=Math.min(30000,(retry||1000)*2);const t=setTimeout(watch,retry);wake=()=>{clearTimeout(t);watch();};}
|
|
61
|
+
}
|
|
62
|
+
// The app origin reports when its server stops answering. Its content is
|
|
63
|
+
// untrusted: the port is only ever placed in a draft the user reviews.
|
|
64
|
+
const down=document.getElementById('down'),downText=document.getElementById('down-text'),ask=document.getElementById('ask');
|
|
65
|
+
let downPort=0;
|
|
66
|
+
addEventListener('message',e=>{
|
|
67
|
+
if(!metadata||e.origin!==metadata.origin||e.source!==frame.contentWindow||e.data?.type!=='bivy:upstream')return;
|
|
68
|
+
downPort=Number(e.data.port)||0;
|
|
69
|
+
down.hidden=e.data.state!=='down';
|
|
70
|
+
downText.textContent='Nothing is answering on port '+downPort+'.';
|
|
71
|
+
ask.hidden=!metadata.returnTo;
|
|
72
|
+
});
|
|
73
|
+
ask.onclick=()=>{
|
|
74
|
+
const to=new URL(metadata.returnTo),session=to.pathname.split('/').pop();
|
|
75
|
+
const text='The app preview "'+metadata.name+'" isn’t loading: nothing is answering on port '+downPort+'. Please find out why the server stopped, restart it, and tell me when it’s back.';
|
|
76
|
+
location.assign(to.origin+'/share?session='+encodeURIComponent(session)+'&text='+encodeURIComponent(text));
|
|
77
|
+
};
|
|
41
78
|
back.onclick=()=>{if(metadata?.returnTo){window.close();setTimeout(()=>location.replace(metadata.returnTo),100);}else{window.close();status.hidden=false;status.textContent='You can close this tab to return to Bivy.';}};
|
|
42
79
|
reload.onclick=()=>{if(metadata){status.hidden=false;status.textContent='Reloading app…';frame.src=metadata.origin+'/';}};
|
|
43
80
|
(async()=>{
|
package/dist/apps/registry.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
6
7
|
const MAX_BYTES = 25 * 1024 * 1024;
|
|
7
8
|
const MAX_TOTAL_BYTES = 100 * 1024 * 1024;
|
|
8
9
|
const MAX_FILES = 2000;
|
|
10
|
+
const sameFiles = (a, b) => a.size === b.size && [...a].every(([key, data]) => b.get(key)?.equals(data));
|
|
9
11
|
/** Snapshot, not a live filesystem server: symlinks and hidden files are never published. */
|
|
10
12
|
function snapshot(workspace, directory) {
|
|
11
13
|
const base = fs.realpathSync(workspace);
|
|
@@ -73,12 +75,14 @@ function snapshot(workspace, directory) {
|
|
|
73
75
|
return { kind: "static", files, bytes };
|
|
74
76
|
}
|
|
75
77
|
/** Deliberately ephemeral: restart invalidates apps rather than trusting reused local ports. */
|
|
76
|
-
export class AppRegistry {
|
|
78
|
+
export class AppRegistry extends EventEmitter {
|
|
77
79
|
reservedPorts;
|
|
78
80
|
apps = new Map();
|
|
79
81
|
views = new Map();
|
|
80
82
|
constructor(reservedPorts = []) {
|
|
83
|
+
super();
|
|
81
84
|
this.reservedPorts = reservedPorts;
|
|
85
|
+
this.setMaxListeners(0);
|
|
82
86
|
}
|
|
83
87
|
publish(sessionId, workspace, input) {
|
|
84
88
|
const name = (value) => {
|
|
@@ -97,6 +101,7 @@ export class AppRegistry {
|
|
|
97
101
|
const base = { id: randomBytes(16).toString("hex"), name: name(spec?.name) };
|
|
98
102
|
let target;
|
|
99
103
|
let view;
|
|
104
|
+
let source;
|
|
100
105
|
if (spec.kind === "web" && spec.source?.kind === "service") {
|
|
101
106
|
const port = spec.source.port;
|
|
102
107
|
if (!Number.isInteger(port) || port < 1024 || port > 65535 || this.reservedPorts.includes(port))
|
|
@@ -108,8 +113,8 @@ export class AppRegistry {
|
|
|
108
113
|
if (typeof spec.source.directory !== "string" || !spec.source.directory)
|
|
109
114
|
throw new Error("Static directory is required.");
|
|
110
115
|
target = snapshot(workspace, spec.source.directory);
|
|
111
|
-
|
|
112
|
-
|
|
116
|
+
source = { workspace, directory: spec.source.directory };
|
|
117
|
+
total += target.bytes;
|
|
113
118
|
if (total > MAX_TOTAL_BYTES)
|
|
114
119
|
throw new Error("Static apps exceed the node's 100 MiB snapshot budget.");
|
|
115
120
|
view = { ...base, kind: "web", source: "static" };
|
|
@@ -126,7 +131,7 @@ export class AppRegistry {
|
|
|
126
131
|
else
|
|
127
132
|
throw new Error("Unsupported app view. This machine supports web and terminal views.");
|
|
128
133
|
app.views.push(view);
|
|
129
|
-
entries.push({ app, view, target });
|
|
134
|
+
entries.push({ app, view, target, revision: 0, source });
|
|
130
135
|
}
|
|
131
136
|
// Commit all views together: a bad later view cannot leave a partial app.
|
|
132
137
|
this.apps.set(app.id, app);
|
|
@@ -138,6 +143,44 @@ export class AppRegistry {
|
|
|
138
143
|
return structuredClone([...this.apps.values()].filter((app) => app.sessionId === sessionId));
|
|
139
144
|
}
|
|
140
145
|
getView(id) { return this.views.get(id); }
|
|
146
|
+
/** An agent turn changed files: re-take changed static snapshots and bump
|
|
147
|
+
* every affected web view (emits `revision`). Returns the bumped view IDs. */
|
|
148
|
+
touch(sessionId) {
|
|
149
|
+
const bumped = [];
|
|
150
|
+
let total = [...this.views.values()].reduce((sum, item) => sum + (item.target.kind === "static" ? item.target.bytes : 0), 0);
|
|
151
|
+
for (const entry of this.views.values()) {
|
|
152
|
+
if (entry.app.sessionId !== sessionId || entry.view.kind !== "web")
|
|
153
|
+
continue;
|
|
154
|
+
if (entry.target.kind === "static") {
|
|
155
|
+
if (!entry.source)
|
|
156
|
+
continue;
|
|
157
|
+
let next;
|
|
158
|
+
// A broken build keeps the last good snapshot rather than blanking the preview.
|
|
159
|
+
try {
|
|
160
|
+
next = snapshot(entry.source.workspace, entry.source.directory);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (sameFiles(entry.target.files, next.files) || total - entry.target.bytes + next.bytes > MAX_TOTAL_BYTES)
|
|
166
|
+
continue;
|
|
167
|
+
total += next.bytes - entry.target.bytes;
|
|
168
|
+
entry.target = next;
|
|
169
|
+
}
|
|
170
|
+
entry.revision++;
|
|
171
|
+
bumped.push(entry.view.id);
|
|
172
|
+
this.emit("revision", entry.view.id);
|
|
173
|
+
}
|
|
174
|
+
return bumped;
|
|
175
|
+
}
|
|
176
|
+
/** Ports a session already previews, and ports no app may claim. */
|
|
177
|
+
claimedPorts(sessionId) {
|
|
178
|
+
const ports = new Set(this.reservedPorts);
|
|
179
|
+
for (const { app, target } of this.views.values())
|
|
180
|
+
if (app.sessionId === sessionId && target.kind === "service")
|
|
181
|
+
ports.add(target.port);
|
|
182
|
+
return ports;
|
|
183
|
+
}
|
|
141
184
|
require(sessionId, id) {
|
|
142
185
|
const app = this.apps.get(id);
|
|
143
186
|
if (!app || app.sessionId !== sessionId)
|
package/dist/apps/service.js
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
1
|
+
import { scanListeners } from "./listeners.js";
|
|
1
2
|
/** Composes view providers; registry never spawns processes and the gateway
|
|
2
3
|
* never knows about terminals. New providers can extend open/remove here. */
|
|
3
4
|
export class AppService {
|
|
4
5
|
registry;
|
|
5
6
|
gateway;
|
|
6
7
|
terminals;
|
|
8
|
+
scan;
|
|
7
9
|
terminalStarts = new Map();
|
|
8
|
-
constructor(registry, gateway, terminals) {
|
|
10
|
+
constructor(registry, gateway, terminals, scan = scanListeners) {
|
|
9
11
|
this.registry = registry;
|
|
10
12
|
this.gateway = gateway;
|
|
11
13
|
this.terminals = terminals;
|
|
14
|
+
this.scan = scan;
|
|
12
15
|
}
|
|
13
16
|
list(sessionId) { return { apps: this.registry.list(sessionId), previewAvailable: Boolean(this.gateway) && this.gateway?.available !== false }; }
|
|
14
17
|
publish(sessionId, workspace, manifest) { return this.registry.publish(sessionId, workspace, manifest); }
|
|
18
|
+
/** An agent turn changed files in this session's workspace. */
|
|
19
|
+
turnChanged(sessionId) { return this.registry.touch(sessionId); }
|
|
20
|
+
/** Servers running in the workspace that this session doesn't preview yet. */
|
|
21
|
+
async offers(sessionId, workspace) {
|
|
22
|
+
const claimed = this.registry.claimedPorts(sessionId);
|
|
23
|
+
return { offers: (await this.scan(workspace)).filter((offer) => !claimed.has(offer.port)) };
|
|
24
|
+
}
|
|
25
|
+
/** Publish a detected server. Re-scanned so a client can't adopt an arbitrary port. */
|
|
26
|
+
async adopt(sessionId, workspace, port) {
|
|
27
|
+
const offer = (await this.offers(sessionId, workspace)).offers.find((item) => item.port === port);
|
|
28
|
+
if (!offer)
|
|
29
|
+
throw new Error(`Nothing in this session's workspace is listening on port ${port} any more.`);
|
|
30
|
+
return this.registry.publish(sessionId, workspace, { version: 1, name: `${offer.command} · :${port}`.slice(0, 100), views: [{ kind: "web", name: `Port ${port}`, source: { kind: "service", port } }] });
|
|
31
|
+
}
|
|
15
32
|
async open(sessionId, appId, viewId, returnTo) {
|
|
16
33
|
const entry = this.registry.requireView(sessionId, appId, viewId);
|
|
17
34
|
if (entry.view.kind === "web") {
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
export function createAppCommands(service, workspaceFor, published = () => { }) {
|
|
2
|
+
const workspaceOf = (sessionId) => {
|
|
3
|
+
const workspace = workspaceFor(sessionId);
|
|
4
|
+
if (!workspace)
|
|
5
|
+
throw new Error("Open the session on this machine before publishing an app.");
|
|
6
|
+
return workspace;
|
|
7
|
+
};
|
|
2
8
|
const operations = {
|
|
3
9
|
"apps.list": (msg) => service.list(String(msg.sessionId)),
|
|
4
10
|
"apps.publish": (msg) => {
|
|
5
11
|
const sessionId = String(msg.sessionId);
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
const app = service.publish(sessionId, workspaceOf(sessionId), msg.manifest);
|
|
13
|
+
published(app);
|
|
14
|
+
return { app };
|
|
15
|
+
},
|
|
16
|
+
"apps.offers": (msg) => service.offers(String(msg.sessionId), workspaceOf(String(msg.sessionId))),
|
|
17
|
+
"apps.adopt": async (msg) => {
|
|
18
|
+
const sessionId = String(msg.sessionId);
|
|
19
|
+
const app = await service.adopt(sessionId, workspaceOf(sessionId), Number(msg.port));
|
|
10
20
|
published(app);
|
|
11
21
|
return { app };
|
|
12
22
|
},
|
|
@@ -18,7 +28,7 @@ export function createAppCommands(service, workspaceFor, published = () => { })
|
|
|
18
28
|
return Object.fromEntries(Object.entries(operations).map(([kind, execute]) => [kind, async (msg, ctx) => {
|
|
19
29
|
try {
|
|
20
30
|
ctx.reply({ type: `${kind}.ok`, requestId: msg.requestId, ...await execute(msg) });
|
|
21
|
-
if (kind === "apps.publish" || kind === "apps.remove")
|
|
31
|
+
if (kind === "apps.publish" || kind === "apps.adopt" || kind === "apps.remove")
|
|
22
32
|
ctx.broadcast({ type: "apps.changed", sessionId: msg.sessionId });
|
|
23
33
|
}
|
|
24
34
|
catch (error) {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/** Direct-mode adapters for operations whose canonical implementation is the
|
|
4
4
|
* client command registry. Relay and HTTP now differ only in framing. */
|
|
5
5
|
export const CLIENT_COMMAND_ROUTES = [
|
|
6
|
-
...["list", "publish", "open", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
6
|
+
...["list", "publish", "offers", "adopt", "open", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
7
7
|
{ method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
|
|
8
8
|
{ method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
|
|
9
9
|
{ method: "post", path: "/api/session/pause", kind: "session.pause" },
|
|
@@ -10,6 +10,8 @@ export const CLIENT_COMMAND_SCHEMAS = {
|
|
|
10
10
|
ping: Type.Object(request),
|
|
11
11
|
"apps.list": Type.Object({ ...request, ...session }),
|
|
12
12
|
"apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
|
|
13
|
+
"apps.offers": Type.Object({ ...request, ...session }),
|
|
14
|
+
"apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
|
|
13
15
|
"apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })) }),
|
|
14
16
|
"apps.share": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
|
15
17
|
"apps.revoke": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
package/dist/server.js
CHANGED
|
@@ -963,6 +963,8 @@ async function harnessEndTurn(record) {
|
|
|
963
963
|
record.workspaceState = (await harness.isDirty(record.id)) ? "dirty" : "clean";
|
|
964
964
|
if (!result || result.changes.length === 0)
|
|
965
965
|
return;
|
|
966
|
+
// Open previews of this session reload; static snapshots are re-taken.
|
|
967
|
+
appService.turnChanged(record.id);
|
|
966
968
|
broadcast({
|
|
967
969
|
type: "session.changes",
|
|
968
970
|
sessionId: record.id,
|
package/package.json
CHANGED