@bivy/bivy 0.17.2 → 0.17.3-staging.10

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.
@@ -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, shellOrigin) {
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)] : []).map((policy) => policy.replace(/(^|[;,])\s*frame-ancestors[^;,]*/gi, "$1"));
61
- result["content-security-policy"] = [...policies, `frame-ancestors ${shellOrigin}; worker-src 'none'`].join(", ");
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
- constructor(registry, originTemplate, returnOrigins = () => []) {
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
- authorize(req, id) {
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 && this.sessions.get(token);
159
- return session && session.appId === id ? session.expires : undefined;
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.shellOrigin(id)}; base-uri 'none'`);
241
- res.end(`<!doctype html><html lang="en"><meta name="viewport" content="width=device-width"><title>Open Bivy preview</title><p id="status" role="status">Opening preview…</p><script nonce="${nonce}">const ticket=location.hash.slice(1);history.replaceState(null,'',location.pathname);fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body:ticket}).then(r=>{if(!r.ok)throw Error();location.replace('/');}).catch(()=>{document.getElementById('status').textContent='Preview link expired or cookies are blocked. Open a new link from Bivy.';});</script></html>`);
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 grant = this.tickets.get(body);
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(body);
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
- res.setHeader("Set-Cookie", `${COOKIE}=${token}; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.max(1, Math.floor((expires - Date.now()) / 1000))}`);
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
- const data = entry.target.files.get(file);
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.shellOrigin(id));
473
+ const headers = responseHeaders({}, this.ancestors(id));
324
474
  headers["content-type"] = MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
325
- res.writeHead(200, { ...headers, "content-length": data.length });
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 upstream = http.request({ hostname: "127.0.0.1", port: entry.target.port, path: req.url, method: req.method, headers: upstreamHeaders(req, this.origin(id)) }, (response) => {
335
- res.writeHead(response.statusCode ?? 502, responseHeaders(response.headers, this.shellOrigin(id)));
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.pipe(res);
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", () => { if (!res.headersSent)
341
- res.writeHead(502, { "content-type": "text/plain" }); res.end("App server unavailable. Start it on the registered port, then reload."); });
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
- if (!entry || !expires || entry.target.kind !== "service" || req.headers.origin !== this.origin(entry.view.id) || req.headers.upgrade?.toLowerCase() !== "websocket" || !req.url?.startsWith("/") || req.url.startsWith("//")) {
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
- const upstream = http.request({ hostname: "127.0.0.1", port: entry.target.port, path: req.url, headers: { ...upstreamHeaders(req, this.origin(entry.view.id)), connection: "Upgrade", upgrade: "websocket" } });
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);