@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 +25 -4
- package/dist/apps/gateway.js +257 -23
- package/dist/apps/inspector.js +97 -0
- package/dist/apps/listeners.js +115 -0
- package/dist/apps/preview-shell.js +198 -17
- package/dist/apps/registry.js +117 -19
- package/dist/apps/remote-preview.js +10 -2
- package/dist/apps/screenshot.js +181 -0
- package/dist/apps/service.js +179 -4
- package/dist/apps/styles.css +3 -0
- package/dist/config-cli.js +2 -0
- package/dist/controllers/app-commands.js +19 -6
- package/dist/node-config.js +6 -3
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +6 -1
- package/dist/server.js +29 -4
- package/package.json +1 -1
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
|
-
|
|
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.");
|
package/dist/apps/gateway.js
CHANGED
|
@@ -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,
|
|
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)] : []).
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
159
|
-
return session
|
|
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.
|
|
241
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
438
|
+
const headers = responseHeaders({}, this.ancestors(id));
|
|
324
439
|
headers["content-type"] = MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
|
|
325
|
-
|
|
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
|
|
335
|
-
|
|
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.
|
|
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", () => {
|
|
341
|
-
|
|
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
|
|
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
|
+
}
|