File size: 5,627 Bytes
88c4c60 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | 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
};
}
|