import { spawn } from 'node:child_process'; import ngrok from '@ngrok/ngrok'; const tunnels = new Map(); async function checkCommandExists(command) { return new Promise((resolve) => { const searchPaths = [ ...process.env.PATH?.split(':') || [], `${process.env.HOME}/.local/bin` ]; for (const dir of searchPaths) { const child = spawn('test', ['-x', `${dir}/${command}`]); child.on('close', (code) => { if (code === 0) { resolve(true); } }); child.on('error', () => {}); } const child = spawn('which', [command], { shell: true }); child.on('close', (code) => resolve(code === 0)); child.on('error', () => resolve(false)); }); } async function createCloudflareTunnel(port, projectId) { console.log(`[${projectId}] Starting Cloudflare Tunnel...`); const cloudflaredExists = await checkCommandExists('cloudflared'); if (!cloudflaredExists) { console.log(`[${projectId}] cloudflared not installed, skipping`); return null; } return new Promise((resolve) => { let resolved = false; let tunnelUrl = null; const cloudflared = spawn('cloudflared', [ 'tunnel', '--url', `http://localhost:${port}`, '--logfile', '/dev/null', '--metrics', 'localhost:0', '--no-autoupdate' ], { stdio: ['ignore', 'pipe', 'pipe'] }); const timeout = setTimeout(() => { if (!resolved) { resolved = true; cloudflared.kill('SIGTERM'); console.log(`[${projectId}] Cloudflare Tunnel timeout`); resolve(null); } }, 30000); const parseOutput = (data) => { if (resolved) return; const output = data.toString(); const match = output.match(/https?:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/); if (match) { tunnelUrl = match[0]; resolved = true; clearTimeout(timeout); console.log(`[${projectId}] Cloudflare Tunnel established at ${tunnelUrl}`); tunnels.set(projectId, { provider: 'cloudflare', url: tunnelUrl, process: cloudflared }); resolve(tunnelUrl); } if (output.includes('ERR_')) { console.error(`[${projectId}] Cloudflare error: ${output.trim()}`); } }; cloudflared.stdout.on('data', parseOutput); cloudflared.stderr.on('data', parseOutput); cloudflared.on('error', (error) => { if (!resolved) { resolved = true; clearTimeout(timeout); console.error(`[${projectId}] Cloudflare process error: ${error.message}`); resolve(null); } }); cloudflared.on('close', (code) => { if (!resolved) { resolved = true; clearTimeout(timeout); console.log(`[${projectId}] Cloudflare process closed with code ${code}`); resolve(tunnelUrl); } }); }); } async function createNgrokTunnel(port, projectId) { const authtoken = process.env.NGROK_AUTH_TOKEN; if (!authtoken) { console.log(`[${projectId}] No NGROK_AUTH_TOKEN found, skipping ngrok`); return null; } console.log(`[${projectId}] Starting ngrok tunnel...`); try { await ngrok.authtoken(authtoken); const tunnel = await ngrok.connect({ addr: port, authtoken: authtoken, onStatusChange: (status) => { console.log(`[${projectId}] Ngrok status: ${status}`); }, onLogEvent: (event) => { console.log(`[${projectId}] Ngrok: ${event}`); } }); const tunnelUrl = tunnel.url(); console.log(`[${projectId}] Ngrok Tunnel established at ${tunnelUrl}`); tunnels.set(projectId, { provider: 'ngrok', url: tunnelUrl, tunnel: tunnel }); return tunnelUrl; } catch (error) { console.error(`[${projectId}] Ngrok error: ${error.message}`); return null; } } export async function createTunnel(port, projectId) { const existingTunnel = tunnels.get(projectId); if (existingTunnel) { console.log(`[${projectId}] Tunnel already exists: ${existingTunnel.url}`); return existingTunnel.url; } const provider = process.env.TUNNEL_PROVIDER || 'cloudflare,ngrok'; const providers = provider.split(',').map(p => p.trim()); for (const p of providers) { let tunnelUrl = null; if (p === 'cloudflare') { tunnelUrl = await createCloudflareTunnel(port, projectId); } else if (p === 'ngrok') { tunnelUrl = await createNgrokTunnel(port, projectId); } if (tunnelUrl) { return tunnelUrl; } console.log(`[${projectId}] ${p} failed, trying next provider...`); } console.log(`[${projectId}] All tunnel providers failed, continuing without public URL`); return null; } export async function destroyTunnel(projectId) { const tunnelInfo = tunnels.get(projectId); if (!tunnelInfo) { return; } try { if (tunnelInfo.provider === 'ngrok' && tunnelInfo.tunnel) { await tunnelInfo.tunnel.close(); } else if (tunnelInfo.provider === 'cloudflare' && tunnelInfo.process) { tunnelInfo.process.kill('SIGTERM'); } tunnels.delete(projectId); console.log(`[${projectId}] Tunnel destroyed`); } catch (error) { console.error(`[${projectId}] Error destroying tunnel: ${error.message}`); } } export function getTunnelInfo(projectId) { return tunnels.get(projectId) || null; } export async function destroyAllTunnels() { for (const projectId of tunnels.keys()) { await destroyTunnel(projectId); } } export { checkCommandExists };