import { spawn, type ChildProcess } from 'node:child_process'; export class CloudflareTunnel { private process: ChildProcess | null = null; private url: string | null = null; async start(port: number): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Tunnel startup timed out after 30s')); }, 30000); this.process = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], { stdio: ['ignore', 'pipe', 'pipe'], }); const handleOutput = (data: Buffer) => { const text = data.toString(); // cloudflared prints the URL to stderr const urlMatch = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/); if (urlMatch && !this.url) { this.url = urlMatch[0]; clearTimeout(timeout); resolve(this.url); } }; this.process.stdout?.on('data', handleOutput); this.process.stderr?.on('data', handleOutput); this.process.on('error', (err) => { clearTimeout(timeout); if (err.message.includes('ENOENT')) { reject( new Error( 'cloudflared not found. Install with: brew install cloudflared\n' + 'Or disable tunnel with: --no-tunnel' ) ); } else { reject(err); } }); this.process.on('exit', (code) => { if (!this.url) { clearTimeout(timeout); reject(new Error(`cloudflared exited with code ${code}`)); } }); }); } getUrl(): string | null { return this.url; } stop(): void { if (this.process) { this.process.kill('SIGTERM'); this.process = null; this.url = null; } } }