import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { ADDRESS_CANDIDATES_TIMEOUT_MS } from "@/lib/constants/rpa"; import { readInfraEnvOverrides } from "@/lib/dev-env"; import type { AddressLookupInput, MothershipCandidatesResult, } from "@/modules/address/types"; import { RpaError, type RpaErrorCode } from "@/modules/rpa/errors"; const CHILD_STARTUP_BUFFER_MS = 15_000; type ChildSuccess = { ok: true; data: MothershipCandidatesResult }; type ChildFailure = { ok: false; code: RpaErrorCode | "INTERNAL_ERROR"; message: string; retryable?: boolean; }; function resolveTsxCommand(): { command: string; args: string[]; shell: boolean } { const root = process.cwd(); const script = path.join(root, "scripts/rpa/fetch-address-candidates.ts"); const tsxCmd = path.join(root, "node_modules", ".bin", "tsx.cmd"); const tsxBin = path.join(root, "node_modules", ".bin", "tsx"); if (process.platform === "win32" && fs.existsSync(tsxCmd)) { return { command: tsxCmd, args: [script], shell: true }; } if (fs.existsSync(tsxBin)) { return { command: tsxBin, args: [script], shell: false }; } return { command: process.platform === "win32" ? "npx.cmd" : "npx", args: ["tsx", script], shell: process.platform === "win32", }; } function parseChildPayload(stdout: string): ChildSuccess | ChildFailure | null { const lines = stdout .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); for (let i = lines.length - 1; i >= 0; i -= 1) { try { const parsed = JSON.parse(lines[i]!) as ChildSuccess | ChildFailure; if (typeof parsed === "object" && parsed !== null && "ok" in parsed) { return parsed; } } catch { /* try previous line */ } } return null; } /** 在独立 Node 子进程运行 RPA 地址候选,规避 Next.js 打包 Playwright */ export async function spawnFetchMothershipAddressCandidates(input: { pickup: AddressLookupInput; delivery: AddressLookupInput; }): Promise { const { command, args, shell } = resolveTsxCommand(); const timeoutMs = ADDRESS_CANDIDATES_TIMEOUT_MS + CHILD_STARTUP_BUFFER_MS; return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: process.cwd(), env: { ...process.env, ...readInfraEnvOverrides() }, stdio: ["pipe", "pipe", "pipe"], shell, }); let stdout = ""; let stderr = ""; let settled = false; const finish = (fn: () => void) => { if (settled) { return; } settled = true; clearTimeout(timer); fn(); }; const timer = setTimeout(() => { child.kill("SIGTERM"); finish(() => { reject( new RpaError( "PAGE_LOAD_TIMEOUT", "MotherShip 地址联想超时,请检查网络或稍后重试", { retryable: true }, ), ); }); }, timeoutMs); child.stdout.on("data", (chunk: Buffer | string) => { stdout += chunk.toString(); }); child.stderr.on("data", (chunk: Buffer | string) => { stderr += chunk.toString(); }); child.on("error", (error) => { finish(() => reject(error)); }); child.on("close", (code) => { const payload = parseChildPayload(stdout); if (payload?.ok) { finish(() => resolve(payload.data)); return; } if (payload && !payload.ok) { finish(() => { reject( new RpaError(payload.code as RpaErrorCode, payload.message, { retryable: payload.retryable ?? false, }), ); }); return; } const detail = stderr.trim() || stdout.trim() || `exit code ${code ?? "unknown"}`; finish(() => { if (code === 0) { reject(new Error(`地址候选子进程输出无效:${detail.slice(0, 240)}`)); return; } reject(new Error(`地址候选 RPA 子进程失败:${detail.slice(0, 240)}`)); }); }); child.stdin.write(JSON.stringify(input)); child.stdin.end(); }); }