#!/usr/bin/env node "use strict"; /** * mnote-web 生产构建与启动入口。 * * 规则: * - 只按已提交的 git HEAD 判断 release 是否已存在,未提交工作区改动不参与判定。 * - 新 release 从 0.0.1 开始递增,0.0.9 后是 0.1.0。 * - 每个 release 保留独立源码快照和二进制,不覆盖旧 release。 * - 默认启动端口是 3003,避免占用开发端口 3000。 */ const fs = require("node:fs"); const fsp = require("node:fs/promises"); const http = require("node:http"); const os = require("node:os"); const path = require("node:path"); const { spawn, spawnSync, execFileSync } = require("node:child_process"); const ROOT = path.resolve(__dirname, ".."); const RELEASE_ROOT = path.join(ROOT, "dist", "releases", "mnote-web"); const BUILD_CACHE_DIR = path.join(ROOT, "dist", "prod-build-cache", "mnote-web"); const RUN_DIR = path.join(ROOT, "dist", "run", "mnote-web-prod"); const RUN_STATE_PATH = path.join(RUN_DIR, "process.json"); const DEFAULT_PORT = 3003; const DEFAULT_CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db"; function runChecked(command, args, options = {}) { const result = spawnSync(command, args, { cwd: ROOT, env: process.env, encoding: "utf8", stdio: options.stdio || "pipe", ...options, }); if (result.error) { throw result.error; } if (result.status !== 0) { const stderr = result.stderr ? `\n${result.stderr.trim()}` : ""; throw new Error(`${command} ${args.join(" ")} failed with code ${result.status}${stderr}`); } return result.stdout || ""; } function currentGitHead() { return runChecked("git", ["rev-parse", "HEAD"]).trim(); } function parseVersion(version) { const match = String(version || "").match(/^(\d+)\.(\d+)\.(\d+)$/); if (!match) return null; return match.slice(1).map((part) => Number(part)); } function compareVersion(a, b) { const av = parseVersion(a); const bv = parseVersion(b); if (!av || !bv) return 0; for (let i = 0; i < 3; i += 1) { if (av[i] !== bv[i]) return av[i] - bv[i]; } return 0; } function nextVersionAfter(version) { if (!version) return "0.0.1"; let [major, minor, patch] = parseVersion(version) || [0, 0, 0]; patch += 1; if (patch > 9) { patch = 0; minor += 1; } if (minor > 9) { minor = 0; major += 1; } return `${major}.${minor}.${patch}`; } async function pathExists(filePath) { try { await fsp.access(filePath); return true; } catch { return false; } } async function listReleases() { if (!(await pathExists(RELEASE_ROOT))) return []; const entries = await fsp.readdir(RELEASE_ROOT, { withFileTypes: true }); const releases = []; for (const entry of entries) { if (!entry.isDirectory() || !parseVersion(entry.name)) continue; const releaseDir = path.join(RELEASE_ROOT, entry.name); const metadataPath = path.join(releaseDir, "metadata.json"); if (!(await pathExists(metadataPath))) { releases.push({ version: entry.name, releaseDir, metadata: null, usable: false }); continue; } try { const metadata = JSON.parse(await fsp.readFile(metadataPath, "utf8")); const binaryPath = path.join(releaseDir, "mnote-web"); const sourceDir = path.join(releaseDir, "source"); const usable = (await pathExists(binaryPath)) && (await pathExists(sourceDir)); releases.push({ version: entry.name, releaseDir, metadata, usable }); } catch { releases.push({ version: entry.name, releaseDir, metadata: null, usable: false }); } } releases.sort((a, b) => compareVersion(a.version, b.version)); return releases; } async function findReleaseForHead(head) { const releases = await listReleases(); return releases.find((release) => release.usable && release.metadata?.gitHead === head) || null; } async function nextReleaseVersion() { const releases = await listReleases(); let version = releases.length > 0 ? releases[releases.length - 1].version : null; let next = nextVersionAfter(version); while (await pathExists(path.join(RELEASE_ROOT, next))) { next = nextVersionAfter(next); } return next; } function pipeGitArchive(head, destination) { return new Promise((resolve, reject) => { const git = spawn("git", ["archive", "--format=tar", head], { cwd: ROOT, stdio: ["ignore", "pipe", "inherit"], }); const tar = spawn("tar", ["-x", "-C", destination], { cwd: ROOT, stdio: ["pipe", "inherit", "inherit"], }); git.stdout.pipe(tar.stdin); let gitCode = null; let tarCode = null; const maybeDone = () => { if (gitCode === null || tarCode === null) return; if (gitCode !== 0) { reject(new Error(`git archive failed with code ${gitCode}`)); return; } if (tarCode !== 0) { reject(new Error(`tar extract failed with code ${tarCode}`)); return; } resolve(); }; git.on("error", reject); tar.on("error", reject); git.on("close", (code) => { gitCode = code; maybeDone(); }); tar.on("close", (code) => { tarCode = code; maybeDone(); }); }); } async function buildRelease(head) { const version = await nextReleaseVersion(); const releaseDir = path.join(RELEASE_ROOT, version); const sourceDir = path.join(releaseDir, "source"); const binaryPath = path.join(releaseDir, "mnote-web"); const metadataPath = path.join(releaseDir, "metadata.json"); await fsp.mkdir(sourceDir, { recursive: true }); console.log(`[prod] 创建 release ${version}: ${releaseDir}`); await pipeGitArchive(head, sourceDir); const targetDir = path.join(BUILD_CACHE_DIR, "target"); await fsp.mkdir(targetDir, { recursive: true }); console.log(`[prod] 构建已提交 HEAD ${head.slice(0, 12)} ...`); runChecked( "cargo", ["build", "--manifest-path", "rust/Cargo.toml", "-p", "mnote-web", "--bin", "mnote-web", "--release"], { cwd: sourceDir, env: { ...process.env, CARGO_TARGET_DIR: targetDir, }, stdio: "inherit", }, ); const builtBinary = path.join(targetDir, "release", "mnote-web"); await fsp.copyFile(builtBinary, binaryPath); await fsp.chmod(binaryPath, 0o755); const metadata = { schema: "mnote.prod_release.v1", version, gitHead: head, builtAt: new Date().toISOString(), binaryPath, sourceDir, cargoTargetDir: targetDir, }; await fsp.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); return { version, releaseDir, metadata, usable: true }; } function readJsonIfExists(filePath) { try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return null; } } function isProcessAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch { return false; } } function processCommandLine(pid) { try { return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ").trim(); } catch { return ""; } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function isPrivateLanAddress(address) { if (/^10\./.test(address)) return true; if (/^192\.168\./.test(address)) return true; const match = address.match(/^172\.(\d+)\./); if (!match) return false; const second = Number(match[1]); return second >= 16 && second <= 31; } function lanAddresses() { const ignoredInterface = /^(docker|br-|veth|virbr|tailscale|zt|lo)/; const addresses = []; for (const [name, entries] of Object.entries(os.networkInterfaces())) { if (ignoredInterface.test(name)) continue; for (const entry of entries || []) { if (entry.family !== "IPv4" || entry.internal) continue; if (!isPrivateLanAddress(entry.address)) continue; addresses.push(entry.address); } } return [...new Set(addresses)]; } function defaultPublicBind(port) { return `${lanAddresses()[0] || "127.0.0.1"}:${port}`; } async function stopProcess(pid) { if (!isProcessAlive(pid)) return; console.log(`[prod] 停止旧进程 pid=${pid}`); process.kill(pid, "SIGTERM"); for (let i = 0; i < 40; i += 1) { if (!isProcessAlive(pid)) return; await sleep(250); } if (isProcessAlive(pid)) { console.log(`[prod] 旧进程未及时退出,发送 SIGKILL pid=${pid}`); process.kill(pid, "SIGKILL"); } } function isManagedMnoteProcess(pid, expectedBinaryPath = "") { const commandLine = processCommandLine(pid); if (!commandLine) return false; if (expectedBinaryPath && commandLine.includes(expectedBinaryPath)) return true; return commandLine.includes(RELEASE_ROOT) || /\bmnote-web\b/.test(commandLine); } function pidsListeningOnPort(port) { const pids = new Set(); try { const out = execFileSync("ss", ["-ltnp", `sport = :${port}`], { encoding: "utf8" }); for (const match of out.matchAll(/pid=(\d+)/g)) { pids.add(Number(match[1])); } } catch { // ignore } if (pids.size > 0) return [...pids]; try { const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { encoding: "utf8" }); for (const line of out.split(/\r?\n/)) { const pid = Number(line.trim()); if (Number.isInteger(pid) && pid > 0) pids.add(pid); } } catch { // ignore } return [...pids]; } async function stopPreviousProcess(port) { const state = readJsonIfExists(RUN_STATE_PATH); if (state?.pid) { const pid = Number(state.pid); if (isManagedMnoteProcess(pid, state.binaryPath || "")) { await stopProcess(pid); } } for (const pid of pidsListeningOnPort(port)) { if (isManagedMnoteProcess(pid)) { await stopProcess(pid); continue; } const commandLine = processCommandLine(pid); throw new Error(`端口 ${port} 已被非 mnote-web 进程占用: pid=${pid} ${commandLine}`); } } function requestLocalAuth(port) { return new Promise((resolve) => { const req = http.get( { host: "127.0.0.1", port, path: "/auth", timeout: 1000, }, (res) => { res.resume(); resolve(res.statusCode && res.statusCode < 500); }, ); req.on("timeout", () => { req.destroy(); resolve(false); }); req.on("error", () => resolve(false)); }); } async function waitForReady(port, logPath, pid) { for (let i = 0; i < 60; i += 1) { if (!isProcessAlive(pid)) break; if (await requestLocalAuth(port)) return; await sleep(250); } let logTail = ""; try { const content = await fsp.readFile(logPath, "utf8"); logTail = content.split(/\r?\n/).slice(-40).join(os.EOL); } catch { // ignore } throw new Error(`mnote-web 启动失败或未就绪,日志:\n${logTail}`); } async function startRelease(release) { const port = Number(process.env.MNOTE_PROD_PORT || DEFAULT_PORT); if (!Number.isInteger(port) || port <= 0 || port > 65535) { throw new Error(`MNOTE_PROD_PORT 非法: ${process.env.MNOTE_PROD_PORT}`); } await fsp.mkdir(RUN_DIR, { recursive: true }); await stopPreviousProcess(port); const binaryPath = path.join(release.releaseDir, "mnote-web"); const logPath = path.join(release.releaseDir, "mnote-web.log"); const logFd = fs.openSync(logPath, "a"); const bind = process.env.MNOTE_WEB_BIND || `0.0.0.0:${port}`; const publicBind = process.env.MNOTE_WEB_PUBLIC_BIND || defaultPublicBind(port); const child = spawn(binaryPath, [], { cwd: release.metadata?.sourceDir || release.releaseDir, detached: true, stdio: ["ignore", logFd, logFd], env: { ...process.env, MNOTE_WEB_BIND: bind, MNOTE_WEB_PUBLIC_BIND: publicBind, MNOTE_CONTROL_PLANE_DB_PATH: process.env.MNOTE_CONTROL_PLANE_DB_PATH || DEFAULT_CONTROL_PLANE_DB, }, }); child.unref(); fs.closeSync(logFd); const state = { schema: "mnote.prod_process.v1", pid: child.pid, version: release.version, gitHead: release.metadata?.gitHead, startedAt: new Date().toISOString(), bind, publicBind, releaseDir: release.releaseDir, binaryPath, logPath, }; await fsp.writeFile(RUN_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8"); await waitForReady(port, logPath, child.pid); console.log(`[prod] 已启动 mnote-web release ${release.version}`); console.log(`[prod] pid=${child.pid}`); console.log(`[prod] local URL=http://127.0.0.1:${port}`); for (const address of lanAddresses()) { console.log(`[prod] LAN URL=http://${address}:${port}`); } console.log(`[prod] log=${logPath}`); } async function main() { const head = currentGitHead(); await fsp.mkdir(RELEASE_ROOT, { recursive: true }); let release = await findReleaseForHead(head); if (release) { console.log(`[prod] 当前 HEAD ${head.slice(0, 12)} 已包含在 release ${release.version},跳过 build`); } else { release = await buildRelease(head); } await startRelease(release); } main().catch((error) => { console.error(`[prod] ${error.message}`); process.exit(1); });