UNPKG

@locol.dev/agent

Version:

Create instant HTTPS tunnels to your localhost for mobile testing via Cloudflare

394 lines (393 loc) 15.2 kB
#!/usr/bin/env node "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const dotenv_1 = require("dotenv"); const path_1 = require("path"); const child_process_1 = require("child_process"); const supabase_js_1 = require("@supabase/supabase-js"); const os_1 = require("os"); const fs_1 = require("fs"); (0, dotenv_1.config)({ path: (0, path_1.join)(__dirname, '..', '.env') }); (0, dotenv_1.config)({ path: (0, path_1.join)(__dirname, '..', '..', '.env') }); const globalConfigPath = (0, path_1.join)((0, os_1.homedir)(), '.locol', 'config.json'); if ((0, fs_1.existsSync)(globalConfigPath)) { try { const globalConfig = JSON.parse((0, fs_1.readFileSync)(globalConfigPath, 'utf8')); if (globalConfig.SUPABASE_URL) process.env.SUPABASE_URL = globalConfig.SUPABASE_URL; if (globalConfig.SUPABASE_ANON_KEY) process.env.SUPABASE_ANON_KEY = globalConfig.SUPABASE_ANON_KEY; } catch (err) { } } const colors = { reset: '\x1b[0m', bright: '\x1b[1m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m', }; const log = { info: (msg) => console.log(`${colors.cyan}${colors.reset} ${msg}`), success: (msg) => console.log(`${colors.green}${colors.reset} ${msg}`), error: (msg) => console.log(`${colors.red}${colors.reset} ${msg}`), warning: (msg) => console.log(`${colors.yellow}${colors.reset} ${msg}`), }; async function retryWithBackoff(fn, maxRetries = 5, baseDelay = 1000, maxDelay = 30000) { let lastError; for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error; if (error?.code === 'PGRST301' || error?.status === 404) { throw error; } if (attempt < maxRetries - 1) { const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); const jitter = Math.random() * 0.3 * delay; const waitTime = delay + jitter; log.warning(`Attempt ${attempt + 1} failed, retrying in ${Math.round(waitTime)}ms...`); await new Promise(resolve => setTimeout(resolve, waitTime)); } } } throw lastError; } async function main() { console.log(`\n${colors.bright}Locol Agent${colors.reset} - Starting tunnel...\n`); const args = process.argv.slice(2); if (args.length < 3) { log.error('Invalid arguments'); console.log('\nUsage: locol-agent <session_id> <secret> <local_port>\n'); console.log('Example:'); console.log(' locol-agent abc123 mysecret123 3000\n'); process.exit(1); } const [sessionId, secret, portArg] = args; const localPort = parseInt(portArg, 10); if (isNaN(localPort) || localPort < 1 || localPort > 65535) { log.error(`Invalid port: ${portArg}`); console.log('Port must be between 1 and 65535\n'); process.exit(1); } const supabaseUrl = process.env.SUPABASE_URL; const supabaseKey = process.env.SUPABASE_ANON_KEY; if (!supabaseUrl || !supabaseKey) { log.error('Missing Supabase credentials'); console.log('\n❌ This agent requires credentials to connect to Locol.\n'); console.log('📋 To get started:'); console.log(' 1. Visit https://locol.dev/app'); console.log(' 2. Sign in and create a new tunnel'); console.log(' 3. Copy the command from the dashboard'); console.log(' 4. Paste and run it in your terminal\n'); process.exit(1); } log.info('Connected to Locol service'); const supabase = (0, supabase_js_1.createClient)(supabaseUrl, supabaseKey, { db: { schema: 'public', }, auth: { persistSession: false, }, global: { headers: { 'x-client-info': 'locol-agent', }, }, }); log.info(`Validating tunnel session: ${sessionId}`); let tunnel = null; try { const result = await retryWithBackoff(async () => { const { data, error } = await supabase .from('tunnels') .select('*') .eq('id', sessionId) .eq('secret', secret) .single(); if (error) throw error; return data; }, 3, 1000); tunnel = result; } catch (error) { log.error('Invalid session ID or secret'); console.log('\nPlease check your tunnel configuration and try again.\n'); process.exit(1); } if (!tunnel) { log.error('Tunnel not found'); process.exit(1); } if (tunnel.status !== 'waiting') { log.error(`Tunnel is not in waiting state (current: ${tunnel.status})`); process.exit(1); } if (tunnel.local_port !== localPort) { log.warning(`Port mismatch: expected ${tunnel.local_port}, got ${localPort}`); log.info(`Using port ${localPort} anyway...`); } log.success('Tunnel session validated'); const enableRemoteShutdown = process.env.ENABLE_REMOTE_SHUTDOWN === 'true' || false; let channel = null; if (enableRemoteShutdown) { log.info('Setting up realtime listener for remote shutdown...'); channel = supabase .channel(`tunnel:${sessionId}`) .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'tunnels', filter: `id=eq.${sessionId}`, }, (payload) => { const updatedTunnel = payload.new; if (updatedTunnel.status === 'closed') { log.info('Received remote shutdown signal'); log.info('Closing tunnel...'); if (cloudflaredRef.current) { cloudflaredRef.current.kill(); } else { process.exit(0); } } }) .subscribe((status) => { if (status === 'SUBSCRIBED') { log.success('Realtime listener active (remote shutdown enabled)'); } else if (status === 'CLOSED' || status === 'CHANNEL_ERROR') { log.warning('Realtime subscription failed, continuing without remote shutdown'); } }); } else { log.info('Remote shutdown disabled - skipping Realtime subscription'); log.info('(Set ENABLE_REMOTE_SHUTDOWN=true to enable remote shutdown via dashboard)'); log.info('This allows unlimited concurrent tunnels without subscription limits'); } log.info('Checking for cloudflared...'); try { await new Promise((resolve, reject) => { const check = (0, child_process_1.spawn)('cloudflared', ['--version']); check.on('error', reject); check.on('close', (code) => { if (code === 0) resolve(); else reject(new Error('cloudflared not found')); }); }); log.success('cloudflared found'); } catch (err) { log.error('cloudflared is not installed'); console.log('\nPlease install cloudflared:'); console.log(' macOS: brew install cloudflared'); console.log(' Linux: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/'); console.log(' Windows: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/\n'); process.exit(1); } log.info(`Starting tunnel to http://localhost:${localPort}...`); const cloudflaredRef = { current: null }; cloudflaredRef.current = (0, child_process_1.spawn)('cloudflared', [ 'tunnel', '--url', `http://localhost:${localPort}`, '--no-autoupdate' ]); let publicUrl = null; let urlDetected = false; let urlDetectionTimeout = null; urlDetectionTimeout = setTimeout(() => { if (!urlDetected) { log.error('Timeout waiting for tunnel URL. cloudflared may not be connecting properly.'); log.warning('Tunnel process will continue, but status may not update.'); log.info('Check cloudflared output above for connection errors.'); } }, 60000); cloudflaredRef.current.stdout?.on('data', (data) => { const output = data.toString(); console.log(output.trim()); const urlMatch = output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i); if (urlMatch && !urlDetected) { publicUrl = urlMatch[0]; urlDetected = true; if (urlDetectionTimeout) { clearTimeout(urlDetectionTimeout); urlDetectionTimeout = null; } onUrlDetected(publicUrl); } }); cloudflaredRef.current.stderr?.on('data', (data) => { const output = data.toString(); console.error(output.trim()); const urlMatch = output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i); if (urlMatch && !urlDetected) { publicUrl = urlMatch[0]; urlDetected = true; if (urlDetectionTimeout) { clearTimeout(urlDetectionTimeout); urlDetectionTimeout = null; } onUrlDetected(publicUrl); } }); cloudflaredRef.current.on('error', (err) => { log.error(`Failed to start cloudflared: ${err.message}`); if (urlDetectionTimeout) clearTimeout(urlDetectionTimeout); process.exit(1); }); cloudflaredRef.current.on('close', async (code) => { log.info(`Tunnel closed with code ${code}`); if (urlDetectionTimeout) clearTimeout(urlDetectionTimeout); await closeTunnel(); if (channel) { await supabase.removeChannel(channel); } process.exit(code || 0); }); let healthCheckInterval = null; let consecutiveFailures = 0; const maxFailures = 3; healthCheckInterval = setInterval(() => { if (!cloudflaredRef.current) { consecutiveFailures++; if (consecutiveFailures >= maxFailures) { log.error('cloudflared process appears to have died'); log.info('Updating tunnel status to closed...'); clearInterval(healthCheckInterval); closeTunnel().then(() => { if (channel) { supabase.removeChannel(channel).then(() => { process.exit(1); }); } else { process.exit(1); } }); } return; } try { cloudflaredRef.current.kill(0); consecutiveFailures = 0; } catch (err) { consecutiveFailures++; if (consecutiveFailures >= maxFailures) { log.error('cloudflared process health check failed'); log.info('Updating tunnel status to closed...'); clearInterval(healthCheckInterval); closeTunnel().then(() => { if (channel) { supabase.removeChannel(channel).then(() => { process.exit(1); }); } else { process.exit(1); } }); } } }, 30000); async function onUrlDetected(url) { log.success(`Public URL detected: ${colors.bright}${url}${colors.reset}`); log.info('Updating tunnel status...'); try { await retryWithBackoff(async () => { const { data, error } = await supabase.functions.invoke('update-tunnel-url', { body: { session_id: sessionId, secret: secret, public_url: url, status: 'online', }, }); if (error) { log.warning('Edge Function not available, using direct update'); const { error: updateError } = await supabase .from('tunnels') .update({ public_url: url, status: 'online' }) .eq('id', sessionId) .eq('secret', secret); if (updateError) { throw updateError; } } }, 5, 1000); log.success('Tunnel is now online!'); console.log(`\n${colors.green}${colors.bright}Your app is now accessible at:${colors.reset}`); console.log(`${colors.cyan}${url}${colors.reset}\n`); console.log('Press Ctrl+C to stop the tunnel.\n'); } catch (err) { log.error(`Failed to update Supabase after retries: ${err.message}`); log.warning('Tunnel is running but status was not updated'); log.info('You can manually update the tunnel status in the dashboard if needed.'); } } async function closeTunnel() { log.info('Closing tunnel session...'); try { await retryWithBackoff(async () => { const { error } = await supabase .from('tunnels') .update({ status: 'closed', closed_at: new Date().toISOString() }) .eq('id', sessionId); if (error) { throw error; } }, 3, 500); log.success('Tunnel session closed'); } catch (err) { log.error(`Error closing tunnel: ${err.message}`); } } const shutdown = async (signal) => { log.info(`Received ${signal}, shutting down...`); if (healthCheckInterval) { clearInterval(healthCheckInterval); } if (urlDetectionTimeout) { clearTimeout(urlDetectionTimeout); } if (cloudflaredRef.current) { cloudflaredRef.current.kill(); } await new Promise(resolve => setTimeout(resolve, 1000)); await closeTunnel(); if (channel) { await supabase.removeChannel(channel); } process.exit(0); }; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('uncaughtException', async (err) => { log.error(`Uncaught exception: ${err.message}`); await shutdown('uncaughtException'); }); process.on('unhandledRejection', async (reason) => { log.error(`Unhandled rejection: ${reason}`); await shutdown('unhandledRejection'); }); } main().catch((err) => { log.error(`Fatal error: ${err.message}`); process.exit(1); });