Files
Piik/scripts/embedded-sfu-gate.ts
T

304 lines
19 KiB
TypeScript

import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createSocket } from "node:dgram";
import { mkdir, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { createServer as createViteServer, type ViteDevServer } from "vite";
import type { CreateRoomResponse } from "../src/shared/protocol";
import {
CdpConnection, cleanupRun, createPage, evaluate, fetchJsonBefore, launchChrome,
reservePort, waitForSample, waitForVersion, withDeadline, type PageHandle,
} from "./browser-gate-harness";
import type { snapshot } from "./embedded-sfu-page";
import { readClientEndpoint } from "./client-gate-endpoint";
import { SOURCE_TITLE, sourceServer, startSourceBrowser, waitForCaptureWindow } from "./client-native-host-gate";
const ROOT = resolve(import.meta.dirname, "..");
const BUILD_ROOT = join(ROOT, "build", "embedded-media");
type Snapshot = ReturnType<typeof snapshot>;
const AUDIO_PROBE = `(() => {
const peers = [], BrowserPeer = RTCPeerConnection;
window.RTCPeerConnection = class extends BrowserPeer {
constructor(...args) { super(...args); peers.push(this); }
};
window.__piikGateAudioEnergy = async () => {
const trackId = document.querySelector('video')?.srcObject?.getAudioTracks()[0]?.id;
const diagnostic = window.__piikGateAudioStats = { hasAudioTrack: !!trackId, peerCount: peers.length, inbound: [] };
if (!trackId) return 0;
for (const peer of peers) {
if (peer.connectionState !== 'connected') continue;
const report = await peer.getStats();
for (const row of report.values()) {
if (row.type === 'inbound-rtp' && row.kind === 'audio') diagnostic.inbound.push({
trackMatches: row.trackIdentifier === trackId, energy: row.totalAudioEnergy ?? null,
sampleDuration: row.totalSamplesDuration ?? null, receivedSamples: row.totalSamplesReceived ?? null,
});
if (row.type === 'inbound-rtp' && row.kind === 'audio' && row.trackIdentifier === trackId &&
Number.isFinite(row.totalAudioEnergy) && row.totalAudioEnergy > 0) return row.totalAudioEnergy;
}
}
return 0;
};
})()`;
async function udpPort(requested = 0): Promise<number> {
const socket = createSocket("udp4");
return await new Promise((resolvePort, reject) => {
socket.once("error", reject);
socket.bind(requested, "127.0.0.1", () => {
const port = socket.address().port;
socket.close(() => resolvePort(port));
});
});
}
async function main(): Promise<void> {
if (process.env.PIIK_EMBEDDED_SFU_GATE !== "true") {
throw new Error("PIIK_EMBEDDED_SFU_GATE=true is required; this is an explicit local Browser media gate");
}
const chromePath = process.env.CHROME_PATH?.trim();
if (!chromePath) throw new Error("CHROME_PATH is required");
const nativeArm = process.env.PIIK_EMBEDDED_SFU_NATIVE === "true";
const twoRooms = process.env.PIIK_EMBEDDED_SFU_TWO_ROOMS === "true";
if (twoRooms && !nativeArm) throw new Error("Two-room arm requires Native publication");
const codec = process.env.PIIK_EMBEDDED_SFU_CODEC?.trim() || "vp8";
if (codec !== "vp8" && codec !== "h264") throw new Error("SFU gate codec must be vp8 or h264");
const captureBinary = process.env.PIIK_CAPTURE_PROCESS?.trim();
if (nativeArm && (process.platform !== "win32" || !captureBinary)) {
throw new Error("Native arm requires Windows and PIIK_CAPTURE_PROCESS");
}
await mkdir(BUILD_ROOT, { recursive: true });
const binary = join(BUILD_ROOT, `piik-server${process.platform === "win32" ? ".exe" : ""}`);
const clientBinary = join(BUILD_ROOT, "piik-app.exe");
for (const command of nativeArm ? ["piik-server", "piik-app"] : ["piik-server"]) {
const build = spawnSync(process.env.PIIK_GO?.trim() || "go", [
"build", "-p", "1", "-trimpath", "-o", command === "piik-server" ? binary : clientBinary, `./cmd/${command}`,
], { cwd: ROOT, env: { ...process.env, GOMAXPROCS: "2" }, encoding: "utf8", windowsHide: true, timeout: 120_000 });
if (build.error || build.status !== 0) throw build.error ?? new Error(build.stderr || `${command} build failed`);
}
const [serverPort, vitePort, debugPort, mediaPort] = await Promise.all([
reservePort(), reservePort(), reservePort(), udpPort(),
]);
const origin = `http://127.0.0.1:${vitePort}`;
const backend = `http://127.0.0.1:${serverPort}`;
const profile = await mkdtemp(join(tmpdir(), "piik-client-media-"));
const sourceProfile = nativeArm ? await mkdtemp(join(tmpdir(), "piik-client-media-")) : null;
const [sourcePort, sourceDebugPort] = nativeArm ? [await reservePort(), await reservePort()] : [0, 0];
const result = { passed: false, arm: `${nativeArm ? "native" : "browser"}-${codec}-simulcast${twoRooms ? "-two-rooms" : ""}`, stage: "start",
startedAt: new Date().toISOString(), finishedAt: "", processes: [] as Array<{ role: string; pid: number | null }>,
high: null as { frames: number; width: number; height: number } | null,
low: null as { frames: number; width: number; height: number } | null,
audioEnergy: { high: 0, low: 0 },
audioDiagnostics: null as unknown,
secondRoom: twoRooms ? { connected: false, framesAfterFirstStopped: 0, audioBefore: 0, audioAfter: 0, stopped: false } : null,
host: null as Snapshot | null, viewer: null as Snapshot | null,
publicationRetired: false, nativeShareStopped: !nativeArm, udpReleased: false, cleanup: false, error: null as string | null };
let server: ChildProcessWithoutNullStreams | null = null;
let chrome: ChildProcessWithoutNullStreams | null = null;
let vite: ViteDevServer | null = null;
let cdp: CdpConnection | null = null;
let host: PageHandle | null = null;
let viewer: PageHandle | null = null;
let secondHost: PageHandle | null = null;
let secondViewer: PageHandle | null = null;
let serverError = "";
let client: ChildProcessWithoutNullStreams | null = null;
let clientPort = 0;
let sourceChrome: ChildProcessWithoutNullStreams | null = null;
let sourceCdp: CdpConnection | null = null;
let sourceHTTP: Awaited<ReturnType<typeof sourceServer>> | null = null;
const call = async <T>(page: PageHandle, expression: string, timeout = 25_000): Promise<T> => {
const value = await evaluate<{ value?: T; error?: string }>(cdp!, page,
`import('/scripts/embedded-sfu-page.ts').then(async (gate) => { try { return { value: await (${expression}) }; } catch (error) { return { error: String(error?.message || error) }; } })`,
Date.now() + timeout);
if (value.error) throw new Error(value.error);
return value.value as T;
};
const until = async (page: PageHandle, accept: (value: Snapshot) => boolean) => {
let last: Snapshot | null = null;
try {
await waitForSample(() => call<Snapshot>(page, "gate.snapshot()", 3_000),
(value) => { last = value; return !!value.error || accept(value); }, 25_000);
} catch { throw new Error(`Browser stage timed out: ${JSON.stringify(last)}`); }
if (last!.error) throw new Error(last!.error);
return last!;
};
const processStarted = (role: string, child: ChildProcessWithoutNullStreams) => {
const entry = { role, pid: child.pid ?? null };
result.processes.push(entry);
process.stderr.write(`${JSON.stringify({ ...entry, startedAt: new Date().toISOString() })}\n`);
};
try {
server = spawn(binary, [], { cwd: ROOT, stdio: "pipe", windowsHide: true,
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
PIIK_ENV: "development", LISTEN_HOST: "127.0.0.1", PORT: String(serverPort),
PUBLIC_BASE_URL: origin, ALLOWED_ORIGINS: origin,
SFU_LISTEN_HOST: "127.0.0.1", SFU_UDP_PORT: String(mediaPort), SFU_PUBLIC_IP: "127.0.0.1" },
});
processStarted("server", server);
server.stdout.resume();
server.stderr.on("data", (data: Buffer) => { serverError = (serverError + data.toString()).slice(-2000); });
await waitForSample((deadline) => fetchJsonBefore<{ status: string }>(`${backend}/healthz`, deadline),
() => true, 15_000);
vite = await createViteServer({ root: ROOT, configFile: false, appType: "custom", logLevel: "silent",
server: { host: "127.0.0.1", port: vitePort, strictPort: true, watch: null,
proxy: { "/api": { target: backend }, "/signal": { target: backend, ws: true } } },
plugins: [{ name: "embedded-sfu-gate", configureServer(instance) {
instance.middlewares.use((request, response, next) => {
if (request.url !== "/embedded-sfu-gate") return next();
response.setHeader("Content-Type", "text/html; charset=utf-8");
response.end("<!doctype html><html><head><title>Embedded SFU acceptance</title></head><body></body></html>");
});
} }],
});
await vite.listen();
if (nativeArm) {
result.stage = "native-client-start";
client = spawn(clientBinary, ["--site", origin, "--capture-process", captureBinary!,
"--config", join(profile, "client.json")], { cwd: ROOT, stdio: "pipe", windowsHide: true,
env: { ...process.env, GOMAXPROCS: "2", PIIK_CLIENT_GATE_NO_BROWSER: "true" } });
processStarted("client", client);
client.stderr.resume();
clientPort = (await readClientEndpoint(client, { timeoutMs: 15_000, ignoreNonEndpointLines: true })).port;
client.stdout.resume();
result.stage = "native-source-window";
sourceHTTP = await sourceServer(sourcePort);
({ child: sourceChrome, cdp: sourceCdp } = await startSourceBrowser(chromePath, sourceProfile!, sourceDebugPort, sourcePort));
processStarted("source-browser", sourceChrome);
await waitForCaptureWindow(captureBinary!);
const targets = await sourceCdp.call<{ targetInfos: Array<{ targetId: string; url: string }> }>(
"Target.getTargets", {}, undefined, Date.now() + 5_000);
const target = targets.targetInfos.find((entry) => entry.url === `http://127.0.0.1:${sourcePort}/`);
if (!target) throw new Error("Gate source page is unavailable");
const attached = await sourceCdp.call<{ sessionId: string }>("Target.attachToTarget",
{ targetId: target.targetId, flatten: true }, undefined, Date.now() + 5_000);
const audioState = await evaluate<string>(sourceCdp, attached, `(async () => {
const audio = new AudioContext(), tone = audio.createOscillator(), gain = audio.createGain();
gain.gain.value = 0.01; tone.connect(gain).connect(audio.destination); tone.start();
window.__gateAudio = audio; await audio.resume(); return audio.state;
})()`, Date.now() + 5_000);
if (audioState !== "running") throw new Error("Native source tone did not start");
}
chrome = launchChrome(chromePath, debugPort, profile, [
"--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check",
"--disable-extensions", "--disable-background-networking", "--autoplay-policy=no-user-gesture-required",
"--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding", "--disable-features=WebRtcHideLocalIpsWithMdns",
]);
processStarted("decoder-browser", chrome);
chrome.stdout.resume();
chrome.stderr.resume();
const version = await waitForVersion(debugPort, chrome);
cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000);
if (nativeArm) await cdp.call("Browser.setPermission", {
permission: { name: "local-network-access" }, setting: "granted", origin,
}, undefined, Date.now() + 5_000);
result.stage = "gate-page";
const gatePage = await fetch(`${origin}/embedded-sfu-gate`, { signal: AbortSignal.timeout(5_000) });
if (!gatePage.ok || !(await gatePage.text()).includes("Embedded SFU acceptance")) {
throw new Error("Local SFU gate page is unavailable");
}
host = await createPage(cdp, `${origin}/embedded-sfu-gate`);
viewer = await createPage(cdp, `${origin}/embedded-sfu-gate`, AUDIO_PROBE);
result.stage = "authenticate";
const room = await call<CreateRoomResponse>(host, nativeArm
? `gate.startHost(${JSON.stringify({ title: SOURCE_TITLE, port: clientPort })}, ${JSON.stringify(codec)})`
: `gate.startHost(undefined, ${JSON.stringify(codec)})`);
await until(host, (value) => value.authenticated);
await call(viewer, `gate.startViewer(${JSON.stringify(room)})`);
result.stage = "simulcast-first-frame";
result.viewer = await until(viewer, (value) => value.committed && value.audioKbps > 0);
if (twoRooms) {
result.stage = "second-native-room";
secondHost = await createPage(cdp, `${origin}/embedded-sfu-gate`);
secondViewer = await createPage(cdp, `${origin}/embedded-sfu-gate`, AUDIO_PROBE);
const secondRoom = await call<CreateRoomResponse>(secondHost,
`gate.startHost(${JSON.stringify({ title: SOURCE_TITLE, port: clientPort })}, ${JSON.stringify(codec)})`);
if (secondRoom.roomId === room.roomId) throw new Error("Native sessions did not create independent rooms");
await until(secondHost, (value) => value.authenticated);
await call(secondViewer, `gate.startViewer(${JSON.stringify(secondRoom)})`);
await until(secondViewer, (value) => value.committed && value.audioKbps > 0);
await call(secondViewer, "gate.waitForFrames(1280, 720)");
await waitForSample(() => call<number>(secondViewer!, "gate.audioEnergy()", 3_000),
(energy) => { result.secondRoom!.audioBefore = energy; return energy > 0; }, 5_000);
const second = await call<Snapshot>(secondHost, "gate.snapshot()");
result.secondRoom!.connected = second.published && second.simulcast && second.nativeShareStarted;
}
result.stage = "simulcast-first-frame";
await cdp.call("Page.bringToFront", {}, viewer.sessionId, Date.now() + 3_000);
result.host = await call(host, "gate.snapshot()");
result.high = await call(viewer, nativeArm ? "gate.waitForFrames(1280, 720, 300)" : "gate.waitForFrames(1280, 720)", nativeArm ? 32_000 : 25_000);
result.stage = "decoded-audio-energy";
await waitForSample(() => call<number>(viewer!, "gate.audioEnergy()", 3_000),
(energy) => { result.audioEnergy.high = energy; return energy > 0; }, 5_000);
result.host = await call(host, "gate.snapshot()");
result.stage = "live-profile";
if (!await call<boolean>(host, "gate.lowerProfile()")) {
result.host = await call(host, "gate.snapshot()");
throw new Error("Live profile update failed");
}
result.low = await call(viewer, "gate.waitForFrames(854, 480)");
await waitForSample(() => call<number>(viewer!, "gate.audioEnergy()", 3_000),
(energy) => { result.audioEnergy.low = energy; return energy > result.audioEnergy.high; }, 5_000);
result.stage = "publication-retirement";
await call(host, "gate.stopSharing()");
await until(host, (value) => value.publicationRetired);
await until(viewer, (value) => value.sharingStopped);
result.publicationRetired = true;
if (secondHost && secondViewer) {
result.stage = "second-room-isolation";
if (!await call<boolean>(host, "gate.stop()")) throw new Error("First Native session did not stop");
if (!(await call<Snapshot>(host, "gate.snapshot()")).nativeShareStopped) throw new Error("First Native capture did not stop");
result.secondRoom!.audioBefore = await call<number>(secondViewer, "gate.audioEnergy()");
await cdp.call("Page.bringToFront", {}, secondViewer.sessionId, Date.now() + 3_000);
const continued = await call<{ frames: number }>(secondViewer, "gate.waitForFrames(1280, 720, 30)");
result.secondRoom!.framesAfterFirstStopped = continued.frames;
await waitForSample(() => call<number>(secondViewer!, "gate.audioEnergy()", 3_000),
(energy) => { result.secondRoom!.audioAfter = energy; return energy > result.secondRoom!.audioBefore; }, 5_000);
if ((await call<Snapshot>(secondHost, "gate.snapshot()")).error) throw new Error("First session retirement affected second Native Host");
await call(secondHost, "gate.stopSharing()");
await until(secondHost, (value) => value.publicationRetired);
await until(secondViewer, (value) => value.sharingStopped);
if (!await call<boolean>(secondHost, "gate.stop()")) throw new Error("Second Native session did not stop");
result.secondRoom!.stopped = (await call<Snapshot>(secondHost, "gate.snapshot()")).nativeShareStopped;
}
} catch (error) {
result.error = error instanceof Error ? error.message : String(error);
if (viewer && cdp && result.stage === "decoded-audio-energy") result.audioDiagnostics = await evaluate(
cdp, viewer, "window.__piikGateAudioStats ?? null", Date.now() + 3_000).catch(() => null);
if (server?.exitCode !== null && serverError) result.error += `; server: ${serverError}`;
} finally {
const pagesStopped = await Promise.all([host, viewer, secondHost, secondViewer].map((page) =>
page && cdp ? call<boolean>(page, "gate.stop()", 5_000).catch(() => false) : true));
if (host && cdp && nativeArm) {
result.nativeShareStopped = await call<Snapshot>(host, "gate.snapshot()", 3_000)
.then((value) => value.nativeShareStopped, () => false);
}
const viteClosed = vite
? await withDeadline(() => vite!.close(), Date.now() + 5_000).then(() => true, () => false)
: true;
const cleanup = await cleanupRun({ cdp, native: client, chrome, server, profile,
ports: [serverPort, vitePort, debugPort, ...(clientPort ? [clientPort] : [])] });
const sourceCleanup = sourceProfile ? await cleanupRun({ cdp: sourceCdp, native: null,
chrome: sourceChrome, server: sourceHTTP, profile: sourceProfile, ports: [sourcePort, sourceDebugPort] }) : null;
result.udpReleased = await withDeadline(() => udpPort(mediaPort), Date.now() + 3_000)
.then(() => true, () => false);
result.cleanup = viteClosed && result.udpReleased && pagesStopped.every(Boolean) &&
Object.values(cleanup).every(Boolean) && (!sourceCleanup || Object.values(sourceCleanup).every(Boolean));
}
result.finishedAt = new Date().toISOString();
result.passed = !result.error && result.host?.published === true && result.host.simulcast &&
result.viewer?.decoded === true && result.viewer.committed && result.viewer.peerFailures > 0 &&
result.viewer.audioKbps > 0 && result.audioEnergy.high > 0 && result.audioEnergy.low > result.audioEnergy.high &&
result.high !== null && result.low !== null &&
result.publicationRetired && result.cleanup && result.nativeShareStopped &&
(!result.secondRoom || result.secondRoom.connected && result.secondRoom.framesAfterFirstStopped >= 30 &&
result.secondRoom.audioBefore > 0 && result.secondRoom.audioAfter > result.secondRoom.audioBefore && result.secondRoom.stopped) &&
(!nativeArm || result.host.nativeShareStarted && result.host.nativeFramesPerSecond > 0 && result.host.nativeBitrateKbps > 0 && result.host.nativeEncodingCount === 2);
process.stdout.write(`${JSON.stringify(result)}\n`);
if (!result.passed) process.exitCode = 1;
}
await main();