bzl-launch.js (11534B)
1 const fs = require("fs"); 2 const http = require("http"); 3 const https = require("https"); 4 const path = require("path"); 5 const { spawn } = require("child_process"); 6 7 const ROOT = path.join(__dirname, ".."); 8 const ENV_PATH = path.join(ROOT, ".env"); 9 10 function log(msg) { 11 console.log(`[bzl-launch] ${msg}`); 12 } 13 14 function warn(msg) { 15 console.warn(`[bzl-launch] WARN: ${msg}`); 16 } 17 18 function fail(msg) { 19 console.error(`[bzl-launch] ERROR: ${msg}`); 20 process.exit(1); 21 } 22 23 function isWin() { 24 return process.platform === "win32"; 25 } 26 27 function readEnvFile() { 28 try { 29 const raw = fs.readFileSync(ENV_PATH, "utf8"); 30 const out = {}; 31 for (const line of raw.split(/\r?\n/)) { 32 const s = line.trim(); 33 if (!s || s.startsWith("#")) continue; 34 const m = s.match(/^([A-Z0-9_]+)=(.*)$/); 35 if (!m) continue; 36 let v = m[2] || ""; 37 v = v.trim(); 38 if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); 39 out[m[1]] = v; 40 } 41 return out; 42 } catch { 43 return null; 44 } 45 } 46 47 function mergeEnv(baseEnv, fromEnvFile) { 48 const out = { ...(baseEnv || {}) }; 49 const src = fromEnvFile && typeof fromEnvFile === "object" ? fromEnvFile : {}; 50 for (const [k, v] of Object.entries(src)) { 51 if (out[k] == null || String(out[k]) === "") out[k] = String(v ?? ""); 52 } 53 return out; 54 } 55 56 function getPortFromEnv() { 57 const fromProc = Number(process.env.PORT || 0); 58 if (Number.isFinite(fromProc) && fromProc > 0) return Math.floor(fromProc); 59 const env = readEnvFile(); 60 const fromFile = Number(env?.PORT || 0); 61 if (Number.isFinite(fromFile) && fromFile > 0) return Math.floor(fromFile); 62 return 3000; 63 } 64 65 function getHostFromEnv() { 66 const fromProc = String(process.env.HOST || "").trim(); 67 if (fromProc) return fromProc; 68 const env = readEnvFile(); 69 const fromFile = String(env?.HOST || "").trim(); 70 return fromFile || "0.0.0.0"; 71 } 72 73 function parseArgs() { 74 const args = process.argv.slice(2); 75 const opts = { 76 supervised: true, 77 open: true, 78 cloudflared: "off", // off | quick | named 79 logFile: "" 80 }; 81 for (const a of args) { 82 if (a === "--no-open") opts.open = false; 83 if (a === "--open") opts.open = true; 84 if (a === "--supervised") opts.supervised = true; 85 if (a === "--no-supervised") opts.supervised = false; 86 if (a === "--cloudflared") opts.cloudflared = "quick"; 87 if (a === "--no-cloudflared") opts.cloudflared = "off"; 88 if (a.startsWith("--log=")) { 89 opts.logFile = String(a.split("=", 2)[1] || "").trim(); 90 } 91 if (a.startsWith("--cloudflared=")) { 92 const v = String(a.split("=", 2)[1] || "").trim().toLowerCase(); 93 if (v === "off" || v === "quick" || v === "named") opts.cloudflared = v; 94 } 95 } 96 return opts; 97 } 98 99 function getJson(url, { timeoutMs = 1500 } = {}) { 100 return new Promise((resolve) => { 101 let settled = false; 102 const done = (v) => { 103 if (settled) return; 104 settled = true; 105 resolve(v); 106 }; 107 108 try { 109 const u = new URL(url); 110 const mod = u.protocol === "https:" ? https : http; 111 const req = mod.request( 112 { 113 method: "GET", 114 protocol: u.protocol, 115 hostname: u.hostname, 116 port: u.port, 117 path: `${u.pathname || "/"}${u.search || ""}`, 118 headers: { Accept: "application/json", "Cache-Control": "no-store" } 119 }, 120 (res) => { 121 let buf = ""; 122 res.setEncoding("utf8"); 123 res.on("data", (c) => (buf += c)); 124 res.on("end", () => { 125 const status = Number(res.statusCode || 0); 126 if (status < 200 || status >= 300) return done({ ok: false, status }); 127 try { 128 return done({ ok: true, json: JSON.parse(buf || "{}") }); 129 } catch { 130 return done({ ok: true, json: {} }); 131 } 132 }); 133 } 134 ); 135 req.on("error", () => done({ ok: false })); 136 req.setTimeout(timeoutMs, () => { 137 try { 138 req.destroy(new Error("timeout")); 139 } catch { 140 // ignore 141 } 142 done({ ok: false, timeout: true }); 143 }); 144 req.end(); 145 } catch { 146 done({ ok: false }); 147 } 148 }); 149 } 150 151 async function healthcheck(url, { timeoutMs = 1500 } = {}) { 152 const res = await getJson(url, { timeoutMs }); 153 return res?.ok === true && res?.json?.ok === true; 154 } 155 156 async function waitForHealthy(url, { maxMs = 20_000 } = {}) { 157 const t0 = Date.now(); 158 while (Date.now() - t0 < maxMs) { 159 // eslint-disable-next-line no-await-in-loop 160 const ok = await healthcheck(url); 161 if (ok) return true; 162 // eslint-disable-next-line no-await-in-loop 163 await new Promise((r) => setTimeout(r, 400)); 164 } 165 return false; 166 } 167 168 function openUrl(url) { 169 const u = String(url || "").trim(); 170 if (!u) return; 171 if (isWin()) { 172 const trySpawn = (cmd, args, onFail) => { 173 try { 174 const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true }); 175 child.once("error", () => (typeof onFail === "function" ? onFail() : undefined)); 176 child.unref(); 177 } catch { 178 if (typeof onFail === "function") onFail(); 179 } 180 }; 181 const escapePsSingleQuoted = (s) => `'${String(s || "").replaceAll("'", "''")}'`; 182 trySpawn("powershell.exe", ["-NoProfile", "-Command", `Start-Process ${escapePsSingleQuoted(u)}`], () => 183 trySpawn("cmd.exe", ["/d", "/s", "/c", "start", "", u], () => 184 trySpawn("rundll32.exe", ["url.dll,FileProtocolHandler", u]) 185 ) 186 ); 187 return; 188 } 189 if (process.platform === "darwin") { 190 const child = spawn("open", [u], { stdio: "ignore", detached: true, windowsHide: true }); 191 child.on("error", () => {}); 192 child.unref(); 193 return; 194 } 195 const child = spawn("xdg-open", [u], { stdio: "ignore", detached: true, windowsHide: true }); 196 child.on("error", () => {}); 197 child.unref(); 198 } 199 200 function teeStreamTo(stream, out, fileStream) { 201 if (!stream) return; 202 stream.on("data", (chunk) => { 203 try { 204 out.write(chunk); 205 } catch { 206 // ignore 207 } 208 try { 209 if (fileStream) fileStream.write(chunk); 210 } catch { 211 // ignore 212 } 213 }); 214 } 215 216 function spawnServer({ supervised, fileStream }) { 217 const entry = supervised ? path.join(ROOT, "scripts", "service-runner.js") : path.join(ROOT, "server.js"); 218 const args = [entry]; 219 const childEnv = mergeEnv(process.env, readEnvFile()); 220 const child = spawn(process.execPath, args, { cwd: ROOT, env: childEnv, stdio: ["inherit", "pipe", "pipe"] }); 221 teeStreamTo(child.stdout, process.stdout, fileStream); 222 teeStreamTo(child.stderr, process.stderr, fileStream); 223 child.on("exit", (code) => { 224 const c = Number(code || 0); 225 if (c !== 0) warn(`Server process exited (code ${c}).`); 226 }); 227 return child; 228 } 229 230 function defaultCloudflaredConfigPath() { 231 const env = readEnvFile(); 232 const fromEnv = String(process.env.CLOUDFLARED_CONFIG || env?.CLOUDFLARED_CONFIG || "").trim(); 233 if (fromEnv) return fromEnv; 234 const home = process.env.USERPROFILE || process.env.HOME || ""; 235 const userCfg = home ? path.join(home, ".cloudflared", "config.yml") : ""; 236 const localCfg = path.join(ROOT, "cloudflared", "config.yml"); 237 if (userCfg && fs.existsSync(userCfg)) return userCfg; 238 if (fs.existsSync(localCfg)) return localCfg; 239 return userCfg || localCfg; 240 } 241 242 function parseTunnelIdFromConfig(configPath) { 243 try { 244 const raw = fs.readFileSync(configPath, "utf8"); 245 for (const line of raw.split(/\r?\n/)) { 246 const m = line.match(/^\s*tunnel\s*:\s*(.+?)\s*$/); 247 if (m) return String(m[1] || "").trim().replace(/^['"]|['"]$/g, ""); 248 } 249 return ""; 250 } catch { 251 return ""; 252 } 253 } 254 255 function spawnCloudflared({ mode, port }) { 256 if (mode === "off") return null; 257 258 const localUrl = `http://localhost:${port}`; 259 const env = readEnvFile(); 260 const tunnelFromEnv = String(process.env.CLOUDFLARED_TUNNEL || env?.CLOUDFLARED_TUNNEL || "").trim(); 261 const cfg = defaultCloudflaredConfigPath(); 262 263 // Fail early if cloudflared isn't installed. 264 try { 265 const probe = spawn("cloudflared", ["--version"], { cwd: ROOT, env: process.env, stdio: "ignore" }); 266 probe.on("error", () => { 267 warn("cloudflared not found on PATH. Install it first (Windows: winget install Cloudflare.cloudflared)."); 268 }); 269 } catch { 270 warn("cloudflared not found on PATH. Install it first (Windows: winget install Cloudflare.cloudflared)."); 271 } 272 273 let args = []; 274 if (mode === "quick") { 275 args = ["tunnel", "--url", localUrl]; 276 } else if (mode === "named") { 277 const tunnel = tunnelFromEnv || (cfg && fs.existsSync(cfg) ? parseTunnelIdFromConfig(cfg) : ""); 278 if (!tunnel) { 279 warn("Named tunnel requested but no tunnel id found. Set CLOUDFLARED_TUNNEL or provide a config.yml with `tunnel:`."); 280 return null; 281 } 282 if (cfg && fs.existsSync(cfg)) { 283 args = ["--config", cfg, "tunnel", "run", tunnel]; 284 } else { 285 args = ["tunnel", "run", tunnel]; 286 } 287 } 288 289 log(`Starting cloudflared (${mode})...`); 290 if (mode === "quick") log("cloudflared will print a public URL (trycloudflare.com) in the console."); 291 292 const child = spawn("cloudflared", args, { cwd: ROOT, env: mergeEnv(process.env, env), stdio: "inherit" }); 293 child.on("error", (e) => warn(`cloudflared failed to start: ${e?.message || e}`)); 294 child.on("exit", (code) => { 295 const c = Number(code || 0); 296 if (c !== 0) warn(`cloudflared exited (code ${c}).`); 297 }); 298 return child; 299 } 300 301 async function main() { 302 const { supervised, open, cloudflared, logFile } = parseArgs(); 303 const port = getPortFromEnv(); 304 const host = getHostFromEnv(); 305 306 if (!fs.existsSync(ENV_PATH)) { 307 warn("No .env found. Consider running `npm run init` first."); 308 } 309 310 const localUrl = `http://localhost:${port}`; 311 const healthUrl = `http://127.0.0.1:${port}/api/health`; 312 313 log(`Launching Bzl (${supervised ? "supervised" : "direct"})...`); 314 log(`Bind: ${host}:${port}`); 315 log(`URL: ${localUrl}`); 316 317 let fileStream = null; 318 if (logFile) { 319 try { 320 const abs = path.isAbsolute(logFile) ? logFile : path.join(ROOT, logFile); 321 fs.mkdirSync(path.dirname(abs), { recursive: true }); 322 fileStream = fs.createWriteStream(abs, { flags: "a" }); 323 fileStream.write(`--- ${new Date().toISOString()} ---\n`); 324 log(`Logging to: ${abs}`); 325 } catch (e) { 326 warn(`Failed to open log file: ${e?.message || e}`); 327 } 328 } 329 330 const server = spawnServer({ supervised, fileStream }); 331 const tunnel = spawnCloudflared({ mode: cloudflared, port }); 332 333 const shutdown = () => { 334 try { 335 if (tunnel && !tunnel.killed) tunnel.kill("SIGTERM"); 336 } catch { 337 // ignore 338 } 339 try { 340 if (server && !server.killed) server.kill("SIGTERM"); 341 } catch { 342 // ignore 343 } 344 try { 345 if (fileStream) fileStream.end(); 346 } catch { 347 // ignore 348 } 349 }; 350 process.on("SIGINT", shutdown); 351 process.on("SIGTERM", shutdown); 352 353 const ok = await waitForHealthy(healthUrl); 354 if (!ok) { 355 warn("Server did not become healthy within 20s. It may still be starting—check the console output."); 356 process.exitCode = 1; 357 return; 358 } 359 360 log("Server is healthy."); 361 if (open) openUrl(localUrl); 362 363 // Keep this launcher process alive; if it exits, a double-clicked console window will close 364 // and can terminate the child processes (server/tunnel) on Windows. 365 log("Press Ctrl+C to stop."); 366 await new Promise((resolve) => { 367 server.on("exit", () => resolve()); 368 }); 369 } 370 371 main().catch((err) => fail(err?.message || String(err)));