@locol.dev/agent
Version:
Create instant HTTPS tunnels to your localhost for mobile testing via Cloudflare
394 lines (393 loc) • 15.2 kB
JavaScript
;
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);
});