import { loadState, saveState, generateShortId } from "../shared/state.js"; import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedExitHandler } from "./cloudflared.js"; import { clearPid } from "./pid.js"; import { waitForHealth, probeUrlAlive } from "./healthCheck.js"; import { WORKER_URL } from "./config.js"; import { getSettings, updateSettings } from "@/lib/localDb"; const svc = { cancelToken: { cancelled: false }, spawnInProgress: false, lastRestartAt: 0, activeLocalPort: null, }; export function getTunnelService() { return svc; } export function isTunnelManuallyDisabled() { return svc.cancelToken.cancelled; } export function isTunnelReconnecting() { return svc.spawnInProgress; } let onUnexpectedExit = null; export function setTunnelUnexpectedExitCallback(cb) { onUnexpectedExit = cb; } async function registerTunnelUrl(shortId, tunnelUrl) { await fetch(`${WORKER_URL}/api/tunnel/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ shortId, tunnelUrl }) }); } function throwIfCancelled(token) { if (token.cancelled) throw new Error("tunnel cancelled"); } export async function enableTunnel(localPort = 20128) { console.log(`[Tunnel] enable start (port=${localPort})`); svc.cancelToken = { cancelled: false }; svc.activeLocalPort = localPort; svc.spawnInProgress = true; const token = svc.cancelToken; try { if (isCloudflaredRunning()) { const existing = loadState(); if (existing?.tunnelUrl && existing?.shortId) { const publicUrl = `https://r${existing.shortId}.abc-tunnel.us`; // Reuse only if BOTH direct + public URL alive (avoid stale socket after network change) const [directOk, publicOk] = await Promise.all([ probeUrlAlive(existing.tunnelUrl), probeUrlAlive(publicUrl), ]); if (directOk && publicOk) { console.log(`[Tunnel] already running, reuse: ${existing.tunnelUrl}`); return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, publicUrl, alreadyRunning: true }; } console.log(`[Tunnel] stale (direct=${directOk} public=${publicOk}), respawn`); } } killCloudflared(localPort); console.log("[Tunnel] killed existing cloudflared"); throwIfCancelled(token); const existing = loadState(); const shortId = existing?.shortId || generateShortId(); const onUrlUpdate = async (url) => { if (token.cancelled) return; console.log(`[Tunnel] url updated: ${url}`); await registerTunnelUrl(shortId, url); saveState({ shortId, tunnelUrl: url }); await updateSettings({ tunnelEnabled: true, tunnelUrl: url }); }; // Register exit handler BEFORE spawn so it fires even on early exit setUnexpectedExitHandler(() => { console.warn("[Tunnel] cloudflared exited unexpectedly, scheduling respawn"); if (onUnexpectedExit) onUnexpectedExit(); }); const { tunnelUrl } = await spawnQuickTunnel(localPort, onUrlUpdate); console.log(`[Tunnel] spawned: ${tunnelUrl}`); throwIfCancelled(token); const publicUrl = `https://r${shortId}.abc-tunnel.us`; await registerTunnelUrl(shortId, tunnelUrl); saveState({ shortId, tunnelUrl }); await updateSettings({ tunnelEnabled: true, tunnelUrl }); console.log(`[Tunnel] registered shortId=${shortId} publicUrl=${publicUrl}`); // Verify publicUrl first (worker route is reliable; direct *.trycloudflare.com DNS may lag) await waitForHealth(publicUrl, token); console.log("[Tunnel] public URL healthy"); // Direct tunnel probe is best-effort: DNS for *.trycloudflare.com can be slow/blocked if (!(await probeUrlAlive(tunnelUrl))) { console.warn("[Tunnel] direct URL not reachable yet, continuing via publicUrl"); } else { console.log("[Tunnel] direct URL healthy"); } console.log("[Tunnel] enable success"); return { success: true, tunnelUrl, shortId, publicUrl }; } catch (e) { // Suppress noise when spawn was deliberately killed (restart/disable superseded it) if (!/cloudflared killed|tunnel cancelled/.test(e.message)) { console.error(`[Tunnel] enable error: ${e.message}`); } throw e; } finally { svc.spawnInProgress = false; } } export async function disableTunnel() { console.log("[Tunnel] disable"); // Abort any in-flight enable so it cannot resurrect state after we clear it svc.cancelToken.cancelled = true; setUnexpectedExitHandler(null); try { killCloudflared(svc.activeLocalPort); } catch (e) { console.warn(`[Tunnel] kill warn: ${e.message}`); } clearPid(); const state = loadState(); if (state) saveState({ shortId: state.shortId, tunnelUrl: null }); await updateSettings({ tunnelEnabled: false, tunnelUrl: "" }); // Force-clear flags so a subsequent enable is not blocked by a stuck spawnInProgress svc.spawnInProgress = false; svc.activeLocalPort = null; return { success: true }; } export async function getTunnelStatus() { const settings = await getSettings(); const settingsEnabled = settings.tunnelEnabled === true; const state = loadState(); const shortId = state?.shortId || ""; const publicUrl = shortId ? `https://r${shortId}.abc-tunnel.us` : ""; const tunnelUrl = state?.tunnelUrl || ""; // Lazy: skip PID probe entirely when user disabled tunnel const running = settingsEnabled ? isCloudflaredRunning() : false; return { enabled: settingsEnabled && running, settingsEnabled, tunnelUrl, shortId, publicUrl, running }; }