Files
2026-03-27 23:57:47 +08:00

567 lines
15 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import net from "node:net";
import http from "node:http";
import https from "node:https";
const STACK_DIR_NAME = ".dev-stack";
const DEFAULT_TEMPORAL_CANDIDATES = [
"temporal-sdk-python-1.24.0",
"temporal-sdk-python-1.25.0",
"temporal-sdk-python-1.26.0",
"temporal-cli",
];
export function getDefaultTemporalCandidates(tempDirectory) {
return DEFAULT_TEMPORAL_CANDIDATES.map((name) => path.join(tempDirectory, name));
}
export function resolveTemporalCli({
pathLookupResult,
candidatePaths,
existingPaths,
}) {
if (pathLookupResult && existingPaths.has(pathLookupResult)) {
return pathLookupResult;
}
for (const candidatePath of candidatePaths) {
if (existingPaths.has(candidatePath)) {
return candidatePath;
}
}
return null;
}
export function createStackConfig(frontendRoot, backendRoot = path.join(path.dirname(frontendRoot), "auto-virtual-tryon")) {
const runtimeRoot = path.join(frontendRoot, STACK_DIR_NAME);
const pidRoot = path.join(runtimeRoot, "pids");
const logRoot = path.join(runtimeRoot, "logs");
const temporalDatabaseFile = path.join(runtimeRoot, "temporal-cli-dev.db");
const backendPython = path.join(backendRoot, ".venv", "bin", "python");
const frontendNext = path.join(frontendRoot, "node_modules", ".bin", "next");
return {
frontendRoot,
backendRoot,
runtimeRoot,
pidRoot,
logRoot,
temporalDatabaseFile,
services: [
{
key: "temporal",
cwd: runtimeRoot,
port: 7233,
command: [
"__TEMPORAL_CLI__",
"server",
"start-dev",
"--ip",
"127.0.0.1",
"--port",
"7233",
"--headless",
"--db-filename",
temporalDatabaseFile,
],
},
{
key: "backend-api",
cwd: backendRoot,
port: 8000,
command: [
backendPython,
"-m",
"uvicorn",
"app.main:app",
"--host",
"127.0.0.1",
"--port",
"8000",
],
},
{
key: "backend-worker",
cwd: backendRoot,
port: null,
command: [backendPython, "-m", "app.workers.runner"],
},
{
key: "frontend",
cwd: frontendRoot,
port: 3000,
command: [frontendNext, "dev", "--hostname", "127.0.0.1", "--port", "3000"],
},
],
};
}
export function selectServicesForLogs(services, serviceFilter) {
if (!serviceFilter) {
return services;
}
const matched = services.find((service) => service.key === serviceFilter);
if (!matched) {
const supportedKeys = services.map((service) => service.key).join(", ");
throw new Error(`Unknown service "${serviceFilter}". Expected one of: ${supportedKeys}`);
}
return [matched];
}
export function formatServiceLogs(entries) {
return entries
.flatMap((entry, index) => {
const header = `--- ${entry.key} (${entry.logFilePath}) ---`;
const body = entry.lines.length > 0 ? entry.lines : ["(log file is empty)"];
return [...(index === 0 ? [] : [""]), header, ...body];
})
.join("\n");
}
function ensureDirectory(directoryPath) {
fs.mkdirSync(directoryPath, { recursive: true });
}
function getServicePidFile(config, serviceKey) {
return path.join(config.pidRoot, `${serviceKey}.pid`);
}
function getServiceLogFile(config, serviceKey) {
return path.join(config.logRoot, `${serviceKey}.log`);
}
function readLastLogLines(logFilePath, lineLimit) {
if (!fs.existsSync(logFilePath)) {
return ["(log file not found yet)"];
}
const raw = fs.readFileSync(logFilePath, "utf8");
if (!raw.trim()) {
return [];
}
const normalizedLines = raw.replace(/\r\n/g, "\n").split("\n");
if (normalizedLines.at(-1) === "") {
normalizedLines.pop();
}
return normalizedLines.slice(-lineLimit);
}
function readPid(pidFilePath) {
if (!fs.existsSync(pidFilePath)) {
return null;
}
const raw = fs.readFileSync(pidFilePath, "utf8").trim();
if (!raw) {
return null;
}
const parsed = Number(raw);
return Number.isInteger(parsed) ? parsed : null;
}
function isPidRunning(pid) {
if (!pid) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function readProcessCommand(pid) {
if (!pid) {
return null;
}
const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], {
encoding: "utf8",
});
if (result.status !== 0) {
return null;
}
const command = result.stdout.trim();
return command.length > 0 ? command : null;
}
export function getServiceCommandMatchers(service) {
switch (service.key) {
case "temporal":
return ["temporal", "server", "start-dev"];
case "backend-api":
return ["uvicorn", "app.main:app"];
case "backend-worker":
return ["app.workers.runner"];
case "frontend":
return ["next", "dev", "127.0.0.1", "3000"];
default:
return service.command.map((part) => path.basename(part));
}
}
export function isServiceProcessMatch(service, processCommand) {
if (!processCommand) {
return false;
}
const normalizedCommand = processCommand.toLowerCase();
return getServiceCommandMatchers(service).every((matcher) =>
normalizedCommand.includes(matcher.toLowerCase()),
);
}
export function isServiceHealthy(service, { pid, processCommand, portOpen }) {
if (!pid || !isServiceProcessMatch(service, processCommand)) {
return false;
}
if (service.port) {
return portOpen;
}
return true;
}
function removePidFile(pidFilePath) {
if (fs.existsSync(pidFilePath)) {
fs.unlinkSync(pidFilePath);
}
}
function pathExists(candidatePath) {
try {
fs.accessSync(candidatePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function getTemporalCliPath() {
const pathLookup = spawnSync("which", ["temporal"], { encoding: "utf8" });
const fromPath = pathLookup.status === 0 ? pathLookup.stdout.trim() : null;
const tempDirectory = process.env.TMPDIR || "/tmp";
const candidatePaths = getDefaultTemporalCandidates(tempDirectory);
const existingPaths = new Set(
[fromPath, ...candidatePaths].filter(Boolean).filter((candidatePath) => pathExists(candidatePath)),
);
return resolveTemporalCli({
pathLookupResult: fromPath,
candidatePaths,
existingPaths,
});
}
function materializeServices(config, temporalCliPath) {
return config.services.map((service) => ({
...service,
command: service.command.map((part) => (part === "__TEMPORAL_CLI__" ? temporalCliPath : part)),
}));
}
function waitForPort(port, timeoutMs) {
return new Promise((resolve, reject) => {
const startedAt = Date.now();
const attempt = () => {
const socket = net.createConnection({ host: "127.0.0.1", port });
socket.once("connect", () => {
socket.end();
resolve();
});
socket.once("error", () => {
socket.destroy();
if (Date.now() - startedAt >= timeoutMs) {
reject(new Error(`Timed out waiting for port ${port}`));
return;
}
setTimeout(attempt, 250);
});
};
attempt();
});
}
function waitForHttp(url, timeoutMs) {
const client = url.startsWith("https://") ? https : http;
return new Promise((resolve, reject) => {
const startedAt = Date.now();
const attempt = () => {
const request = client.get(url, (response) => {
response.resume();
if (response.statusCode && response.statusCode < 500) {
resolve();
return;
}
if (Date.now() - startedAt >= timeoutMs) {
reject(new Error(`Timed out waiting for ${url}`));
return;
}
setTimeout(attempt, 250);
});
request.on("error", () => {
if (Date.now() - startedAt >= timeoutMs) {
reject(new Error(`Timed out waiting for ${url}`));
return;
}
setTimeout(attempt, 250);
});
};
attempt();
});
}
function isLocalPortBusy(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host: "127.0.0.1", port });
socket.once("connect", () => {
socket.end();
resolve(true);
});
socket.once("error", () => {
socket.destroy();
resolve(false);
});
});
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function stopService(config, service) {
const pidFilePath = getServicePidFile(config, service.key);
const pid = readPid(pidFilePath);
if (!pid) {
return false;
}
if (!isPidRunning(pid)) {
removePidFile(pidFilePath);
return false;
}
const processCommand = readProcessCommand(pid);
if (!isServiceProcessMatch(service, processCommand)) {
removePidFile(pidFilePath);
return false;
}
process.kill(-pid, "SIGTERM");
for (let index = 0; index < 20; index += 1) {
if (!isPidRunning(pid)) {
removePidFile(pidFilePath);
return true;
}
await sleep(250);
}
process.kill(-pid, "SIGKILL");
removePidFile(pidFilePath);
return true;
}
async function startService(config, service) {
const pidFilePath = getServicePidFile(config, service.key);
const logFilePath = getServiceLogFile(config, service.key);
const existingPid = readPid(pidFilePath);
if (existingPid && isPidRunning(existingPid)) {
return { key: service.key, pid: existingPid, reused: true };
}
removePidFile(pidFilePath);
if (service.port && (await isLocalPortBusy(service.port))) {
throw new Error(`Port ${service.port} is already in use. Stop the existing process before running stack:start.`);
}
ensureDirectory(path.dirname(logFilePath));
const logDescriptor = fs.openSync(logFilePath, "a");
const child = spawn(service.command[0], service.command.slice(1), {
cwd: service.cwd,
detached: true,
stdio: ["ignore", logDescriptor, logDescriptor],
env: {
...process.env,
BACKEND_BASE_URL: process.env.BACKEND_BASE_URL || "http://127.0.0.1:8000/api/v1",
},
});
child.unref();
fs.writeFileSync(pidFilePath, `${child.pid}\n`, "utf8");
fs.closeSync(logDescriptor);
if (service.key === "temporal") {
await waitForPort(7233, 15_000);
} else if (service.key === "backend-api") {
await waitForHttp("http://127.0.0.1:8000/healthz", 15_000);
} else if (service.key === "frontend") {
await waitForHttp("http://127.0.0.1:3000", 20_000);
} else {
await sleep(1_500);
}
if (!isPidRunning(child.pid)) {
throw new Error(`${service.key} exited early. Check ${logFilePath}`);
}
return { key: service.key, pid: child.pid, reused: false };
}
async function startStack() {
const frontendRoot = process.cwd();
const config = createStackConfig(frontendRoot);
const temporalCliPath = getTemporalCliPath();
if (!temporalCliPath) {
throw new Error(
"Temporal CLI not found. Install `temporal` or ensure the SDK-downloaded CLI exists under TMPDIR.",
);
}
ensureDirectory(config.runtimeRoot);
ensureDirectory(config.pidRoot);
ensureDirectory(config.logRoot);
const services = materializeServices(config, temporalCliPath);
const startedServices = [];
try {
for (const service of services) {
const result = await startService(config, service);
startedServices.push(service);
const marker = result.reused ? "reused" : "started";
console.log(`${service.key}: ${marker} (pid ${result.pid})`);
}
console.log(`frontend: http://127.0.0.1:3000`);
console.log(`backend: http://127.0.0.1:8000`);
console.log(`temporal: 127.0.0.1:7233`);
} catch (error) {
for (const service of startedServices.reverse()) {
await stopService(config, service);
}
throw error;
}
}
async function stopStack() {
const frontendRoot = process.cwd();
const config = createStackConfig(frontendRoot);
const temporalCliPath = getTemporalCliPath() || "__TEMPORAL_CLI__";
const services = materializeServices(config, temporalCliPath);
for (const service of [...services].reverse()) {
const stopped = await stopService(config, service);
console.log(`${service.key}: ${stopped ? "stopped" : "not running"}`);
}
}
async function printStatus() {
const frontendRoot = process.cwd();
const config = createStackConfig(frontendRoot);
const temporalCliPath = getTemporalCliPath() || "__TEMPORAL_CLI__";
const services = materializeServices(config, temporalCliPath);
for (const service of services) {
const pidFilePath = getServicePidFile(config, service.key);
const logFilePath = getServiceLogFile(config, service.key);
const pid = readPid(pidFilePath);
const processCommand =
pid && isPidRunning(pid) ? readProcessCommand(pid) : null;
const portOpen = service.port ? await isLocalPortBusy(service.port) : false;
const running = isServiceHealthy(service, {
pid,
processCommand,
portOpen,
});
if (!running && pid) {
removePidFile(pidFilePath);
}
const displayPid = running ? pid : null;
const portText = service.port ? ` port=${service.port}` : "";
const logText = fs.existsSync(logFilePath) ? ` log=${logFilePath}` : "";
console.log(`${service.key}: ${running ? "running" : "stopped"} pid=${displayPid ?? "-"}${portText}${logText}`);
}
}
async function printLogs() {
const frontendRoot = process.cwd();
const config = createStackConfig(frontendRoot);
const temporalCliPath = getTemporalCliPath() || "__TEMPORAL_CLI__";
const services = materializeServices(config, temporalCliPath);
const serviceFilter = process.env.SERVICE || null;
const lineLimit = Number(process.env.LINES || "80");
const selectedServices = selectServicesForLogs(services, serviceFilter);
const entries = selectedServices.map((service) => {
const logFilePath = getServiceLogFile(config, service.key);
return {
key: service.key,
logFilePath,
lines: readLastLogLines(logFilePath, Number.isFinite(lineLimit) && lineLimit > 0 ? lineLimit : 80),
};
});
console.log(formatServiceLogs(entries));
}
async function main() {
const command = process.argv[2];
if (command === "start") {
await startStack();
return;
}
if (command === "stop") {
await stopStack();
return;
}
if (command === "status") {
await printStatus();
return;
}
if (command === "logs") {
await printLogs();
return;
}
console.error("Usage: node scripts/dev-stack/stack.mjs <start|stop|status|logs>");
process.exitCode = 1;
}
const currentFilePath = fileURLToPath(import.meta.url);
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}