mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:50:46 +00:00
build: harden tsdown wrapper
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -17,6 +17,10 @@ const INEFFECTIVE_DYNAMIC_IMPORT_RE = /\[INEFFECTIVE_DYNAMIC_IMPORT\]/;
|
||||
const UNRESOLVED_IMPORT_RE = /\[UNRESOLVED_IMPORT\]/;
|
||||
const ANSI_ESCAPE_RE = new RegExp(String.raw`\u001B\[[0-9;]*m`, "g");
|
||||
const HASHED_ROOT_JS_RE = /^(?<base>.+)-[A-Za-z0-9_-]+\.js$/u;
|
||||
const DEFAULT_CAPTURE_BYTES = 8 * 1024 * 1024;
|
||||
const DEFAULT_HEARTBEAT_MS = 30_000;
|
||||
const TERMINATION_GRACE_MS = 5_000;
|
||||
const TSDOWN_OUTPUT_ROOTS = ["dist", "dist-runtime"];
|
||||
|
||||
function removeDistPluginNodeModulesSymlinks(rootDir) {
|
||||
const extensionsDir = path.join(rootDir, "extensions");
|
||||
@@ -48,10 +52,23 @@ function pruneStaleRuntimeSymlinks() {
|
||||
removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist-runtime"));
|
||||
}
|
||||
|
||||
export function cleanTsdownOutputRoots(params = {}) {
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const fsImpl = params.fs ?? fs;
|
||||
for (const root of TSDOWN_OUTPUT_ROOTS) {
|
||||
const rootPath = path.join(cwd, root);
|
||||
try {
|
||||
fsImpl.rmSync(rootPath, { force: true, recursive: true });
|
||||
} catch {
|
||||
// Best-effort cleanup. tsdown will recreate the output tree it needs.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneStaleRootChunkFiles(params = {}) {
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const fsImpl = params.fs ?? fs;
|
||||
const roots = [path.join(cwd, "dist"), path.join(cwd, "dist-runtime")];
|
||||
const roots = TSDOWN_OUTPUT_ROOTS.map((root) => path.join(cwd, root));
|
||||
for (const root of roots) {
|
||||
let entries = [];
|
||||
try {
|
||||
@@ -112,10 +129,83 @@ function findFatalUnresolvedImport(lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
export function createTsdownOutputScanner(params = {}) {
|
||||
const maxCaptureBytes = params.maxCaptureBytes ?? DEFAULT_CAPTURE_BYTES;
|
||||
let captured = "";
|
||||
let pendingLine = "";
|
||||
let hasIneffectiveDynamicImport = false;
|
||||
let fatalUnresolvedImport = null;
|
||||
|
||||
function scanLines(text) {
|
||||
const combined = pendingLine + text;
|
||||
const lines = combined.split(/\r?\n/u);
|
||||
pendingLine = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
fatalUnresolvedImport ??= findFatalUnresolvedImport([line]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
append(chunk) {
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
||||
if (INEFFECTIVE_DYNAMIC_IMPORT_RE.test(text)) {
|
||||
hasIneffectiveDynamicImport = true;
|
||||
}
|
||||
scanLines(text);
|
||||
captured += text;
|
||||
if (captured.length > maxCaptureBytes) {
|
||||
captured = captured.slice(-maxCaptureBytes);
|
||||
}
|
||||
},
|
||||
finish() {
|
||||
if (pendingLine) {
|
||||
fatalUnresolvedImport ??= findFatalUnresolvedImport([pendingLine]);
|
||||
pendingLine = "";
|
||||
}
|
||||
return {
|
||||
captured,
|
||||
hasIneffectiveDynamicImport,
|
||||
fatalUnresolvedImport,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTsdownBuildInvocation(params = {}) {
|
||||
const env = params.env ?? process.env;
|
||||
const runner = resolvePnpmRunner({
|
||||
pnpmArgs: ["exec", "tsdown", "--config-loader", "unrun", "--logLevel", logLevel, ...extraArgs],
|
||||
pnpmArgs: [
|
||||
"exec",
|
||||
"tsdown",
|
||||
"--config-loader",
|
||||
"unrun",
|
||||
"--logLevel",
|
||||
logLevel,
|
||||
"--no-clean",
|
||||
...extraArgs,
|
||||
],
|
||||
nodeExecPath: params.nodeExecPath ?? process.execPath,
|
||||
npmExecPath: params.npmExecPath ?? env.npm_execpath,
|
||||
comSpec: params.comSpec ?? env.ComSpec,
|
||||
@@ -125,8 +215,7 @@ export function resolveTsdownBuildInvocation(params = {}) {
|
||||
command: runner.command,
|
||||
args: runner.args,
|
||||
options: {
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: runner.shell,
|
||||
windowsVerbatimArguments: runner.windowsVerbatimArguments,
|
||||
env,
|
||||
@@ -134,6 +223,99 @@ export function resolveTsdownBuildInvocation(params = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function runTsdownBuildInvocation(invocation, params = {}) {
|
||||
const stdout = params.stdout ?? process.stdout;
|
||||
const stderr = params.stderr ?? process.stderr;
|
||||
const env = params.env ?? process.env;
|
||||
const scanner = params.scanner ?? createTsdownOutputScanner();
|
||||
const timeoutMs = parsePositiveInteger(env.OPENCLAW_TSDOWN_TIMEOUT_MS);
|
||||
const heartbeatMs =
|
||||
parseNonNegativeInteger(env.OPENCLAW_TSDOWN_HEARTBEAT_MS) ?? DEFAULT_HEARTBEAT_MS;
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
let lastOutputAt = Date.now();
|
||||
|
||||
const child = spawn(invocation.command, invocation.args, invocation.options);
|
||||
const pidText = child.pid ? ` pid=${child.pid}` : "";
|
||||
|
||||
function markOutput() {
|
||||
lastOutputAt = Date.now();
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
markOutput();
|
||||
scanner.append(chunk);
|
||||
stdout.write(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
markOutput();
|
||||
scanner.append(chunk);
|
||||
stderr.write(chunk);
|
||||
});
|
||||
|
||||
const heartbeat =
|
||||
heartbeatMs > 0
|
||||
? setInterval(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
const silentForMs = Date.now() - lastOutputAt;
|
||||
if (silentForMs < heartbeatMs) {
|
||||
return;
|
||||
}
|
||||
stderr.write(
|
||||
`[tsdown-build] still running${pidText}; no output for ${Math.round(
|
||||
silentForMs / 1000,
|
||||
)}s\n`,
|
||||
);
|
||||
lastOutputAt = Date.now();
|
||||
}, heartbeatMs).unref()
|
||||
: null;
|
||||
|
||||
const timeout =
|
||||
timeoutMs !== null
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
stderr.write(`[tsdown-build] timeout after ${timeoutMs}ms${pidText}; sending SIGTERM\n`);
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
stderr.write(`[tsdown-build] forcing SIGKILL${pidText}\n`);
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, TERMINATION_GRACE_MS).unref();
|
||||
}, timeoutMs).unref()
|
||||
: null;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
child.once("error", (error) => {
|
||||
settled = true;
|
||||
clearInterval(heartbeat);
|
||||
clearTimeout(timeout);
|
||||
stderr.write(`[tsdown-build] failed to start: ${String(error)}\n`);
|
||||
resolve({
|
||||
status: 1,
|
||||
signal: null,
|
||||
timedOut,
|
||||
error,
|
||||
...scanner.finish(),
|
||||
});
|
||||
});
|
||||
child.once("close", (status, signal) => {
|
||||
settled = true;
|
||||
clearInterval(heartbeat);
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
status,
|
||||
signal,
|
||||
timedOut,
|
||||
error: null,
|
||||
...scanner.finish(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
const argv1 = process.argv[1];
|
||||
if (!argv1) {
|
||||
@@ -145,34 +327,28 @@ function isMainModule() {
|
||||
if (isMainModule()) {
|
||||
pruneSourceCheckoutBundledPluginNodeModules();
|
||||
pruneStaleRuntimeSymlinks();
|
||||
pruneStaleRootChunkFiles();
|
||||
cleanTsdownOutputRoots();
|
||||
const invocation = resolveTsdownBuildInvocation();
|
||||
const result = spawnSync(invocation.command, invocation.args, invocation.options);
|
||||
const result = await runTsdownBuildInvocation(invocation);
|
||||
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
if (stdout) {
|
||||
process.stdout.write(stdout);
|
||||
}
|
||||
if (stderr) {
|
||||
process.stderr.write(stderr);
|
||||
}
|
||||
|
||||
if (result.status === 0 && INEFFECTIVE_DYNAMIC_IMPORT_RE.test(`${stdout}\n${stderr}`)) {
|
||||
if (result.status === 0 && result.hasIneffectiveDynamicImport) {
|
||||
console.error(
|
||||
"Build emitted [INEFFECTIVE_DYNAMIC_IMPORT]. Replace transparent runtime re-export facades with real runtime boundaries.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fatalUnresolvedImport =
|
||||
result.status === 0 ? findFatalUnresolvedImport(`${stdout}\n${stderr}`.split("\n")) : null;
|
||||
|
||||
if (fatalUnresolvedImport) {
|
||||
console.error(`Build emitted [UNRESOLVED_IMPORT] outside extensions: ${fatalUnresolvedImport}`);
|
||||
if (result.status === 0 && result.fatalUnresolvedImport) {
|
||||
console.error(
|
||||
`Build emitted [UNRESOLVED_IMPORT] outside extensions: ${result.fatalUnresolvedImport}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.timedOut) {
|
||||
process.exit(124);
|
||||
}
|
||||
|
||||
if (typeof result.status === "number") {
|
||||
process.exit(result.status);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ import fsPromises from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanTsdownOutputRoots,
|
||||
createTsdownOutputScanner,
|
||||
pruneSourceCheckoutBundledPluginNodeModules,
|
||||
pruneStaleRootChunkFiles,
|
||||
resolveTsdownBuildInvocation,
|
||||
runTsdownBuildInvocation,
|
||||
} from "../../scripts/tsdown-build.mjs";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
@@ -30,10 +33,10 @@ describe("resolveTsdownBuildInvocation", () => {
|
||||
"unrun",
|
||||
"--logLevel",
|
||||
"warn",
|
||||
"--no-clean",
|
||||
],
|
||||
options: {
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: false,
|
||||
windowsVerbatimArguments: undefined,
|
||||
env: {},
|
||||
@@ -102,4 +105,119 @@ describe("resolveTsdownBuildInvocation", () => {
|
||||
fsPromises.stat(path.join(distRuntimeDir, "heartbeat-runner.runtime-fspOEj_1.js")),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("cleans tsdown output roots before using tsdown --no-clean", async () => {
|
||||
const rootDir = createTempDir("openclaw-tsdown-clean-");
|
||||
const distFile = path.join(rootDir, "dist", "stale.js");
|
||||
const distRuntimeFile = path.join(rootDir, "dist-runtime", "stale.js");
|
||||
const unrelatedFile = path.join(rootDir, "tmp", "keep.js");
|
||||
await fsPromises.mkdir(path.dirname(distFile), { recursive: true });
|
||||
await fsPromises.mkdir(path.dirname(distRuntimeFile), { recursive: true });
|
||||
await fsPromises.mkdir(path.dirname(unrelatedFile), { recursive: true });
|
||||
await fsPromises.writeFile(distFile, "stale\n");
|
||||
await fsPromises.writeFile(distRuntimeFile, "stale\n");
|
||||
await fsPromises.writeFile(unrelatedFile, "keep\n");
|
||||
|
||||
cleanTsdownOutputRoots({ cwd: rootDir });
|
||||
|
||||
await expect(fsPromises.stat(path.join(rootDir, "dist"))).rejects.toThrow();
|
||||
await expect(fsPromises.stat(path.join(rootDir, "dist-runtime"))).rejects.toThrow();
|
||||
await expect(fsPromises.readFile(unrelatedFile, "utf8")).resolves.toBe("keep\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTsdownOutputScanner", () => {
|
||||
it("tracks fatal build diagnostics while bounding captured output", () => {
|
||||
const scanner = createTsdownOutputScanner({ maxCaptureBytes: 20 });
|
||||
|
||||
scanner.append("prefix that should be trimmed\n");
|
||||
scanner.append("[INEFFECTIVE_DYNAMIC_IMPORT]\n");
|
||||
scanner.append("[UNRESOLVED_IMPORT] src/index.ts\n");
|
||||
|
||||
const result = scanner.finish();
|
||||
|
||||
expect(result.hasIneffectiveDynamicImport).toBe(true);
|
||||
expect(result.fatalUnresolvedImport).toContain("[UNRESOLVED_IMPORT] src/index.ts");
|
||||
expect(result.captured.length).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
it("ignores unresolved imports from bundled plugin and dependency paths", () => {
|
||||
const scanner = createTsdownOutputScanner();
|
||||
|
||||
scanner.append("[UNRESOLVED_IMPORT] extensions/telegram/src/index.ts\n");
|
||||
scanner.append("[UNRESOLVED_IMPORT] node_modules/example/index.js\n");
|
||||
|
||||
expect(scanner.finish().fatalUnresolvedImport).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTsdownBuildInvocation", () => {
|
||||
function createWriteSink() {
|
||||
const chunks: string[] = [];
|
||||
return {
|
||||
sink: {
|
||||
write(chunk: unknown) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
chunks,
|
||||
};
|
||||
}
|
||||
|
||||
it("streams child output while preserving diagnostics for post-run checks", async () => {
|
||||
const output = createWriteSink();
|
||||
const result = await runTsdownBuildInvocation(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write('stdout-ok\\n'); process.stderr.write('[INEFFECTIVE_DYNAMIC_IMPORT]\\n')",
|
||||
],
|
||||
options: {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: false,
|
||||
env: process.env,
|
||||
},
|
||||
},
|
||||
{
|
||||
stdout: output.sink,
|
||||
stderr: output.sink,
|
||||
env: { ...process.env, OPENCLAW_TSDOWN_HEARTBEAT_MS: "0" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.hasIneffectiveDynamicImport).toBe(true);
|
||||
expect(output.chunks.join("")).toContain("stdout-ok");
|
||||
});
|
||||
|
||||
it("terminates the child when OPENCLAW_TSDOWN_TIMEOUT_MS elapses", async () => {
|
||||
const output = createWriteSink();
|
||||
const result = await runTsdownBuildInvocation(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ["-e", "setTimeout(() => {}, 10000)"],
|
||||
options: {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: false,
|
||||
env: process.env,
|
||||
},
|
||||
},
|
||||
{
|
||||
stdout: output.sink,
|
||||
stderr: output.sink,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_TSDOWN_HEARTBEAT_MS: "0",
|
||||
OPENCLAW_TSDOWN_TIMEOUT_MS: "50",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.status).toBeNull();
|
||||
expect(result.signal).toBe("SIGTERM");
|
||||
expect(output.chunks.join("")).toContain("timeout after 50ms");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user