@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 +25 -4
- package/dist/apps/gateway.js +180 -19
- package/dist/apps/inspector.js +97 -0
- package/dist/apps/preview-shell.js +180 -36
- package/dist/apps/registry.js +70 -15
- package/dist/apps/remote-preview.js +10 -2
- package/dist/apps/screenshot.js +181 -0
- package/dist/apps/service.js +166 -8
- package/dist/apps/styles.css +3 -0
- package/dist/config-cli.js +2 -0
- package/dist/controllers/app-commands.js +4 -1
- package/dist/node-config.js +6 -3
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +4 -1
- package/dist/server.js +27 -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",
|
|
@@ -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,
|
|
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)] : []).
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
return session
|
|
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.
|
|
259
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
371
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|