import { execFile } from 'node:child_process'; import { createServer } from 'node:net'; import { promisify } from 'node:util'; const run = promisify(execFile); export interface TurnServerHandle { urls: string; username: string; credential: string; stop: () => Promise; } const IMAGE = 'coturn/coturn:latest'; const CONTAINER_NAME = 'metoyou-e2e-turn'; const USERNAME = 'e2e'; const CREDENTIAL = 'e2epass'; const RELAY_MIN_PORT = 49_160; const RELAY_MAX_PORT = 49_200; /** Whether a working Docker daemon is reachable, so a spec can skip instead of failing. */ export async function isDockerAvailable(): Promise { try { await run('docker', ['info'], { timeout: 15_000 }); return true; } catch { return false; } } /** * Run a throwaway coturn on the loopback interface. Relay-only tests need a real * TURN server: `iceTransportPolicy: 'relay'` discards every other candidate, so * without one there is no path at all and the test would prove nothing. */ export async function startTurnServer(): Promise { await removeContainer(); const port = await allocatePort(); await run('docker', [ 'run', '--detach', '--name', CONTAINER_NAME, '--network', 'host', IMAGE, '-n', `--listening-port=${port}`, '--listening-ip=127.0.0.1', '--relay-ip=127.0.0.1', `--min-port=${RELAY_MIN_PORT}`, `--max-port=${RELAY_MAX_PORT}`, '--lt-cred-mech', `--user=${USERNAME}:${CREDENTIAL}`, // Both browsers are on this machine. Without this coturn still hands out a // relay candidate but refuses to forward to a 127.x peer, so ICE fails in a // way that looks like a broken app rather than a blocked relay. '--allow-loopback-peers', '--realm=metoyou.test', '--fingerprint', '--no-tls', '--no-dtls', // Readiness is read back off `docker logs`: coturn logs to a file inside the // container unless pointed at stdout, and the per-listener lines only appear // at verbose level. '--log-file=stdout', '--verbose' ], { timeout: 120_000 }); await waitForTurnPort(port); return { credential: CREDENTIAL, stop: removeContainer, urls: `turn:127.0.0.1:${port}?transport=udp`, username: USERNAME }; } async function removeContainer(): Promise { try { await run('docker', [ 'rm', '--force', CONTAINER_NAME ], { timeout: 30_000 }); } catch { // No such container - nothing to clean up. } } async function waitForTurnPort(port: number, timeoutMs = 20_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const { stdout, stderr } = await run('docker', ['logs', CONTAINER_NAME], { timeout: 10_000 }) .catch(() => ({ stderr: '', stdout: '' })); if (`${stdout}${stderr}`.includes(`UDP listener opened on: 127.0.0.1:${port}`)) { return; } await delay(250); } throw new Error(`coturn did not open a UDP listener on 127.0.0.1:${port}`); } /** coturn binds this itself, so only probe for a free port and hand it over. */ async function allocatePort(): Promise { return await new Promise((resolve, reject) => { const probe = createServer(); probe.once('error', reject); probe.listen(0, '127.0.0.1', () => { const address = probe.address(); if (!address || typeof address === 'string') { probe.close(); reject(new Error('Failed to resolve an ephemeral TURN port')); return; } const { port } = address; probe.close((error) => (error ? reject(error) : resolve(port))); }); }); } function delay(durationMs: number): Promise { return new Promise((resolve) => { setTimeout(resolve, durationMs); }); }