You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
chajia/lib/rpa/spawn-address-candidates.ts

144 lines
4.1 KiB

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<MothershipCandidatesResult> {
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();
});
}