multi-projects-runner / lib /tunnelManager.js
Karan6933's picture
Upload 35 files
05d91af verified
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 };