UNPKG

@debugg-ai/debugg-ai-mcp

Version:

Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.

157 lines (156 loc) 6.01 kB
/** * Local reachability probes (bead 1om). * * MCP owns the tunnel lifecycle. It must validate that the user's claimed * localhost URL is actually reachable BEFORE calling the backend provision * API and BEFORE committing to the slow ngrok/browser-agent path. Without * these probes, unreachable apps result in a 5-minute false-positive pass * as the browser agent burns its step budget on ERR_NGROK_8012. * * Two probes: * - probeLocalPort(port): pre-flight TCP connect to 127.0.0.1:<port> * - probeTunnelHealth(url): HTTP check that traffic actually flows through * the tunnel to our local server (catches IPv4/IPv6 bind mismatches, * misconfigured ngrok, etc.) */ import { createConnection } from 'node:net'; export async function probeLocalPort(port, opts = {}) { const host = opts.host ?? '127.0.0.1'; const timeoutMs = opts.timeoutMs ?? 1500; const started = Date.now(); return new Promise((resolve) => { const socket = createConnection({ host, port, timeout: timeoutMs }); let settled = false; const done = (result) => { if (settled) return; settled = true; try { socket.destroy(); } catch { /* ignore */ } resolve(result); }; socket.once('connect', () => { done({ reachable: true, elapsedMs: Date.now() - started }); }); socket.once('timeout', () => { done({ reachable: false, code: 'ETIMEDOUT', detail: `connect timeout after ${timeoutMs}ms`, elapsedMs: Date.now() - started, }); }); socket.once('error', (err) => { done({ reachable: false, code: err.code ?? 'UNKNOWN', detail: err.message, elapsedMs: Date.now() - started, }); }); }); } export async function probeTunnelHealth(tunnelUrl, opts = {}) { const timeoutMs = opts.timeoutMs ?? 5000; const fetchImpl = opts.fetchFn ?? fetch; const started = Date.now(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetchImpl(tunnelUrl, { method: 'GET', redirect: 'manual', signal: controller.signal, // Many user apps reject HEAD; stick to GET for broader compatibility. headers: { 'User-Agent': 'debugg-ai-mcp/tunnel-health-probe' }, }); clearTimeout(timer); // Read body so we can inspect for ngrok error markers. Cap at 4KB — // ngrok error pages are small; a full user app body is a waste. const bodyText = await readCapped(res, 4096); const ngrokErr = extractNgrokErrorCode(bodyText); // 502/504 + ngrok error marker → ngrok couldn't reach our server if (ngrokErr) { return { healthy: false, status: res.status, code: 'NGROK_ERROR', ngrokErrorCode: ngrokErr, detail: `ngrok returned ${ngrokErr} — tunnel established but traffic could not reach dev server`, elapsedMs: Date.now() - started, }; } if (res.status === 502 || res.status === 504) { return { healthy: false, status: res.status, code: 'BAD_GATEWAY', detail: `tunnel returned ${res.status} without an ngrok error marker — gateway is rejecting upstream`, elapsedMs: Date.now() - started, }; } // Any other response (incl. 4xx from user's app) means traffic reached // the dev server — that's healthy from the TUNNEL's perspective. The // user's 404 is a user concern, not a tunnel concern. return { healthy: true, status: res.status, elapsedMs: Date.now() - started, }; } catch (err) { clearTimeout(timer); const e = err; if (e?.name === 'AbortError' || /abort|timeout/i.test(e?.message ?? '')) { return { healthy: false, code: 'TIMEOUT', detail: `tunnel health probe timed out after ${timeoutMs}ms`, elapsedMs: Date.now() - started, }; } return { healthy: false, code: 'NETWORK_ERROR', detail: e?.message ?? String(err), elapsedMs: Date.now() - started, }; } } // ─ helpers ─────────────────────────────────────────────────────────────────── async function readCapped(res, maxBytes) { if (!res.body) return ''; const reader = res.body.getReader(); const decoder = new TextDecoder(); let total = 0; let out = ''; try { while (total < maxBytes) { const { value, done } = await reader.read(); if (done) break; const remaining = maxBytes - total; const chunk = value.length > remaining ? value.slice(0, remaining) : value; out += decoder.decode(chunk, { stream: true }); total += chunk.length; if (value.length > remaining) { // Got enough; cancel the rest. await reader.cancel().catch(() => { }); break; } } out += decoder.decode(); } catch { /* ignore read errors — we return what we have */ } return out; } export function extractNgrokErrorCode(body) { // ngrok error pages surface codes like "ERR_NGROK_8012", "ERR_NGROK_3200", etc. const match = body.match(/ERR_NGROK_\d+/); return match ? match[0] : undefined; }