@bivy/bivy 0.17.3-staging.3 → 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",
@@ -16,6 +17,35 @@ const COOKIE = "__Host-bivy-preview";
16
17
  const OPEN_PATH = "/__bivy/open";
17
18
  const REDEEM_PATH = "/__bivy/redeem";
18
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
+ }
19
49
  const HOUR = 60 * 60_000;
20
50
  /** Marks gateway-generated "server not answering" responses, so recovery
21
51
  * polling can tell them apart from an app's own 502s. */
@@ -59,7 +89,7 @@ function upstreamHeaders(req, origin) {
59
89
  headers["x-forwarded-proto"] = "https";
60
90
  return headers;
61
91
  }
62
- function responseHeaders(headers, shellOrigin) {
92
+ function responseHeaders(headers, ancestors, inspector) {
63
93
  const result = cleanHeaders(headers);
64
94
  if (headers["set-cookie"]) {
65
95
  result["set-cookie"] = headers["set-cookie"].filter((cookie) => cookie.split("=", 1)[0].trim() !== COOKIE)
@@ -75,13 +105,16 @@ function responseHeaders(headers, shellOrigin) {
75
105
  // CSP directives, but replace framing restrictions with this narrower host.
76
106
  delete result["x-frame-options"];
77
107
  const csp = result["content-security-policy"];
78
- const policies = (Array.isArray(csp) ? csp : csp ? [String(csp)] : []).map((policy) => policy.replace(/(^|[;,])\s*frame-ancestors[^;,]*/gi, "$1"));
79
- 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(", ");
80
112
  return result;
81
113
  }
82
114
  export class AppGateway {
83
115
  registry;
84
116
  returnOrigins;
117
+ signIn;
85
118
  server;
86
119
  template;
87
120
  tickets = new Map();
@@ -89,11 +122,15 @@ export class AppGateway {
89
122
  ["/__bivy/tokens.css", readFileSync(new URL("./tokens.css", import.meta.url))],
90
123
  ["/__bivy/styles.css", readFileSync(new URL("./styles.css", import.meta.url))],
91
124
  ]);
125
+ /** `reviewer`: the browser session came from a copied (shared) link. */
92
126
  sessions = new Map();
93
127
  sockets = new Map();
94
- 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) {
95
131
  this.registry = registry;
96
132
  this.returnOrigins = returnOrigins;
133
+ this.signIn = signIn;
97
134
  this.template = previewOriginTemplate(originTemplate);
98
135
  this.server = http.createServer((req, res) => { void this.handle(req, res).catch(() => { if (!res.headersSent)
99
136
  res.writeHead(502); res.end("Preview request failed."); }); });
@@ -104,6 +141,8 @@ export class AppGateway {
104
141
  }
105
142
  origin(id) { return this.template.replace("{app}", id); }
106
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(" "); }
107
146
  open(id, returnTo) {
108
147
  const entry = this.requireWeb(id);
109
148
  if (returnTo) {
@@ -118,6 +157,14 @@ export class AppGateway {
118
157
  // Fragment never reaches reverse-proxy access logs or the application.
119
158
  return `${this.shellOrigin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: Date.now() + 60_000, returnTo })}`;
120
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
+ }
121
168
  /** A reusable link straight to the app origin (no shell frame), so it opens
122
169
  * in any browser and can be inspected. Valid until revoked or SHARE_TTL. */
123
170
  share(id) {
@@ -170,12 +217,13 @@ export class AppGateway {
170
217
  const entry = this.registry.getView(id);
171
218
  return entry?.view.kind === "web" ? entry : undefined;
172
219
  }
173
- authorize(req, id) {
220
+ session(req, id) {
174
221
  this.sweep();
175
222
  const token = (req.headers.cookie ?? "").split(";").map((part) => part.trim()).find((part) => part.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
176
- const session = token && this.sessions.get(token);
177
- return session && session.appId === id ? session.expires : undefined;
223
+ const session = token ? this.sessions.get(token) : undefined;
224
+ return session?.appId === id ? session : undefined;
178
225
  }
226
+ authorize(req, id) { return this.session(req, id)?.expires; }
179
227
  track(id, socket, expires) {
180
228
  let sockets = this.sockets.get(id);
181
229
  if (!sockets) {
@@ -194,7 +242,7 @@ export class AppGateway {
194
242
  const origin = this.shellOrigin(id);
195
243
  const nonce = randomBytes(16).toString("hex");
196
244
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
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'`);
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'`);
198
246
  if (req.method === "GET" && this.styles.has(req.url ?? "")) {
199
247
  res.setHeader("Content-Type", "text/css; charset=utf-8");
200
248
  res.end(this.styles.get(req.url));
@@ -255,8 +303,13 @@ export class AppGateway {
255
303
  const nonce = randomBytes(16).toString("hex");
256
304
  res.setHeader("Content-Type", "text/html; charset=utf-8");
257
305
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
258
- res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${this.shellOrigin(id)}; base-uri 'none'`);
259
- 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>`);
260
313
  return;
261
314
  }
262
315
  if (req.url === REDEEM_PATH && req.method === "POST") {
@@ -275,24 +328,37 @@ export class AppGateway {
275
328
  }
276
329
  }
277
330
  this.sweep();
278
- 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);
279
334
  if (!grant || grant.appId !== id || this.sessions.size >= 500) {
280
335
  res.writeHead(401);
281
336
  res.end();
282
337
  return;
283
338
  }
284
339
  if (!grant.reusable)
285
- this.tickets.delete(body);
340
+ this.tickets.delete(ticket);
286
341
  // A browser session never outlives the link that created it.
287
342
  const expires = Math.min(Date.now() + HOUR, grant.expires);
288
343
  const token = randomBytes(32).toString("hex");
289
- this.sessions.set(token, { appId: id, expires });
290
- 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))}`);
291
349
  res.writeHead(204);
292
350
  res.end();
293
351
  return;
294
352
  }
295
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
+ }
296
362
  if (!expires) {
297
363
  res.writeHead(401);
298
364
  res.end("Preview access expired. Open a new preview link from Bivy.");
@@ -319,6 +385,20 @@ export class AppGateway {
319
385
  this.revision(req, res, entry);
320
386
  return;
321
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);
322
402
  // Remember the framed page so a turn reload lands where the user was.
323
403
  if (req.method === "GET" && req.headers["sec-fetch-dest"] === "iframe" && req.url.length <= 2048)
324
404
  entry.lastPath = req.url;
@@ -355,8 +435,10 @@ export class AppGateway {
355
435
  res.end("File not found.");
356
436
  return;
357
437
  }
358
- const headers = responseHeaders({}, this.shellOrigin(id));
438
+ const headers = responseHeaders({}, this.ancestors(id));
359
439
  headers["content-type"] = MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
440
+ if (inspect && path.extname(file).toLowerCase() === ".html" && data.length <= MAX_INJECT_BYTES)
441
+ data = withInspector(data);
360
442
  res.writeHead(status, { ...headers, "content-length": data.length });
361
443
  res.end(req.method === "HEAD" ? undefined : data);
362
444
  return;
@@ -367,10 +449,32 @@ export class AppGateway {
367
449
  return;
368
450
  }
369
451
  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) => {
371
- res.writeHead(response.statusCode ?? 502, responseHeaders(response.headers, this.shellOrigin(id)));
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) => {
372
457
  response.on("error", () => res.destroy());
373
- 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
+ });
374
478
  });
375
479
  upstream.setTimeout(60_000, () => upstream.destroy(new Error("Preview timed out")));
376
480
  upstream.on("error", () => {
@@ -384,12 +488,69 @@ export class AppGateway {
384
488
  return;
385
489
  }
386
490
  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'` });
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'` });
388
492
  res.end(upstreamDownPage(nonce, port, this.shellOrigin(id)));
389
493
  });
390
494
  res.on("close", () => upstream.destroy());
391
495
  req.pipe(upstream);
392
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
+ }
393
554
  /** Long poll from the trusted shell (same-site, credentialed CORS): answers
394
555
  * when the view's revision differs from `after`, or after 25 seconds. */
395
556
  revision(req, res, entry) {
@@ -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
+ }