@bivy/bivy 0.17.3-staging.2 → 0.17.3-staging.4

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/bin/bivy.mjs CHANGED
@@ -2426,6 +2426,7 @@ async function cmdApp(args = []) {
2426
2426
  console.log(`Usage: bivy app publish <manifest.json> [--session <id>]
2427
2427
  bivy app list [--session <id>]
2428
2428
  bivy app remove <app-id> [--session <id>]
2429
+ bivy app shot [app-id] [--widths 390,1280] [--themes light,dark] [--path /page] [--session <id>]
2429
2430
 
2430
2431
  Manifest: {"version":1,"name":"My app","views":[
2431
2432
  {"kind":"web","name":"Website","source":{"kind":"service","port":3000}},
@@ -2433,19 +2434,39 @@ Manifest: {"version":1,"name":"My app","views":[
2433
2434
  ]}
2434
2435
 
2435
2436
  Static web source: {"kind":"static","directory":"./dist"}
2436
- Publishing does not start commands. Open Apps in the session menu to view or run.
2437
+ Server Bivy runs: {"kind":"service","port":5173,"start":{"command":"pnpm","args":["dev"]}}
2438
+ Publishing does not start commands. Open Apps in the session menu to view or run;
2439
+ a server with "start" starts on first open and is restarted if it exits.
2437
2440
  Terminal commands run with the node user's permissions, not in a new sandbox.
2441
+ "shot" screenshots the session's web views (or one app) with a local headless
2442
+ Chrome/Chromium and prints the PNG paths — look at them before saying the UI is
2443
+ done. It is off by default: turn it on in Bivy → Settings → this machine, or
2444
+ run: bivy config set sessions.appScreenshots true
2438
2445
  Web previews require operator setup; see docs/apps.md.`);
2439
2446
  return;
2440
2447
  }
