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/README.md +33 -10
- package/listeners.js +126 -3
- package/listeners.ts +81 -2
- package/package.json +1 -1
- package/rechrome.js +299 -91
- package/rechrome.ts +299 -91
- package/serve.js +84 -8
- package/serve.ts +84 -8
- package/shares.js +5 -2
- package/shares.ts +8 -2
- package/webrtc.js +16 -8
- package/webrtc.ts +15 -10
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
|
-
|
|
1291
|
-
const
|
|
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
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
1291
|
-
const
|
|
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
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
export type
|
|
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
|
-
|
|
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
|
-
|
|
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();
|