From 4cb4aad7b12a455114aa7ce61abc1c5eea616ba9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 21:54:46 +0100 Subject: [PATCH] build: harden tsdown wrapper --- scripts/tsdown-build.mjs | 220 +++++++++++++++++++++++++++--- test/scripts/tsdown-build.test.ts | 122 ++++++++++++++++- 2 files changed, 318 insertions(+), 24 deletions(-) diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 0e11c439e44..30e36273514 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -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 = /^(?.+)-[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); } diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts index fe0f0ab5e6d..2203d74a15c 100644 --- a/test/scripts/tsdown-build.test.ts +++ b/test/scripts/tsdown-build.test.ts @@ -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"); + }); });