2441
- if (!["publish", "list", "remove"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
2448
+ if (!["publish", "list", "remove", "shot"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
2449
+ const shot = {};
2450
+ if (action === "shot") {
2451
+ for (const flag of ["--widths", "--themes", "--path"]) {
2452
+ const i = rest.indexOf(flag);
2453
+ if (i < 0) continue;
2454
+ const value = rest[i + 1];
2455
+ if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value.`);
2456
+ if (flag === "--widths") shot.widths = value.split(",").map((w) => Number(w.trim()));
2457
+ else if (flag === "--themes") shot.themes = value.split(",").map((t) => t.trim());
2458
+ else shot.path = value;
2459
+ rest.splice(i, 2);
2460
+ }
2461
+ }
2442
2462
  const sessionIndex = rest.indexOf("--session");
2443
2463
  if (sessionIndex >= 0 && (!rest[sessionIndex + 1] || rest[sessionIndex + 1].startsWith("--"))) throw new Error("--session requires an id.");
2444
2464
  const sessionId = resolveAttachSessionId({ sessionFlag: sessionIndex >= 0 ? rest[sessionIndex + 1] : undefined, env: process.env });
2445
2465
  if (!sessionId) throw new Error("Set --session <id> or run inside a Bivy agent session.");
2446
2466
  const positional = rest.filter((_, i) => i !== sessionIndex && (sessionIndex < 0 || i !== sessionIndex + 1));
2447
- if (positional.some((a) => a.startsWith("-")) || positional.length !== (action === "list" ? 0 : 1)) throw new Error("Invalid arguments. Run bivy app --help.");
2448
- const body = { sessionId };
2467
+ if (positional.some((a) => a.startsWith("-")) || (action === "shot" ? positional.length > 1 : positional.length !== (action === "list" ? 0 : 1))) throw new Error("Invalid arguments. Run bivy app --help.");
2468
+ const body = { sessionId, ...shot };
2469
+ if (action === "shot" && positional[0]) body.appId = positional[0];
2449
2470
  if (action === "publish") {
2450
2471
  const file = path.resolve(positional[0]);
2451
2472
  if (fs.statSync(file).size > 64 * 1024) throw new Error("App manifest exceeds 64 KiB.");
@@ -5,6 +5,7 @@ import { randomBytes } from "node:crypto";
5
5
  import path from "node:path";
6
6
  import { readFileSync } from "node:fs";
7
7
  import { previewShell } from "./preview-shell.js";
8
+ import { inspectorScript } from "./inspector.js";
8
9
  const MIME = {
9
10
  ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
10
11
  ".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
@@ -15,7 +16,54 @@ const MIME = {
15
16
  const COOKIE = "__Host-bivy-preview";
16
17
  const OPEN_PATH = "/__bivy/open";
17
18
  const REDEEM_PATH = "/__bivy/redeem";
19
+ const REVISION_PATH = "/__bivy/revision";
20
+ const INSPECTOR_PATH = "/__bivy/inspector.js";
21
+ const COMPARE_PATH = "/__bivy/compare";
22
+ const NOTES_PATH = "/__bivy/notes";
23
+ const MAX_NOTES = 50;
24
+ /** Larger HTML documents pass through without the inspector. */
25
+ const MAX_INJECT_BYTES = 5 * 1024 * 1024;
26
+ /** Put the inspector first in <head>, so it sees errors from the app's own scripts. */
27
+ function withInspector(html) {
28
+ const text = html.toString("utf8");
29
+ const tag = `<script src="${INSPECTOR_PATH}"></script>`;
30
+ const at = /<head\b[^>]*>/i.exec(text) ?? /<html\b[^>]*>/i.exec(text);
31
+ return Buffer.from(at ? text.slice(0, at.index + at[0].length) + tag + text.slice(at.index + at[0].length) : tag + text);
32
+ }
33
+ /** Let the inspector through an app's CSP by exact URL; other sources are unchanged. */
34
+ function allowInspector(policy, url) {
35
+ const directives = policy.split(";").map((d) => d.trim()).filter(Boolean);
36
+ const find = (name) => directives.findIndex((d) => d.toLowerCase().split(/\s+/)[0] === name);
37
+ const index = [find("script-src-elem"), find("script-src")].find((i) => i >= 0) ?? -1;
38
+ const add = (directive) => directive.replace(/\s'none'/i, "") + " " + url;
39
+ if (index >= 0)
40
+ directives[index] = add(directives[index]);
41
+ else {
42
+ const fallback = find("default-src");
43
+ if (fallback < 0)
44
+ return policy;
45
+ directives.push(add(directives[fallback].replace(/^default-src/i, "script-src")));
46
+ }
47
+ return directives.join("; ");
48
+ }
18
49
  const HOUR = 60 * 60_000;
50
+ /** Marks gateway-generated "server not answering" responses, so recovery
51
+ * polling can tell them apart from an app's own 502s. */
52
+ const UPSTREAM_DOWN = "x-bivy-upstream-down";
53
+ /** A top-level or framed page load. Browsers without Fetch Metadata still ask for HTML. */
54
+ function isPageLoad(req) {
55
+ const dest = req.headers["sec-fetch-dest"];
56
+ return req.method === "GET" && (dest ? ["document", "iframe"].includes(dest) : /text\/html/.test(req.headers.accept ?? ""));
57
+ }
58
+ /** Served on the app's origin in place of a blank frame. It reloads itself once
59
+ * the server answers, and tells a framing shell so it can offer next steps. */
60
+ function upstreamDownPage(nonce, port, shellOrigin) {
61
+ 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>
62
+ <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>
63
+ <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>
64
+ <script nonce="${nonce}">const tell=state=>{if(parent!==window)parent.postMessage({type:'bivy:upstream',state,port:${port}},${JSON.stringify(shellOrigin)});};tell('down');
65
+ 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>`;
66
+ }
19
67
  /** Copied links are reusable until revoked, capped so a forgotten one lapses. */
20
68
  export const SHARE_TTL = 24 * HOUR;
21
69
  const HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
@@ -41,7 +89,7 @@ function upstreamHeaders(req, origin) {
41
89
  headers["x-forwarded-proto"] = "https";
42
90
  return headers;
43
91
  }
44
- function responseHeaders(headers, shellOrigin) {
92
+ function responseHeaders(headers, ancestors, inspector) {
45
93
  const result = cleanHeaders(headers);
46
94
  if (headers["set-cookie"]) {
47
95
  result["set-cookie"] = headers["set-cookie"].filter((cookie) => cookie.split("=", 1)[0].trim() !== COOKIE)
@@ -57,13 +105,16 @@ function responseHeaders(headers, shellOrigin) {
57
105
  // CSP directives, but replace framing restrictions with this narrower host.
58
106
  delete result["x-frame-options"];
59
107
  const csp = result["content-security-policy"];
60
- const policies = (Array.isArray(csp) ? csp : csp ? [String(csp)] : []).map((policy) => policy.replace(/(^|[;,])\s*frame-ancestors[^;,]*/gi, "$1"));
61
- result["content-security-policy"] = [...policies, `frame-ancestors ${shellOrigin}; worker-src 'none'`].join(", ");
108
+ const policies = (Array.isArray(csp) ? csp : csp ? [String(csp)] : []).flatMap((policy) => policy.split(","))
109
+ .map((policy) => policy.replace(/(^|;)\s*frame-ancestors[^;]*/gi, "$1"))
110
+ .map((policy) => inspector ? allowInspector(policy, inspector) : policy);
111
+ result["content-security-policy"] = [...policies, `frame-ancestors ${ancestors}; worker-src 'none'`].join(", ");
62
112
  return result;
63
113
  }
64
114
  export class AppGateway {
65
115
  registry;
66
116
  returnOrigins;
117
+ signIn;
67
118
  server;
68
119
  template;
69
120
  tickets = new Map();
@@ -71,11 +122,15 @@ export class AppGateway {
71
122
  ["/__bivy/tokens.css", readFileSync(new URL("./tokens.css", import.meta.url))],
72
123
  ["/__bivy/styles.css", readFileSync(new URL("./styles.css", import.meta.url))],
73
124
  ]);
125
+ /** `reviewer`: the browser session came from a copied (shared) link. */
74
126
  sessions = new Map();
75
127
  sockets = new Map();
76
- constructor(registry, originTemplate, returnOrigins = () => []) {
128
+ /** `signIn` sends a signed-out visit to a view's stable address back
129
+ * through Bivy, which re-opens it for a signed-in device. */
130
+ constructor(registry, originTemplate, returnOrigins = () => [], signIn) {
77
131
  this.registry = registry;
78
132
  this.returnOrigins = returnOrigins;
133
+ this.signIn = signIn;
79
134
  this.template = previewOriginTemplate(originTemplate);
80
135
  this.server = http.createServer((req, res) => { void this.handle(req, res).catch(() => { if (!res.headersSent)
81
136
  res.writeHead(502); res.end("Preview request failed."); }); });
@@ -86,6 +141,8 @@ export class AppGateway {
86
141
  }
87
142
  origin(id) { return this.template.replace("{app}", id); }
88
143
  shellOrigin(id) { return this.template.replace("{app}", `view-${id}`); }
144
+ /** Bivy clients may frame the shell (Peek), so app content allows both. */
145
+ ancestors(id) { return [this.shellOrigin(id), ...this.returnOrigins()].join(" "); }
89
146
  open(id, returnTo) {
90
147
  const entry = this.requireWeb(id);
91
148
  if (returnTo) {
@@ -100,6 +157,14 @@ export class AppGateway {
100
157
  // Fragment never reaches reverse-proxy access logs or the application.
101
158
  return `${this.shellOrigin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: Date.now() + 60_000, returnTo })}`;
102
159
  }
160
+ /** The view's stable address: grants nothing by itself. */
161
+ address(id) { this.requireWeb(id); return `${this.origin(id)}/`; }
162
+ /** A one-use link straight to the app origin (no shell), for a signed-in
163
+ * device returning to a stable address. `~path` resumes the page. */
164
+ openDirect(id) {
165
+ this.requireWeb(id);
166
+ return `${this.origin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: Date.now() + 60_000 })}`;
167
+ }
103
168
  /** A reusable link straight to the app origin (no shell frame), so it opens
104
169
  * in any browser and can be inspected. Valid until revoked or SHARE_TTL. */
105
170
  share(id) {
@@ -152,12 +217,13 @@ export class AppGateway {
152
217
  const entry = this.registry.getView(id);
153
218
  return entry?.view.kind === "web" ? entry : undefined;
154
219
  }
155
- authorize(req, id) {
220
+ session(req, id) {
156
221
  this.sweep();
157
222
  const token = (req.headers.cookie ?? "").split(";").map((part) => part.trim()).find((part) => part.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
158
- const session = token && this.sessions.get(token);
159
- return session && session.appId === id ? session.expires : undefined;
223
+ const session = token ? this.sessions.get(token) : undefined;
224
+ return session?.appId === id ? session : undefined;
160
225
  }
226
+ authorize(req, id) { return this.session(req, id)?.expires; }
161
227
  track(id, socket, expires) {
162
228
  let sockets = this.sockets.get(id);
163
229
  if (!sockets) {
@@ -176,7 +242,7 @@ export class AppGateway {
176
242
  const origin = this.shellOrigin(id);
177
243
  const nonce = randomBytes(16).toString("hex");
178
244
  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'`);
245
+ res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; connect-src 'self' ${this.origin(id)}; img-src blob:; frame-src ${this.origin(id)}; frame-ancestors ${this.returnOrigins().join(" ") || "'none'"}; base-uri 'none'; form-action 'none'`);
180
246
  if (req.method === "GET" && this.styles.has(req.url ?? "")) {
181
247
  res.setHeader("Content-Type", "text/css; charset=utf-8");
182
248
  res.end(this.styles.get(req.url));
@@ -237,8 +303,13 @@ export class AppGateway {
237
303
  const nonce = randomBytes(16).toString("hex");
238
304
  res.setHeader("Content-Type", "text/html; charset=utf-8");
239
305
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
240
- res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${this.shellOrigin(id)}; base-uri 'none'`);
241
- res.end(`<!doctype html><html lang="en"><meta name="viewport" content="width=device-width"><title>Open Bivy preview</title><p id="status" role="status">Opening preview…</p><script nonce="${nonce}">const ticket=location.hash.slice(1);history.replaceState(null,'',location.pathname);fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body:ticket}).then(r=>{if(!r.ok)throw Error();location.replace('/');}).catch(()=>{document.getElementById('status').textContent='Preview link expired or cookies are blocked. Open a new link from Bivy.';});</script></html>`);
306
+ res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${this.ancestors(id)}; base-uri 'none'`);
307
+ // An embedded launch ("e:" prefix) comes from a shell framed inside Bivy.
308
+ // After redeeming, check the cookie stuck: browsers that refuse framed
309
+ // cookies are reported to the shell, which falls back to a tab.
310
+ res.end(`<!doctype html><html lang="en"><meta name="viewport" content="width=device-width"><title>Open Bivy preview</title><p id="status" role="status">Opening preview…</p><script nonce="${nonce}">const [ticket,after='/']=location.hash.slice(1).split('~');history.replaceState(null,'',location.pathname);let next='/';try{next=decodeURIComponent(after);}catch{}if(!next.startsWith('/')||next.startsWith('//'))next='/';
311
+ const fail=blocked=>{if(blocked&&parent!==window)parent.postMessage({type:'bivy:access',state:'blocked'},${JSON.stringify(this.shellOrigin(id))});document.getElementById('status').textContent=blocked?'This browser blocks preview cookies here. Open the preview in a new tab from Bivy.':'Preview link expired or cookies are blocked. Open a new link from Bivy.';};
312
+ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body:ticket}).then(async r=>{if(!r.ok)return fail(false);const check=await fetch('${REVISION_PATH}?after=-2',{cache:'no-store'});if(!check.ok)return fail(true);location.replace(next);}).catch(()=>fail(false));</script></html>`);
242
313
  return;
243
314
  }
244
315
  if (req.url === REDEEM_PATH && req.method === "POST") {
@@ -257,24 +328,37 @@ export class AppGateway {
257
328
  }
258
329
  }
259
330
  this.sweep();
260
- const grant = this.tickets.get(body);
331
+ const embedded = body.startsWith("e:");
332
+ const ticket = embedded ? body.slice(2) : body;
333
+ const grant = this.tickets.get(ticket);
261
334
  if (!grant || grant.appId !== id || this.sessions.size >= 500) {
262
335
  res.writeHead(401);
263
336
  res.end();
264
337
  return;
265
338
  }
266
339
  if (!grant.reusable)
267
- this.tickets.delete(body);
340
+ this.tickets.delete(ticket);
268
341
  // A browser session never outlives the link that created it.
269
342
  const expires = Math.min(Date.now() + HOUR, grant.expires);
270
343
  const token = randomBytes(32).toString("hex");
271
- this.sessions.set(token, { appId: id, expires });
272
- res.setHeader("Set-Cookie", `${COOKIE}=${token}; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.max(1, Math.floor((expires - Date.now()) / 1000))}`);
344
+ this.sessions.set(token, { appId: id, expires, reviewer: grant.reusable === true });
345
+ // Framed inside Bivy, the cookie is third-party: it must be SameSite=None,
346
+ // and Partitioned keys it to Bivy's top-level site so no other site can use it.
347
+ const scope = embedded ? "SameSite=None; Partitioned" : "SameSite=Lax";
348
+ res.setHeader("Set-Cookie", `${COOKIE}=${token}; Secure; HttpOnly; ${scope}; Path=/; Max-Age=${Math.max(1, Math.floor((expires - Date.now()) / 1000))}`);
273
349
  res.writeHead(204);
274
350
  res.end();
275
351
  return;
276
352
  }
277
353
  const expires = this.authorize(req, id);
354
+ // A home-screen visit without access goes through Bivy to sign in, then
355
+ // comes back to the same page. Framed loads keep the plain message.
356
+ const signIn = !expires && req.method === "GET" && req.headers["sec-fetch-dest"] === "document" && req.url?.startsWith("/") ? this.signIn?.(entry, req.url) : undefined;
357
+ if (signIn) {
358
+ res.writeHead(303, { location: signIn });
359
+ res.end();
360
+ return;
361
+ }
278
362
  if (!expires) {
279
363
  res.writeHead(401);
280
364
  res.end("Preview access expired. Open a new preview link from Bivy.");
@@ -297,6 +381,27 @@ export class AppGateway {
297
381
  return;
298
382
  }
299
383
  this.track(id, req.socket, expires);
384
+ if (req.url.startsWith(`${REVISION_PATH}?`) && req.method === "GET") {
385
+ this.revision(req, res, entry);
386
+ return;
387
+ }
388
+ if ((req.url === COMPARE_PATH || req.url.startsWith(`${COMPARE_PATH}/`)) && req.method === "GET") {
389
+ this.compare(req, res, entry);
390
+ return;
391
+ }
392
+ if (req.url === INSPECTOR_PATH && req.method === "GET") {
393
+ res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
394
+ res.end(inspectorScript(this.shellOrigin(id), this.session(req, id)?.reviewer === true));
395
+ return;
396
+ }
397
+ if (req.url === NOTES_PATH && req.method === "POST") {
398
+ await this.note(req, res, entry);
399
+ return;
400
+ }
401
+ const inspect = isPageLoad(req);
402
+ // Remember the framed page so a turn reload lands where the user was.
403
+ if (req.method === "GET" && req.headers["sec-fetch-dest"] === "iframe" && req.url.length <= 2048)
404
+ entry.lastPath = req.url;
300
405
  if (entry.target.kind === "static") {
301
406
  if (!["GET", "HEAD"].includes(req.method ?? "")) {
302
407
  res.writeHead(405, { Allow: "GET, HEAD" });
@@ -314,15 +419,27 @@ export class AppGateway {
314
419
  }
315
420
  if (file.endsWith("/"))
316
421
  file += "index.html";
317
- const data = entry.target.files.get(file);
422
+ let data = entry.target.files.get(file);
423
+ let status = 200;
424
+ // Client-side routes: an extensionless page load falls back like common
425
+ // static hosts do — to 404.html (status 404) when present, else index.html.
426
+ if (!data && !path.extname(file) && isPageLoad(req)) {
427
+ const notFound = entry.target.files.get("/404.html");
428
+ file = notFound ? "/404.html" : "/index.html";
429
+ data = notFound ?? entry.target.files.get(file);
430
+ if (notFound)
431
+ status = 404;
432
+ }
318
433
  if (!data) {
319
434
  res.writeHead(404);
320
435
  res.end("File not found.");
321
436
  return;
322
437
  }
323
- const headers = responseHeaders({}, this.shellOrigin(id));
438
+ const headers = responseHeaders({}, this.ancestors(id));
324
439
  headers["content-type"] = MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
325
- res.writeHead(200, { ...headers, "content-length": data.length });
440
+ if (inspect && path.extname(file).toLowerCase() === ".html" && data.length <= MAX_INJECT_BYTES)
441
+ data = withInspector(data);
442
+ res.writeHead(status, { ...headers, "content-length": data.length });
326
443
  res.end(req.method === "HEAD" ? undefined : data);
327
444
  return;
328
445
  }
@@ -331,17 +448,133 @@ export class AppGateway {
331
448
  res.end();
332
449
  return;
333
450
  }
334
- const upstream = http.request({ hostname: "127.0.0.1", port: entry.target.port, path: req.url, method: req.method, headers: upstreamHeaders(req, this.origin(id)) }, (response) => {
335
- res.writeHead(response.statusCode ?? 502, responseHeaders(response.headers, this.shellOrigin(id)));
451
+ const port = entry.target.port;
452
+ const forward = upstreamHeaders(req, this.origin(id));
453
+ // Uncompressed HTML so the inspector can be added to page loads.
454
+ if (inspect)
455
+ forward["accept-encoding"] = "identity";
456
+ const upstream = http.request({ hostname: "127.0.0.1", port, path: req.url, method: req.method, headers: forward }, (response) => {
336
457
  response.on("error", () => res.destroy());
337
- response.pipe(res);
458
+ const html = inspect && /^text\/html/i.test(response.headers["content-type"] ?? "") && !response.headers["content-encoding"] && Number(response.headers["content-length"] ?? 0) <= MAX_INJECT_BYTES;
459
+ if (!html) {
460
+ res.writeHead(response.statusCode ?? 502, responseHeaders(response.headers, this.ancestors(id)));
461
+ response.pipe(res);
462
+ return;
463
+ }
464
+ const chunks = [];
465
+ let size = 0;
466
+ response.on("data", (chunk) => { size += chunk.length; if (size > MAX_INJECT_BYTES) {
467
+ response.destroy();
468
+ res.destroy();
469
+ return;
470
+ } chunks.push(chunk); });
471
+ response.on("end", () => {
472
+ const body = withInspector(Buffer.concat(chunks));
473
+ const headers = responseHeaders(response.headers, this.ancestors(id), `${this.origin(id)}${INSPECTOR_PATH}`);
474
+ delete headers["content-length"];
475
+ res.writeHead(response.statusCode ?? 200, { ...headers, "content-length": body.length });
476
+ res.end(body);
477
+ });
338
478
  });
339
479
  upstream.setTimeout(60_000, () => upstream.destroy(new Error("Preview timed out")));
340
- upstream.on("error", () => { if (!res.headersSent)
341
- res.writeHead(502, { "content-type": "text/plain" }); res.end("App server unavailable. Start it on the registered port, then reload."); });
480
+ upstream.on("error", () => {
481
+ if (res.headersSent) {
482
+ res.destroy();
483
+ return;
484
+ }
485
+ if (!isPageLoad(req)) {
486
+ res.writeHead(502, { "content-type": "text/plain", [UPSTREAM_DOWN]: "1" });
487
+ res.end("App server unavailable. Start it on the registered port, then reload.");
488
+ return;
489
+ }
490
+ const nonce = randomBytes(16).toString("hex");
491
+ 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.ancestors(id)}; base-uri 'none'` });
492
+ res.end(upstreamDownPage(nonce, port, this.shellOrigin(id)));
493
+ });
342
494
  res.on("close", () => upstream.destroy());
343
495
  req.pipe(upstream);
344
496
  }
497
+ /** A reviewer's note. Untrusted text: stored bounded, shown as text, and only
498
+ * ever turned into a draft by the owner. Same-origin POSTs only (checked above). */
499
+ async note(req, res, entry) {
500
+ let body = "";
501
+ for await (const chunk of req) {
502
+ body += chunk.toString();
503
+ if (body.length > 4096) {
504
+ res.writeHead(413);
505
+ res.end();
506
+ return;
507
+ }
508
+ }
509
+ let input;
510
+ try {
511
+ input = JSON.parse(body);
512
+ }
513
+ catch {
514
+ res.writeHead(400);
515
+ res.end();
516
+ return;
517
+ }
518
+ const text = (value, max) => typeof value === "string" ? value.trim().slice(0, max) : "";
519
+ const note = text(input.note, 1000);
520
+ if (!note) {
521
+ res.writeHead(400);
522
+ res.end("A note needs some text.");
523
+ return;
524
+ }
525
+ const viewport = input.viewport;
526
+ const path = text(input.path, 2048);
527
+ entry.notes = [...(entry.notes ?? []), {
528
+ id: randomBytes(8).toString("hex"), at: Date.now(), note, selector: text(input.selector, 300), text: text(input.text, 200),
529
+ path: path.startsWith("/") ? path : "/", viewport: { width: Number(viewport?.width) || 0, height: Number(viewport?.height) || 0 },
530
+ }].slice(-MAX_NOTES);
531
+ this.registry.emit("notes", entry.view.id);
532
+ res.writeHead(204);
533
+ res.end();
534
+ }
535
+ /** Compare shots for the shell: the list, or one PNG by index. */
536
+ compare(req, res, entry) {
537
+ const shots = entry.shots ?? [];
538
+ const cors = { "access-control-allow-origin": this.shellOrigin(entry.view.id), "access-control-allow-credentials": "true", vary: "Origin" };
539
+ const index = req.url === COMPARE_PATH ? -1 : Number(req.url.slice(COMPARE_PATH.length + 1));
540
+ if (index < 0) {
541
+ res.writeHead(200, { ...cors, "content-type": "application/json" });
542
+ res.end(JSON.stringify({ shots: shots.map((shot, i) => ({ index: i, revision: shot.revision, at: shot.at })) }));
543
+ return;
544
+ }
545
+ const shot = Number.isInteger(index) ? shots[index] : undefined;
546
+ if (!shot) {
547
+ res.writeHead(404, cors);
548
+ res.end();
549
+ return;
550
+ }
551
+ res.writeHead(200, { ...cors, "content-type": "image/png", "content-length": shot.png.length });
552
+ res.end(shot.png);
553
+ }
554
+ /** Long poll from the trusted shell (same-site, credentialed CORS): answers
555
+ * when the view's revision differs from `after`, or after 25 seconds. */
556
+ revision(req, res, entry) {
557
+ const id = entry.view.id;
558
+ const after = Number(new URL(req.url, "http://gateway").searchParams.get("after"));
559
+ let timer;
560
+ const onRevision = (viewId) => { if (viewId === id)
561
+ send(); };
562
+ const cleanup = () => { clearTimeout(timer); this.registry.off("revision", onRevision); };
563
+ const send = () => {
564
+ cleanup();
565
+ if (res.writableEnded || res.destroyed)
566
+ return;
567
+ res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": this.shellOrigin(id), "access-control-allow-credentials": "true", vary: "Origin" });
568
+ res.end(JSON.stringify({ revision: entry.revision, path: entry.lastPath ?? "/" }));
569
+ };
570
+ if (entry.revision !== after) {
571
+ send();
572
+ return;
573
+ }
574
+ this.registry.on("revision", onRevision);
575
+ timer = setTimeout(send, 25_000);
576
+ res.on("close", cleanup);
577
+ }
345
578
  upgrade(req, socket, head) {
346
579
  const entry = this.entry(req);
347
580
  const expires = entry && this.authorize(req, entry.view.id);
@@ -350,7 +583,8 @@ export class AppGateway {
350
583
  return;
351
584
  }
352
585
  this.track(entry.view.id, socket, expires);
353
- const upstream = http.request({ hostname: "127.0.0.1", port: entry.target.port, path: req.url, headers: { ...upstreamHeaders(req, this.origin(entry.view.id)), connection: "Upgrade", upgrade: "websocket" } });
586
+ const port = entry.target.port;
587
+ 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
588
  upstream.setTimeout(10_000, () => upstream.destroy());
355
589
  upstream.on("upgrade", (response, peer, upstreamHead) => {
356
590
  peer.setTimeout(0);
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /** Served on the app's own origin and injected into its HTML. It reports only
4
+ * to the trusted shell that frames it, and everything it reports is untrusted
5
+ * app data: the shell shows it and turns it into drafts, never into actions.
6
+ * Agent-neutral: it observes the browser, not the framework or the agent. */
7
+ export function inspectorScript(shellOrigin, reviewer = false) {
8
+ return `(()=>{
9
+ // Framed by the shell: report to it. Opened from a shared link: offer notes.
10
+ const framed=parent!==window,REVIEWER=${reviewer};
11
+ if(window.__bivyInspector||(!framed&&!REVIEWER))return;window.__bivyInspector=1;
12
+ const SHELL=${JSON.stringify(shellOrigin)};
13
+ const post=m=>{if(!framed)return;try{parent.postMessage(Object.assign({source:'bivy-inspector'},m),SHELL);}catch{}};
14
+ const fmt=v=>{if(v instanceof Error)return v.name+': '+v.message;if(typeof v==='string')return v;try{return JSON.stringify(v);}catch{return String(v);}};
15
+ const log=(level,text)=>post({type:'console',level,text:String(text).slice(0,500),at:Date.now()});
16
+ for(const level of ['error','warn']){const original=console[level];console[level]=function(...args){log(level,args.map(fmt).join(' '));return original.apply(this,args);};}
17
+ addEventListener('error',e=>{if(e.message)log('error',e.message+(e.filename?' ('+e.filename.split('/').pop()+':'+e.lineno+')':''));},true);
18
+ addEventListener('unhandledrejection',e=>log('error','Unhandled rejection: '+fmt(e.reason)));
19
+ const route=()=>post({type:'route',path:location.pathname+location.search+location.hash});
20
+ for(const name of ['pushState','replaceState']){const original=history[name];history[name]=function(...args){const result=original.apply(this,args);route();return result;};}
21
+ addEventListener('popstate',route);addEventListener('hashchange',route);route();
22
+ const selector=el=>{
23
+ const parts=[];
24
+ for(let node=el;node&&node.nodeType===1&&node!==document.body&&node!==document.documentElement&&parts.length<4;node=node.parentElement){
25
+ if(node.id){parts.unshift('#'+CSS.escape(node.id));break;}
26
+ let part=node.localName;
27
+ const classes=[...node.classList].filter(c=>!/\\d{3,}|^css-|^_/.test(c)).slice(0,2);
28
+ if(classes.length)part+='.'+classes.map(c=>CSS.escape(c)).join('.');
29
+ const same=node.parentElement?[...node.parentElement.children].filter(c=>c.localName===node.localName):[];
30
+ if(same.length>1)part+=':nth-of-type('+(same.indexOf(node)+1)+')';
31
+ parts.unshift(part);
32
+ }
33
+ return parts.join(' > ')||el.localName;
34
+ };
35
+ let box=null,target=null,onPicked=post,host=null;
36
+ const stop=()=>{box?.remove();box=null;target=null;removeEventListener('pointermove',hover,true);removeEventListener('click',pick,true);removeEventListener('keydown',key,true);};
37
+ const hover=e=>{
38
+ const el=document.elementFromPoint(e.clientX,e.clientY);
39
+ if(!el||el===box||el===host)return;target=el;const r=el.getBoundingClientRect();
40
+ Object.assign(box.style,{left:r.left+'px',top:r.top+'px',width:r.width+'px',height:r.height+'px'});
41
+ };
42
+ const pick=e=>{
43
+ e.preventDefault();e.stopPropagation();
44
+ const el=document.elementFromPoint(e.clientX,e.clientY)||target;stop();
45
+ if(!el||el===host){onPicked({type:'picked',cancelled:true});return;}const r=el.getBoundingClientRect();
46
+ onPicked({type:'picked',selector:selector(el),tag:el.localName,text:(el.innerText||el.getAttribute('aria-label')||el.getAttribute('alt')||'').trim().replace(/\\s+/g,' ').slice(0,200),
47
+ rect:{x:Math.round(r.left),y:Math.round(r.top),width:Math.round(r.width),height:Math.round(r.height)},
48
+ viewport:{width:innerWidth,height:innerHeight},path:location.pathname+location.search+location.hash});
49
+ };
50
+ const key=e=>{if(e.key==='Escape'){stop();onPicked({type:'picked',cancelled:true});}};
51
+ function startPointing(){
52
+ stop();
53
+ box=document.createElement('div');
54
+ box.setAttribute('aria-hidden','true');
55
+ Object.assign(box.style,{position:'fixed',zIndex:2147483647,pointerEvents:'none',outline:'2px solid Highlight',outlineOffset:'2px',borderRadius:'4px',transition:'all 60ms ease-out'});
56
+ document.documentElement.append(box);
57
+ addEventListener('pointermove',hover,true);addEventListener('click',pick,true);addEventListener('keydown',key,true);
58
+ }
59
+ addEventListener('message',e=>{
60
+ if(!framed||e.origin!==SHELL||e.source!==parent||e.data?.type!=='bivy:point')return;
61
+ if(e.data.on)startPointing();else stop();
62
+ });
63
+ if(REVIEWER&&!framed){
64
+ // A shadow root keeps the app's CSS off these controls (and ours off the app).
65
+ host=document.createElement('bivy-review');
66
+ const root=host.attachShadow({mode:'open'});
67
+ root.innerHTML='<style>:host{all:initial}*{box-sizing:border-box;font:14px/1.4 system-ui,sans-serif}'
68
+ +'.dock{position:fixed;right:12px;bottom:calc(12px + env(safe-area-inset-bottom));z-index:2147483647;display:flex;flex-direction:column;align-items:flex-end;gap:8px;max-width:calc(100vw - 24px)}'
69
+ +'button{border:1px solid ButtonBorder;background:ButtonFace;color:ButtonText;border-radius:999px;padding:10px 14px;font-weight:600;cursor:pointer;min-height:44px}'
70
+ +'.primary{background:CanvasText;color:Canvas;border-color:CanvasText}'
71
+ +'form{width:min(340px,calc(100vw - 24px));background:Canvas;color:CanvasText;border:1px solid GrayText;border-radius:14px;padding:12px;box-shadow:0 8px 30px rgb(0 0 0/.25)}'
72
+ +'textarea{width:100%;min-height:5em;margin:8px 0;border:1px solid GrayText;border-radius:8px;padding:8px;background:Canvas;color:CanvasText;resize:vertical}'
73
+ +'.row{display:flex;justify-content:flex-end;gap:8px}.muted{color:GrayText;font-size:12px;overflow-wrap:anywhere}[hidden]{display:none!important}</style>'
74
+ +'<div class="dock"><p class="muted" role="status" id="status" hidden></p>'
75
+ +'<form id="form" hidden><label for="note">Your note</label><div class="muted" id="about"></div><textarea id="note" required maxlength="1000"></textarea>'
76
+ +'<div class="row"><button type="button" id="cancel">Cancel</button><button class="primary" id="send">Send note</button></div></form>'
77
+ +'<button id="leave">Leave a note</button></div>';
78
+ const $=id=>root.getElementById(id);let picked=null;
79
+ const say=text=>{$('status').hidden=!text;$('status').textContent=text;};
80
+ $('leave').onclick=()=>{say('Tap the part of the page your note is about. Escape cancels.');$('leave').hidden=true;startPointing();};
81
+ onPicked=d=>{
82
+ $('leave').hidden=false;say('');
83
+ if(d.cancelled)return;picked=d;
84
+ $('about').textContent='About: '+(d.text?'“'+d.text.slice(0,80)+'” ':'')+'('+d.selector+')';
85
+ $('form').hidden=false;$('leave').hidden=true;$('note').focus();
86
+ };
87
+ $('cancel').onclick=()=>{$('form').hidden=true;$('leave').hidden=false;};
88
+ $('form').onsubmit=async e=>{
89
+ e.preventDefault();
90
+ const r=await fetch('/__bivy/notes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(Object.assign({},picked,{note:$('note').value}))}).catch(()=>null);
91
+ if(r&&r.ok){$('note').value='';$('form').hidden=true;$('leave').hidden=false;say('Sent. Thanks — the app’s owner will see it.');setTimeout(()=>say(''),4000);}
92
+ else say('Couldn’t send the note. Your link may have expired.');
93
+ };
94
+ (document.body||document.documentElement).append(host);
95
+ }
96
+ })();`;
97
+ }