rechrome 1.44.0 → 1.46.0

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/serve.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { receiveUpload, readRequestJson } from "./scoped-upload.js";
2
- import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, type Listener } from "./listeners.js";
2
+ import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, shareRtcListeners, rtcListeners, type Listener } from "./listeners.js";
3
3
  import { WEBRTC, authorizeCredential, credentialForBearer, readShares, redeemInvite, shareProblem, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.js";
4
4
  import { bearerOf, safeEqual } from "./secret.js";
5
5
  import { WebrtcHost } from "./webrtc.js";
@@ -100,6 +100,32 @@ const runsInFlight = new Map<Promise<void>, string | undefined>();
100
100
  const CLI_TEXT_ARTIFACT = /(^|\/)\.playwright-cli(-multi-tab)?\/[^/]+\.(?:ya?ml|json|md|txt|log)$/;
101
101
  // Artifacts that could not be checked AND could not be deleted: never served.
102
102
  const deniedFiles = new Set<string>();
103
+ // Browser downloads go to the client: a download waits here (real path → cleanup timer) until the
104
+ // client fetches it once, then the host copy is deleted. A listener with keepDownloads: true
105
+ // keeps them on the host instead (opt-in).
106
+ const pendingDownloads = new Map<string, ReturnType<typeof setTimeout>>();
107
+ const DOWNLOAD_PICKUP_MS = 10 * 60_000;
108
+ // playwright-cli's line for a finished download (tools/backend/response.js).
109
+ const DOWNLOADED_LINE = /^- Downloaded file (.+?) to "(.+)"$/gm;
110
+ // Where the CLI saves downloads: its output dir under the session's cwd (context.js outputDir).
111
+ const CLI_OUTPUT_DIRS = new Set([".playwright-cli", ".playwright-mcp"]);
112
+
113
+ /**
114
+ * The downloads the CLI itself reported: `- Downloaded file …` lines inside its own `### Events`
115
+ * section only, with paths in its output dir. The rest of the output carries page-controlled text
116
+ * (snapshots, console messages, eval results), and a page that prints a look-alike line must not
117
+ * be able to make the host forward and delete a file of its choosing.
118
+ */
119
+ export function reportedDownloads(output: string): { name: string; rel: string }[] {
120
+ const out: { name: string; rel: string }[] = [];
121
+ for (const m of output.matchAll(/^### Events\n([\s\S]*?)(?=^### |(?![\s\S]))/gm)) {
122
+ for (const [, name, rel] of m[1]!.matchAll(DOWNLOADED_LINE)) {
123
+ const first = rel!.replaceAll("\\", "/").split("/")[0]!;
124
+ if (CLI_OUTPUT_DIRS.has(first)) out.push({ name: name!, rel: rel! });
125
+ }
126
+ }
127
+ return out;
128
+ }
103
129
  function noteSession(sess: string, now: number): void {
104
130
  recentSessions.set(sess, now);
105
131
  if (recentSessions.size > 64) {
@@ -752,6 +778,19 @@ export async function serve() {
752
778
  const real = realpathSync(resolved);
753
779
  if (!isUnderDir(realpathSync(workDir), real) || !readable(relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
754
780
  if (deniedFiles.has(real)) return new Response("Forbidden", { status: 403 });
781
+ const pickup = pendingDownloads.get(real);
782
+ if (pickup) {
783
+ // Handed over once, then gone from the host.
784
+ clearTimeout(pickup);
785
+ pendingDownloads.delete(real);
786
+ // Streamed, then deleted once fully sent (or when the client hangs up).
787
+ const done = () => { try { unlinkSync(real); } catch { /* already gone */ } };
788
+ const reader = file(real).stream().getReader();
789
+ return new Response(new ReadableStream({
790
+ async pull(c) { const { done: end, value } = await reader.read(); if (end) { c.close(); done(); } else c.enqueue(value); },
791
+ cancel() { void reader.cancel(); done(); },
792
+ }));
793
+ }
755
794
  if (TEXT_OUTPUT.test(real)) {
756
795
  const busy = () => [...runsInFlight].filter(([, dir]) => !dir || real.startsWith(dir + sep)).map(([p]) => p);
757
796
  for (let pending = busy(); pending.length; pending = busy()) await Promise.all(pending); // re-check: new runs may start
@@ -1122,6 +1161,7 @@ export async function serve() {
1122
1161
  }
1123
1162
 
1124
1163
  const commandStartedAt = Date.now();
1164
+ const runStartedAt = Date.now(); // downloads must be newer than this
1125
1165
  const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
1126
1166
  cwd: runWorkDir,
1127
1167
  stdin: "ignore",
@@ -1244,6 +1284,29 @@ export async function serve() {
1244
1284
  }
1245
1285
  }
1246
1286
 
1287
+ // Browser downloads, whatever their type: forwarded to the client and logged.
1288
+ for (const { name, rel } of reportedDownloads(rawOutput)) {
1289
+ if (!isUnderDir(runWorkDir, rel)) continue;
1290
+ const path = resolve(runWorkDir, rel);
1291
+ if (!existsSync(path)) continue;
1292
+ // Only a regular file really inside the work dir: never follow a link to delete elsewhere.
1293
+ const real = realpathSync(path);
1294
+ if (!isUnderDir(realpathSync(runWorkDir), real) || !lstatSync(real).isFile()) continue;
1295
+ // Written during this command: an older file named by a replayed line is left alone.
1296
+ if (statSync(real).mtimeMs < runStartedAt - 2000) continue;
1297
+ const f = relative(runWorkDir, path).replaceAll("\\", "/");
1298
+ if (!outputFiles.includes(f)) outputFiles.push(f);
1299
+ const hash = createHash("sha256");
1300
+ let size = 0;
1301
+ for await (const chunk of file(real).stream()) { hash.update(chunk); size += chunk.byteLength; }
1302
+ const keep = listener.keepDownloads === true;
1303
+ log(`download: ${JSON.stringify({ listener: listener.name, name: name.slice(0, 256), size, sha256: hash.digest("hex"), kept: keep })}`);
1304
+ if (keep) continue;
1305
+ clearTimeout(pendingDownloads.get(real));
1306
+ // Never fetched (the client went away): delete it anyway.
1307
+ pendingDownloads.set(real, setTimeout(() => { pendingDownloads.delete(real); try { unlinkSync(real); } catch {} }, DOWNLOAD_PICKUP_MS));
1308
+ }
1309
+
1247
1310
  // A file the command was told to write (`snapshot --filename=x.yml`) is its own artifact too.
1248
1311
  const ownFilenames = filteredArgs.flatMap((a, i) => a.startsWith("--filename=") ? [a.slice(11)] : a === "--filename" && filteredArgs[i + 1] ? [filteredArgs[i + 1]!] : []);
1249
1312
  // Snapshot files echo input values: scrub them on disk too, not only on download. Only
@@ -1287,11 +1350,18 @@ export async function serve() {
1287
1350
 
1288
1351
  // WebRTC: the same handler, reached over an encrypted DataChannel. Each request carries the
1289
1352
  // bearer of the credential whose room it came in on, so share checks apply unchanged.
1290
- const webrtcListener: Listener = { name: WEBRTC, host: "webrtc", port: 0, key: randomBytes(24).toString("base64url"), profiles: [] };
1291
- const webrtc = new WebrtcHost(async (req, token) => {
1353
+ // Each WebRTC listener is its own transport: a share is checked against the one its room is on.
1354
+ const webrtcKey = randomBytes(24).toString("base64url");
1355
+ const webrtcListeners = new Map<string, Listener>();
1356
+ const webrtcListener = (name = WEBRTC): Listener => {
1357
+ let l = webrtcListeners.get(name);
1358
+ if (!l) webrtcListeners.set(name, l = { name, host: "webrtc", port: 0, key: webrtcKey, profiles: [] });
1359
+ return l;
1360
+ };
1361
+ const webrtc = new WebrtcHost(async (req, token, listenerName) => {
1292
1362
  const headers = new Headers(req.headers);
1293
1363
  headers.set("Authorization", `Bearer ${bearerOf(token)}`);
1294
- return handle(new Request(req, { headers }), webrtcListener, () => "webrtc");
1364
+ return handle(new Request(req, { headers }), webrtcListener(listenerName), () => "webrtc");
1295
1365
  }, log);
1296
1366
 
1297
1367
  const startServer = (initial: Listener, reusePort = false) => Bun.serve({
@@ -1383,10 +1453,11 @@ export async function serve() {
1383
1453
  let appliedShares = "";
1384
1454
  const reloadShares = async () => {
1385
1455
  const next = await readShares();
1456
+ const listenerConfig = await readListeners().catch(() => null);
1386
1457
  // Expiry is time-based: fold "what is live right now" into the fingerprint so an expiring
1387
1458
  // share or invite closes its rooms and sessions without anyone touching the file.
1388
1459
  const now = Date.now();
1389
- const fingerprint = JSON.stringify(next) + JSON.stringify(next.shares.map(s => [!!shareProblem(s, now), !!(s.inviteExpiresAt && now >= s.inviteExpiresAt)]));
1460
+ const fingerprint = JSON.stringify(next) + JSON.stringify(listenerConfig?.rtcListeners ?? []) + JSON.stringify(next.shares.map(s => [!!shareProblem(s, now), !!(s.inviteExpiresAt && now >= s.inviteExpiresAt)]));
1390
1461
  if (fingerprint === appliedShares) return;
1391
1462
  const allowed = new Set(next.shares.filter(s => !shareProblem(s, now)).flatMap(s => s.devices.map(d => `share:${s.id}:${d.id}`)));
1392
1463
  const dropped = [...sessionOwners.entries()].filter(([, o]) => o.owner.startsWith("share:") && !allowed.has(o.owner));
@@ -1401,9 +1472,14 @@ export async function serve() {
1401
1472
  log(`closed session ${session} of ${o.label}: its share was revoked or its device removed`);
1402
1473
  }
1403
1474
  }
1404
- const creds = webrtcCredentials(next);
1405
- webrtc.sync(creds.map(c => ({ token: c.token, signal: c.share.signal!, label: c.kind === "device" ? `${c.share.label}/${c.device.name}` : `${c.share.label} (invite)` })));
1406
- if (creds.length || webrtc.size) log(`webrtc: listening for ${creds.length} share credential(s)`);
1475
+ // One room per share credential on each WebRTC listener the share names.
1476
+ const creds = webrtcCredentials(next, now, rtcListeners(listenerConfig).map(l => l.name));
1477
+ const rooms = creds.flatMap(c => shareRtcListeners(c.share, listenerConfig).map(l => ({
1478
+ token: c.token, signal: l.signal, listener: l.name, ...(l.ice?.length ? { ice: l.ice } : {}),
1479
+ label: `${c.kind === "device" ? `${c.share.label}/${c.device.name}` : `${c.share.label} (invite)`} via ${l.name}`,
1480
+ })));
1481
+ webrtc.sync(rooms);
1482
+ if (rooms.length || webrtc.size) log(`webrtc: listening for ${creds.length} share credential(s) in ${rooms.length} room(s)`);
1407
1483
  };
1408
1484
  await reloadShares().catch(error => log(`shares.json rejected: ${error}`));
1409
1485
  await reload(true);
package/serve.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { receiveUpload, readRequestJson } from "./scoped-upload.ts";
2
- import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, type Listener } from "./listeners.ts";
2
+ import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, shareRtcListeners, rtcListeners, type Listener } from "./listeners.ts";
3
3
  import { WEBRTC, authorizeCredential, credentialForBearer, readShares, redeemInvite, shareProblem, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.ts";
4
4
  import { bearerOf, safeEqual } from "./secret.ts";
5
5
  import { WebrtcHost } from "./webrtc.ts";
@@ -100,6 +100,32 @@ const runsInFlight = new Map<Promise<void>, string | undefined>();
100
100
  const CLI_TEXT_ARTIFACT = /(^|\/)\.playwright-cli(-multi-tab)?\/[^/]+\.(?:ya?ml|json|md|txt|log)$/;
101
101
  // Artifacts that could not be checked AND could not be deleted: never served.
102
102
  const deniedFiles = new Set<string>();
103
+ // Browser downloads go to the client: a download waits here (real path → cleanup timer) until the
104
+ // client fetches it once, then the host copy is deleted. A listener with keepDownloads: true
105
+ // keeps them on the host instead (opt-in).
106
+ const pendingDownloads = new Map<string, ReturnType<typeof setTimeout>>();
107
+ const DOWNLOAD_PICKUP_MS = 10 * 60_000;
108
+ // playwright-cli's line for a finished download (tools/backend/response.ts).
109
+ const DOWNLOADED_LINE = /^- Downloaded file (.+?) to "(.+)"$/gm;
110
+ // Where the CLI saves downloads: its output dir under the session's cwd (context.ts outputDir).
111
+ const CLI_OUTPUT_DIRS = new Set([".playwright-cli", ".playwright-mcp"]);
112
+
113
+ /**
114
+ * The downloads the CLI itself reported: `- Downloaded file …` lines inside its own `### Events`
115
+ * section only, with paths in its output dir. The rest of the output carries page-controlled text
116
+ * (snapshots, console messages, eval results), and a page that prints a look-alike line must not
117
+ * be able to make the host forward and delete a file of its choosing.
118
+ */
119
+ export function reportedDownloads(output: string): { name: string; rel: string }[] {
120
+ const out: { name: string; rel: string }[] = [];
121
+ for (const m of output.matchAll(/^### Events\n([\s\S]*?)(?=^### |(?![\s\S]))/gm)) {
122
+ for (const [, name, rel] of m[1]!.matchAll(DOWNLOADED_LINE)) {
123
+ const first = rel!.replaceAll("\\", "/").split("/")[0]!;
124
+ if (CLI_OUTPUT_DIRS.has(first)) out.push({ name: name!, rel: rel! });
125
+ }
126
+ }
127
+ return out;
128
+ }
103
129
  function noteSession(sess: string, now: number): void {
104
130
  recentSessions.set(sess, now);
105
131
  if (recentSessions.size > 64) {
@@ -752,6 +778,19 @@ export async function serve() {
752
778
  const real = realpathSync(resolved);
753
779
  if (!isUnderDir(realpathSync(workDir), real) || !readable(relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
754
780
  if (deniedFiles.has(real)) return new Response("Forbidden", { status: 403 });
781
+ const pickup = pendingDownloads.get(real);
782
+ if (pickup) {
783
+ // Handed over once, then gone from the host.
784
+ clearTimeout(pickup);
785
+ pendingDownloads.delete(real);
786
+ // Streamed, then deleted once fully sent (or when the client hangs up).
787
+ const done = () => { try { unlinkSync(real); } catch { /* already gone */ } };
788
+ const reader = file(real).stream().getReader();
789
+ return new Response(new ReadableStream({
790
+ async pull(c) { const { done: end, value } = await reader.read(); if (end) { c.close(); done(); } else c.enqueue(value); },
791
+ cancel() { void reader.cancel(); done(); },
792
+ }));
793
+ }
755
794
  if (TEXT_OUTPUT.test(real)) {
756
795
  const busy = () => [...runsInFlight].filter(([, dir]) => !dir || real.startsWith(dir + sep)).map(([p]) => p);
757
796
  for (let pending = busy(); pending.length; pending = busy()) await Promise.all(pending); // re-check: new runs may start
@@ -1122,6 +1161,7 @@ export async function serve() {
1122
1161
  }
1123
1162
 
1124
1163
  const commandStartedAt = Date.now();
1164
+ const runStartedAt = Date.now(); // downloads must be newer than this
1125
1165
  const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
1126
1166
  cwd: runWorkDir,
1127
1167
  stdin: "ignore",
@@ -1244,6 +1284,29 @@ export async function serve() {
1244
1284
  }
1245
1285
  }
1246
1286
 
1287
+ // Browser downloads, whatever their type: forwarded to the client and logged.
1288
+ for (const { name, rel } of reportedDownloads(rawOutput)) {
1289
+ if (!isUnderDir(runWorkDir, rel)) continue;
1290
+ const path = resolve(runWorkDir, rel);
1291
+ if (!existsSync(path)) continue;
1292
+ // Only a regular file really inside the work dir: never follow a link to delete elsewhere.
1293
+ const real = realpathSync(path);
1294
+ if (!isUnderDir(realpathSync(runWorkDir), real) || !lstatSync(real).isFile()) continue;
1295
+ // Written during this command: an older file named by a replayed line is left alone.
1296
+ if (statSync(real).mtimeMs < runStartedAt - 2000) continue;
1297
+ const f = relative(runWorkDir, path).replaceAll("\\", "/");
1298
+ if (!outputFiles.includes(f)) outputFiles.push(f);
1299
+ const hash = createHash("sha256");
1300
+ let size = 0;
1301
+ for await (const chunk of file(real).stream()) { hash.update(chunk); size += chunk.byteLength; }
1302
+ const keep = listener.keepDownloads === true;
1303
+ log(`download: ${JSON.stringify({ listener: listener.name, name: name.slice(0, 256), size, sha256: hash.digest("hex"), kept: keep })}`);
1304
+ if (keep) continue;
1305
+ clearTimeout(pendingDownloads.get(real));
1306
+ // Never fetched (the client went away): delete it anyway.
1307
+ pendingDownloads.set(real, setTimeout(() => { pendingDownloads.delete(real); try { unlinkSync(real); } catch {} }, DOWNLOAD_PICKUP_MS));
1308
+ }
1309
+
1247
1310
  // A file the command was told to write (`snapshot --filename=x.yml`) is its own artifact too.
1248
1311
  const ownFilenames = filteredArgs.flatMap((a, i) => a.startsWith("--filename=") ? [a.slice(11)] : a === "--filename" && filteredArgs[i + 1] ? [filteredArgs[i + 1]!] : []);
1249
1312
  // Snapshot files echo input values: scrub them on disk too, not only on download. Only
@@ -1287,11 +1350,18 @@ export async function serve() {
1287
1350
 
1288
1351
  // WebRTC: the same handler, reached over an encrypted DataChannel. Each request carries the
1289
1352
  // bearer of the credential whose room it came in on, so share checks apply unchanged.
1290
- const webrtcListener: Listener = { name: WEBRTC, host: "webrtc", port: 0, key: randomBytes(24).toString("base64url"), profiles: [] };
1291
- const webrtc = new WebrtcHost(async (req, token) => {
1353
+ // Each WebRTC listener is its own transport: a share is checked against the one its room is on.
1354
+ const webrtcKey = randomBytes(24).toString("base64url");
1355
+ const webrtcListeners = new Map<string, Listener>();
1356
+ const webrtcListener = (name = WEBRTC): Listener => {
1357
+ let l = webrtcListeners.get(name);
1358
+ if (!l) webrtcListeners.set(name, l = { name, host: "webrtc", port: 0, key: webrtcKey, profiles: [] });
1359
+ return l;
1360
+ };
1361
+ const webrtc = new WebrtcHost(async (req, token, listenerName) => {
1292
1362
  const headers = new Headers(req.headers);
1293
1363
  headers.set("Authorization", `Bearer ${bearerOf(token)}`);
1294
- return handle(new Request(req, { headers }), webrtcListener, () => "webrtc");
1364
+ return handle(new Request(req, { headers }), webrtcListener(listenerName), () => "webrtc");
1295
1365
  }, log);
1296
1366
 
1297
1367
  const startServer = (initial: Listener, reusePort = false) => Bun.serve({
@@ -1383,10 +1453,11 @@ export async function serve() {
1383
1453
  let appliedShares = "";
1384
1454
  const reloadShares = async () => {
1385
1455
  const next = await readShares();
1456
+ const listenerConfig = await readListeners().catch(() => null);
1386
1457
  // Expiry is time-based: fold "what is live right now" into the fingerprint so an expiring
1387
1458
  // share or invite closes its rooms and sessions without anyone touching the file.
1388
1459
  const now = Date.now();
1389
- const fingerprint = JSON.stringify(next) + JSON.stringify(next.shares.map(s => [!!shareProblem(s, now), !!(s.inviteExpiresAt && now >= s.inviteExpiresAt)]));
1460
+ const fingerprint = JSON.stringify(next) + JSON.stringify(listenerConfig?.rtcListeners ?? []) + JSON.stringify(next.shares.map(s => [!!shareProblem(s, now), !!(s.inviteExpiresAt && now >= s.inviteExpiresAt)]));
1390
1461
  if (fingerprint === appliedShares) return;
1391
1462
  const allowed = new Set(next.shares.filter(s => !shareProblem(s, now)).flatMap(s => s.devices.map(d => `share:${s.id}:${d.id}`)));
1392
1463
  const dropped = [...sessionOwners.entries()].filter(([, o]) => o.owner.startsWith("share:") && !allowed.has(o.owner));
@@ -1401,9 +1472,14 @@ export async function serve() {
1401
1472
  log(`closed session ${session} of ${o.label}: its share was revoked or its device removed`);
1402
1473
  }
1403
1474
  }
1404
- const creds = webrtcCredentials(next);
1405
- webrtc.sync(creds.map(c => ({ token: c.token, signal: c.share.signal!, label: c.kind === "device" ? `${c.share.label}/${c.device.name}` : `${c.share.label} (invite)` })));
1406
- if (creds.length || webrtc.size) log(`webrtc: listening for ${creds.length} share credential(s)`);
1475
+ // One room per share credential on each WebRTC listener the share names.
1476
+ const creds = webrtcCredentials(next, now, rtcListeners(listenerConfig).map(l => l.name));
1477
+ const rooms = creds.flatMap(c => shareRtcListeners(c.share, listenerConfig).map(l => ({
1478
+ token: c.token, signal: l.signal, listener: l.name, ...(l.ice?.length ? { ice: l.ice } : {}),
1479
+ label: `${c.kind === "device" ? `${c.share.label}/${c.device.name}` : `${c.share.label} (invite)`} via ${l.name}`,
1480
+ })));
1481
+ webrtc.sync(rooms);
1482
+ if (rooms.length || webrtc.size) log(`webrtc: listening for ${creds.length} share credential(s) in ${rooms.length} room(s)`);
1407
1483
  };
1408
1484
  await reloadShares().catch(error => log(`shares.json rejected: ${error}`));
1409
1485
  await reload(true);
package/shares.js CHANGED
@@ -95,6 +95,7 @@ function safeEqual(a, b) {
95
95
  // shares.ts
96
96
  var WEBRTC = "webrtc";
97
97
  var DEFAULT_SIGNAL = "https://rechrome.pages.dev/";
98
+ var DEFAULT_RTC = "webrtc-default";
98
99
  var SHARES_FILE = join(homedir(), ".rechrome", "shares.json");
99
100
  var LABEL = /^[a-z0-9][a-z0-9_-]{0,39}$/;
100
101
  function validateShares(value) {
@@ -314,10 +315,11 @@ function parseDuration(text) {
314
315
  const unit = { m: 60000, min: 60000, h: 3600000, d: 86400000, w: 604800000 }[m[2]];
315
316
  return Math.round(Number(m[1]) * unit);
316
317
  }
317
- function webrtcCredentials(config, now = Date.now()) {
318
+ function webrtcCredentials(config, now = Date.now(), rtcNames = [DEFAULT_RTC]) {
319
+ const names = new Set([WEBRTC, DEFAULT_RTC, ...rtcNames]);
318
320
  const out = [];
319
321
  for (const share of config.shares) {
320
- if (!share.via.includes(WEBRTC) || shareProblem(share, now))
322
+ if (!share.via.some((v) => names.has(v)) || shareProblem(share, now))
321
323
  continue;
322
324
  if (!(share.inviteExpiresAt && now >= share.inviteExpiresAt))
323
325
  out.push({ share, kind: "invite", token: share.invite });
@@ -327,6 +329,7 @@ function webrtcCredentials(config, now = Date.now()) {
327
329
  return out;
328
330
  }
329
331
  export {
332
+ DEFAULT_RTC,
330
333
  DEFAULT_SIGNAL,
331
334
  SHARES_FILE,
332
335
  WEBRTC,
package/shares.ts CHANGED
@@ -13,6 +13,8 @@ import { bearerOf, credentialIdOf, newToken, safeEqual } from "./secret.ts";
13
13
 
14
14
  export const WEBRTC = "webrtc";
15
15
  export const DEFAULT_SIGNAL = "https://rechrome.pages.dev/";
16
+ // The built-in WebRTC listener's name (see listeners.ts): the public signal server above.
17
+ export const DEFAULT_RTC = "webrtc-default";
16
18
  export const SHARES_FILE = join(homedir(), ".rechrome", "shares.json");
17
19
 
18
20
  export type Device = { id: string; name: string; token: string; addedAt: number; lastSeen?: number; approved: boolean };
@@ -127,6 +129,8 @@ export function createShare(config: ShareConfig, opts: NewShareOptions): Share {
127
129
  inviteTtlMs: opts.inviteTtlMs,
128
130
  profiles: [...new Set(opts.profiles)],
129
131
  via: [...new Set(opts.via)],
132
+ // Only the legacy "webrtc" transport keeps its signal server on the share; a WebRTC
133
+ // listener carries its own.
130
134
  signal: opts.via.includes(WEBRTC) ? normalizeSignal(opts.signal ?? DEFAULT_SIGNAL) : undefined,
131
135
  createdAt: now,
132
136
  expiresAt: opts.expiresAt,
@@ -244,10 +248,12 @@ export function parseDuration(text: string): number | undefined {
244
248
  }
245
249
 
246
250
  /** Every credential a WebRTC host should be reachable on: unused invites and devices. */
247
- export function webrtcCredentials(config: ShareConfig, now = Date.now()): Credential[] {
251
+ /** Live credentials of shares offered on a WebRTC listener (`rtcNames`; the legacy "webrtc" always counts). */
252
+ export function webrtcCredentials(config: ShareConfig, now = Date.now(), rtcNames: Iterable<string> = [DEFAULT_RTC]): Credential[] {
253
+ const names = new Set([WEBRTC, DEFAULT_RTC, ...rtcNames]);
248
254
  const out: Credential[] = [];
249
255
  for (const share of config.shares) {
250
- if (!share.via.includes(WEBRTC) || shareProblem(share, now)) continue;
256
+ if (!share.via.some(v => names.has(v)) || shareProblem(share, now)) continue;
251
257
  // A used-up invite keeps its room while the share lives, so whoever tries it again hears
252
258
  // "already used" (a sign the link leaked) rather than "host offline".
253
259
  if (!(share.inviteExpiresAt && now >= share.inviteExpiresAt)) out.push({ share, kind: "invite", token: share.invite });
package/webrtc.js CHANGED
@@ -231,8 +231,8 @@ function iceServersFromEnv(env = process.env) {
231
231
  return DEFAULT_STUN;
232
232
  }
233
233
  var turnCache = null;
234
- async function hostIceServers(log, env = process.env) {
235
- const base = iceServersFromEnv(env);
234
+ async function hostIceServers(log, env = process.env, extra = []) {
235
+ const base = [...iceServersFromEnv(env), ...extra];
236
236
  const { CF_TURN_KEY_ID: keyId, CF_TURN_API_TOKEN: apiToken } = env;
237
237
  if (!keyId || !apiToken)
238
238
  return base;
@@ -421,15 +421,23 @@ class WebrtcHost {
421
421
  this.log = log;
422
422
  }
423
423
  sync(creds) {
424
- const wanted = new Map(creds.map((c) => [`${c.signal}\x00${c.token}`, c]));
424
+ const wanted = new Map;
425
+ for (const c of creds) {
426
+ const key = `${c.signal}\x00${c.token}`;
427
+ if (!wanted.has(key))
428
+ wanted.set(key, c);
429
+ }
425
430
  for (const [key, room] of this.rooms)
426
431
  if (!wanted.has(key)) {
427
432
  this.stopRoom(room);
428
433
  this.rooms.delete(key);
429
434
  }
430
435
  for (const [key, cred] of wanted) {
431
- if (this.rooms.has(key))
436
+ const live = this.rooms.get(key);
437
+ if (live) {
438
+ live.cred = cred;
432
439
  continue;
440
+ }
433
441
  const room = { cred, peers: new Map, backoff: 1000, stopped: false };
434
442
  this.rooms.set(key, room);
435
443
  this.connect(room);
@@ -515,7 +523,7 @@ class WebrtcHost {
515
523
  }
516
524
  async startPeer(room, peerId) {
517
525
  const { RTCPeerConnection } = await loadRTC(this.log);
518
- const ice = await hostIceServers(this.log);
526
+ const ice = await hostIceServers(this.log, process.env, room.cred.ice ?? []);
519
527
  const pc = new RTCPeerConnection({ iceServers: ice });
520
528
  let keysReady;
521
529
  const keys = new Promise((r) => keysReady = r);
@@ -539,7 +547,7 @@ class WebrtcHost {
539
547
  a.abort();
540
548
  this.closePeer(room, peerId);
541
549
  };
542
- channel.onFrame = (frame) => void this.onFrame(channel, room.cred.token, aborts, frame);
550
+ channel.onFrame = (frame) => void this.onFrame(channel, room.cred.token, aborts, frame, room.cred.listener);
543
551
  dc.onopen = async () => {
544
552
  await channel.setKeys(room.cred.token, await keys);
545
553
  if (await channel.confirm())
@@ -557,7 +565,7 @@ class WebrtcHost {
557
565
  await pc.setLocalDescription(offer);
558
566
  send({ type: "offer", sdp: pc.localDescription.sdp, ice });
559
567
  }
560
- async onFrame(channel, token, aborts, f) {
568
+ async onFrame(channel, token, aborts, f, listener) {
561
569
  if (f?.t === "abort") {
562
570
  aborts.get(f.id)?.abort();
563
571
  aborts.delete(f.id);
@@ -574,7 +582,7 @@ class WebrtcHost {
574
582
  body: f.body != null && !["GET", "HEAD"].includes(f.method) ? f.body : undefined,
575
583
  signal: ac.signal
576
584
  });
577
- const res = await this.handler(req, token);
585
+ const res = await this.handler(req, token, listener);
578
586
  await channel.sendFrame({ t: "res", id: f.id, status: res.status, ct: res.headers.get("content-type") ?? "" });
579
587
  if (res.body) {
580
588
  const reader = res.body.getReader();
package/webrtc.ts CHANGED
@@ -61,8 +61,9 @@ export function iceServersFromEnv(env = process.env): IceServer[] {
61
61
  // Short-lived Cloudflare TURN credentials from the HOST owner's own TURN key (CF_TURN_KEY_ID +
62
62
  // CF_TURN_API_TOKEN), handed to each peer in the offer. Without them: STUN only.
63
63
  let turnCache: { servers: IceServer[]; exp: number } | null = null;
64
- export async function hostIceServers(log: Log, env = process.env): Promise<IceServer[]> {
65
- const base = iceServersFromEnv(env);
64
+ export async function hostIceServers(log: Log, env = process.env, extra: IceServer[] = []): Promise<IceServer[]> {
65
+ // A listener's own STUN/TURN servers come after the base ones (RECH_ICE_SERVERS, else public STUN).
66
+ const base = [...iceServersFromEnv(env), ...extra];
66
67
  const keyId = env.CF_TURN_KEY_ID, apiToken = env.CF_TURN_API_TOKEN;
67
68
  if (!keyId || !apiToken) return base;
68
69
  if (turnCache && turnCache.exp > Date.now()) return [...base, ...turnCache.servers];
@@ -205,8 +206,9 @@ class SignalSocket {
205
206
  }
206
207
 
207
208
  // ---- host ------------------------------------------------------------------------------
208
- export type HostCredential = { token: string; signal: string; label: string };
209
- export type HostHandler = (req: Request, token: string) => Promise<Response>;
209
+ // listener: the WebRTC listener this room belongs to (a share's `via` entry); ice: its STUN/TURN.
210
+ export type HostCredential = { token: string; signal: string; label: string; listener?: string; ice?: IceServer[] };
211
+ export type HostHandler = (req: Request, token: string, listener?: string) => Promise<Response>;
210
212
 
211
213
  /** Keeps one signal room per credential and serves each joining peer through `handler`. */
212
214
  export class WebrtcHost {
@@ -215,10 +217,13 @@ export class WebrtcHost {
215
217
 
216
218
  /** Open rooms for new credentials, close rooms for ones that are gone. */
217
219
  sync(creds: HostCredential[]): void {
218
- const wanted = new Map(creds.map(c => [`${c.signal}\0${c.token}`, c]));
220
+ // One room per (signal server, credential): the first listener naming a signal server wins.
221
+ const wanted = new Map<string, HostCredential>();
222
+ for (const c of creds) { const key = `${c.signal}\0${c.token}`; if (!wanted.has(key)) wanted.set(key, c); }
219
223
  for (const [key, room] of this.rooms) if (!wanted.has(key)) { this.stopRoom(room); this.rooms.delete(key); }
220
224
  for (const [key, cred] of wanted) {
221
- if (this.rooms.has(key)) continue;
225
+ const live = this.rooms.get(key);
226
+ if (live) { live.cred = cred; continue; } // a listener's ICE may have changed: next peer uses it
222
227
  const room = { cred, peers: new Map(), backoff: 1000, stopped: false } as any;
223
228
  this.rooms.set(key, room);
224
229
  void this.connect(room);
@@ -288,7 +293,7 @@ export class WebrtcHost {
288
293
 
289
294
  private async startPeer(room: any, peerId: string): Promise<void> {
290
295
  const { RTCPeerConnection } = await loadRTC(this.log);
291
- const ice = await hostIceServers(this.log);
296
+ const ice = await hostIceServers(this.log, process.env, room.cred.ice ?? []);
292
297
  const pc = new RTCPeerConnection({ iceServers: ice });
293
298
  let keysReady!: (th: Uint8Array) => void;
294
299
  const keys = new Promise<Uint8Array>(r => (keysReady = r));
@@ -302,7 +307,7 @@ export class WebrtcHost {
302
307
  peer.channel = channel;
303
308
  const aborts = new Map<string, AbortController>();
304
309
  channel.onClose = () => { for (const a of aborts.values()) a.abort(); this.closePeer(room, peerId); };
305
- channel.onFrame = frame => void this.onFrame(channel, room.cred.token, aborts, frame);
310
+ channel.onFrame = frame => void this.onFrame(channel, room.cred.token, aborts, frame, room.cred.listener);
306
311
  dc.onopen = async () => {
307
312
  await channel.setKeys(room.cred.token, await keys);
308
313
  if (await channel.confirm()) this.log(`webrtc ${room.cred.label}: peer ${peerId} connected`);
@@ -315,7 +320,7 @@ export class WebrtcHost {
315
320
  send({ type: "offer", sdp: pc.localDescription.sdp, ice });
316
321
  }
317
322
 
318
- private async onFrame(channel: SealedChannel, token: string, aborts: Map<string, AbortController>, f: any): Promise<void> {
323
+ private async onFrame(channel: SealedChannel, token: string, aborts: Map<string, AbortController>, f: any, listener?: string): Promise<void> {
319
324
  if (f?.t === "abort") { aborts.get(f.id)?.abort(); aborts.delete(f.id); return; }
320
325
  if (f?.t !== "req" || typeof f.id !== "string" || typeof f.path !== "string" || !f.path.startsWith("/")) return;
321
326
  const ac = new AbortController();
@@ -327,7 +332,7 @@ export class WebrtcHost {
327
332
  body: f.body != null && !["GET", "HEAD"].includes(f.method) ? f.body : undefined,
328
333
  signal: ac.signal,
329
334
  });
330
- const res = await this.handler(req, token);
335
+ const res = await this.handler(req, token, listener);
331
336
  await channel.sendFrame({ t: "res", id: f.id, status: res.status, ct: res.headers.get("content-type") ?? "" });
332
337
  if (res.body) {
333
338
  const reader = res.body.getReader();