bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

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)));