@bivy/bivy 0.17.3-staging.2 → 0.17.3-staging.20
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 +57 -10
- package/dist/agents/claude-code/runtime.js +17 -0
- package/dist/apps/display-viewer.js +81 -0
- package/dist/apps/display.js +135 -0
- package/dist/apps/gateway.js +388 -25
- package/dist/apps/inspector.js +106 -0
- package/dist/apps/listeners.js +115 -0
- package/dist/apps/preview-shell.js +206 -17
- package/dist/apps/registry.js +125 -21
- package/dist/apps/remote-preview.js +10 -2
- package/dist/apps/rfb.js +126 -0
- package/dist/apps/screenshot.js +194 -0
- package/dist/apps/service.js +244 -5
- package/dist/apps/styles.css +124 -9
- package/dist/apps/x11.js +273 -0
- package/dist/attach.js +37 -4
- package/dist/config-cli.js +2 -0
- package/dist/controllers/app-commands.js +20 -7
- package/dist/controllers/artifact-commands.js +42 -0
- package/dist/controllers/presence-commands.js +19 -0
- package/dist/node-config.js +6 -3
- package/dist/policy/session-limit.js +63 -0
- package/dist/protocol/client-command-routes.js +3 -1
- package/dist/protocol/client-command-schemas.js +11 -2
- package/dist/server.js +168 -14
- package/dist/session/presence.js +82 -0
- package/dist/session/run-terminal.js +114 -70
- package/dist/session/session-new-dedupe.js +1 -1
- package/package.json +2 -1
package/dist/apps/gateway.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
3
|
import http from "node:http";
|
|
4
|
+
import net from "node:net";
|
|
4
5
|
import { randomBytes } from "node:crypto";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { readFileSync } from "node:fs";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { WebSocketServer } from "ws";
|
|
7
10
|
import { previewShell } from "./preview-shell.js";
|
|
11
|
+
import { displayViewer, DISPLAY_SOCKET_PATH, DISPLAY_STATS_PATH, NOVNC_PATH } from "./display-viewer.js";
|
|
12
|
+
import { inspectorScript } from "./inspector.js";
|
|
8
13
|
const MIME = {
|
|
9
14
|
".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
|
|
10
15
|
".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
|
|
@@ -15,7 +20,79 @@ const MIME = {
|
|
|
15
20
|
const COOKIE = "__Host-bivy-preview";
|
|
16
21
|
const OPEN_PATH = "/__bivy/open";
|
|
17
22
|
const REDEEM_PATH = "/__bivy/redeem";
|
|
23
|
+
const REVISION_PATH = "/__bivy/revision";
|
|
24
|
+
const INSPECTOR_PATH = "/__bivy/inspector.js";
|
|
25
|
+
const COMPARE_PATH = "/__bivy/compare";
|
|
26
|
+
const NOTES_PATH = "/__bivy/notes";
|
|
27
|
+
const MAX_NOTES = 50;
|
|
28
|
+
/** Larger HTML documents pass through without the inspector. */
|
|
29
|
+
const MAX_INJECT_BYTES = 5 * 1024 * 1024;
|
|
30
|
+
/** Put the inspector first in <head>, so it sees errors from the app's own scripts. */
|
|
31
|
+
function withInspector(html) {
|
|
32
|
+
const text = html.toString("utf8");
|
|
33
|
+
const tag = `<script src="${INSPECTOR_PATH}"></script>`;
|
|
34
|
+
const at = /<head\b[^>]*>/i.exec(text) ?? /<html\b[^>]*>/i.exec(text);
|
|
35
|
+
return Buffer.from(at ? text.slice(0, at.index + at[0].length) + tag + text.slice(at.index + at[0].length) : tag + text);
|
|
36
|
+
}
|
|
37
|
+
/** Let the inspector through an app's CSP by exact URL; other sources are unchanged. */
|
|
38
|
+
function allowInspector(policy, url) {
|
|
39
|
+
const directives = policy.split(";").map((d) => d.trim()).filter(Boolean);
|
|
40
|
+
const find = (name) => directives.findIndex((d) => d.toLowerCase().split(/\s+/)[0] === name);
|
|
41
|
+
const index = [find("script-src-elem"), find("script-src")].find((i) => i >= 0) ?? -1;
|
|
42
|
+
const add = (directive) => directive.replace(/\s'none'/i, "") + " " + url;
|
|
43
|
+
if (index >= 0)
|
|
44
|
+
directives[index] = add(directives[index]);
|
|
45
|
+
else {
|
|
46
|
+
const fallback = find("default-src");
|
|
47
|
+
if (fallback < 0)
|
|
48
|
+
return policy;
|
|
49
|
+
directives.push(add(directives[fallback].replace(/^default-src/i, "script-src")));
|
|
50
|
+
}
|
|
51
|
+
return directives.join("; ");
|
|
52
|
+
}
|
|
18
53
|
const HOUR = 60 * 60_000;
|
|
54
|
+
/** A viewer that stops reading doesn't make the node buffer the display. */
|
|
55
|
+
const DISPLAY_BUFFER_BYTES = 4 * 1024 * 1024;
|
|
56
|
+
/** noVNC's browser modules (core/ and its vendored zlib), served as-is. */
|
|
57
|
+
const novncRoot = (() => { try {
|
|
58
|
+
return path.dirname(path.dirname(createRequire(import.meta.url).resolve("@novnc/novnc")));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
} })();
|
|
63
|
+
const novncFiles = new Map();
|
|
64
|
+
function novncFile(relative) {
|
|
65
|
+
if (!novncRoot || !/^(?:core|vendor)(?:\/[A-Za-z0-9_-]+)+\.js$/.test(relative))
|
|
66
|
+
return undefined;
|
|
67
|
+
let data = novncFiles.get(relative);
|
|
68
|
+
if (!data) {
|
|
69
|
+
try {
|
|
70
|
+
data = readFileSync(path.join(novncRoot, relative));
|
|
71
|
+
novncFiles.set(relative, data);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return data;
|
|
78
|
+
}
|
|
79
|
+
/** Marks gateway-generated "server not answering" responses, so recovery
|
|
80
|
+
* polling can tell them apart from an app's own 502s. */
|
|
81
|
+
const UPSTREAM_DOWN = "x-bivy-upstream-down";
|
|
82
|
+
/** A top-level or framed page load. Browsers without Fetch Metadata still ask for HTML. */
|
|
83
|
+
function isPageLoad(req) {
|
|
84
|
+
const dest = req.headers["sec-fetch-dest"];
|
|
85
|
+
return req.method === "GET" && (dest ? ["document", "iframe"].includes(dest) : /text\/html/.test(req.headers.accept ?? ""));
|
|
86
|
+
}
|
|
87
|
+
/** Served on the app's origin in place of a blank frame. It reloads itself once
|
|
88
|
+
* the server answers, and tells a framing shell so it can offer next steps. */
|
|
89
|
+
function upstreamDownPage(nonce, port, shellOrigin) {
|
|
90
|
+
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>
|
|
91
|
+
<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>
|
|
92
|
+
<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>
|
|
93
|
+
<script nonce="${nonce}">const tell=state=>{if(parent!==window)parent.postMessage({type:'bivy:upstream',state,port:${port}},${JSON.stringify(shellOrigin)});};tell('down');
|
|
94
|
+
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>`;
|
|
95
|
+
}
|
|
19
96
|
/** Copied links are reusable until revoked, capped so a forgotten one lapses. */
|
|
20
97
|
export const SHARE_TTL = 24 * HOUR;
|
|
21
98
|
const HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
|
|
@@ -41,7 +118,7 @@ function upstreamHeaders(req, origin) {
|
|
|
41
118
|
headers["x-forwarded-proto"] = "https";
|
|
42
119
|
return headers;
|
|
43
120
|
}
|
|
44
|
-
function responseHeaders(headers,
|
|
121
|
+
function responseHeaders(headers, ancestors, inspector) {
|
|
45
122
|
const result = cleanHeaders(headers);
|
|
46
123
|
if (headers["set-cookie"]) {
|
|
47
124
|
result["set-cookie"] = headers["set-cookie"].filter((cookie) => cookie.split("=", 1)[0].trim() !== COOKIE)
|
|
@@ -57,13 +134,16 @@ function responseHeaders(headers, shellOrigin) {
|
|
|
57
134
|
// CSP directives, but replace framing restrictions with this narrower host.
|
|
58
135
|
delete result["x-frame-options"];
|
|
59
136
|
const csp = result["content-security-policy"];
|
|
60
|
-
const policies = (Array.isArray(csp) ? csp : csp ? [String(csp)] : []).
|
|
61
|
-
|
|
137
|
+
const policies = (Array.isArray(csp) ? csp : csp ? [String(csp)] : []).flatMap((policy) => policy.split(","))
|
|
138
|
+
.map((policy) => policy.replace(/(^|;)\s*frame-ancestors[^;]*/gi, "$1"))
|
|
139
|
+
.map((policy) => inspector ? allowInspector(policy, inspector) : policy);
|
|
140
|
+
result["content-security-policy"] = [...policies, `frame-ancestors ${ancestors}; worker-src 'none'`].join(", ");
|
|
62
141
|
return result;
|
|
63
142
|
}
|
|
64
143
|
export class AppGateway {
|
|
65
144
|
registry;
|
|
66
145
|
returnOrigins;
|
|
146
|
+
signIn;
|
|
67
147
|
server;
|
|
68
148
|
template;
|
|
69
149
|
tickets = new Map();
|
|
@@ -71,11 +151,16 @@ export class AppGateway {
|
|
|
71
151
|
["/__bivy/tokens.css", readFileSync(new URL("./tokens.css", import.meta.url))],
|
|
72
152
|
["/__bivy/styles.css", readFileSync(new URL("./styles.css", import.meta.url))],
|
|
73
153
|
]);
|
|
154
|
+
/** `reviewer`: the browser session came from a copied (shared) link. */
|
|
74
155
|
sessions = new Map();
|
|
75
156
|
sockets = new Map();
|
|
76
|
-
|
|
157
|
+
displays = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: false });
|
|
158
|
+
/** `signIn` sends a signed-out visit to a view's stable address back
|
|
159
|
+
* through Bivy, which re-opens it for a signed-in device. */
|
|
160
|
+
constructor(registry, originTemplate, returnOrigins = () => [], signIn) {
|
|
77
161
|
this.registry = registry;
|
|
78
162
|
this.returnOrigins = returnOrigins;
|
|
163
|
+
this.signIn = signIn;
|
|
79
164
|
this.template = previewOriginTemplate(originTemplate);
|
|
80
165
|
this.server = http.createServer((req, res) => { void this.handle(req, res).catch(() => { if (!res.headersSent)
|
|
81
166
|
res.writeHead(502); res.end("Preview request failed."); }); });
|
|
@@ -86,6 +171,8 @@ export class AppGateway {
|
|
|
86
171
|
}
|
|
87
172
|
origin(id) { return this.template.replace("{app}", id); }
|
|
88
173
|
shellOrigin(id) { return this.template.replace("{app}", `view-${id}`); }
|
|
174
|
+
/** Bivy clients may frame the shell (Peek), so app content allows both. */
|
|
175
|
+
ancestors(id) { return [this.shellOrigin(id), ...this.returnOrigins()].join(" "); }
|
|
89
176
|
open(id, returnTo) {
|
|
90
177
|
const entry = this.requireWeb(id);
|
|
91
178
|
if (returnTo) {
|
|
@@ -100,6 +187,14 @@ export class AppGateway {
|
|
|
100
187
|
// Fragment never reaches reverse-proxy access logs or the application.
|
|
101
188
|
return `${this.shellOrigin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: Date.now() + 60_000, returnTo })}`;
|
|
102
189
|
}
|
|
190
|
+
/** The view's stable address: grants nothing by itself. */
|
|
191
|
+
address(id) { this.requireWeb(id); return `${this.origin(id)}/`; }
|
|
192
|
+
/** A one-use link straight to the app origin (no shell), for a signed-in
|
|
193
|
+
* device returning to a stable address. `~path` resumes the page. */
|
|
194
|
+
openDirect(id) {
|
|
195
|
+
this.requireWeb(id);
|
|
196
|
+
return `${this.origin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: Date.now() + 60_000 })}`;
|
|
197
|
+
}
|
|
103
198
|
/** A reusable link straight to the app origin (no shell frame), so it opens
|
|
104
199
|
* in any browser and can be inspected. Valid until revoked or SHARE_TTL. */
|
|
105
200
|
share(id) {
|
|
@@ -121,6 +216,7 @@ export class AppGateway {
|
|
|
121
216
|
this.revoke(id);
|
|
122
217
|
this.tickets.clear();
|
|
123
218
|
this.sessions.clear();
|
|
219
|
+
this.displays.close();
|
|
124
220
|
this.server.close();
|
|
125
221
|
this.server.closeAllConnections();
|
|
126
222
|
}
|
|
@@ -152,12 +248,13 @@ export class AppGateway {
|
|
|
152
248
|
const entry = this.registry.getView(id);
|
|
153
249
|
return entry?.view.kind === "web" ? entry : undefined;
|
|
154
250
|
}
|
|
155
|
-
|
|
251
|
+
session(req, id) {
|
|
156
252
|
this.sweep();
|
|
157
253
|
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
|
|
254
|
+
const session = token ? this.sessions.get(token) : undefined;
|
|
255
|
+
return session?.appId === id ? session : undefined;
|
|
160
256
|
}
|
|
257
|
+
authorize(req, id) { return this.session(req, id)?.expires; }
|
|
161
258
|
track(id, socket, expires) {
|
|
162
259
|
let sockets = this.sockets.get(id);
|
|
163
260
|
if (!sockets) {
|
|
@@ -176,7 +273,7 @@ export class AppGateway {
|
|
|
176
273
|
const origin = this.shellOrigin(id);
|
|
177
274
|
const nonce = randomBytes(16).toString("hex");
|
|
178
275
|
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'`);
|
|
276
|
+
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
277
|
if (req.method === "GET" && this.styles.has(req.url ?? "")) {
|
|
181
278
|
res.setHeader("Content-Type", "text/css; charset=utf-8");
|
|
182
279
|
res.end(this.styles.get(req.url));
|
|
@@ -211,7 +308,7 @@ export class AppGateway {
|
|
|
211
308
|
return;
|
|
212
309
|
}
|
|
213
310
|
res.setHeader("Content-Type", "application/json");
|
|
214
|
-
res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo }));
|
|
311
|
+
res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo, ...(entry.target.kind === "display" ? { inspect: false } : {}) }));
|
|
215
312
|
return;
|
|
216
313
|
}
|
|
217
314
|
res.writeHead(404);
|
|
@@ -237,8 +334,13 @@ export class AppGateway {
|
|
|
237
334
|
const nonce = randomBytes(16).toString("hex");
|
|
238
335
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
239
336
|
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
|
-
|
|
337
|
+
res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${this.ancestors(id)}; base-uri 'none'`);
|
|
338
|
+
// An embedded launch ("e:" prefix) comes from a shell framed inside Bivy.
|
|
339
|
+
// After redeeming, check the cookie stuck: browsers that refuse framed
|
|
340
|
+
// cookies are reported to the shell, which falls back to a tab.
|
|
341
|
+
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='/';
|
|
342
|
+
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.';};
|
|
343
|
+
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
344
|
return;
|
|
243
345
|
}
|
|
244
346
|
if (req.url === REDEEM_PATH && req.method === "POST") {
|
|
@@ -257,24 +359,37 @@ export class AppGateway {
|
|
|
257
359
|
}
|
|
258
360
|
}
|
|
259
361
|
this.sweep();
|
|
260
|
-
const
|
|
362
|
+
const embedded = body.startsWith("e:");
|
|
363
|
+
const ticket = embedded ? body.slice(2) : body;
|
|
364
|
+
const grant = this.tickets.get(ticket);
|
|
261
365
|
if (!grant || grant.appId !== id || this.sessions.size >= 500) {
|
|
262
366
|
res.writeHead(401);
|
|
263
367
|
res.end();
|
|
264
368
|
return;
|
|
265
369
|
}
|
|
266
370
|
if (!grant.reusable)
|
|
267
|
-
this.tickets.delete(
|
|
371
|
+
this.tickets.delete(ticket);
|
|
268
372
|
// A browser session never outlives the link that created it.
|
|
269
373
|
const expires = Math.min(Date.now() + HOUR, grant.expires);
|
|
270
374
|
const token = randomBytes(32).toString("hex");
|
|
271
|
-
this.sessions.set(token, { appId: id, expires });
|
|
272
|
-
|
|
375
|
+
this.sessions.set(token, { appId: id, expires, reviewer: grant.reusable === true });
|
|
376
|
+
// Framed inside Bivy, the cookie is third-party: it must be SameSite=None,
|
|
377
|
+
// and Partitioned keys it to Bivy's top-level site so no other site can use it.
|
|
378
|
+
const scope = embedded ? "SameSite=None; Partitioned" : "SameSite=Lax";
|
|
379
|
+
res.setHeader("Set-Cookie", `${COOKIE}=${token}; Secure; HttpOnly; ${scope}; Path=/; Max-Age=${Math.max(1, Math.floor((expires - Date.now()) / 1000))}`);
|
|
273
380
|
res.writeHead(204);
|
|
274
381
|
res.end();
|
|
275
382
|
return;
|
|
276
383
|
}
|
|
277
384
|
const expires = this.authorize(req, id);
|
|
385
|
+
// A home-screen visit without access goes through Bivy to sign in, then
|
|
386
|
+
// comes back to the same page. Framed loads keep the plain message.
|
|
387
|
+
const signIn = !expires && req.method === "GET" && req.headers["sec-fetch-dest"] === "document" && req.url?.startsWith("/") ? this.signIn?.(entry, req.url) : undefined;
|
|
388
|
+
if (signIn) {
|
|
389
|
+
res.writeHead(303, { location: signIn });
|
|
390
|
+
res.end();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
278
393
|
if (!expires) {
|
|
279
394
|
res.writeHead(401);
|
|
280
395
|
res.end("Preview access expired. Open a new preview link from Bivy.");
|
|
@@ -297,6 +412,31 @@ export class AppGateway {
|
|
|
297
412
|
return;
|
|
298
413
|
}
|
|
299
414
|
this.track(id, req.socket, expires);
|
|
415
|
+
if (req.url.startsWith(`${REVISION_PATH}?`) && req.method === "GET") {
|
|
416
|
+
this.revision(req, res, entry);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if ((req.url === COMPARE_PATH || req.url.startsWith(`${COMPARE_PATH}/`)) && req.method === "GET") {
|
|
420
|
+
this.compare(req, res, entry);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (req.url === INSPECTOR_PATH && req.method === "GET") {
|
|
424
|
+
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
|
|
425
|
+
res.end(inspectorScript(this.shellOrigin(id), this.session(req, id)?.reviewer === true));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (req.url === NOTES_PATH && req.method === "POST") {
|
|
429
|
+
await this.note(req, res, entry);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (entry.target.kind === "display") {
|
|
433
|
+
await this.display(req, res, entry);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const inspect = isPageLoad(req);
|
|
437
|
+
// Remember the framed page so a turn reload lands where the user was.
|
|
438
|
+
if (req.method === "GET" && req.headers["sec-fetch-dest"] === "iframe" && req.url.length <= 2048)
|
|
439
|
+
entry.lastPath = req.url;
|
|
300
440
|
if (entry.target.kind === "static") {
|
|
301
441
|
if (!["GET", "HEAD"].includes(req.method ?? "")) {
|
|
302
442
|
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
@@ -314,15 +454,27 @@ export class AppGateway {
|
|
|
314
454
|
}
|
|
315
455
|
if (file.endsWith("/"))
|
|
316
456
|
file += "index.html";
|
|
317
|
-
|
|
457
|
+
let data = entry.target.files.get(file);
|
|
458
|
+
let status = 200;
|
|
459
|
+
// Client-side routes: an extensionless page load falls back like common
|
|
460
|
+
// static hosts do — to 404.html (status 404) when present, else index.html.
|
|
461
|
+
if (!data && !path.extname(file) && isPageLoad(req)) {
|
|
462
|
+
const notFound = entry.target.files.get("/404.html");
|
|
463
|
+
file = notFound ? "/404.html" : "/index.html";
|
|
464
|
+
data = notFound ?? entry.target.files.get(file);
|
|
465
|
+
if (notFound)
|
|
466
|
+
status = 404;
|
|
467
|
+
}
|
|
318
468
|
if (!data) {
|
|
319
469
|
res.writeHead(404);
|
|
320
470
|
res.end("File not found.");
|
|
321
471
|
return;
|
|
322
472
|
}
|
|
323
|
-
const headers = responseHeaders({}, this.
|
|
473
|
+
const headers = responseHeaders({}, this.ancestors(id));
|
|
324
474
|
headers["content-type"] = MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
|
|
325
|
-
|
|
475
|
+
if (inspect && path.extname(file).toLowerCase() === ".html" && data.length <= MAX_INJECT_BYTES)
|
|
476
|
+
data = withInspector(data);
|
|
477
|
+
res.writeHead(status, { ...headers, "content-length": data.length });
|
|
326
478
|
res.end(req.method === "HEAD" ? undefined : data);
|
|
327
479
|
return;
|
|
328
480
|
}
|
|
@@ -331,26 +483,237 @@ export class AppGateway {
|
|
|
331
483
|
res.end();
|
|
332
484
|
return;
|
|
333
485
|
}
|
|
334
|
-
const
|
|
335
|
-
|
|
486
|
+
const port = entry.target.port;
|
|
487
|
+
const forward = upstreamHeaders(req, this.origin(id));
|
|
488
|
+
// Uncompressed HTML so the inspector can be added to page loads.
|
|
489
|
+
if (inspect)
|
|
490
|
+
forward["accept-encoding"] = "identity";
|
|
491
|
+
const upstream = http.request({ hostname: "127.0.0.1", port, path: req.url, method: req.method, headers: forward }, (response) => {
|
|
336
492
|
response.on("error", () => res.destroy());
|
|
337
|
-
response.
|
|
493
|
+
const html = inspect && /^text\/html/i.test(response.headers["content-type"] ?? "") && !response.headers["content-encoding"] && Number(response.headers["content-length"] ?? 0) <= MAX_INJECT_BYTES;
|
|
494
|
+
if (!html) {
|
|
495
|
+
res.writeHead(response.statusCode ?? 502, responseHeaders(response.headers, this.ancestors(id)));
|
|
496
|
+
response.pipe(res);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const chunks = [];
|
|
500
|
+
let size = 0;
|
|
501
|
+
response.on("data", (chunk) => { size += chunk.length; if (size > MAX_INJECT_BYTES) {
|
|
502
|
+
response.destroy();
|
|
503
|
+
res.destroy();
|
|
504
|
+
return;
|
|
505
|
+
} chunks.push(chunk); });
|
|
506
|
+
response.on("end", () => {
|
|
507
|
+
const body = withInspector(Buffer.concat(chunks));
|
|
508
|
+
const headers = responseHeaders(response.headers, this.ancestors(id), `${this.origin(id)}${INSPECTOR_PATH}`);
|
|
509
|
+
delete headers["content-length"];
|
|
510
|
+
res.writeHead(response.statusCode ?? 200, { ...headers, "content-length": body.length });
|
|
511
|
+
res.end(body);
|
|
512
|
+
});
|
|
338
513
|
});
|
|
339
514
|
upstream.setTimeout(60_000, () => upstream.destroy(new Error("Preview timed out")));
|
|
340
|
-
upstream.on("error", () => {
|
|
341
|
-
|
|
515
|
+
upstream.on("error", () => {
|
|
516
|
+
if (res.headersSent) {
|
|
517
|
+
res.destroy();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (!isPageLoad(req)) {
|
|
521
|
+
res.writeHead(502, { "content-type": "text/plain", [UPSTREAM_DOWN]: "1" });
|
|
522
|
+
res.end("App server unavailable. Start it on the registered port, then reload.");
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const nonce = randomBytes(16).toString("hex");
|
|
526
|
+
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'` });
|
|
527
|
+
res.end(upstreamDownPage(nonce, port, this.shellOrigin(id)));
|
|
528
|
+
});
|
|
342
529
|
res.on("close", () => upstream.destroy());
|
|
343
530
|
req.pipe(upstream);
|
|
344
531
|
}
|
|
532
|
+
/** A display view's origin serves only its viewer and noVNC's modules; the
|
|
533
|
+
* pixels come over the WebSocket (see upgrade). */
|
|
534
|
+
async display(req, res, entry) {
|
|
535
|
+
const id = entry.view.id;
|
|
536
|
+
// The viewer's measurements (same-origin POST, checked above). Numbers only.
|
|
537
|
+
if (req.url === DISPLAY_STATS_PATH && req.method === "POST") {
|
|
538
|
+
let body = "";
|
|
539
|
+
for await (const chunk of req) {
|
|
540
|
+
body += chunk.toString();
|
|
541
|
+
if (body.length > 1024) {
|
|
542
|
+
res.writeHead(413);
|
|
543
|
+
res.end();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
let input;
|
|
548
|
+
try {
|
|
549
|
+
input = JSON.parse(body);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
res.writeHead(400);
|
|
553
|
+
res.end();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const num = (value, max) => { const n = Number(value); return Number.isFinite(n) ? Math.min(Math.max(0, Math.round(n * 10) / 10), max) : 0; };
|
|
557
|
+
entry.stats = { at: Date.now(), latencyMs: { p50: num(input.latencyMs?.p50, 60_000), p95: num(input.latencyMs?.p95, 60_000) }, kBps: num(input.kBps, 1e6),
|
|
558
|
+
viewport: { width: num(input.viewport?.width, 10_000), height: num(input.viewport?.height, 10_000), scale: num(input.viewport?.scale, 8) } };
|
|
559
|
+
res.writeHead(204);
|
|
560
|
+
res.end();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (!["GET", "HEAD"].includes(req.method ?? "")) {
|
|
564
|
+
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
565
|
+
res.end();
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const url = req.url.split("?")[0];
|
|
569
|
+
if (url.startsWith(NOVNC_PATH)) {
|
|
570
|
+
const data = novncFile(url.slice(NOVNC_PATH.length));
|
|
571
|
+
if (!data) {
|
|
572
|
+
res.writeHead(404);
|
|
573
|
+
res.end();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
res.writeHead(200, { ...responseHeaders({}, this.ancestors(id)), "content-type": "text/javascript; charset=utf-8", "content-length": data.length });
|
|
577
|
+
res.end(req.method === "HEAD" ? undefined : data);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (!isPageLoad(req)) {
|
|
581
|
+
res.writeHead(404);
|
|
582
|
+
res.end();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const nonce = randomBytes(16).toString("hex");
|
|
586
|
+
const body = Buffer.from(displayViewer(nonce, entry.app.name, entry.displayScale ?? 1));
|
|
587
|
+
const headers = responseHeaders({}, this.ancestors(id));
|
|
588
|
+
headers["content-security-policy"] = `default-src 'none'; script-src 'self' 'nonce-${nonce}'; style-src 'nonce-${nonce}'; img-src data: blob:; connect-src 'self' ${this.origin(id).replace(/^https:/, "wss:")}; frame-ancestors ${this.ancestors(id)}; worker-src 'none'; base-uri 'none'; form-action 'none'`;
|
|
589
|
+
res.writeHead(200, { ...headers, "content-type": "text/html; charset=utf-8", "content-length": body.length });
|
|
590
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
591
|
+
}
|
|
592
|
+
/** Relays a viewer's WebSocket to the display's private VNC socket. */
|
|
593
|
+
displaySocket(req, socket, head, entry) {
|
|
594
|
+
this.displays.handleUpgrade(req, socket, head, (ws) => {
|
|
595
|
+
// Not running yet (or any more): the viewer retries while the app starts.
|
|
596
|
+
if (!entry.display) {
|
|
597
|
+
ws.close(1013, "The display isn't running.");
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const peer = net.connect(entry.display);
|
|
601
|
+
const close = () => { peer.destroy(); ws.terminate(); };
|
|
602
|
+
peer.on("data", (data) => {
|
|
603
|
+
ws.send(data);
|
|
604
|
+
if (ws.bufferedAmount > DISPLAY_BUFFER_BYTES) {
|
|
605
|
+
peer.pause();
|
|
606
|
+
const resume = setInterval(() => { if (ws.bufferedAmount < DISPLAY_BUFFER_BYTES / 2 || ws.readyState !== ws.OPEN) {
|
|
607
|
+
clearInterval(resume);
|
|
608
|
+
peer.resume();
|
|
609
|
+
} }, 20);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
ws.on("message", (data) => peer.write(data));
|
|
613
|
+
peer.on("error", close);
|
|
614
|
+
peer.on("close", close);
|
|
615
|
+
ws.on("error", close);
|
|
616
|
+
ws.on("close", close);
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
/** A reviewer's note. Untrusted text: stored bounded, shown as text, and only
|
|
620
|
+
* ever turned into a draft by the owner. Same-origin POSTs only (checked above). */
|
|
621
|
+
async note(req, res, entry) {
|
|
622
|
+
let body = "";
|
|
623
|
+
for await (const chunk of req) {
|
|
624
|
+
body += chunk.toString();
|
|
625
|
+
if (body.length > 4096) {
|
|
626
|
+
res.writeHead(413);
|
|
627
|
+
res.end();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
let input;
|
|
632
|
+
try {
|
|
633
|
+
input = JSON.parse(body);
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
res.writeHead(400);
|
|
637
|
+
res.end();
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const text = (value, max) => typeof value === "string" ? value.trim().slice(0, max) : "";
|
|
641
|
+
const note = text(input.note, 1000);
|
|
642
|
+
if (!note) {
|
|
643
|
+
res.writeHead(400);
|
|
644
|
+
res.end("A note needs some text.");
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const viewport = input.viewport;
|
|
648
|
+
const path = text(input.path, 2048);
|
|
649
|
+
entry.notes = [...(entry.notes ?? []), {
|
|
650
|
+
id: randomBytes(8).toString("hex"), at: Date.now(), note, selector: text(input.selector, 300), text: text(input.text, 200),
|
|
651
|
+
path: path.startsWith("/") ? path : "/", viewport: { width: Number(viewport?.width) || 0, height: Number(viewport?.height) || 0 },
|
|
652
|
+
}].slice(-MAX_NOTES);
|
|
653
|
+
this.registry.emit("notes", entry.view.id);
|
|
654
|
+
res.writeHead(204);
|
|
655
|
+
res.end();
|
|
656
|
+
}
|
|
657
|
+
/** Compare shots for the shell: the list, or one PNG by index. */
|
|
658
|
+
compare(req, res, entry) {
|
|
659
|
+
const shots = entry.shots ?? [];
|
|
660
|
+
const cors = { "access-control-allow-origin": this.shellOrigin(entry.view.id), "access-control-allow-credentials": "true", vary: "Origin" };
|
|
661
|
+
const index = req.url === COMPARE_PATH ? -1 : Number(req.url.slice(COMPARE_PATH.length + 1));
|
|
662
|
+
if (index < 0) {
|
|
663
|
+
res.writeHead(200, { ...cors, "content-type": "application/json" });
|
|
664
|
+
res.end(JSON.stringify({ shots: shots.map((shot, i) => ({ index: i, revision: shot.revision, at: shot.at })) }));
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const shot = Number.isInteger(index) ? shots[index] : undefined;
|
|
668
|
+
if (!shot) {
|
|
669
|
+
res.writeHead(404, cors);
|
|
670
|
+
res.end();
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
res.writeHead(200, { ...cors, "content-type": "image/png", "content-length": shot.png.length });
|
|
674
|
+
res.end(shot.png);
|
|
675
|
+
}
|
|
676
|
+
/** Long poll from the trusted shell (same-site, credentialed CORS): answers
|
|
677
|
+
* when the view's revision differs from `after`, or after 25 seconds. */
|
|
678
|
+
revision(req, res, entry) {
|
|
679
|
+
const id = entry.view.id;
|
|
680
|
+
const after = Number(new URL(req.url, "http://gateway").searchParams.get("after"));
|
|
681
|
+
let timer;
|
|
682
|
+
const onRevision = (viewId) => { if (viewId === id)
|
|
683
|
+
send(); };
|
|
684
|
+
const cleanup = () => { clearTimeout(timer); this.registry.off("revision", onRevision); };
|
|
685
|
+
const send = () => {
|
|
686
|
+
cleanup();
|
|
687
|
+
if (res.writableEnded || res.destroyed)
|
|
688
|
+
return;
|
|
689
|
+
res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": this.shellOrigin(id), "access-control-allow-credentials": "true", vary: "Origin" });
|
|
690
|
+
res.end(JSON.stringify({ revision: entry.revision, path: entry.lastPath ?? "/" }));
|
|
691
|
+
};
|
|
692
|
+
if (entry.revision !== after) {
|
|
693
|
+
send();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
this.registry.on("revision", onRevision);
|
|
697
|
+
timer = setTimeout(send, 25_000);
|
|
698
|
+
res.on("close", cleanup);
|
|
699
|
+
}
|
|
345
700
|
upgrade(req, socket, head) {
|
|
346
701
|
const entry = this.entry(req);
|
|
347
702
|
const expires = entry && this.authorize(req, entry.view.id);
|
|
348
|
-
|
|
703
|
+
const display = entry?.target.kind === "display";
|
|
704
|
+
if (!entry || !expires || (entry.target.kind !== "service" && !(display && req.url === DISPLAY_SOCKET_PATH)) || req.headers.origin !== this.origin(entry.view.id) || req.headers.upgrade?.toLowerCase() !== "websocket" || !req.url?.startsWith("/") || req.url.startsWith("//")) {
|
|
349
705
|
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
350
706
|
return;
|
|
351
707
|
}
|
|
352
708
|
this.track(entry.view.id, socket, expires);
|
|
353
|
-
|
|
709
|
+
if (display) {
|
|
710
|
+
this.displaySocket(req, socket, head, entry);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (entry.target.kind !== "service")
|
|
714
|
+
return;
|
|
715
|
+
const port = entry.target.port;
|
|
716
|
+
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
717
|
upstream.setTimeout(10_000, () => upstream.destroy());
|
|
355
718
|
upstream.on("upgrade", (response, peer, upstreamHead) => {
|
|
356
719
|
peer.setTimeout(0);
|