refactor: unify Go runtime and audited frontend

Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
This commit is contained in:
AAA authored and GitHub committed 2026-09-06 23:26:01 +08:00
1 parent 0653b7a58a
commit da47189fc3
291 files changed
+42361 -34024

No files matched your search

+240 -46
View File
@@ -1,22 +1,51 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomBytes } from "node:crypto";
import { access, mkdtemp, readFile, readdir } from "node:fs/promises";
import { access, mkdir, mkdtemp, readFile, readdir } from "node:fs/promises";
import {
createServer,
request as httpRequest,
type IncomingHttpHeaders,
type IncomingMessage,
type OutgoingHttpHeaders,
type Server,
} from "node:http";
import { connect } from "node:net";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { createRoomResponseSchema } from "../src/shared/protocol";
import { createScreenerServer, type ScreenerServer } from "../src/server/app";
import type { ServerConfig } from "../src/server/config";
import WebSocket from "ws";
import {
SIGNALING_PROTOCOL,
createRoomResponseSchema,
decodeServerMessage,
roomAccessUpdateResponseSchema,
} from "../src/shared/protocol";
import {
CdpConnection,
cleanupRun,
createPage,
evaluate,
fetchJsonBefore,
launchChrome,
reservePort,
waitForSample,
waitForVersion,
withDeadline,
} from "./browser-gate-harness";
const root = resolve(import.meta.dirname, "..");
const nginxConfigPath = resolve(root, "deploy/nginx/share.bonfire.icu.conf.example");
const serverExecutable = join(root, "build", "client-check",
process.platform === "win32" ? "screener-server.exe" : "screener-server");
// SCREENER_ENV=production refuses to start without STUN. Nothing listens on
// this address, so the gate still never leaves the loopback interface.
const gateStunUrl = "stun:127.0.0.1:3478";
const hostClientId = "privacy-gate-host";
const serverReadyTimeoutMs = 20_000;
const signalDeadlineMs = 10_000;
const serverStopTimeoutMs = 10_000;
// Hop-by-hop headers the proxy must not forward (RFC 9110 7.6.1); Node frames
// the forwarded body itself.
const hopByHopHeaders = new Set(["connection", "keep-alive", "transfer-encoding", "upgrade"]);
const fragmentProbe = `(() => {
let domContentLoaded = false;
const state = { fragmentAtStart: location.hash.length > 0, replaced: false, beforeDomContentLoaded: false };
@@ -47,20 +76,145 @@ interface BrowserAudit {
otherSessionGrantMatches: number; localGrantMatches: number;
cookieGrantMatches: number; resourceGrantMatches: number;
}
// An observer records the request line and returns the sink for its status.
type RequestObserver = (request: IncomingMessage) => (status: number) => void;
function secret(): string { return randomBytes(24).toString("base64url"); }
function leakCount(values: readonly (string | Buffer)[], secrets: readonly string[]): number {
return values.filter((value) => secrets.some((item) => value.includes(item))).length;
}
function diagnosticText(values: readonly unknown[]): string {
return values.map((value) => {
if (typeof value === "string") return value;
if (value instanceof Error) return `${value.name}:${value.message}`;
try { return JSON.stringify(value) ?? ""; } catch { return ""; }
}).join(" ");
}
// The profile is also the server's working directory, so a room database the
// gate never configured would land here.
async function persistentRoomFiles(profile: string): Promise<string[]> {
return (await readdir(profile)).filter((name) => name.startsWith("rooms.sqlite"));
}
async function reserveDistinctPorts(count: number): Promise<number[]> {
const ports: number[] = [];
while (ports.length < count) {
const port = await reservePort();
if (!ports.includes(port)) ports.push(port);
}
return ports;
}
function buildServer(): void {
const go = process.env.SCREENER_GO?.trim() || "go";
const result = spawnSync(go, ["build", "-trimpath", "-o", serverExecutable, "./cmd/screener-server"], {
cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, CGO_ENABLED: "0" },
});
if (result.error) throw result.error;
if (result.status !== 0) throw new Error("screener-server build failed");
}
function forwardedHeaders(headers: IncomingHttpHeaders): OutgoingHttpHeaders {
return Object.fromEntries(
Object.entries(headers).filter(([name]) => !hopByHopHeaders.has(name)),
);
}
function requestPreamble(request: IncomingMessage): string {
const lines = [`${request.method} ${request.url} HTTP/1.1`];
for (let index = 0; index < request.rawHeaders.length; index += 2) {
lines.push(`${request.rawHeaders[index]}: ${request.rawHeaders[index + 1]}`);
}
return `${lines.join("\r\n")}\r\n\r\n`;
}
function upgradeStatus(chunk: Buffer): number {
const statusLine = chunk.toString("latin1", 0, Math.min(chunk.length, 32));
const match = /^HTTP\/1\.[01] (\d{3})/.exec(statusLine);
return match ? Number(match[1]) : 0;
}
// startProxy stands in for the nginx reverse proxy the deployment runs in
// front of the server (deploy/nginx/share.bonfire.icu.conf.example). It
// observes every request line the browser and the gate emit, which is what the
// in-process httpServer.prependListener("request") hook used to do, and it is
// the only origin the browser ever sees.
// ponytail: the upgrade pipes ignore backpressure; gate traffic is a handful of
// signaling frames. Use stream.pipe with drain handling if that ever changes.
function startProxy(port: number, upstreamPort: number, observe: RequestObserver): Promise<Server> {
const proxy = createServer((request, response) => {
const finish = observe(request);
response.once("finish", () => finish(response.statusCode));
const upstream = httpRequest({
host: "127.0.0.1", port: upstreamPort, method: request.method,
path: request.url, headers: request.headers,
}, (upstreamResponse) => {
response.writeHead(upstreamResponse.statusCode ?? 502, forwardedHeaders(upstreamResponse.headers));
upstreamResponse.pipe(response);
});
upstream.on("error", () => response.destroy());
request.pipe(upstream);
});
proxy.on("upgrade", (request, clientSocket, head) => {
const finish = observe(request);
const upstream = connect({ host: "127.0.0.1", port: upstreamPort });
const destroy = () => { clientSocket.destroy(); upstream.destroy(); };
let statusRecorded = false;
upstream.on("connect", () => {
upstream.write(requestPreamble(request));
if (head.length > 0) upstream.write(head);
});
upstream.on("data", (chunk: Buffer) => {
if (!statusRecorded) { statusRecorded = true; finish(upgradeStatus(chunk)); }
clientSocket.write(chunk);
});
clientSocket.on("data", (chunk: Buffer) => upstream.write(chunk));
for (const socket of [clientSocket, upstream]) {
socket.on("error", destroy);
socket.on("close", destroy);
}
});
return new Promise((resolveProxy, rejectProxy) => {
proxy.once("error", rejectProxy);
proxy.listen(port, "127.0.0.1", () => resolveProxy(proxy));
});
}
async function stopChild(child: ChildProcessWithoutNullStreams): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
const closed = new Promise<void>((resolveClosed) => child.once("close", () => resolveClosed()));
child.kill();
await withDeadline(() => closed, Date.now() + serverStopTimeoutMs);
}
async function closeProxy(proxy: Server | null): Promise<void> {
if (!proxy) return;
proxy.closeAllConnections();
await new Promise<void>((resolveClosed) => proxy.close(() => resolveClosed()));
}
// authenticateHost replaces the in-process roomStore.connectParticipant call:
// the room needs a connected Host, and out of process the only way to get one
// is the handshake tests/server-signal.test.ts drives over /signal.
async function authenticateHost(
webSocketUrl: string, origin: string, cookie: string, roomId: string, hostToken: string,
): Promise<WebSocket> {
const socket = new WebSocket(webSocketUrl, { origin, headers: { Cookie: cookie } });
const deadline = Date.now() + signalDeadlineMs;
await withDeadline(() => new Promise<void>((resolveOpen, rejectOpen) => {
socket.once("open", resolveOpen);
socket.once("error", rejectOpen);
}), deadline);
const authenticated = new Promise<void>((resolveAuth, rejectAuth) => {
socket.on("error", rejectAuth);
socket.once("close", () => rejectAuth(new Error("Host signaling closed before authenticating")));
socket.on("message", (data) => {
try {
if (decodeServerMessage(data.toString()).type === "authenticated") resolveAuth();
} catch (error) {
rejectAuth(error instanceof Error ? error : new Error("Unreadable server message"));
}
});
});
socket.send(JSON.stringify({
type: "authenticate", protocol: SIGNALING_PROTOCOL, roomId,
role: "host", token: hostToken, clientId: hostClientId,
}));
try {
await withDeadline(() => authenticated, deadline);
} catch (error) {
socket.terminate();
throw error;
}
// The Host session lives until the server stops; a late transport error must
// not surface as an unhandled event.
socket.on("error", () => {});
return socket;
}
async function main(): Promise<void> {
const report: GateReport = {
passed: false, serverReady: false, roomPrepared: false,
@@ -79,39 +233,71 @@ async function main(): Promise<void> {
const requestRecords: string[] = [];
const nginxLines: string[] = [];
const appLogs: string[] = [];
const originalConsoleError = console.error;
let profile: string | null = null;
let server: ScreenerServer | null = null;
let server: { close(): Promise<void> } | null = null;
let chrome: ChildProcessWithoutNullStreams | null = null;
let cdp: CdpConnection | null = null;
let appPort = 0;
let proxyPort = 0;
let serverPort = 0;
let debugPort = 0;
console.error = (...values: unknown[]) => { appLogs.push(diagnosticText(values)); };
try {
await access(chromePath);
appPort = await reservePort();
do { debugPort = await reservePort(); } while (debugPort === appPort);
[proxyPort, serverPort, debugPort] = await reserveDistinctPorts(3);
profile = await mkdtemp(join(tmpdir(), "screener-access-privacy-"));
const baseUrl = `http://127.0.0.1:${appPort}`;
const config: ServerConfig = {
nodeEnv: "production", port: appPort, listenHost: "127.0.0.1",
publicBaseUrl: new URL(baseUrl), allowedOrigins: new Set([baseUrl]),
siteAccessPassword: sitePassword, roomLeaseMs: 86_400_000,
maxViewersPerRoom: 2, endpointMediaCopyCapacity: 2,
peerAssistedMedia: false, stunUrls: [], natPredictionEnabled: false,
const baseUrl = `http://127.0.0.1:${proxyPort}`;
await mkdir(dirname(serverExecutable), { recursive: true });
buildServer();
let proxy: Server | null = null;
let hostSocket: WebSocket | null = null;
const child = spawn(serverExecutable, [], {
// The working directory decides where .env is read and where an
// unconfigured room database would be written; the empty profile keeps
// both the developer's environment and the repository out of the run.
cwd: profile, stdio: "pipe", windowsHide: true,
// An explicit environment: config.Load fails closed on a removed
// variable, so nothing from the developer's shell may reach the server.
env: {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
SCREENER_ENV: "production",
PORT: String(serverPort),
LISTEN_HOST: "127.0.0.1",
// Production requires an https public origin. The browser reaches the
// same host and port over plain HTTP through the observing proxy, so
// only the scheme of the invite URL is rewritten before navigating.
PUBLIC_BASE_URL: `https://127.0.0.1:${proxyPort}`,
ALLOWED_ORIGINS: baseUrl,
SITE_ACCESS_PASSWORD: sitePassword,
MAX_VIEWERS_PER_ROOM: "2",
ENDPOINT_MEDIA_COPY_CAPACITY: "2",
STUN_URLS: gateStunUrl,
},
});
child.stdout.on("data", (chunk: Buffer) => appLogs.push(chunk.toString()));
child.stderr.on("data", (chunk: Buffer) => appLogs.push(chunk.toString()));
server = {
async close() {
hostSocket?.terminate();
await stopChild(child);
await closeProxy(proxy);
},
};
server = await createScreenerServer({
config,
frontend: { mode: "static", directory: resolve(root, "dist/client") },
});
server.httpServer.prependListener("request", (request, response) => {
const target = request.url ?? "";
const record = `${request.method ?? ""} ${target} ${request.headers.referer ?? ""}`;
proxy = await startProxy(proxyPort, serverPort, (request) => {
// The fields the in-process request listener recorded, plus whether a
// Cookie travelled at all: the audit must see credential-bearing
// requests without ever storing a credential.
const record = `${request.method ?? ""} ${request.url ?? ""} ` +
`${request.headers.referer ?? ""} ${request.headers.cookie ? "cookie" : "no-cookie"}`;
requestRecords.push(record);
response.once("finish", () => nginxLines.push(`${record} ${response.statusCode}`));
return (status) => nginxLines.push(`${record} ${status}`);
});
await server.listen(appPort, "127.0.0.1");
await waitForSample(
(deadline) => fetchJsonBefore<{ status: string }>(
`http://127.0.0.1:${serverPort}/healthz`, deadline),
(health) => health.status === "ok",
serverReadyTimeoutMs,
);
report.serverReady = true;
const login = await fetch(`${baseUrl}/api/site-access`, {
method: "POST", headers: { Authorization: `Bearer ${sitePassword}`, Origin: baseUrl },
@@ -126,17 +312,26 @@ async function main(): Promise<void> {
const invite = new URL(room.inviteUrl);
const grant = new URLSearchParams(invite.hash.slice(1)).get("v") ?? "";
if (!grant) throw new Error("Room creation did not return a Viewer grant");
server.roomStore.connectParticipant({
roomId: room.roomId, role: "host", token: room.hostToken,
clientId: "privacy-gate-host", sessionId: "privacy-gate-host-session",
invite.protocol = "http:";
hostSocket = await authenticateHost(
`ws://127.0.0.1:${proxyPort}/signal`, baseUrl, cookie, room.roomId, room.hostToken);
const accessResponse = await fetch(`${baseUrl}/api/rooms/${room.roomId}/access`, {
method: "POST",
headers: {
Authorization: `Bearer ${room.hostToken}`, "Content-Type": "application/json",
Cookie: cookie, Origin: baseUrl,
},
body: JSON.stringify({ action: "set-viewer-password", password: roomPassword }),
});
await server.roomStore.setViewerPassword(room.roomId, roomPassword, room.hostToken);
const accessUpdate = roomAccessUpdateResponseSchema.parse(await accessResponse.json());
if (accessUpdate.type !== "viewer-password-updated" || !accessUpdate.enabled) {
throw new Error("Room preparation did not enable the Viewer password");
}
report.roomPrepared = true;
chrome = spawn(chromePath, [
`--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`,
chrome = launchChrome(chromePath, debugPort, profile, [
"--headless=new", "--no-first-run", "--disable-extensions",
"--disable-logging", "about:blank",
], { cwd: root, stdio: "pipe", windowsHide: true });
"--disable-logging",
], { cwd: root });
chrome.stdout.resume();
chrome.stderr.resume();
const version = await waitForVersion(debugPort, chrome);
@@ -145,7 +340,7 @@ async function main(): Promise<void> {
const context = await cdp.call<{ browserContextId: string }>(
"Target.createBrowserContext", { disposeOnDetach: true }, undefined, Date.now() + 5_000,
);
const page = await createPage(cdp, room.inviteUrl, fragmentProbe, true, context.browserContextId);
const page = await createPage(cdp, invite.toString(), fragmentProbe, true, context.browserContextId);
report.pageLoaded = true;
const expected = JSON.stringify({ roomId: room.roomId, grant });
const audit = await evaluate<BrowserAudit>(cdp, page, `(() => {
@@ -185,10 +380,9 @@ async function main(): Promise<void> {
} catch {
// The report intentionally exposes no exception text or secret-bearing diagnostics.
} finally {
console.error = originalConsoleError;
const cleanup = await cleanupRun({
cdp, native: null, chrome, server, profile,
ports: [appPort, debugPort].filter((port) => port > 0),
ports: [proxyPort, serverPort, debugPort].filter((port) => port > 0),
});
report.cleanupPassed = Object.values(cleanup).every(Boolean);
}
+10
View File
@@ -0,0 +1,10 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
export function tarExecutable() {
if (process.platform !== "win32") return "tar";
const systemRoot = process.env.SystemRoot || process.env.WINDIR;
if (!systemRoot) return "tar";
const systemTar = join(systemRoot, "System32", "tar.exe");
return existsSync(systemTar) ? systemTar : "tar";
}
+22 -39
View File
@@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url";
import { clientPackageTarget, CLOUDFLARED_VERSION } from "./client-package-targets.mjs";
import { writeClientPlatformAssets } from "./client-icons.mjs";
import { writeClientLicenseNotices } from "./package-licenses.mjs";
import { tarExecutable } from "./archive-tool.mjs";
function fail(message) {
throw new Error(message);
@@ -131,7 +132,7 @@ function readDescriptor(path) {
function assertOutsideRepository(repositoryRoot, outputRoot) {
const path = relative(repositoryRoot, outputRoot);
if (path === "" || (!path.startsWith("..") && !isAbsolute(path))) {
if (path === "" || (path.split(/[\\/]/)[0] !== ".." && !isAbsolute(path))) {
fail("Client output directory must be outside the repository");
}
if (existsSync(outputRoot)) {
@@ -139,11 +140,11 @@ function assertOutsideRepository(repositoryRoot, outputRoot) {
}
}
const positional = process.argv.slice(2, 5);
const options = process.argv.slice(5);
if (positional.length !== 3 || options.length % 2 !== 0) {
const positional = process.argv.slice(2, 4);
const options = process.argv.slice(4);
if (positional.length !== 2 || options.length % 2 !== 0) {
fail(
"Usage: node scripts/assemble-client.mjs <app-release.json> <node-executable> <new-output-directory> --target <windows-amd64|linux-amd64|darwin-arm64> [--capture <executable>] [--tunnel <executable>]",
"Usage: node scripts/assemble-client.mjs <app-release.json> <new-output-directory> --target <windows-amd64|linux-amd64|darwin-arm64> [--capture <executable>] [--tunnel <executable>]",
);
}
@@ -174,19 +175,10 @@ if (!target) fail("Client package target is invalid");
const repositoryRoot = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
const descriptorPath = realpathSync(resolve(positional[0]));
const nodePath = realpathSync(resolve(positional[1]));
const outputRoot = resolve(process.cwd(), positional[2]);
const outputRoot = resolve(process.cwd(), positional[1]);
const capturePath = captureArgument ? realpathSync(resolve(captureArgument)) : null;
const tunnelPath = tunnelArgument ? realpathSync(resolve(tunnelArgument)) : null;
assertOutsideRepository(repositoryRoot, outputRoot);
assertTargetExecutable(nodePath, target, "Node runtime");
const expectedNodeVersion = `v${readFileSync(
join(repositoryRoot, ".node-version"),
"ascii",
).trim()}`;
if (run(nodePath, ["--version"], repositoryRoot) !== expectedNodeVersion) {
fail(`Node runtime must be ${expectedNodeVersion}`);
}
if (capturePath && !target.captureName) {
fail("Capture runtime is invalid for the Client package target");
}
@@ -208,21 +200,20 @@ if (!existsSync(artifactPath) || sha256(artifactPath) !== descriptor.artifactSha
const temporaryRoot = mkdtempSync(join(tmpdir(), `screener-client-${revision.slice(0, 7)}-`));
const packageRoot = join(temporaryRoot, "package");
const appRoot = join(packageRoot, "app");
const releaseRoot = join(temporaryRoot, "release");
try {
mkdirSync(appRoot, { recursive: true });
run("tar", ["-xzf", artifactPath, "-C", appRoot], repositoryRoot);
if (readFileSync(join(appRoot, "REVISION"), "ascii") !== `${revision}\n`) {
// The application release is consumed for its provenance only: the Client
// embeds the Web assets, so the revision, clean-tree and digest assertions
// above plus this REVISION check tie the binary to that immutable release.
mkdirSync(releaseRoot, { recursive: true });
run(tarExecutable(), ["-xzf", artifactPath, "-C", releaseRoot], repositoryRoot);
if (readFileSync(join(releaseRoot, "REVISION"), "ascii") !== `${revision}\n`) {
fail("Extracted application revision does not match the Client");
}
runNpm(["ci", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], appRoot);
const runtimeRoot = join(packageRoot, "runtime", "node");
mkdirSync(runtimeRoot, { recursive: true });
const nodeName = target.nodeName;
const packagedNode = join(runtimeRoot, nodeName);
copyFileSync(nodePath, packagedNode);
chmodSync(packagedNode, 0o755);
// Build the Web assets the Go binary embeds. The checkout is clean and at the
// release revision, so this reproduces that release's client.
runNpm(["run", "build:client"], repositoryRoot);
mkdirSync(packageRoot, { recursive: true });
let packagedCapture = null;
if (capturePath) {
@@ -250,31 +241,25 @@ try {
"build",
"-trimpath",
"-ldflags",
`-s -w -X github.com/TNTcraftHIM/Screener/native/client/internal/clientapp.BuildRevision=${revision}`,
`-s -w -X github.com/TNTcraftHIM/Screener/internal/client/clientapp.BuildRevision=${revision}`,
"-o",
clientPath,
"./cmd/screener-client",
], join(repositoryRoot, "native", "client"), {
], repositoryRoot, {
...process.env,
GOOS: target.goos,
GOARCH: target.goarch,
CGO_ENABLED: "0",
});
chmodSync(clientPath, 0o755);
assertTargetExecutable(clientPath, target, "Client executable");
writeClientLicenseNotices(repositoryRoot, packageRoot, goCommand, target,
packagedTunnel ? CLOUDFLARED_VERSION : null);
const platformAssets = writeClientPlatformAssets({
packageRoot,
target,
revision,
iconPath: join(
repositoryRoot,
"native",
"client",
"cmd",
"screener-client",
"screener.ico",
),
iconPath: join(repositoryRoot, "cmd", "screener-client", "screener.ico"),
});
writeFileSync(join(packageRoot, "REVISION"), `${revision}\n`, "ascii");
@@ -290,13 +275,11 @@ try {
platform: target.goos,
arch: target.goarch,
client: clientName,
node: `runtime/node/${nodeName}`,
nativeCapture: packagedCapture ? `runtime/native/${target.captureName}` : null,
publicTunnel: packagedTunnel
? `runtime/tunnel/${target.tunnelName}`
: null,
platformAssets,
app: "app",
})}\n`);
} finally {
rmSync(temporaryRoot, { recursive: true, force: true });
+37 -5
View File
@@ -27,11 +27,15 @@ interface AsyncCloseable {
close(): Promise<void>;
}
// A gate's server is either an in-process page server it closes directly or a
// spawned Screener server it stops like any other child.
export type CleanupServer = AsyncCloseable | ChildProcessWithoutNullStreams;
export interface CleanupResources {
cdp: CdpConnection | null;
native: ChildProcessWithoutNullStreams | null;
chrome: ChildProcessWithoutNullStreams | null;
server: AsyncCloseable | null;
server: CleanupServer | null;
profile: string | null;
ports: number[];
}
@@ -137,6 +141,27 @@ export class CdpConnection {
}
}
// launchChrome starts an isolated Chrome process with the shared CDP flags.
export function launchChrome(
executable: string,
debugPort: number,
profile: string,
flags: string[] = [],
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
): ChildProcessWithoutNullStreams {
return spawn(executable, [
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${profile}`,
...flags,
"about:blank",
], {
cwd: options.cwd,
env: options.env,
stdio: "pipe",
windowsHide: true,
});
}
export function withDeadline<T>(
operation: () => Promise<T>,
deadline: number,
@@ -297,12 +322,16 @@ export async function reservePort(): Promise<number> {
});
}
// Readiness is the debugging endpoint, never the spawned process: Chrome's
// Windows launcher exits as soon as the browser process takes over the profile
// (measured: exit at ~140 ms, endpoint ready at ~440 ms). The deadline bounds
// the wait, and the launcher's exit code is reported when it runs out.
export async function waitForVersion(
port: number,
child: ChildProcessWithoutNullStreams,
): Promise<{ Browser: string; webSocketDebuggerUrl: string }> {
const deadline = Date.now() + 20_000;
while (Date.now() < deadline && child.exitCode === null) {
while (Date.now() < deadline) {
try {
return await fetchJsonBefore<{ Browser: string; webSocketDebuggerUrl: string }>(
`http://127.0.0.1:${port}/json/version`,
@@ -311,7 +340,7 @@ export async function waitForVersion(
} catch {}
await delay(Math.min(100, Math.max(0, deadline - Date.now())));
}
throw new Error("Chrome startup timed out");
throw new Error(`Chrome startup timed out (exit ${child.exitCode ?? "running"})`);
}
export async function cleanupRun(
@@ -339,13 +368,16 @@ export async function cleanupRun(
const nativeExited = resources.native
? await stopProcessTree(resources.native)
: true;
const server = resources.server;
let serverClosed = true;
if (resources.server) {
if (server && "close" in server) {
try {
await withDeadline(() => resources.server!.close(), Date.now() + 5_000);
await withDeadline(() => server.close(), Date.now() + 5_000);
} catch {
serverClosed = false;
}
} else if (server) {
serverClosed = await stopProcessTree(server);
}
const portsClosed = await waitForPortsClosed(
[...new Set(resources.ports)],
+45 -37
View File
@@ -9,10 +9,15 @@ import {
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { CLIENT_PACKAGE_TARGETS } from "./client-package-targets.mjs";
import { CLIENT_PACKAGE_TARGETS, clientPackageTarget } from "./client-package-targets.mjs";
// Every Go command embeds the Vite output, so the build, vet and test steps all
// fail without it. The Hosted binary is cross-built for its deployment target.
const CLIENT_INDEX = join("internal", "server", "webassets", "dist", "index.html");
const SERVER_TARGET = clientPackageTarget("linux-amd64");
const root = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
const clientRoot = join(root, "native", "client");
const captureRoot = join(root, "native", "capture");
const mode = process.argv[2] ?? "--all";
if (!["--all", "--core", "--capture-only"].includes(mode)) {
@@ -36,54 +41,65 @@ function run(command, args, options = {}) {
return options.capture ? result.stdout.trim() : "";
}
function buildWebAssets() {
if (existsSync(join(root, CLIENT_INDEX))) return;
if (process.platform === "win32") {
run(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"]);
return;
}
run("npm", ["run", "build:client"]);
}
function checkCore() {
buildWebAssets();
const go = process.env.SCREENER_GO?.trim() || "go";
const goRoot = run(go, ["env", "GOROOT"], { cwd: clientRoot, capture: true });
const goRoot = run(go, ["env", "GOROOT"], { capture: true });
const gofmt = process.env.SCREENER_GOFMT?.trim() ||
join(goRoot, "bin", process.platform === "win32" ? "gofmt.exe" : "gofmt");
const unformatted = run(gofmt, ["-l", "."], { cwd: clientRoot, capture: true });
const unformatted = run(gofmt, ["-l", "."], { capture: true });
if (unformatted) {
throw new Error(`Go source is not formatted:\n${unformatted}`);
}
runClientTests(go);
run(go, ["vet", "./..."], { cwd: clientRoot });
run(go, ["vet", "./..."]);
const buildRoot = join(root, "build", "client-check");
mkdirSync(buildRoot, { recursive: true });
for (const target of CLIENT_PACKAGE_TARGETS) {
for (const command of ["screener-client", "screener-peer-gate"]) {
const outputName = target.goos === "windows"
? `${command}.exe`
: `${command}-${target.id}`;
run(go, ["build", "-trimpath", "-o", join(buildRoot, outputName), `./cmd/${command}`], {
cwd: clientRoot,
env: {
...process.env,
GOOS: target.goos,
GOARCH: target.goarch,
CGO_ENABLED: "0",
},
});
}
const builds = CLIENT_PACKAGE_TARGETS.flatMap((target) =>
["screener-client", "screener-peer-gate"].map((command) => ({ target, command })),
);
builds.push({ target: SERVER_TARGET, command: "screener-server" });
for (const { target, command } of builds) {
const outputName = target.goos === "windows"
? `${command}.exe`
: `${command}-${target.id}`;
run(go, ["build", "-trimpath", "-o", join(buildRoot, outputName), `./cmd/${command}`], {
env: {
...process.env,
GOOS: target.goos,
GOARCH: target.goarch,
CGO_ENABLED: "0",
},
});
}
}
function runClientTests(go) {
if (process.platform !== "win32") {
run(go, ["test", "./..."], { cwd: clientRoot });
run(go, ["test", "./..."]);
return;
}
// Windows associates its listen prompt with the test executable path. Keep
// the one UDP integration package at a stable path so repeated checks do not
// create a new firewall rule for every Go build directory.
const packages = run(go, ["list", "./..."], { cwd: clientRoot, capture: true })
const packages = run(go, ["list", "./..."], { capture: true })
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean);
const stableNetworkPackages = [
{
package: "github.com/TNTcraftHIM/Screener/native/client/internal/mediaedge",
package: "github.com/TNTcraftHIM/Screener/internal/client/mediaedge",
binary: "mediaedge.test.exe",
},
];
@@ -92,16 +108,14 @@ function runClientTests(go) {
);
const otherPackages = packages.filter((value) => !stablePackageNames.has(value));
if (otherPackages.length > 0) {
run(go, ["test", ...otherPackages], { cwd: clientRoot });
run(go, ["test", ...otherPackages]);
}
const stableRoot = join(root, "build", "client-check");
mkdirSync(stableRoot, { recursive: true });
for (const entry of stableNetworkPackages) {
const binary = join(stableRoot, entry.binary);
run(go, ["test", "-c", "-o", binary, entry.package], {
cwd: clientRoot,
});
run(binary, ["-test.v"], { cwd: clientRoot });
run(go, ["test", "-c", "-o", binary, entry.package]);
run(binary, ["-test.v"]);
}
}
@@ -138,23 +152,17 @@ function checkPlatformCapture() {
"-ExecutionPolicy",
"Bypass",
"-File",
join(clientRoot, "platform", "windows", "capture", "build.ps1"),
join(captureRoot, "windows", "build.ps1"),
"-OutputDirectory",
buildRoot,
"-Check",
]);
executable = join(buildRoot, "screener-client-capture.exe");
} else if (process.platform === "darwin") {
run("sh", [
join(clientRoot, "platform", "darwin", "capture", "build.sh"),
buildRoot,
]);
run("sh", [join(captureRoot, "darwin", "build.sh"), buildRoot]);
executable = join(buildRoot, "screener-client-capture");
} else {
run("sh", [
join(clientRoot, "platform", "linux", "capture", "build.sh"),
buildRoot,
]);
run("sh", [join(captureRoot, "linux", "build.sh"), buildRoot]);
executable = join(buildRoot, "screener-client-capture");
}
if (!existsSync(executable)) {
+1 -1
View File
@@ -19,7 +19,7 @@ if ($LASTEXITCODE -ne 0) { throw "Working-tree whitespace check failed." }
git diff --cached --check
if ($LASTEXITCODE -ne 0) { throw "Staged whitespace check failed." }
if ($env:SCREENER_BASE_SHA) {
if ($env:SCREENER_BASE_SHA -and $env:SCREENER_BASE_SHA -notmatch '^0{40}$') {
git diff --check "$env:SCREENER_BASE_SHA...HEAD"
if ($LASTEXITCODE -ne 0) { throw "Commit-range whitespace check failed." }
}
+1 -1
View File
@@ -15,7 +15,7 @@ done < scripts/required-project-paths.txt
git diff --check
git diff --cached --check
if [ -n "${SCREENER_BASE_SHA:-}" ]; then
if [ -n "${SCREENER_BASE_SHA:-}" ] && [ "$SCREENER_BASE_SHA" != "0000000000000000000000000000000000000000" ]; then
git diff --check "$SCREENER_BASE_SHA"...HEAD
fi
+70 -22
View File
@@ -1,16 +1,20 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { createScreenerServer, type ScreenerServer } from "../src/server/app";
import { createLocalServerConfig } from "../src/server/local-config";
import {
CdpConnection, cleanupRun, createPage, evaluate, reservePort,
CdpConnection, cleanupRun, createPage, evaluate, fetchJsonBefore, launchChrome, reservePort,
waitForSample, waitForVersion, type PageHandle,
} from "./browser-gate-harness";
import { decodeClientEndpoint } from "./client-gate-endpoint";
const ROOT = resolve(import.meta.dirname, "..");
const BUILD_ROOT = join(ROOT, "build/client-check");
// A documentation-only LAN address: it makes the room server publish a LAN
// invitation the gate rewrites to its loopback origins, exactly as the packaged
// Client does on a real network.
const LAN_ADDRESS = "192.0.2.1";
const initialAudio = process.env.SCREENER_CLIENT_GATE_AUDIO !== "false";
const probe = String.raw`(() => {
localStorage.setItem('screener:ui-lang', 'en');
@@ -91,6 +95,54 @@ const probe = String.raw`(() => {
};
})()`;
function build(command: string, args: string[]): void {
const result = spawnSync(command, args, { cwd: ROOT, encoding: "utf8", windowsHide: true });
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout).trim() || command + " failed");
}
}
// startRoomServer builds and starts the Screener server the Browser pages and
// both Clients share. SCREENER_ENV stays development because production
// requires an https public origin, a site password and STUN; the Web assets are
// embedded either way.
async function startRoomServer(
port: number,
workingDirectory: string,
): Promise<ChildProcessWithoutNullStreams> {
const go = process.env.SCREENER_GO?.trim() || "go";
await mkdir(BUILD_ROOT, { recursive: true });
// The server embeds the Vite output, so the Web build precedes the Go build.
build(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"]);
const binary = join(BUILD_ROOT, "screener-server.exe");
build(go, ["build", "-trimpath", "-o", binary, "./cmd/screener-server"]);
const origins = [`http://localhost:${port}`, `http://127.0.0.1:${port}`, `http://${LAN_ADDRESS}:${port}`];
const child = spawn(binary, [], {
// The gate profile has no .env, and the environment is explicit: the
// configuration this measurement runs against is these five values, never
// the repository's development file or the operator's shell.
cwd: workingDirectory, stdio: "pipe", windowsHide: true,
env: {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
SCREENER_ENV: "development",
PORT: String(port),
LISTEN_HOST: "127.0.0.1",
PUBLIC_BASE_URL: `http://${LAN_ADDRESS}:${port}`,
ALLOWED_ORIGINS: origins.join(","),
},
});
child.stdout.resume();
child.stderr.resume();
await waitForSample(
(deadline) => fetchJsonBefore<{ status: string }>(`http://127.0.0.1:${port}/healthz`, deadline),
(value) => value.status === "ok",
20000,
);
return child;
}
async function main(): Promise<void> {
if (process.env.SCREENER_CLIENT_BROWSER_FANOUT_GATE !== "true") throw new Error("Browser fanout gate was not enabled");
const chromePath = process.env.CHROME_PATH;
@@ -102,21 +154,17 @@ async function main(): Promise<void> {
let relayClient: ChildProcessWithoutNullStreams | null = null;
let chrome: ChildProcessWithoutNullStreams | null = null;
let cdp: CdpConnection | null = null;
let server: ScreenerServer | null = null;
let server: ChildProcessWithoutNullStreams | null = null;
let clientPort = 0;
let relayPort = 0;
const pages: PageHandle[] = [];
const checks: Record<string, unknown> = {};
let stage = "startup", error: string | null = null;
try {
server = await createScreenerServer({
config: createLocalServerConfig({ port: appPort, publicAddress: "192.0.2.1" }),
frontend: { mode: "static", directory: join(ROOT, "dist/client") },
});
await server.listen(appPort, "127.0.0.1");
client = spawn(process.env.SCREENER_CLIENT_EXE || join(ROOT, "build/client-check/screener-client.exe"), [
server = await startRoomServer(appPort, profile);
client = spawn(process.env.SCREENER_CLIENT_EXE || join(BUILD_ROOT, "screener-client.exe"), [
"--site", origin, "--config", join(profile, "client.json"),
"--capture-process", join(ROOT, "build/client-check/screener-client-capture.exe"),
"--capture-process", join(BUILD_ROOT, "screener-client-capture.exe"),
], { windowsHide: true, stdio: "pipe", env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" } });
client.stderr.resume();
let output = "";
@@ -125,12 +173,12 @@ async function main(): Promise<void> {
if (!clientPort && output.includes("\n")) clientPort = decodeClientEndpoint(output.split("\n")[0]!).port;
});
await waitForSample(async () => clientPort, (value) => value > 0, 15000);
chrome = spawn(chromePath, [
`--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`, "--headless=new",
chrome = launchChrome(chromePath, debugPort, profile, [
"--headless=new",
"--no-first-run", "--no-default-browser-check", "--no-proxy-server",
"--autoplay-policy=no-user-gesture-required", "--disable-background-timer-throttling",
"--disable-renderer-backgrounding", "--disable-backgrounding-occluded-windows", "about:blank",
], { windowsHide: true, stdio: "pipe" });
"--disable-renderer-backgrounding", "--disable-backgrounding-occluded-windows",
]);
chrome.stdout.resume(); chrome.stderr.resume();
const version = await waitForVersion(debugPort, chrome);
checks.browser = version.Browser;
@@ -161,9 +209,9 @@ async function main(): Promise<void> {
let invite = await evaluate<string>(cdp, host, "document.querySelector('.lr-invite-url').value", Date.now()+5000);
const url = new URL(invite); url.host = `localhost:${appPort}`; invite = url.toString();
const relayOrigin = `http://127.0.0.1:${appPort}`;
relayClient = spawn(process.env.SCREENER_CLIENT_EXE || join(ROOT,"build/client-check/screener-client.exe"), [
relayClient = spawn(process.env.SCREENER_CLIENT_EXE || join(BUILD_ROOT,"screener-client.exe"), [
"--site", relayOrigin, "--config", join(profile,"relay-client.json"),
"--capture-process", join(ROOT,"build/client-check/screener-client-capture.exe"),
"--capture-process", join(BUILD_ROOT,"screener-client-capture.exe"),
], { windowsHide:true, stdio:"pipe", env:{...process.env,SCREENER_CLIENT_GATE_NO_BROWSER:"true"} });
relayClient.stderr.resume(); let relayOutput="";
relayClient.stdout.on("data", (chunk:Buffer) => {
@@ -224,9 +272,9 @@ async function main(): Promise<void> {
checks.clientExitRecovery = true;
checks.finalHost = await read(host);
if ((checks.finalHost as any).errors.length > 0) throw new Error("Browser reported an unhandled media error");
await mkdir(join(ROOT,"build/client-check"), {recursive:true});
await mkdir(BUILD_ROOT, {recursive:true});
const screenshot = await cdp.call<{data:string}>("Page.captureScreenshot", {format:"png"}, host.sessionId, Date.now()+5000);
await writeFile(join(ROOT,"build/client-check/browser-fanout.png"), Buffer.from(screenshot.data,"base64"));
await writeFile(join(BUILD_ROOT,"browser-fanout.png"), Buffer.from(screenshot.data,"base64"));
await click(host, 'button[aria-label="Stop sharing"]');
stage = "done";
} catch (caught) {
@@ -237,8 +285,8 @@ async function main(): Promise<void> {
const relayCleanup = await cleanupRun({cdp:null,native:relayClient,chrome:null,server:null,profile:null,ports:relayPort?[relayPort]:[]});
const cleanup = await cleanupRun({cdp, native:client, chrome, server, profile, ports:[appPort,debugPort,...(clientPort?[clientPort]:[])]});
const result = {passed: error===null && Object.values(cleanup).every(Boolean) && relayCleanup.nativeExited && relayCleanup.portsClosed, stage, checks, error, cleanup};
await mkdir(join(ROOT,"build/client-check"),{recursive:true});
await writeFile(join(ROOT,"build/client-check/browser-fanout.json"), JSON.stringify(result,null,2));
await mkdir(BUILD_ROOT,{recursive:true});
await writeFile(join(BUILD_ROOT,"browser-fanout.json"), JSON.stringify(result,null,2));
process.stdout.write(JSON.stringify(result)+"\n");
if (!result.passed) process.exitCode=1;
}
+65 -4
View File
@@ -1,6 +1,12 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import {
NATIVE_CLIENT_PORT_END,
NATIVE_CLIENT_PORT_START,
} from "../src/client/native/wire";
import { withDeadline } from "./browser-gate-harness";
const INSTANCE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const CLIENT_PORT_START = 39_721;
const CLIENT_PORT_END = 39_730;
export interface ClientEndpoint {
url: string;
@@ -22,8 +28,8 @@ export function decodeClientEndpoint(line: string): ClientEndpoint {
const endpoint = value as Partial<ClientEndpoint>;
if (
!Number.isInteger(endpoint.port) ||
endpoint.port! < CLIENT_PORT_START ||
endpoint.port! > CLIENT_PORT_END ||
endpoint.port! < NATIVE_CLIENT_PORT_START ||
endpoint.port! > NATIVE_CLIENT_PORT_END ||
endpoint.host !== `127.0.0.1:${endpoint.port}` ||
endpoint.url !== `http://${endpoint.host}` ||
typeof endpoint.instanceToken !== "string" ||
@@ -34,3 +40,58 @@ export function decodeClientEndpoint(line: string): ClientEndpoint {
}
return endpoint as ClientEndpoint;
}
// readClientEndpoint waits for the first valid loopback endpoint from stdout.
export async function readClientEndpoint(
client: ChildProcessWithoutNullStreams,
options: { timeoutMs?: number; ignoreNonEndpointLines?: boolean } = {},
): Promise<ClientEndpoint> {
const deadline = Date.now() + (options.timeoutMs ?? 10_000);
let cleanup = () => undefined;
try {
return await withDeadline(
() => new Promise<ClientEndpoint>((resolveEndpoint, rejectEndpoint) => {
let buffered = "";
const finish = (callback: () => void) => {
cleanup();
callback();
};
const onData = (chunk: Buffer) => {
buffered += chunk.toString();
for (;;) {
const newline = buffered.indexOf("\n");
if (newline < 0) return;
const line = buffered.slice(0, newline).trim();
buffered = buffered.slice(newline + 1);
try {
const endpoint = decodeClientEndpoint(line);
finish(() => resolveEndpoint(endpoint));
return;
} catch (error) {
if (!options.ignoreNonEndpointLines) {
finish(() => rejectEndpoint(error));
return;
}
}
}
};
const onError = (error: Error) => finish(() => rejectEndpoint(error));
const onExit = (code: number | null, signal: NodeJS.Signals | null) =>
finish(() => rejectEndpoint(
new Error(`Client exited before endpoint (${code ?? signal ?? "signal"})`),
));
cleanup = () => {
client.stdout.off("data", onData);
client.off("error", onError);
client.off("exit", onExit);
};
client.stdout.on("data", onData);
client.once("error", onError);
client.once("exit", onExit);
}),
deadline,
);
} finally {
cleanup();
}
}
+8 -5
View File
@@ -204,8 +204,6 @@ async function main(): Promise<void> {
const destination = `${remote.user}@${remote.host}`;
const go = process.env.SCREENER_GO?.trim() || "go";
const configuredClient = process.env.SCREENER_CLIENT_EXE?.trim();
const node = process.env.SCREENER_CLIENT_NODE?.trim() || process.execPath;
const app = process.env.SCREENER_CLIENT_APP?.trim() || ROOT;
const tunnel = process.env.SCREENER_CLOUDFLARED?.trim() ||
join(BUILD_ROOT, process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
const profile = await mkdtemp(join(tmpdir(), "screener-client-link-"));
@@ -231,14 +229,19 @@ async function main(): Promise<void> {
try {
result.stage = "build";
if (!configuredClient) {
// The Client embeds the Vite output, and this gate asserts on the page it
// serves over the public link, so the Web build precedes the Go build.
if (process.platform === "win32") {
run(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"]);
} else {
run("npm", ["run", "build:client"]);
}
run(go, ["build", "-trimpath", "-o", clientBinary, "./cmd/screener-client"],
join(ROOT, "native", "client"));
ROOT);
}
result.stage = "client-start";
client = spawn(clientBinary, [
"--link",
"--node", node,
"--app", app,
"--config", join(profile, "client.json"),
"--port", String(port),
"--lan-address", localLANAddress(),
+9 -46
View File
@@ -1,8 +1,7 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import {
CdpConnection,
@@ -10,18 +9,15 @@ import {
createPage,
evaluate,
fetchJsonBefore,
launchChrome,
reservePort,
waitForSample,
waitForVersion,
withDeadline,
} from "./browser-gate-harness";
import {
decodeClientEndpoint,
type ClientEndpoint as Endpoint,
readClientEndpoint,
} from "./client-gate-endpoint";
const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
interface GateReport {
passed: boolean;
platform: NodeJS.Platform;
@@ -55,45 +51,16 @@ const LOCAL_PAGE_STATE = `fetch('/api/site-access')
hostReady: Boolean(document.querySelector('.lr-host-personal-controls')),
}))`;
async function readEndpoint(
client: ChildProcessWithoutNullStreams,
): Promise<Endpoint> {
let buffered = "";
return withDeadline(
() => new Promise<Endpoint>((resolveEndpoint, rejectEndpoint) => {
const onData = (chunk: Buffer) => {
buffered += chunk.toString();
const newline = buffered.indexOf("\n");
if (newline < 0) return;
client.stdout.off("data", onData);
try {
resolveEndpoint(decodeClientEndpoint(buffered.slice(0, newline)));
} catch (error) {
rejectEndpoint(error);
}
};
client.stdout.on("data", onData);
client.once("error", rejectEndpoint);
client.once("exit", (code) =>
rejectEndpoint(new Error(`Client exited before readiness (${code ?? "signal"})`)),
);
}),
Date.now() + 10_000,
);
}
async function main(): Promise<void> {
if (process.env.SCREENER_CLIENT_LOCAL_GATE !== "true") {
throw new Error("Local Client gate was not explicitly enabled");
}
const browserPath = process.env.CHROME_PATH?.trim();
const clientPath = process.env.SCREENER_CLIENT_EXE?.trim();
const nodePath = process.env.SCREENER_CLIENT_NODE?.trim();
const appDirectory = process.env.SCREENER_CLIENT_APP?.trim() || ROOT;
const lanAddress = process.env.SCREENER_CLIENT_GATE_LAN_ADDRESS?.trim();
if (!browserPath || !clientPath || !nodePath || !lanAddress) {
if (!browserPath || !clientPath || !lanAddress) {
throw new Error(
"CHROME_PATH, SCREENER_CLIENT_EXE, SCREENER_CLIENT_NODE, and SCREENER_CLIENT_GATE_LAN_ADDRESS are required",
"CHROME_PATH, SCREENER_CLIENT_EXE, and SCREENER_CLIENT_GATE_LAN_ADDRESS are required",
);
}
@@ -123,8 +90,7 @@ async function main(): Promise<void> {
let loopbackPort = 0;
try {
client = spawn(clientPath, [
"--node", nodePath,
"--app", appDirectory,
"--local",
"--config", configPath,
"--lan-address", lanAddress,
"--port", String(appPort),
@@ -134,7 +100,7 @@ async function main(): Promise<void> {
env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" },
});
client.stderr.resume();
const endpoint = await readEndpoint(client);
const endpoint = await readClientEndpoint(client);
loopbackPort = endpoint.port;
report.clientStarted = true;
await waitForSample(
@@ -158,9 +124,7 @@ async function main(): Promise<void> {
"screener-client": "1",
}).toString();
browser = spawn(browserPath, [
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${profile}`,
browser = launchChrome(browserPath, debugPort, profile, [
"--headless=new",
...(process.env.SCREENER_CLIENT_GATE_NO_SANDBOX === "true"
? ["--no-sandbox"]
@@ -168,8 +132,7 @@ async function main(): Promise<void> {
"--no-first-run",
"--no-proxy-server",
"--disable-logging",
"about:blank",
], { stdio: "pipe", windowsHide: true });
]);
browser.stdout.resume();
browser.stderr.resume();
const version = await waitForVersion(debugPort, browser);
+13 -38
View File
@@ -9,14 +9,19 @@ import {
cleanupRun,
createPage,
evaluate,
launchChrome,
waitForVersion,
withDeadline,
} from "./browser-gate-harness";
import {
decodeClientEndpoint,
readClientEndpoint,
type ClientEndpoint as Endpoint,
} from "./client-gate-endpoint";
import { NATIVE_CLIENT_PROTOCOL, NATIVE_CLIENT_SUBPROTOCOL } from "../src/client/native/wire";
import {
NATIVE_CLIENT_PORT_END,
NATIVE_CLIENT_PORT_START,
NATIVE_CLIENT_PROTOCOL,
NATIVE_CLIENT_SUBPROTOCOL,
} from "../src/client/native/wire";
const PAGE_URL = "https://share.bonfire.icu/";
const NATIVE_PROTOCOL = NATIVE_CLIENT_PROTOCOL;
@@ -40,33 +45,6 @@ interface GateReport {
profileRemoved: boolean;
}
async function readEndpoint(
client: ChildProcessWithoutNullStreams,
): Promise<Endpoint> {
let buffered = "";
return await withDeadline(
() => new Promise<Endpoint>((resolveEndpoint, rejectEndpoint) => {
const onData = (chunk: Buffer) => {
buffered += chunk.toString();
const newline = buffered.indexOf("\n");
if (newline < 0) return;
client.stdout.off("data", onData);
try {
resolveEndpoint(decodeClientEndpoint(buffered.slice(0, newline)));
} catch (error) {
rejectEndpoint(error instanceof Error ? error : new Error("Client endpoint is invalid"));
}
};
client.stdout.on("data", onData);
client.once("error", rejectEndpoint);
client.once("exit", (code) => {
rejectEndpoint(new Error(`Client exited before endpoint (${code ?? "signal"})`));
});
}),
Date.now() + 8_000,
);
}
async function browserHandshake(
cdp: CdpConnection,
page: { sessionId: string },
@@ -75,8 +53,8 @@ async function browserHandshake(
const endpointJSON = JSON.stringify({
host: "127.0.0.1",
expectedPort: endpoint.port,
portStart: 39721,
portEnd: 39730,
portStart: NATIVE_CLIENT_PORT_START,
portEnd: NATIVE_CLIENT_PORT_END,
});
return await evaluate(
cdp,
@@ -205,7 +183,7 @@ async function main(): Promise<void> {
env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" },
});
client.stderr.resume();
const endpoint = await readEndpoint(client);
const endpoint = await readClientEndpoint(client, { timeoutMs: 8_000 });
report.clientStarted = true;
const portServer = createServer();
@@ -220,16 +198,13 @@ async function main(): Promise<void> {
portServer.close((error) => error ? rejectPort(error) : resolvePort(address.port));
});
});
browser = spawn(browserPath, [
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${profile}`,
browser = launchChrome(browserPath, debugPort, profile, [
"--headless=new",
...(disposableNoSandbox ? ["--no-sandbox"] : []),
"--no-first-run",
"--no-proxy-server",
"--disable-logging",
"about:blank",
], { stdio: "pipe", windowsHide: true });
]);
browser.stdout.resume();
browser.stderr.resume();
const version = await waitForVersion(debugPort, browser);
+28 -42
View File
@@ -9,22 +9,25 @@ import {
cleanupRun,
createPage,
evaluate,
launchChrome,
reservePort,
waitForSample,
waitForVersion,
withDeadline,
} from "./browser-gate-harness";
import {
decodeClientEndpoint,
readClientEndpoint,
type ClientEndpoint as Endpoint,
} from "./client-gate-endpoint";
import {
NATIVE_CLIENT_PROTOCOL,
NATIVE_CLIENT_SUBPROTOCOL,
} from "../src/client/native/wire";
const ROOT = resolve(import.meta.dirname, "..");
const BUILD_ROOT = join(ROOT, "build", "client-check");
const SOURCE_TITLE = "Screener Native Gate Source";
const GATE_STUN_URL =
process.env.SCREENER_CLIENT_GATE_STUN_URL?.trim() ||
"stun:share.bonfire.icu:3478";
const GATE_STUN_URLS = process.env.SCREENER_CLIENT_GATE_STUN_URLS?.trim();
interface Probe {
protocol: number;
@@ -242,36 +245,13 @@ async function stopCapture(child: ChildProcessWithoutNullStreams): Promise<void>
if (code !== 0) throw new Error("Native capture did not stop cleanly");
}
async function readEndpoint(child: ChildProcessWithoutNullStreams): Promise<Endpoint> {
let buffered = "";
return withDeadline(
() => new Promise<Endpoint>((resolveEndpoint, rejectEndpoint) => {
const onData = (chunk: Buffer) => {
buffered += chunk.toString();
const newline = buffered.indexOf("\n");
if (newline < 0) return;
child.stdout.off("data", onData);
try {
resolveEndpoint(decodeClientEndpoint(buffered.slice(0, newline)));
} catch (error) {
rejectEndpoint(error);
}
};
child.stdout.on("data", onData);
child.once("error", rejectEndpoint);
child.once("exit", (code) => {
rejectEndpoint(new Error("Client exited before readiness (" + String(code) + ")"));
});
}),
Date.now() + 10_000,
);
}
async function browserMediaGate(input: {
endpoint: Endpoint;
protocol: number;
subprotocol: string;
sourceTitle: string;
sourceKind: "window" | "display";
stunUrl: string;
stunUrls: string;
}): Promise<MediaEvidence> {
let socket: WebSocket | null = null;
const peers: RTCPeerConnection[] = [];
@@ -306,7 +286,7 @@ async function browserMediaGate(input: {
} as RequestInit);
const health = await healthResponse.json();
if (
health.protocol !== 7 ||
health.protocol !== input.protocol ||
health.service !== "screener-client" ||
health.instanceToken !== input.endpoint.instanceToken ||
health.nativeMedia?.video !== true ||
@@ -320,7 +300,7 @@ async function browserMediaGate(input: {
socket = new WebSocket(
"ws://127.0.0.1:" + input.endpoint.port + "/control",
["screener-client-v8." + input.endpoint.instanceToken],
[input.subprotocol + "." + input.endpoint.instanceToken],
);
await new Promise<void>((resolveOpen, rejectOpen) => {
const timer = window.setTimeout(
@@ -493,7 +473,7 @@ async function browserMediaGate(input: {
const offer = await request("prepare-edge", {
shareId,
connectionId: nextConnectionId,
iceServers: [{ urls: [input.stunUrl] }],
iceServers: [{ urls: input.stunUrls.split(",") }],
});
await peer.setRemoteDescription({ type: "offer", sdp: offer.sdp });
edge.remoteDescriptionSet = true;
@@ -668,6 +648,9 @@ async function main(): Promise<void> {
}
const chromePath = process.env.CHROME_PATH?.trim();
if (!chromePath) throw new Error("CHROME_PATH is required");
if (!GATE_STUN_URLS) {
throw new Error("SCREENER_CLIENT_GATE_STUN_URLS is required");
}
const profile = await mkdtemp(join(tmpdir(), "screener-client-media-"));
// Keep network-capable binaries at a stable repository path. Windows
@@ -733,7 +716,7 @@ async function main(): Promise<void> {
"-ExecutionPolicy",
"Bypass",
"-File",
join(ROOT, "native", "client", "platform", "windows", "capture", "build.ps1"),
join(ROOT, "native", "capture", "windows", "build.ps1"),
"-OutputDirectory",
buildRoot,
]);
@@ -746,9 +729,7 @@ async function main(): Promise<void> {
}
server = await startPageServer(pagePort);
chrome = spawn(chromePath, [
"--remote-debugging-port=" + debugPort,
"--user-data-dir=" + profile,
chrome = launchChrome(chromePath, debugPort, profile, [
"--no-first-run",
"--no-default-browser-check",
"--disable-extensions",
@@ -757,8 +738,7 @@ async function main(): Promise<void> {
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--window-size=1280,900",
"about:blank",
], { stdio: "pipe", windowsHide: true });
]);
chrome.stdout.resume();
chrome.stderr.resume();
const version = await waitForVersion(debugPort, chrome);
@@ -868,10 +848,14 @@ async function main(): Promise<void> {
);
const clientExecutable = join(packageRoot, "screener-client.exe");
const go = process.env.SCREENER_GO?.trim() || "go";
// The Client embeds the Vite output, so the Web build precedes the Go build.
run(process.env.ComSpec || "cmd.exe", [
"/d", "/s", "/c", "npm run build:client",
], ROOT);
run(
go,
["build", "-trimpath", "-o", clientExecutable, "./cmd/screener-client"],
join(ROOT, "native", "client"),
ROOT,
);
client = spawn(clientExecutable, [
"--site",
@@ -884,7 +868,7 @@ async function main(): Promise<void> {
env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" },
});
client.stderr.resume();
const endpoint = await readEndpoint(client);
const endpoint = await readClientEndpoint(client);
clientPort = endpoint.port;
const controlPage = await createPage(
cdp,
@@ -897,9 +881,11 @@ async function main(): Promise<void> {
controlPage,
"((__name) => (" + browserMediaGate.toString() + ")(" + JSON.stringify({
endpoint,
protocol: NATIVE_CLIENT_PROTOCOL,
subprotocol: NATIVE_CLIENT_SUBPROTOCOL,
sourceTitle: SOURCE_TITLE,
sourceKind,
stunUrl: GATE_STUN_URL,
stunUrls: GATE_STUN_URLS,
}) + "))((target) => target)",
Date.now() + 70_000,
);
+18 -24
View File
@@ -16,6 +16,7 @@ import {
cleanupRun,
createPage,
evaluate,
launchChrome,
reservePort,
type PageHandle,
waitForVersion,
@@ -411,16 +412,14 @@ export async function startSourceBrowser(
child: ChildProcessWithoutNullStreams;
cdp: CdpConnection;
}> {
const child = spawn(chromePath, [
"--remote-debugging-port=" + debugPort,
"--user-data-dir=" + profile,
const child = launchChrome(chromePath, debugPort, profile, [
"--no-first-run", "--no-default-browser-check",
"--disable-extensions", "--disable-logging",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--app=http://127.0.0.1:" + sourcePort + "/",
], { stdio: "pipe", windowsHide: true });
]);
child.stdout.resume();
child.stderr.resume();
const version = await waitForVersion(debugPort, child);
@@ -547,6 +546,10 @@ async function main(): Promise<void> {
throw new Error("Cross-NAT and one-link gate modes are mutually exclusive");
}
const mode: GateMode = linkMedia ? "one-link" : crossNat ? "cross-nat" : "local";
const gateStunUrls = process.env.SCREENER_CLIENT_GATE_STUN_URLS?.trim();
if (mode === "cross-nat" && !gateStunUrls) {
throw new Error("SCREENER_CLIENT_GATE_STUN_URLS is required for the cross-NAT gate");
}
const crashGate =
process.env.SCREENER_CLIENT_NATIVE_HOST_CRASH_GATE === "true";
if (crashGate && mode !== "local") {
@@ -564,7 +567,6 @@ async function main(): Promise<void> {
const chromePath = process.env.CHROME_PATH?.trim();
if (!chromePath) throw new Error("CHROME_PATH is required");
const go = process.env.SCREENER_GO?.trim() || "go";
const node = process.env.SCREENER_NODE?.trim() || process.execPath;
const tunnel = process.env.SCREENER_CLOUDFLARED?.trim() || join(
BUILD_ROOT,
"cloudflared.exe",
@@ -634,26 +636,26 @@ async function main(): Promise<void> {
try {
stage = "application-build";
run(process.env.ComSpec || "cmd.exe", [
"/d", "/s", "/c", "npm run build",
"/d", "/s", "/c", "npm run build:client",
]);
stage = "source-server";
source = await sourceServer(sourcePort);
stage = "capture-build";
run(powershell(), [
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
join(ROOT, "native", "client", "platform", "windows", "capture", "build.ps1"),
join(ROOT, "native", "capture", "windows", "build.ps1"),
"-OutputDirectory", captureBuild,
]);
stage = "client-build";
run(go, [
"build", "-trimpath", "-o", clientBinary, "./cmd/screener-client",
], join(ROOT, "native", "client"));
], ROOT);
if (remote) {
stage = "remote-peer-build";
run(
go,
["build", "-trimpath", "-o", remoteBinary, "./cmd/screener-peer-gate"],
join(ROOT, "native", "client"),
ROOT,
{ ...process.env, GOOS: "linux", GOARCH: "amd64", CGO_ENABLED: "0" },
);
}
@@ -672,8 +674,6 @@ async function main(): Promise<void> {
? ["--tunnel-process", tunnel]
: []),
"--capture-process", captureBinary,
"--node", node,
"--app", ROOT,
"--config", clientConfig,
"--port", String(appPort),
], {
@@ -681,15 +681,12 @@ async function main(): Promise<void> {
windowsHide: true,
env: {
...process.env,
NODE_DEBUG: "screener-route",
PEER_ASSISTED_MEDIA: "true",
SCREENER_DEBUG: "route",
SCREENER_CLIENT_GATE_NO_BROWSER: "true",
...(mode === "cross-nat"
? {
STUN_URLS:
process.env.SCREENER_CLIENT_GATE_STUN_URLS?.trim() ||
"stun:share.bonfire.icu:3478",
}
// STUN_URLS reaches only the Client's own Pion edge: the in-process
// room server never reads it, so the cross-NAT arm stays isolated.
...(mode === "cross-nat" && gateStunUrls
? { STUN_URLS: gateStunUrls }
: {}),
},
});
@@ -729,17 +726,14 @@ async function main(): Promise<void> {
15_000,
);
stage = "host-browser";
chrome = spawn(chromePath, [
"--remote-debugging-port=" + debugPort,
"--user-data-dir=" + profile,
chrome = launchChrome(chromePath, debugPort, profile, [
"--no-first-run", "--no-default-browser-check",
"--disable-extensions", "--disable-logging",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--window-size=1280,900",
"about:blank",
], { stdio: "pipe", windowsHide: true });
]);
chrome.stdout.resume();
chrome.stderr.resume();
stage = "host-cdp";
+10 -44
View File
@@ -17,6 +17,7 @@ import {
cleanupRun,
createPage,
evaluate,
launchChrome,
reservePort,
type PageHandle,
waitForVersion,
@@ -30,8 +31,7 @@ import {
waitForCaptureWindow,
} from "./client-native-host-gate";
import {
decodeClientEndpoint,
type ClientEndpoint as Endpoint,
readClientEndpoint,
} from "./client-gate-endpoint";
const ROOT = resolve(import.meta.dirname, "..");
@@ -73,39 +73,6 @@ async function waitForHTTP(url: string, timeoutMs: number): Promise<void> {
throw new Error(`service did not become ready: ${url}`);
}
async function readEndpoint(
process: ChildProcessWithoutNullStreams,
): Promise<Endpoint> {
return await new Promise<Endpoint>((resolveEndpoint, rejectEndpoint) => {
const timer = setTimeout(
() => rejectEndpoint(new Error("native Client did not become ready")),
10_000,
);
let buffered = "";
const onData = (chunk: Buffer) => {
buffered += chunk.toString();
for (;;) {
const newline = buffered.indexOf("\n");
if (newline < 0) return;
const line = buffered.slice(0, newline).trim();
buffered = buffered.slice(newline + 1);
try {
const value = decodeClientEndpoint(line);
clearTimeout(timer);
process.stdout.off("data", onData);
resolveEndpoint(value);
return;
} catch {
// Ignore non-JSON informational output.
}
}
};
process.stdout.on("data", onData);
process.once("error", rejectEndpoint);
process.once("exit", () => rejectEndpoint(new Error("native Client exited")));
});
}
async function issueToken(
apiKey: string,
apiSecret: string,
@@ -190,14 +157,14 @@ async function main(): Promise<void> {
let clientPort = 0;
try {
result.stage = "build";
run(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build"]);
run(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"]);
run(powershell(), [
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
join(ROOT, "native", "client", "platform", "windows", "capture", "build.ps1"),
join(ROOT, "native", "capture", "windows", "build.ps1"),
"-OutputDirectory", BUILD_ROOT,
]);
run(go, ["build", "-trimpath", "-o", clientBinary, "./cmd/screener-client"],
join(ROOT, "native", "client"));
ROOT);
result.stage = "livekit";
livekit = spawn(livekitPath, [
@@ -260,12 +227,12 @@ async function main(): Promise<void> {
env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" },
});
client.stderr.resume();
clientPort = (await readEndpoint(client)).port;
clientPort = (await readClientEndpoint(client, {
ignoreNonEndpointLines: true,
})).port;
result.stage = "browser";
chrome = spawn(chromePath, [
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${profile}`,
chrome = launchChrome(chromePath, debugPort, profile, [
"--no-first-run",
"--no-default-browser-check",
"--disable-extensions",
@@ -274,8 +241,7 @@ async function main(): Promise<void> {
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--disable-features=WebRtcHideLocalIpsWithMdns",
"about:blank",
], { stdio: "pipe", windowsHide: true });
]);
chrome.stdout.resume();
chrome.stderr.resume();
const version = await waitForVersion(debugPort, chrome);
-3
View File
@@ -8,7 +8,6 @@ export const CLIENT_PACKAGE_TARGETS = [
goos: "windows",
goarch: "amd64",
clientName: "screener-client.exe",
nodeName: "node.exe",
tunnelName: "cloudflared.exe",
captureName: "screener-client-capture.exe",
tunnelAsset: "cloudflared-windows-amd64.exe",
@@ -22,7 +21,6 @@ export const CLIENT_PACKAGE_TARGETS = [
goos: "linux",
goarch: "amd64",
clientName: "screener-client",
nodeName: "node",
tunnelName: "cloudflared",
captureName: "screener-client-capture",
tunnelAsset: "cloudflared-linux-amd64",
@@ -36,7 +34,6 @@ export const CLIENT_PACKAGE_TARGETS = [
goos: "darwin",
goarch: "arm64",
clientName: "screener-client",
nodeName: "node",
tunnelName: "cloudflared",
captureName: "screener-client-capture",
tunnelAsset: "cloudflared-darwin-arm64.tgz",
+70 -62
View File
@@ -3,6 +3,7 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync,
copyFileSync,
existsSync,
lstatSync,
@@ -19,13 +20,31 @@ import { tmpdir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { writeServerLicenseNotices } from "./package-licenses.mjs";
import { tarExecutable } from "./archive-tool.mjs";
// The Hosted deployment target. deploy/release-app.sh runs the archived binary
// as the service user, so the release is always built for linux/amd64, and
// CGO_ENABLED=0 keeps it self-contained.
const SERVER_TARGET = { goos: "linux", goarch: "amd64" };
const SERVER_NAME = "screener-server";
// Everything the archive may contain. deploy/release-app.sh enforces the same
// four names in its entry allowlist and manifest pattern; both change together.
const RUNTIME_PATHS = ["REVISION", "LICENSE", "THIRD-PARTY-NOTICES.txt", SERVER_NAME];
// The Vite output the server embeds. Its index.html names the main asset the
// deployment postflight proves over HTTP.
const CLIENT_DIST = "internal/server/webassets/dist";
function fail(message) {
throw new Error(message);
}
function run(command, args, cwd) {
function run(command, args, cwd, environment = process.env) {
const result = spawnSync(command, args, {
cwd,
env: environment,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
@@ -36,16 +55,16 @@ function run(command, args, cwd) {
return result.stdout.trim();
}
function buildRuntime(cwd) {
function buildClient(cwd) {
if (process.platform === "win32") {
run(
process.env.ComSpec || "cmd.exe",
["/d", "/s", "/c", "npm run build"],
["/d", "/s", "/c", "npm run build:client"],
cwd,
);
return;
}
run("npm", ["run", "build"], cwd);
run("npm", ["run", "build:client"], cwd);
}
function assertCleanRevision(repositoryRoot, expectedRevision) {
@@ -77,17 +96,6 @@ function normalizedRelative(root, path) {
return value;
}
function runtimePathAllowed(path) {
return (
path === "REVISION" ||
path === "LICENSE" ||
path === "package.json" ||
path === "package-lock.json" ||
path.startsWith("dist/client/") ||
path.startsWith("dist/server/")
);
}
function regularFiles(root) {
const files = [];
const pending = [root];
@@ -111,18 +119,10 @@ function regularFiles(root) {
);
}
function copyRuntimeFile(sourceRoot, targetRoot, source) {
const path = normalizedRelative(sourceRoot, source);
if (!runtimePathAllowed(path)) fail(`Unexpected runtime file: ${path}`);
const target = join(targetRoot, ...path.split("/"));
mkdirSync(dirname(target), { recursive: true });
copyFileSync(source, target);
}
function recordsFor(root) {
return regularFiles(root).map((absolute) => {
const path = normalizedRelative(root, absolute);
if (!runtimePathAllowed(path)) fail(`Unexpected packaged file: ${path}`);
if (!RUNTIME_PATHS.includes(path)) fail(`Unexpected packaged file: ${path}`);
return { path, size: statSync(absolute).size, sha256: sha256(absolute) };
});
}
@@ -155,20 +155,23 @@ function validateArchiveEntries(entries) {
) {
fail(`Invalid archive entry: ${rawEntry}`);
}
if (
entry !== "REVISION" &&
entry !== "LICENSE" &&
entry !== "package.json" &&
entry !== "package-lock.json" &&
entry !== "dist" &&
!entry.startsWith("dist/client") &&
!entry.startsWith("dist/server")
) {
fail(`Unexpected archive entry: ${rawEntry}`);
}
if (!RUNTIME_PATHS.includes(entry)) fail(`Unexpected archive entry: ${rawEntry}`);
}
}
// mainAsset is the hashed entry script Vite named in the embedded index.html.
// The deployment postflight proves it over HTTP, so it is read from the same
// build the binary embeds rather than from the archive.
function mainAssetOf(distributionRoot) {
const html = readFileSync(join(distributionRoot, "index.html"), "utf8");
const match = html.match(/<script[^>]+src="\/(assets\/index-[A-Za-z0-9_-]+\.js)"/);
if (!match) fail("Built client HTML has no unique main asset");
if (!existsSync(join(distributionRoot, ...match[1].split("/")))) {
fail("Built client main asset is missing");
}
return match[1];
}
if (process.argv.length !== 3) {
fail("Usage: node scripts/package-app-release.mjs <new-output-directory>");
}
@@ -176,21 +179,17 @@ if (process.argv.length !== 3) {
const repositoryRoot = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
const outputRoot = resolve(process.cwd(), process.argv[2]);
const relativeOutput = relative(repositoryRoot, outputRoot);
if (relativeOutput === "" || (!relativeOutput.startsWith("..") && !isAbsolute(relativeOutput))) {
if (relativeOutput === "" || (relativeOutput.split(/[\\/]/)[0] !== ".." && !isAbsolute(relativeOutput))) {
fail("Output directory must be outside the repository");
}
if (existsSync(outputRoot)) fail("Output directory must not already exist");
const revision = assertCleanRevision(repositoryRoot);
for (const required of ["LICENSE", "package.json", "package-lock.json"]) {
if (!existsSync(join(repositoryRoot, required))) fail(`Missing release input: ${required}`);
}
buildRuntime(repositoryRoot);
if (!existsSync(join(repositoryRoot, "LICENSE"))) fail("Missing release input: LICENSE");
buildClient(repositoryRoot);
assertCleanRevision(repositoryRoot, revision);
for (const required of ["dist/client", "dist/server"]) {
if (!existsSync(join(repositoryRoot, required))) fail(`Missing built runtime path: ${required}`);
}
const mainAsset = mainAssetOf(join(repositoryRoot, CLIENT_DIST));
const releaseId = revision.slice(0, 7);
const temporaryRoot = mkdtempSync(join(tmpdir(), `screener-app-${releaseId}-`));
@@ -199,14 +198,30 @@ const verifyRoot = join(temporaryRoot, "verify");
try {
mkdirSync(runtimeRoot, { recursive: true });
for (const source of [
join(repositoryRoot, "LICENSE"),
join(repositoryRoot, "package.json"),
join(repositoryRoot, "package-lock.json"),
...regularFiles(join(repositoryRoot, "dist")),
]) {
copyRuntimeFile(repositoryRoot, runtimeRoot, source);
}
copyFileSync(join(repositoryRoot, "LICENSE"), join(runtimeRoot, "LICENSE"));
const goCommand = process.env.SCREENER_GO?.trim() || "go";
const serverPath = join(runtimeRoot, SERVER_NAME);
run(goCommand, [
"build",
"-trimpath",
"-ldflags",
`-s -w -X main.BuildRevision=${revision}`,
"-o",
serverPath,
"./cmd/screener-server",
], repositoryRoot, {
...process.env,
GOOS: SERVER_TARGET.goos,
GOARCH: SERVER_TARGET.goarch,
CGO_ENABLED: "0",
});
chmodSync(serverPath, 0o755);
writeServerLicenseNotices(
repositoryRoot,
join(runtimeRoot, "THIRD-PARTY-NOTICES.txt"),
goCommand,
SERVER_TARGET,
);
assertCleanRevision(repositoryRoot, revision);
writeFileSync(join(runtimeRoot, "REVISION"), `${revision}\n`, "ascii");
@@ -224,20 +239,13 @@ try {
"ascii",
);
run("tar", ["-czf", artifactPath, "-C", runtimeRoot, "."], repositoryRoot);
validateArchiveEntries(run("tar", ["-tzf", artifactPath], repositoryRoot));
const tar = tarExecutable();
run(tar, ["-czf", artifactPath, "-C", runtimeRoot, "."], repositoryRoot);
validateArchiveEntries(run(tar, ["-tzf", artifactPath], repositoryRoot));
mkdirSync(verifyRoot);
run("tar", ["-xzf", artifactPath, "-C", verifyRoot], repositoryRoot);
run(tar, ["-xzf", artifactPath, "-C", verifyRoot], repositoryRoot);
compareRecords(records, recordsFor(verifyRoot));
const html = readFileSync(join(runtimeRoot, "dist/client/index.html"), "utf8");
const mainAssetMatch = html.match(/<script[^>]+src="\/(assets\/index-[A-Za-z0-9_-]+\.js)"/);
if (!mainAssetMatch) fail("Built client HTML has no unique main asset");
const mainAsset = mainAssetMatch[1];
if (!existsSync(join(runtimeRoot, "dist/client", ...mainAsset.split("/")))) {
fail("Built client main asset is missing");
}
const descriptor = {
schema: 1,
revision,
+16 -28
View File
@@ -21,6 +21,7 @@ import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
import { clientPackageTarget, CLOUDFLARED_VERSION } from "./client-package-targets.mjs";
import { tarExecutable } from "./archive-tool.mjs";
function fail(message) {
throw new Error(message);
@@ -45,7 +46,7 @@ function sha256(path) {
function assertOutsideRepository(repositoryRoot, outputRoot) {
const path = relative(repositoryRoot, outputRoot);
if (path === "" || (!path.startsWith("..") && !isAbsolute(path))) {
if (path === "" || (path.split(/[\\/]/)[0] !== ".." && !isAbsolute(path))) {
fail("Client candidate output must be outside the repository");
}
if (existsSync(outputRoot)) {
@@ -145,6 +146,7 @@ async function verifyLocalPackage(root, target, temporaryRoot) {
const client = join(root, target.clientName);
const port = await reservePort();
const healthURL = `http://127.0.0.1:${port}/healthz`;
const noticeURL = `http://127.0.0.1:${port}/third-party-licenses.txt`;
const child = spawn(client, [
"--local",
"--config", join(temporaryRoot, "smoke-client.json"),
@@ -195,6 +197,14 @@ async function verifyLocalPackage(root, target, temporaryRoot) {
if (!healthReady || !clientReady) {
fail("Packaged Client Local health did not become ready");
}
// The Web notices ship inside the binary; the embedded assets serve them.
const notices = await fetch(noticeURL, {
headers: { Connection: "close" },
signal: AbortSignal.timeout(2_000),
});
if (!notices.ok || (await notices.text()).length === 0) {
fail("Packaged Client did not serve its Web third-party notices");
}
child.stdin.write("\n");
const exitCode = await waitForExit(child, 10_000);
if (exitCode !== 0) {
@@ -225,29 +235,18 @@ async function verifyLocalPackage(root, target, temporaryRoot) {
}
}
async function verifyPackage(
root,
target,
revision,
expectedNodeVersion,
temporaryRoot,
) {
async function verifyPackage(root, target, revision, temporaryRoot) {
const packagedRevision = readFileSync(join(root, "REVISION"), "ascii").trim();
if (packagedRevision !== revision) fail("Client package revision mismatch");
for (const file of ["LICENSE", "THIRD-PARTY-NOTICES.txt", "app/LICENSE",
"app/dist/client/third-party-licenses.txt", "runtime/node/LICENSE",
for (const file of ["LICENSE", "THIRD-PARTY-NOTICES.txt",
"runtime/tunnel/THIRD-PARTY-NOTICES.txt"]) {
if (!existsSync(join(root, file)) || readFileSync(join(root, file)).length === 0) {
fail(`Client package license text is missing: ${file}`);
}
}
const node = join(root, "runtime", "node", target.nodeName);
const client = join(root, target.clientName);
const tunnel = join(root, "runtime", "tunnel", target.tunnelName);
if (run(node, ["--version"], root) !== expectedNodeVersion) {
fail(`Packaged Node runtime must be ${expectedNodeVersion}`);
}
run(client, ["--help"], root);
run(tunnel, ["--version"], root);
verifyPlatformAssets(root, target);
@@ -323,10 +322,6 @@ if (process.platform !== target.nodePlatform || process.arch !== target.nodeArch
assertOutsideRepository(repositoryRoot, outputRoot);
const revision = run("git", ["rev-parse", "HEAD"], repositoryRoot).toLowerCase();
const expectedNodeVersion = `v${readFileSync(
join(repositoryRoot, ".node-version"),
"ascii",
).trim()}`;
const temporaryRoot = join(tmpdir(), "screener-client-candidate", target.id);
rmSync(temporaryRoot, { recursive: true, force: true });
mkdirSync(temporaryRoot, { recursive: true, mode: 0o700 });
@@ -342,7 +337,7 @@ try {
let tunnel = tunnelDownload;
if (target.tunnelArchive) {
run("tar", ["-xzf", tunnelDownload, "-C", temporaryRoot], repositoryRoot);
run(tarExecutable(), ["-xzf", tunnelDownload, "-C", temporaryRoot], repositoryRoot);
tunnel = join(temporaryRoot, target.tunnelName);
}
if (!existsSync(tunnel)) fail("Public-link runtime is missing");
@@ -362,7 +357,6 @@ try {
const assembleArguments = [
join(repositoryRoot, "scripts", "assemble-client.mjs"),
applicationDescriptor(applicationRoot),
process.execPath,
packageRoot,
"--target",
target.id,
@@ -371,19 +365,13 @@ try {
];
if (capture) assembleArguments.push("--capture", capture);
run(process.execPath, assembleArguments, repositoryRoot);
await verifyPackage(
packageRoot,
target,
revision,
expectedNodeVersion,
temporaryRoot,
);
await verifyPackage(packageRoot, target, revision, temporaryRoot);
mkdirSync(outputRoot, { recursive: false, mode: 0o700 });
const shortRevision = revision.slice(0, 7);
const archiveName = `Screener-Client-${target.id}-${shortRevision}.tar.gz`;
const archive = join(outputRoot, archiveName);
run("tar", ["-czf", archive, "-C", packageRoot, "."], repositoryRoot);
run(tarExecutable(), ["-czf", archive, "-C", packageRoot, "."], repositoryRoot);
const digest = sha256(archive);
writeFileSync(join(outputRoot, `${archiveName}.sha256`), `${digest} ${archiveName}\n`, "ascii");
process.stdout.write(`${JSON.stringify({
+62 -28
View File
@@ -10,6 +10,14 @@ const pinned = JSON.parse(readFileSync(join(root, "licenses", "upstream.json"),
const licenseName = /^(licen[cs]e|copying|notice|copyright|authors)([._-]|$)/i;
const section = (label, body) => `\n===== ${label} =====\n\n${body.trim()}\n`;
// The packages the Vite bundle ships to the Browser. Nothing installs
// node_modules at runtime any more, so this list is the notice contract rather
// than package.json's dependencies field.
const WEB_BUNDLE_PACKAGES = ["react", "react-dom", "livekit-client", "sdp-transform", "zod"];
// Both binaries embed the Web bundle, which serves its own notice file.
const WEB_NOTICE_LINE = "Web dependencies: served at /third-party-licenses.txt\n";
function pinnedNotice(key) {
const entry = pinned[key];
if (!entry) throw new Error(`Missing pinned license text for ${key}`);
@@ -28,10 +36,8 @@ function noticesIn(directory) {
export function writeWebLicenseNotices(repositoryRoot, outputFile) {
const packagePath = join(repositoryRoot, "package.json");
const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
const lock = JSON.parse(readFileSync(join(repositoryRoot, "package-lock.json"), "utf8"));
const queue = [...Object.keys(pkg.dependencies), "react", "react-dom", "livekit-client", "sdp-transform"]
.map((name) => [name, packagePath]);
const queue = WEB_BUNDLE_PACKAGES.map((name) => [name, packagePath]);
const visited = new Set();
const entries = new Map();
for (const [name, parent] of queue) {
@@ -61,21 +67,16 @@ export function writeWebLicenseNotices(repositoryRoot, outputFile) {
[...entries].sort(([a], [b]) => a.localeCompare(b)).map(([, body]) => body).join(""));
}
export function writeClientLicenseNotices(repositoryRoot, packageRoot, goCommand, target, tunnelVersion) {
copyFileSync(join(repositoryRoot, "LICENSE"), join(packageRoot, "LICENSE"));
const nodeVersion = readFileSync(join(repositoryRoot, ".node-version"), "ascii").trim();
writeFileSync(join(packageRoot, "runtime", "node", "LICENSE"), pinnedNotice(`node@${nodeVersion}`));
if (tunnelVersion) {
writeFileSync(join(packageRoot, "runtime", "tunnel", "THIRD-PARTY-NOTICES.txt"),
pinnedNotice(`cloudflared@${tunnelVersion}`));
}
const cwd = join(repositoryRoot, "native", "client");
const options = { cwd, encoding: "utf8", windowsHide: true,
// goNotices enumerates the Go toolchain notice and every license file that
// belongs to a module actually compiled into `command` for `target`. A module
// without a recognised license filename fails closed.
function goNotices(repositoryRoot, goCommand, target, command) {
const options = { cwd: repositoryRoot, encoding: "utf8", windowsHide: true,
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: "0" } };
const runGo = (args) => execFileSync(goCommand, args, options).trim();
const goroot = runGo(["env", "GOROOT"]);
const template = '{{if .Module}}{{if not .Module.Main}}[{{printf "%q" .Module.Path}},{{printf "%q" .Module.Version}},{{printf "%q" .Module.Dir}},{{printf "%q" .Dir}}]{{end}}{{end}}';
const packages = runGo(["list", "-deps", "-f", template, "./cmd/screener-client"])
const packages = runGo(["list", "-deps", "-f", template, command])
.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
const entries = new Map();
for (const [name, version, directory, packageDirectory] of packages) {
@@ -92,24 +93,57 @@ export function writeClientLicenseNotices(repositoryRoot, packageRoot, goCommand
}
entries.set(key, files);
}
let text = "Native Client third-party software notices\n" +
"Web and Server dependencies: app/dist/client/third-party-licenses.txt\n" +
"Node runtime: runtime/node/LICENSE\n" +
(tunnelVersion ? "Cloudflared: runtime/tunnel/THIRD-PARTY-NOTICES.txt\n" : "") +
section(runGo(["version"]), readFileSync(join(goroot, "LICENSE"), "utf8"));
if (target.goos === "windows" &&
existsSync(join(packageRoot, "runtime", "native", target.captureName))) {
const dependencies = JSON.parse(readFileSync(join(repositoryRoot, "native", "client",
"platform", "windows", "capture", "libvpx-dependencies.json"), "utf8"));
const name = `libvpx@${dependencies.libvpx.version}`;
text += section(name, pinnedNotice(name));
}
for (const [name, files] of [...entries].sort(([a], [b]) => a.localeCompare(b))) {
const toolchain = section(runGo(["version"]), readFileSync(join(goroot, "LICENSE"), "utf8"));
return { toolchain, modules: entries };
}
function moduleSections(modules) {
let text = "";
for (const [name, files] of [...modules].sort(([a], [b]) => a.localeCompare(b))) {
for (const [file, body] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
text += section(`${name}/${file}`, body);
}
}
writeFileSync(join(packageRoot, "THIRD-PARTY-NOTICES.txt"), text);
return text;
}
// writeServerLicenseNotices produces the Hosted application release notice file,
// which ships beside the single screener-server binary.
export function writeServerLicenseNotices(repositoryRoot, outputFile, goCommand, target) {
const notices = goNotices(repositoryRoot, goCommand, target, "./cmd/screener-server");
writeFileSync(outputFile, "Screener server third-party software notices\n" +
WEB_NOTICE_LINE + notices.toolchain + moduleSections(notices.modules));
}
export function writeClientLicenseNotices(repositoryRoot, packageRoot, goCommand, target, tunnelVersion) {
copyFileSync(join(repositoryRoot, "LICENSE"), join(packageRoot, "LICENSE"));
if (tunnelVersion) {
writeFileSync(join(packageRoot, "runtime", "tunnel", "THIRD-PARTY-NOTICES.txt"),
pinnedNotice(`cloudflared@${tunnelVersion}`));
}
const notices = goNotices(repositoryRoot, goCommand, target, "./cmd/screener-client");
let text = "Native Client third-party software notices\n" +
WEB_NOTICE_LINE +
(tunnelVersion ? "Cloudflared: runtime/tunnel/THIRD-PARTY-NOTICES.txt\n" : "") +
notices.toolchain;
const linuxCapture = target.goos === "linux" && target.captureName &&
existsSync(join(packageRoot, "runtime", "native", target.captureName));
if (linuxCapture) {
text += section("Linux system dependencies (not bundled)",
readFileSync(join(repositoryRoot, "licenses", "linux-system-dependencies.txt"), "utf8"));
text += section("LGPL-3.0-only (libportal)",
readFileSync(join(repositoryRoot, "licenses", "LGPL-3.0.txt"), "utf8"));
text += section("GPL-3.0 (libportal terms)",
readFileSync(join(repositoryRoot, "licenses", "GPL-3.0.txt"), "utf8"));
}
if (target.goos === "windows" &&
existsSync(join(packageRoot, "runtime", "native", target.captureName))) {
const dependencies = JSON.parse(readFileSync(join(repositoryRoot, "native", "capture",
"windows", "libvpx-dependencies.json"), "utf8"));
const name = `libvpx@${dependencies.libvpx.version}`;
text += section(name, pinnedNotice(name));
}
writeFileSync(join(packageRoot, "THIRD-PARTY-NOTICES.txt"), text + moduleSections(notices.modules));
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+100 -67
View File
@@ -1,4 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { mkdirSync } from "node:fs";
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
@@ -15,15 +16,6 @@ import {
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
MAX_ENDPOINT_MEDIA_COPY_CAPACITY,
} from "../src/shared/media-copy-accounting";
import {
createScreenerServer,
type ScreenerServer,
} from "../src/server/app";
import { loadConfig } from "../src/server/config";
import {
ROOM_CAPACITY,
RoomStore,
} from "../src/server/room-store";
import {
QUALITY_PROFILES,
QUALITY_RESOLUTIONS,
@@ -34,8 +26,8 @@ import {
const PROFILE_SETTINGS = QUALITY_PROFILES;
type ProfileId = QualityProfileId;
type PageRole = "host" | "viewer";
export type BenchmarkCodecMode = "vp8" | "auto" | "h264";
export type BenchmarkCanaryMode = "none" | "viewer-mbb";
type BenchmarkCodecMode = "vp8" | "auto" | "h264";
type BenchmarkCanaryMode = "none" | "viewer-mbb";
const ROUTE_TIMING_KEYS = [
"queueWaitMs",
@@ -52,12 +44,12 @@ interface RouteTimingDistribution {
p95Ms: number | null;
maxMs: number | null;
}
export type BenchmarkRouteTimingSamples = {
type BenchmarkRouteTimingSamples = {
[Key in RouteTimingKey]: Array<
RouteDiagnosticSnapshot["children"][number][Key]
>;
};
export type BenchmarkRouteTimingSummary = Record<
type BenchmarkRouteTimingSummary = Record<
RouteTimingKey,
RouteTimingDistribution
>;
@@ -70,7 +62,7 @@ interface ViewerMbbCanaryResult {
failureCode?: "topology-unavailable" | "probe-unavailable";
}
export interface BenchmarkConfig {
interface BenchmarkConfig {
chromePath: string;
viewerCounts: number[];
expectedEndpointCap: number;
@@ -116,7 +108,7 @@ interface ConnectionObservation {
error?: string;
}
export interface PagePerformanceSample {
interface PagePerformanceSample {
timestampSeconds: number;
taskDurationSeconds: number;
scriptDurationSeconds: number;
@@ -190,7 +182,7 @@ interface RunCheck {
expected: string;
}
export interface RecoveryResult {
interface RecoveryResult {
triggered: boolean;
relayPeerId?: string;
affectedPeerIds?: string[];
@@ -203,12 +195,12 @@ export interface RecoveryResult {
error?: string;
}
export interface RecoveryHostPeaks {
interface RecoveryHostPeaks {
maxHostActiveMediaEdges?: number;
maxHostAssignedChildren?: number;
}
export interface RecoveryViewerBaseline {
interface RecoveryViewerBaseline {
label: string;
routeRevision: number;
upstreamKind: "peer" | "sfu";
@@ -305,7 +297,7 @@ class BoundedLog {
}
}
export class CdpConnection {
class CdpConnection {
private nextId = 1;
private readonly pending = new Map<
number,
@@ -432,7 +424,7 @@ export class CdpConnection {
}
}
export function summarizeBenchmarkRouteTiming(
function summarizeBenchmarkRouteTiming(
samples: BenchmarkRouteTimingSamples,
): BenchmarkRouteTimingSummary {
return Object.fromEntries(
@@ -467,7 +459,7 @@ function nearestRank(
return sortedValues[Math.ceil(percentile * sortedValues.length) - 1] ?? null;
}
export function buildRouteTimingCheck(
function buildRouteTimingCheck(
status: BenchmarkRun["routeTimingStatus"],
summary: BenchmarkRouteTimingSummary | null,
expectedChildCount = MAX_VIEWERS,
@@ -492,7 +484,7 @@ export function buildRouteTimingCheck(
};
}
export async function joinViewerBurst<T>(
async function joinViewerBurst<T>(
viewerCount: number,
createViewer: (viewerIndex: number) => Promise<T>,
waitForMedia: (viewer: T, viewerIndex: number) => Promise<unknown>,
@@ -523,7 +515,7 @@ export async function joinViewerBurst<T>(
return viewers;
}
export function parseViewerCounts(value: string | undefined): number[] {
function parseViewerCounts(value: string | undefined): number[] {
if (!value?.trim()) {
return [...DEFAULT_VIEWER_COUNTS];
}
@@ -575,13 +567,13 @@ function parseBoolean(value: string | undefined, fallback: boolean, name: string
throw new Error(`${name} must be true, false, 1, or 0`);
}
export function parseBenchmarkCanaryMode(value: string | undefined): BenchmarkCanaryMode {
function parseBenchmarkCanaryMode(value: string | undefined): BenchmarkCanaryMode {
const mode = value?.trim() || "none";
if (mode === "none" || mode === "viewer-mbb") return mode;
throw new Error("BENCHMARK_CANARY must be none or viewer-mbb");
}
export function parseBenchmarkCodecMode(
function parseBenchmarkCodecMode(
value: string | undefined,
): BenchmarkCodecMode {
const mode = value?.trim().toLowerCase() || "auto";
@@ -589,7 +581,7 @@ export function parseBenchmarkCodecMode(
throw new Error("BENCHMARK_CODEC_MODE must be vp8, auto, or h264");
}
export function parseExpectedEndpointCap(value: string | undefined): number {
function parseExpectedEndpointCap(value: string | undefined): number {
const parsed = value?.trim()
? Number(value)
: DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY;
@@ -605,7 +597,7 @@ export function parseExpectedEndpointCap(value: string | undefined): number {
return parsed;
}
export function parseBenchmarkConfig(
function parseBenchmarkConfig(
environment: NodeJS.ProcessEnv = process.env,
): BenchmarkConfig {
const chromePath = environment.CHROME_PATH?.trim();
@@ -709,7 +701,7 @@ export function parseBenchmarkConfig(
};
}
export function parseExternalServerTarget(
function parseExternalServerTarget(
environment: NodeJS.ProcessEnv = process.env,
): ExternalServerTarget | null {
const value = environment.BENCHMARK_SERVER_URL?.trim();
@@ -736,7 +728,7 @@ export function parseExternalServerTarget(
};
}
export function activeVideoEdgeCount(
function activeVideoEdgeCount(
page: PageObservation,
direction: "send" | "receive",
): number {
@@ -753,7 +745,7 @@ export function activeVideoEdgeCount(
}).length;
}
export function sanitizeFailurePageEvidence(
function sanitizeFailurePageEvidence(
page: PageObservation,
): FailurePageEvidence {
const connectionStates = new Set([
@@ -891,7 +883,7 @@ const PAGE_PERFORMANCE_METRICS = {
JSHeapUsedSize: "jsHeapUsedBytes",
} as const satisfies Record<string, keyof PagePerformanceSample>;
export function parsePagePerformanceMetrics(
function parsePagePerformanceMetrics(
rawMetrics: unknown,
): PagePerformanceSample | null {
if (!Array.isArray(rawMetrics)) return null;
@@ -927,7 +919,7 @@ export function parsePagePerformanceMetrics(
};
}
export function summarizeSamples(samples: TimedSample[], viewerCount: number) {
function summarizeSamples(samples: TimedSample[], viewerCount: number) {
let maxHostActiveMediaEdges = 0;
let maxHostAssignedChildren = 0;
let maxRelayActiveMediaEdges = 0;
@@ -1214,7 +1206,7 @@ function finiteMetric(metrics: Record<string, unknown>, key: string): number | n
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export function summarizeViewerExperienceByDepth(samples: TimedSample[]) {
function summarizeViewerExperienceByDepth(samples: TimedSample[]) {
const cohorts = new Map<string, ViewerExperienceAccumulator>();
let unresolvedRouteSamples = 0;
const cohort = (route: ViewerExperienceRoute, depth: number | null) => {
@@ -1516,7 +1508,7 @@ function finishPageRuntime(accumulator: PageRuntimeAccumulator) {
};
}
export function summarizePageRuntimeResources(samples: TimedSample[]) {
function summarizePageRuntimeResources(samples: TimedSample[]) {
const accumulators: Record<PageRuntimeRole, PageRuntimeAccumulator> = {
host: emptyPageRuntimeAccumulator(),
viewer: emptyPageRuntimeAccumulator(),
@@ -1593,7 +1585,7 @@ export function summarizePageRuntimeResources(samples: TimedSample[]) {
};
}
export function buildRunChecks(
function buildRunChecks(
summary: NonNullable<BenchmarkRun["summary"]>,
viewerCount: number,
profileId: ProfileId,
@@ -1656,7 +1648,7 @@ export function buildRunChecks(
];
}
export function buildRecoveryCheck(
function buildRecoveryCheck(
recovery: RecoveryResult,
expectedEndpointCap = DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
): RunCheck {
@@ -1676,7 +1668,7 @@ export function buildRecoveryCheck(
};
}
export function buildBenchmarkInitScript(options: {
function buildBenchmarkInitScript(options: {
label: string;
role: PageRole;
viewerIndex: number | null;
@@ -2258,10 +2250,47 @@ async function reservePort(): Promise<number> {
return address.port;
}
function startProcess(command: string, args: string[], environment?: NodeJS.ProcessEnv): ManagedProcess {
function buildStep(command: string, args: string[], failure: string): void {
const result = spawnSync(command, args, {
cwd: REPO_ROOT,
encoding: "utf8",
windowsHide: true,
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout).trim() || failure);
}
}
// buildServer compiles the Screener server this run starts. The Web assets are
// embedded, so the Vite build precedes the Go build and the Browser reaches the
// same bundle the binary serves.
function buildServer(): string {
const go = process.env.SCREENER_GO?.trim() || "go";
const buildRoot = join(REPO_ROOT, "build", "peer-benchmark");
mkdirSync(buildRoot, { recursive: true });
const output = join(buildRoot,
process.platform === "win32" ? "screener-server.exe" : "screener-server");
if (process.platform === "win32") {
buildStep(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"],
"Browser client build failed");
} else {
buildStep("npm", ["run", "build:client"], "Browser client build failed");
}
buildStep(go, ["build", "-trimpath", "-o", output, "./cmd/screener-server"],
"Screener server build failed");
return output;
}
function startProcess(
command: string,
args: string[],
environment?: NodeJS.ProcessEnv,
cwd: string = REPO_ROOT,
): ManagedProcess {
const log = new BoundedLog();
const child = spawn(command, args, {
cwd: REPO_ROOT,
cwd,
env: environment,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
@@ -2271,6 +2300,9 @@ function startProcess(command: string, args: string[], environment?: NodeJS.Proc
return { child, log };
}
// Readiness is the endpoint, never the spawned process: Chrome's Windows
// launcher exits as soon as the browser process takes over the profile. The
// deadline bounds the wait; the exit code and captured log explain a failure.
async function waitForHttp(
url: string,
process: ManagedProcess,
@@ -2279,9 +2311,6 @@ async function waitForHttp(
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (process.child.exitCode !== null) {
throw new Error(`Process exited before ${url} was ready\n${process.log.tail()}`);
}
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
if (response.ok) {
@@ -2292,7 +2321,9 @@ async function waitForHttp(
}
await delay(100, signal);
}
throw new Error(`Timed out waiting for ${url}\n${process.log.tail()}`);
throw new Error(
`Timed out waiting for ${url} (exit ${process.child.exitCode ?? "running"})\n${process.log.tail()}`,
);
}
async function stopProcess(process: ManagedProcess | null): Promise<void> {
@@ -2359,7 +2390,7 @@ async function evaluate<T>(
return evaluation.result.value as T;
}
export async function createPage(
async function createPage(
cdp: CdpConnection,
url: string,
options: Parameters<typeof buildBenchmarkInitScript>[0],
@@ -2479,7 +2510,7 @@ async function progressSample(
);
}
export function buildBenchmarkFailureEvidence(
function buildBenchmarkFailureEvidence(
pages: readonly PageObservation[],
expectedPageCount: number,
): BenchmarkFailureEvidence {
@@ -2935,7 +2966,7 @@ function totalDecodedFrames(page: PageObservation): number {
);
}
export function everyViewerAdvanced(
function everyViewerAdvanced(
before: PageObservation[],
after: PageObservation[],
): boolean {
@@ -2991,7 +3022,7 @@ function recoveryViewerBaseline(
};
}
export function captureRecoveryViewerBaselines(
function captureRecoveryViewerBaselines(
pages: PageObservation[],
): RecoveryViewerBaseline[] | null {
const viewers = pages.filter((page) => page.role === "viewer");
@@ -3001,7 +3032,7 @@ export function captureRecoveryViewerBaselines(
: null;
}
export function everyViewerRecoveredMedia(
function everyViewerRecoveredMedia(
baselines: RecoveryViewerBaseline[] | null,
after: PageObservation[],
): boolean {
@@ -3030,7 +3061,7 @@ export function everyViewerRecoveredMedia(
);
}
export function mergeRecoveryHostPeaks(
function mergeRecoveryHostPeaks(
peaks: RecoveryHostPeaks,
host: PageObservation,
): RecoveryHostPeaks {
@@ -3590,7 +3621,7 @@ async function authenticateExternalServer(
}
}
export async function main(): Promise<number> {
async function main(): Promise<number> {
let config: BenchmarkConfig;
let externalServer: ExternalServerTarget | null;
try {
@@ -3656,7 +3687,7 @@ export async function main(): Promise<number> {
runs: [],
};
let server: ScreenerServer | null = null;
let server: ManagedProcess | null = null;
let chrome: ManagedProcess | null = null;
let cdp: CdpConnection | null = null;
let profileDirectory: string | null = null;
@@ -3673,30 +3704,32 @@ export async function main(): Promise<number> {
const baseUrl = externalServer?.origin ?? `http://127.0.0.1:${appPort}`;
profileDirectory = await mkdtemp(join(tmpdir(), "screener-peer-benchmark-"));
if (appPort !== null) {
const serverConfig = loadConfig({
NODE_ENV: "development",
// An explicit environment and a profile working directory: the measured
// configuration is exactly these values, never the repository's .env or
// the operator's shell (a stray LIVEKIT_* or SITE_ACCESS_PASSWORD would
// silently change the topology this run reports).
server = startProcess(buildServer(), [], {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
SCREENER_ENV: "development",
PORT: String(appPort),
LISTEN_HOST: "127.0.0.1",
PUBLIC_BASE_URL: baseUrl,
ALLOWED_ORIGINS: baseUrl,
PEER_ASSISTED_MEDIA: "true",
ENDPOINT_MEDIA_COPY_CAPACITY: String(config.expectedEndpointCap),
MAX_VIEWERS_PER_ROOM: String(Math.max(
...config.viewerCounts,
config.canaryMode === "viewer-mbb" ? 3 : 1,
)),
// The loopback gate measures peer topology, never a relayed candidate.
STUN_URLS: "",
});
server = await createScreenerServer({
config: serverConfig,
frontend: { mode: "development" },
roomStore: new RoomStore({
leaseMs: serverConfig.roomLeaseMs,
maxRooms: ROOM_CAPACITY,
maxViewersPerRoom: serverConfig.maxViewersPerRoom,
}),
});
await server.listen(appPort, "127.0.0.1");
}, profileDirectory);
await waitForHttp(
`${baseUrl}/healthz`,
server,
20_000,
abortController.signal,
);
}
const chromeArgs = [
@@ -3773,7 +3806,7 @@ export async function main(): Promise<number> {
}
})(),
),
cleanup(server?.close() ?? Promise.resolve()),
cleanup(stopProcess(server)),
]);
await cleanup(stopProcess(chrome));
if (profileDirectory) {
-206
View File
@@ -1,206 +0,0 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
export const DEFAULT_RELEASE_API_URL =
"https://api.github.com/repos/TNTcraftHIM/Screener/releases/latest";
export const UPDATE_EXIT_CODES = Object.freeze({
upToDate: 0,
available: 10,
unavailable: 20,
});
const FULL_REVISION = /^[0-9a-f]{40}$/i;
const RELEASE_PATH_PREFIX = "/TNTcraftHIM/Screener/releases/tag/";
export function normalizeRevision(value) {
const revision = typeof value === "string" ? value.trim().toLowerCase() : "";
return FULL_REVISION.test(revision) ? revision : null;
}
export function parseReleaseMetadata(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
if (value.draft === true || value.prerelease === true) {
return null;
}
const revision = normalizeRevision(value.tag_name);
if (!revision || typeof value.html_url !== "string") {
return null;
}
let releaseURL;
try {
releaseURL = new URL(value.html_url);
} catch {
return null;
}
if (
releaseURL.protocol !== "https:" ||
releaseURL.hostname !== "github.com" ||
releaseURL.username ||
releaseURL.password ||
releaseURL.port ||
releaseURL.search ||
releaseURL.hash ||
!releaseURL.pathname.startsWith(RELEASE_PATH_PREFIX)
) {
return null;
}
let tag = "";
try {
tag = decodeURIComponent(releaseURL.pathname.slice(RELEASE_PATH_PREFIX.length));
} catch {
return null;
}
if (normalizeRevision(tag) !== revision) {
return null;
}
return { revision, url: releaseURL.toString() };
}
export function unavailableResult(currentRevision) {
return {
status: "unavailable",
currentRevision: normalizeRevision(currentRevision),
latestRevision: null,
releaseUrl: null,
};
}
export async function checkRelease({
currentRevision,
apiURL = DEFAULT_RELEASE_API_URL,
token = process.env.GITHUB_TOKEN || "",
timeoutMs = 5_000,
fetchImpl = globalThis.fetch,
} = {}) {
const current = normalizeRevision(currentRevision);
if (!current || typeof fetchImpl !== "function") {
return unavailableResult(currentRevision);
}
let endpoint;
try {
endpoint = new URL(apiURL);
} catch {
return unavailableResult(currentRevision);
}
if (endpoint.protocol !== "https:" && endpoint.protocol !== "http:") {
return unavailableResult(currentRevision);
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "Screener-release-check",
};
if (
endpoint.protocol === "https:" &&
endpoint.hostname === "api.github.com" &&
typeof token === "string" &&
token.trim() !== ""
) {
headers.Authorization = `Bearer ${token.trim()}`;
}
const response = await fetchImpl(endpoint.toString(), {
headers,
redirect: "error",
signal: controller.signal,
});
if (!response?.ok) {
return unavailableResult(current);
}
const latest = parseReleaseMetadata(await response.json());
if (!latest) {
return unavailableResult(current);
}
return {
status: latest.revision === current ? "up-to-date" : "update-available",
currentRevision: current,
latestRevision: latest.revision,
releaseUrl: latest.url,
};
} catch {
return unavailableResult(current);
} finally {
clearTimeout(timer);
}
}
function usage() {
return "Usage: node scripts/release-update.mjs --current-file <REVISION> [--api-url <URL>]";
}
function parseArguments(argumentsList) {
let currentFile = null;
let apiURL = DEFAULT_RELEASE_API_URL;
let apiURLSet = false;
for (let index = 0; index < argumentsList.length; index += 1) {
const name = argumentsList[index];
const value = argumentsList[index + 1];
if (
(name === "--current-file" || name === "--api-url") &&
(!value || value.startsWith("--"))
) {
throw new Error(usage());
}
if (name === "--current-file" && currentFile === null) {
currentFile = value;
index += 1;
} else if (name === "--api-url" && !apiURLSet) {
apiURL = value;
apiURLSet = true;
index += 1;
} else {
throw new Error(usage());
}
}
if (!currentFile) {
throw new Error(usage());
}
return { currentFile, apiURL };
}
async function main() {
let argumentsValue;
try {
argumentsValue = parseArguments(process.argv.slice(2));
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : usage()}\n`);
process.exitCode = 2;
return;
}
let currentRevision = null;
try {
currentRevision = readFileSync(resolve(argumentsValue.currentFile), "ascii");
} catch {
// An unavailable deployed identity is an unavailable check, not an update.
}
const result = await checkRelease({
currentRevision,
apiURL: argumentsValue.apiURL,
});
process.stdout.write(`${JSON.stringify(result)}\n`);
process.exitCode = UPDATE_EXIT_CODES[
result.status === "update-available"
? "available"
: result.status === "up-to-date"
? "upToDate"
: "unavailable"
];
}
const entry = process.argv[1] ? resolve(process.argv[1]) : "";
const self = resolve(dirname(fileURLToPath(import.meta.url)), "release-update.mjs");
if (entry === self) {
await main();
}
+17 -14
View File
@@ -10,6 +10,8 @@ README.md
package.json
package-lock.json
tsconfig.json
go.mod
go.sum
.codex/config.toml
.codex/README.md
.agents/skills/ponytail/SKILL.md
@@ -32,15 +34,16 @@ docs/deployment.md
docs/operations/self-hosting.md
docs/reference/configuration.md
scripts/package-app-release.mjs
scripts/release-update.mjs
scripts/assemble-client.mjs
scripts/client-icons.mjs
scripts/package-client-candidate.mjs
scripts/check-client.mjs
scripts/client-native-host-gate.ts
native/client/cmd/screener-peer-gate/main.go
native/client/cmd/screener-client/screener.ico
native/client/cmd/screener-client/screener_windows_amd64.syso
cmd/screener-server/main.go
cmd/screener-peer-gate/main.go
cmd/screener-client/screener.ico
cmd/screener-client/screener_windows_amd64.syso
internal/server/webassets/dist/.gitkeep
deploy/release-app.sh
deploy/check-release.sh
docs/adr/0001-p2p-first-media-topology.md
@@ -48,16 +51,16 @@ docs/adr/0010-cross-platform-client-runtime.md
docs/research/agent-context-governance.md
docs/research/cross-platform-client-runtime.md
docs/research/webrtc-p2p-screen-sharing.md
native/client/platform/windows/capture/README.md
native/client/platform/windows/capture/build.ps1
native/client/platform/windows/capture/capture_target.cpp
native/client/platform/windows/capture/capture_target.h
native/client/platform/windows/capture/main.cpp
native/client/platform/windows/capture/process_audio.cpp
native/client/platform/windows/capture/process_audio.h
native/client/platform/darwin/capture/README.md
native/client/platform/darwin/capture/build.sh
native/client/platform/darwin/capture/main.swift
native/capture/windows/README.md
native/capture/windows/build.ps1
native/capture/windows/capture_target.cpp
native/capture/windows/capture_target.h
native/capture/windows/main.cpp
native/capture/windows/process_audio.cpp
native/capture/windows/process_audio.h
native/capture/darwin/README.md
native/capture/darwin/build.sh
native/capture/darwin/main.swift
scripts/required-project-paths.txt
scripts/check-project-state.sh
scripts/check-project-state.ps1