From bb7fccae62d2a7e3f42ce972e8c8c97467311018 Mon Sep 17 00:00:00 2001 From: Adrien Cacciaguerra Date: Thu, 15 Oct 2026 16:39:11 +0000 Subject: [PATCH 1/9] feat(core): keep tsx transforms out of measurements and profiles When a benchmark runs with tsx under CodSpeed, the introspected flags now preload a loader ahead of tsx that patches the esbuild instance tsx uses: - `keepNames` and `minifyWhitespace` are turned off for tsx's own transforms. `keepNames` wraps functions in `__name()` calls that run inside the measured code (+30% on closure-heavy benchmarks) and hide arrow function names from V8, so flamegraphs showed `anonymous function at line 2` instead of the name. - esbuild's service processes start without `--ping`. With it, they ping their host thread every second, and that thread's work was counted in random simulation measurements. The loader is a `--require` preload, so it runs before tsx's own preloads. When tsx cannot be patched, it warns and leaves tsx's defaults. Refs COD-3900 Co-Authored-By: Claude --- packages/core/rollup.config.mjs | 5 + packages/core/src/introspection.ts | 6 + packages/core/src/tsx.ts | 48 ++++++++ packages/core/src/tsxLoader.ts | 186 +++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 packages/core/src/tsx.ts create mode 100644 packages/core/src/tsxLoader.ts diff --git a/packages/core/rollup.config.mjs b/packages/core/rollup.config.mjs index b7b3a87d..509d38db 100644 --- a/packages/core/rollup.config.mjs +++ b/packages/core/rollup.config.mjs @@ -36,4 +36,9 @@ export default defineConfig([ warn(warning); }, }, + { + input: "src/tsxLoader.ts", + output: { file: "dist/tsx-loader.cjs", format: "cjs", sourcemap: true }, + plugins: jsPlugins(pkg.version), + }, ]); diff --git a/packages/core/src/introspection.ts b/packages/core/src/introspection.ts index f4ac29ef..98b1f211 100644 --- a/packages/core/src/introspection.ts +++ b/packages/core/src/introspection.ts @@ -2,6 +2,7 @@ import { writeFileSync } from "fs"; import path from "path"; import { getInstrumentMode } from "./runnerMode"; +import { findTsxPreload } from "./tsx"; const CUSTOM_INTROSPECTION_EXIT_CODE = 0; @@ -13,6 +14,11 @@ export const getV8Flags = () => { const flags = ["--interpreted-frames-native-stack", "--allow-natives-syntax"]; + if (findTsxPreload(process.execArgv) !== undefined) { + // `--require` preloads run before `--import` ones, such as tsx's + flags.push(`--require=${path.join(__dirname, "tsx-loader.cjs")}`); + } + switch (instrumentMode) { case "analysis": { flags.push( diff --git a/packages/core/src/tsx.ts b/packages/core/src/tsx.ts new file mode 100644 index 00000000..2630de34 --- /dev/null +++ b/packages/core/src/tsx.ts @@ -0,0 +1,48 @@ +import { createRequire } from "module"; +import path from "path"; +import { fileURLToPath } from "url"; + +const PRELOAD_FLAGS = ["--import", "--require", "-r", "--loader"]; +const TSX_PACKAGE_PATH = /[\\/]node_modules[\\/]tsx[\\/]/; + +const isBareTsxSpecifier = (specifier: string) => + specifier === "tsx" || specifier.startsWith("tsx/"); + +const isTsxSpecifier = (specifier: string) => + isBareTsxSpecifier(specifier) || TSX_PACKAGE_PATH.test(specifier); + +/** + * Returns the tsx module preloaded by these node arguments: the `tsx` + * specifier of `node --import tsx`, or the loader path the `tsx` CLI passes to + * the node process running the script. + */ +export const findTsxPreload = (execArgv: string[]): string | undefined => { + for (const [i, arg] of execArgv.entries()) { + const separator = arg.indexOf("="); + const flag = separator === -1 ? arg : arg.slice(0, separator); + const value = separator === -1 ? execArgv[i + 1] : arg.slice(separator + 1); + if ( + PRELOAD_FLAGS.includes(flag) && + value !== undefined && + isTsxSpecifier(value) + ) { + return value; + } + } + return undefined; +}; + +export const getTsxPackageDir = (tsxPreload: string): string => { + if (isBareTsxSpecifier(tsxPreload)) { + // Bare preload specifiers resolve from the working directory + const tsxPackageJson = createRequire( + path.join(process.cwd(), "index.js"), + ).resolve("tsx/package.json"); + return path.dirname(tsxPackageJson); + } + const tsxPath = path.resolve( + tsxPreload.startsWith("file:") ? fileURLToPath(tsxPreload) : tsxPreload, + ); + const match = TSX_PACKAGE_PATH.exec(tsxPath)!; + return tsxPath.slice(0, match.index + match[0].length - 1); +}; diff --git a/packages/core/src/tsxLoader.ts b/packages/core/src/tsxLoader.ts new file mode 100644 index 00000000..cdd88aff --- /dev/null +++ b/packages/core/src/tsxLoader.ts @@ -0,0 +1,186 @@ +/** + * Node.js loader, loaded before tsx, that changes how tsx's esbuild instance + * transforms files: + * - turns off `keepNames`, whose `__name()` wrappers run inside the measured + * code and hide arrow function names from V8, and `minifyWhitespace`, which + * puts each module on a single line; + * - starts esbuild's service processes without `--ping`, which makes them ping + * their host thread every second, in the middle of measurements. + */ +import { + createRequire, + register, + type InitializeHook, + type ResolveHookSync, +} from "node:module"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { + isMainThread, + workerData, + type WorkerOptions, +} from "node:worker_threads"; + +import { findTsxPreload, getTsxPackageDir } from "./tsx"; + +interface TransformOptions { + keepNames?: boolean; + minifyWhitespace?: boolean; + sourcemap?: boolean | string; + target?: string | string[]; +} + +interface Esbuild { + transform(input: string, options?: TransformOptions): Promise; + transformSync(input: string, options?: TransformOptions): unknown; +} + +interface LoaderData { + tsxDirUrl: string; + esbuildPath: string; +} + +const LOADER_URL = pathToFileURL(__filename).href; +const TSX_TARGET = `node${process.versions.node}`; +const loaderRequire = createRequire(__filename); + +let data: LoaderData; +let esbuild: Esbuild; + +// The esbuild instance can be shared with user code, so match tsx's own options +const overrideTsxOptions = (options?: TransformOptions) => { + const isTsxTransform = + options?.keepNames === true && + options.minifyWhitespace === true && + options.sourcemap === true && + options.target === TSX_TARGET; + return isTsxTransform + ? { ...options, keepNames: false, minifyWhitespace: false } + : options; +}; + +export const transform = (input: string, options?: TransformOptions) => + esbuild.transform(input, overrideTsxOptions(options)); + +export const transformSync = (input: string, options?: TransformOptions) => + esbuild.transformSync(input, overrideTsxOptions(options)); + +const dropEsbuildPing = () => { + const childProcess: typeof import("node:child_process") = + loaderRequire("node:child_process"); + const { spawn } = childProcess; + childProcess.spawn = ((command: string, ...rest: unknown[]) => { + const [args] = rest; + const isEsbuildService = + Array.isArray(args) && + args.some((arg) => String(arg).startsWith("--service=")); + if (isEsbuildService) { + rest[0] = args.filter((arg) => arg !== "--ping"); + } + return Reflect.apply(spawn, childProcess, [command, ...rest]); + }) as typeof spawn; +}; + +// esbuild's sync API runs its service from a worker thread of its own +const preloadInEsbuildWorkers = () => { + const workerThreads: typeof import("node:worker_threads") = loaderRequire( + "node:worker_threads", + ); + const { Worker } = workerThreads; + workerThreads.Worker = class extends Worker { + constructor(filename: string | URL, options?: WorkerOptions) { + const isEsbuildWorker = options?.workerData?.esbuildVersion !== undefined; + super( + filename, + isEsbuildWorker + ? { + ...options, + execArgv: [ + ...(options?.execArgv ?? []), + `--require=${__filename}`, + ], + } + : options, + ); + } + }; +}; + +const setup = (loaderData: LoaderData) => { + data = loaderData; + dropEsbuildPing(); + preloadInEsbuildWorkers(); + esbuild = loaderRequire(data.esbuildPath); + // Covers tsx's CommonJS chunks, which `require("esbuild")` + loaderRequire.cache[data.esbuildPath]!.exports = Object.defineProperty( + { ...esbuild, transform, transformSync }, + "__esModule", + { value: true }, + ); +}; + +export const initialize: InitializeHook = setup; + +// Covers tsx's ES module chunks, which `import ... from "esbuild"` +export const resolve: ResolveHookSync = (specifier, context, nextResolve) => { + const isEsbuildImportFromTsx = + specifier === "esbuild" && + !context.conditions.includes("require") && + context.parentURL?.startsWith(data.tsxDirUrl) === true; + if (!isEsbuildImportFromTsx) { + return nextResolve(specifier, context); + } + const source = [ + `export * from ${JSON.stringify(pathToFileURL(data.esbuildPath).href)};`, + `export { transform, transformSync } from ${JSON.stringify(LOADER_URL)};`, + ].join("\n"); + return { + url: `data:text/javascript,${encodeURIComponent(source)}`, + shortCircuit: true, + }; +}; + +// tsx registers its hooks in-thread with `registerHooks` on recent Node +// versions and off-thread with `register` otherwise, where ours must be +// registered first to resolve the imports of tsx's hooks module +const registerResolveHook = (loaderData: LoaderData) => { + const nodeModule: typeof import("node:module") = loaderRequire("node:module"); + if (typeof nodeModule.registerHooks !== "function") { + register(LOADER_URL, { data: loaderData }); + return; + } + nodeModule.registerHooks({ resolve }); + const offThreadRegister = nodeModule.register; + let isRegisteredOffThread = false; + nodeModule.register = ((...args: unknown[]) => { + if (!isRegisteredOffThread) { + isRegisteredOffThread = true; + offThreadRegister(LOADER_URL, { data: loaderData }); + } + return Reflect.apply(offThreadRegister, nodeModule, args); + }) as typeof offThreadRegister; + nodeModule.syncBuiltinESMExports(); +}; + +const tsxPreload = isMainThread ? findTsxPreload(process.execArgv) : undefined; +if (tsxPreload !== undefined) { + try { + const tsxDir = getTsxPackageDir(tsxPreload); + const loaderData: LoaderData = { + tsxDirUrl: pathToFileURL(tsxDir).href + "/", + esbuildPath: createRequire(path.join(tsxDir, "package.json")).resolve( + "esbuild", + ), + }; + // tsx caches transforms on disk, keyed on its own options + process.env.TSX_DISABLE_CACHE = "1"; + setup(loaderData); + registerResolveHook(loaderData); + } catch (error) { + console.warn( + `[CodSpeed] could not adjust tsx's transforms, keeping its defaults: ${error}`, + ); + } +} else if (workerData?.esbuildVersion !== undefined) { + dropEsbuildPing(); +} From 47ac8e7ece7f9bfb50c80767cd473300d7ac6609 Mon Sep 17 00:00:00 2001 From: Adrien Cacciaguerra Date: Thu, 15 Oct 2026 16:39:11 +0000 Subject: [PATCH 2/9] fix(tinybench-plugin): yield to the event loop before the first benchmark When the benchmark file calls `run()` while an ES module loader still has promise jobs queued, those jobs run between the awaits of the first measurements and add up to 250k instructions to a random early benchmark. Draining one event loop turn first makes these runs deterministic. Refs COD-3900 Co-Authored-By: Claude --- packages/tinybench-plugin/src/shared.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/tinybench-plugin/src/shared.ts b/packages/tinybench-plugin/src/shared.ts index 0fb811c5..e74d133f 100644 --- a/packages/tinybench-plugin/src/shared.ts +++ b/packages/tinybench-plugin/src/shared.ts @@ -58,6 +58,9 @@ export abstract class BaseBenchRunner { public setupBenchMethods(): void { this.bench.run = async () => { this.setupBenchRun(); + // Promise jobs still queued by module loading would otherwise run + // between the awaits of the first measurements + await new Promise((resolve) => setImmediate(resolve)); for (const task of this.bench.tasks) { const uri = this.getTaskUri(task); From 225710ae5768d79d1041f0d048faf951b69277f3 Mon Sep 17 00:00:00 2001 From: Adrien Cacciaguerra Date: Thu, 15 Oct 2026 16:39:11 +0000 Subject: [PATCH 3/9] chore: run the TypeScript CommonJS benches with tsx esbuild-register is no longer maintained. Use tsx, which the ES module examples already use, and drop esbuild-register. Refs COD-3900 Co-Authored-By: Claude --- examples/with-typescript-cjs/package.json | 6 +-- .../with-typescript-simple-cjs/package.json | 6 +-- package.json | 1 - packages/benchmark.js-plugin/package.json | 5 ++- pnpm-lock.yaml | 37 +++++-------------- 5 files changed, 18 insertions(+), 37 deletions(-) diff --git a/examples/with-typescript-cjs/package.json b/examples/with-typescript-cjs/package.json index 95a6b2ec..b858d866 100644 --- a/examples/with-typescript-cjs/package.json +++ b/examples/with-typescript-cjs/package.json @@ -2,16 +2,16 @@ "name": "with-typescript-cjs", "private": true, "scripts": { - "bench-benchmark-js": "node -r esbuild-register bench/benchmark.js/index.bench.ts", - "bench-tinybench": "node -r esbuild-register bench/tinybench/index.bench.ts" + "bench-benchmark-js": "node --import tsx bench/benchmark.js/index.bench.ts", + "bench-tinybench": "node --import tsx bench/tinybench/index.bench.ts" }, "devDependencies": { "@codspeed/benchmark.js-plugin": "workspace:*", "@codspeed/tinybench-plugin": "workspace:*", "@types/benchmark": "^2.1.2", "benchmark": "^2.1.4", - "esbuild-register": "^3.4.2", "tinybench": "^4.0.1", + "tsx": "^4.23.12", "typescript": "^5.1.3" } } diff --git a/examples/with-typescript-simple-cjs/package.json b/examples/with-typescript-simple-cjs/package.json index b60425e4..f65187e3 100644 --- a/examples/with-typescript-simple-cjs/package.json +++ b/examples/with-typescript-simple-cjs/package.json @@ -2,16 +2,16 @@ "name": "with-typescript-simple-cjs", "private": true, "scripts": { - "bench-benchmark-js": "node -r esbuild-register benchmark-js.ts", - "bench-tinybench": "node -r esbuild-register tinybench.ts" + "bench-benchmark-js": "node --import tsx benchmark-js.ts", + "bench-tinybench": "node --import tsx tinybench.ts" }, "devDependencies": { "@codspeed/benchmark.js-plugin": "workspace:*", "@codspeed/tinybench-plugin": "workspace:*", "@types/benchmark": "^2.1.2", "benchmark": "^2.1.4", - "esbuild-register": "^3.4.2", "tinybench": "^4.0.1", + "tsx": "^4.23.12", "typescript": "^5.1.3" } } diff --git a/package.json b/package.json index 0969bace..c5302cbd 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "@typescript-eslint/eslint-plugin": "^8.57.0", "@typescript-eslint/parser": "^8.57.0", "esbuild": "^0.17.16", - "esbuild-register": "^3.4.2", "eslint": "^8.57.1", "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-import": "^2.32.0", diff --git a/packages/benchmark.js-plugin/package.json b/packages/benchmark.js-plugin/package.json index 9ddb85f1..e4c6ea36 100644 --- a/packages/benchmark.js-plugin/package.json +++ b/packages/benchmark.js-plugin/package.json @@ -22,7 +22,7 @@ "typecheck": "tsc --noEmit --pretty", "format": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --check .", "fix-format": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --write .", - "bench": "node -r esbuild-register benches/sample.ts", + "bench": "node --import tsx benches/sample.ts", "clean": "rm -rf dist" }, "author": "Arthur Pastel ", @@ -34,7 +34,8 @@ "@types/benchmark": "^2.1.2", "@types/lodash": "^4.14.195", "benchmark": "^2.1.4", - "jest-mock-extended": "^3.0.4" + "jest-mock-extended": "^3.0.4", + "tsx": "^4.23.12" }, "dependencies": { "@codspeed/core": "workspace:^6.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5908c3d7..6110070b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,6 @@ importers: esbuild: specifier: ^0.17.16 version: 0.17.16 - esbuild-register: - specifier: ^3.4.2 - version: 3.4.2(esbuild@0.17.16) eslint: specifier: ^8.57.1 version: 8.57.1 @@ -155,12 +152,12 @@ importers: benchmark: specifier: ^2.1.4 version: 2.1.4 - esbuild-register: - specifier: ^3.4.2 - version: 3.4.2(esbuild@0.28.2) tinybench: specifier: ^4.0.1 version: 4.0.1 + tsx: + specifier: ^4.23.12 + version: 4.23.12 typescript: specifier: ^5.1.3 version: 5.1.3 @@ -209,12 +206,12 @@ importers: benchmark: specifier: ^2.1.4 version: 2.1.4 - esbuild-register: - specifier: ^3.4.2 - version: 3.4.2(esbuild@0.28.2) tinybench: specifier: ^4.0.1 version: 4.0.1 + tsx: + specifier: ^4.23.12 + version: 4.23.12 typescript: specifier: ^5.1.3 version: 5.1.3 @@ -267,6 +264,9 @@ importers: jest-mock-extended: specifier: ^3.0.4 version: 3.0.4(jest@29.7.0(@types/node@26.4.1)(ts-node@10.9.1(@types/node@26.4.1)(typescript@5.8.3)))(typescript@5.8.3) + tsx: + specifier: ^4.23.12 + version: 4.23.12 packages/core: dependencies: @@ -3569,11 +3569,6 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild-register@3.4.2: - resolution: {integrity: sha512-kG/XyTDyz6+YDuyfB9ZoSIOOmgyFCH+xPRtsCa8W85HLRV5Csp+o3jWVbOSHgSLfyLc5DmP+KFDNwty4mEjC+Q==} - peerDependencies: - esbuild: '>=0.12 <1' - esbuild@0.17.16: resolution: {integrity: sha512-aeSuUKr9aFVY9Dc8ETVELGgkj4urg5isYx8pLf4wlGgB0vTFjxJQdHnNH6Shmx4vYYrOTLCHtRI5i1XZ9l2Zcg==} engines: {node: '>=12'} @@ -10632,20 +10627,6 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 - esbuild-register@3.4.2(esbuild@0.17.16): - dependencies: - debug: 4.3.4 - esbuild: 0.17.16 - transitivePeerDependencies: - - supports-color - - esbuild-register@3.4.2(esbuild@0.28.2): - dependencies: - debug: 4.3.4 - esbuild: 0.28.2 - transitivePeerDependencies: - - supports-color - esbuild@0.17.16: optionalDependencies: '@esbuild/android-arm': 0.17.16 From 1f6c564207d6645122f3be75e605de89423b2f5a Mon Sep 17 00:00:00 2001 From: Adrien Cacciaguerra Date: Thu, 15 Oct 2026 16:39:11 +0000 Subject: [PATCH 4/9] test(tinybench-plugin): cover top-level await and an async benchmark Run the simple ES module example with a top-level `await bench.run()` and add an async benchmark, the shape users write when running TypeScript benchmarks with tsx. Refs COD-3900 Co-Authored-By: Claude --- examples/with-typescript-simple-esm/tinybench.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/examples/with-typescript-simple-esm/tinybench.ts b/examples/with-typescript-simple-esm/tinybench.ts index e6736b14..15cb52e3 100644 --- a/examples/with-typescript-simple-esm/tinybench.ts +++ b/examples/with-typescript-simple-esm/tinybench.ts @@ -1,6 +1,13 @@ import { withCodSpeed } from "@codspeed/tinybench-plugin"; import { Bench } from "tinybench"; +function fibonacci(n: number): number { + if (n < 2) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + const bench = withCodSpeed(new Bench({ time: 100 })); bench @@ -17,8 +24,11 @@ bench a = b + a; b = a - b; a = b - a; + }) + .add("async fibonacci 15", async () => { + await Promise.resolve(); + fibonacci(15); }); -bench.run().then(() => { - console.table(bench.table()); -}); +await bench.run(); +console.table(bench.table()); From 082f17c5d3a895e73648b89348481893a1ef5570 Mon Sep 17 00:00:00 2001 From: Adrien Cacciaguerra Date: Thu, 15 Oct 2026 16:39:11 +0000 Subject: [PATCH 5/9] docs: document how to get real measurements locally Refs COD-3900 Co-Authored-By: Claude --- CLAUDE.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 00a76a53..0336d3f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,4 +115,9 @@ so exercise all three of these when developing or reviewing a plugin change Running these locally outside the CodSpeed runner is expected to log `instrument-hooks: failed to write environment.json` and skip actual measurement writes — the point is to verify the plugin's control flow and output per mode, -not to produce real measurements. \ No newline at end of file +not to produce real measurements. + +For real measurements and profiles, run under the runner without uploading: +`CODSPEED_SKIP_UPLOAD=true codspeed run --mode --profile-folder -- `. +In simulation, `--simulation-track-subprocess` splits each benchmark's callgrind +costs per thread, which exposes work from other threads counted in a benchmark. From e13c42cf6856d1444fa04172d4eb2476ce29695e Mon Sep 17 00:00:00 2001 From: Adrien Cacciaguerra Date: Sat, 17 Oct 2026 04:07:43 +0000 Subject: [PATCH 6/9] feat(core): support running benchmarks with the tsx CLI `tsx bench.ts` runs the script in a child node process, which gets neither the node flags CodSpeed adds to the CLI's process nor the tsx loader. When the plugin loads in such a process without the flags, rerun it with them, in place with `process.execve` when available, so no parent process stays alive during the measurements. A process reruns at most once, and never from a worker thread or with an IPC channel to its parent. The runner splits the introspected flags on whitespace, so flags holding a path with spaces are left out of them; under tsx, the rerun adds them back. Refs COD-3900 Co-Authored-By: Claude --- packages/core/src/introspection.ts | 49 ++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/core/src/introspection.ts b/packages/core/src/introspection.ts index 98b1f211..594e0f25 100644 --- a/packages/core/src/introspection.ts +++ b/packages/core/src/introspection.ts @@ -1,7 +1,10 @@ +import { spawnSync } from "child_process"; import { writeFileSync } from "fs"; +import os from "os"; import path from "path"; +import { isMainThread } from "worker_threads"; -import { getInstrumentMode } from "./runnerMode"; +import { getCodspeedRunnerMode, getInstrumentMode } from "./runnerMode"; import { findTsxPreload } from "./tsx"; const CUSTOM_INTROSPECTION_EXIT_CODE = 0; @@ -76,10 +79,29 @@ export const getV8Flags = () => { return flags; }; +const RERUN_ENV = "__CODSPEED_NODE_CORE_RERUN__"; +const WHITESPACE = /\s/; + +const rerunWithFlags = (flags: string[]): never => { + const args = [...flags, ...process.execArgv, ...process.argv.slice(1)]; + // `execve` keeps the pid, `spawnSync` makes it the rerun's parent pid + const env = { ...process.env, [RERUN_ENV]: String(process.pid) }; + if (process.execve) { + process.execve(process.execPath, [process.execPath, ...args], env); + } + const { status, signal } = spawnSync(process.execPath, args, { + stdio: "inherit", + env, + }); + process.exit(status ?? 128 + os.constants.signals[signal!]); +}; + export const tryIntrospect = () => { if (process.env.__CODSPEED_NODE_CORE_INTROSPECTION_PATH__ !== undefined) { const introspectionMetadata = { - flags: getV8Flags(), + // The runner splits the flags on whitespace. Under tsx, the rerun below + // adds back the ones left out. + flags: getV8Flags().filter((flag) => !WHITESPACE.test(flag)), }; writeFileSync( process.env.__CODSPEED_NODE_CORE_INTROSPECTION_PATH__, @@ -87,6 +109,29 @@ export const tryIntrospect = () => { ); process.exit(CUSTOM_INTROSPECTION_EXIT_CODE); } + + const isRerun = [process.pid, process.ppid] + .map(String) + .includes(process.env[RERUN_ENV] ?? ""); + delete process.env[RERUN_ENV]; + // The `tsx` CLI runs the script in a child node process, without the flags + // given to the CLI's own process. Rerun at most once, so a flag that never + // shows up in `execArgv` cannot loop, and only a process that can be + // replaced: not a worker thread, nor one with an IPC channel to its parent. + const canRerunUnderTsx = + !isRerun && + isMainThread && + process.channel === undefined && + getCodspeedRunnerMode() !== "disabled" && + findTsxPreload(process.execArgv) !== undefined && + process.argv[1] !== undefined; + if (!canRerunUnderTsx) { + return; + } + const flags = getV8Flags(); + if (flags.some((flag) => !process.execArgv.includes(flag))) { + rerunWithFlags(flags); + } }; export const checkV8Flags = () => { From 16a84e4cda061235d63f0c76f7a96c0331ff4982 Mon Sep 17 00:00:00 2001 From: Adrien Cacciaguerra Date: Sat, 17 Oct 2026 04:07:43 +0000 Subject: [PATCH 7/9] test: run the simple TypeScript examples with the tsx CLI Refs COD-3900 Co-Authored-By: Claude --- examples/with-typescript-simple-cjs/package.json | 4 ++-- examples/with-typescript-simple-esm/package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/with-typescript-simple-cjs/package.json b/examples/with-typescript-simple-cjs/package.json index f65187e3..0151bbf8 100644 --- a/examples/with-typescript-simple-cjs/package.json +++ b/examples/with-typescript-simple-cjs/package.json @@ -2,8 +2,8 @@ "name": "with-typescript-simple-cjs", "private": true, "scripts": { - "bench-benchmark-js": "node --import tsx benchmark-js.ts", - "bench-tinybench": "node --import tsx tinybench.ts" + "bench-benchmark-js": "tsx benchmark-js.ts", + "bench-tinybench": "tsx tinybench.ts" }, "devDependencies": { "@codspeed/benchmark.js-plugin": "workspace:*", diff --git a/examples/with-typescript-simple-esm/package.json b/examples/with-typescript-simple-esm/package.json index 641ffc26..fc1787ee 100644 --- a/examples/with-typescript-simple-esm/package.json +++ b/examples/with-typescript-simple-esm/package.json @@ -3,8 +3,8 @@ "private": true, "type": "module", "scripts": { - "bench-benchmark-js": "node --import tsx benchmark-js.ts", - "bench-tinybench": "node --import tsx tinybench.ts" + "bench-benchmark-js": "tsx benchmark-js.ts", + "bench-tinybench": "tsx tinybench.ts" }, "devDependencies": { "@codspeed/benchmark.js-plugin": "workspace:*", From 97de6ee3144c04eec20897006f4a630b9aa450ea Mon Sep 17 00:00:00 2001 From: Adrien Cacciaguerra Date: Sat, 17 Oct 2026 05:52:00 +0000 Subject: [PATCH 8/9] test(core): cover the tsx loader and the tsx CLI rerun Check that tsx transforms without keepNames and minifyWhitespace, that esbuild's service starts without `--ping`, and that a script run with the `tsx` CLI under CodSpeed reruns once with the node flags it needs. Refs COD-3900 Co-Authored-By: Claude Opus 5.5 --- packages/core/package.json | 3 +- .../core/tests/fixtures/tsx/introspect.mts | 4 + packages/core/tests/fixtures/tsx/names.cts | 6 + packages/core/tests/fixtures/tsx/names.mts | 6 + packages/core/tests/fixtures/tsx/package.json | 5 + packages/core/tests/tsx.integ.test.ts | 123 ++++++++++++++++++ pnpm-lock.yaml | 9 +- 7 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 packages/core/tests/fixtures/tsx/introspect.mts create mode 100644 packages/core/tests/fixtures/tsx/names.cts create mode 100644 packages/core/tests/fixtures/tsx/names.mts create mode 100644 packages/core/tests/fixtures/tsx/package.json create mode 100644 packages/core/tests/tsx.integ.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index e8427eef..d4b43769 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,7 +36,8 @@ "node-addon-api": "^5.1.0", "node-gyp": "^12.2.0", "openapi-typescript-codegen": "^0.23.0", - "prebuildify": "^6.0.1" + "prebuildify": "^6.0.1", + "tsx": "^4.23.12" }, "dependencies": { "axios": "^1.4.0", diff --git a/packages/core/tests/fixtures/tsx/introspect.mts b/packages/core/tests/fixtures/tsx/introspect.mts new file mode 100644 index 00000000..90274561 --- /dev/null +++ b/packages/core/tests/fixtures/tsx/introspect.mts @@ -0,0 +1,4 @@ +import { tryIntrospect } from "../../../dist/index.cjs.js"; + +tryIntrospect(); +console.log(JSON.stringify(process.execArgv)); diff --git a/packages/core/tests/fixtures/tsx/names.cts b/packages/core/tests/fixtures/tsx/names.cts new file mode 100644 index 00000000..745ba892 --- /dev/null +++ b/packages/core/tests/fixtures/tsx/names.cts @@ -0,0 +1,6 @@ +function makeIncrement() { + const increment = (n: number) => n + 1; + return increment; +} + +console.log(makeIncrement.toString()); diff --git a/packages/core/tests/fixtures/tsx/names.mts b/packages/core/tests/fixtures/tsx/names.mts new file mode 100644 index 00000000..d6018de9 --- /dev/null +++ b/packages/core/tests/fixtures/tsx/names.mts @@ -0,0 +1,6 @@ +function makeIncrement() { + const increment = (n: number) => n + 1; + return increment; +} + +console.log(await Promise.resolve(makeIncrement.toString())); diff --git a/packages/core/tests/fixtures/tsx/package.json b/packages/core/tests/fixtures/tsx/package.json new file mode 100644 index 00000000..78d3af8b --- /dev/null +++ b/packages/core/tests/fixtures/tsx/package.json @@ -0,0 +1,5 @@ +{ + "name": "tsx-fixture", + "private": true, + "type": "module" +} diff --git a/packages/core/tests/tsx.integ.test.ts b/packages/core/tests/tsx.integ.test.ts new file mode 100644 index 00000000..e47a16e4 --- /dev/null +++ b/packages/core/tests/tsx.integ.test.ts @@ -0,0 +1,123 @@ +import { execFileSync } from "child_process"; +import fs from "fs"; +import { createRequire } from "module"; +import os from "os"; +import path from "path"; + +import { findTsxPreload, getTsxPackageDir } from "../src/tsx"; + +const FIXTURES_DIR = path.join(__dirname, "fixtures", "tsx"); +const TSX_LOADER = path.join(__dirname, "..", "dist", "tsx-loader.cjs"); +const TSX_PACKAGE_JSON = require.resolve("tsx/package.json"); + +const runNode = (args: string[], env: NodeJS.ProcessEnv = {}) => + execFileSync(process.execPath, args, { + cwd: FIXTURES_DIR, + env: { ...process.env, ...env }, + encoding: "utf8", + timeout: 60_000, + }); + +describe("findTsxPreload", () => { + it.each([ + [["--import", "tsx"], "tsx"], + [["--import=tsx/esm"], "tsx/esm"], + [["-r", "tsx/cjs"], "tsx/cjs"], + [ + [ + "--require", + "/app/node_modules/tsx/dist/preflight.cjs", + "--import", + "file:///app/node_modules/tsx/dist/loader.mjs", + ], + "/app/node_modules/tsx/dist/preflight.cjs", + ], + ])("finds tsx in %j", (execArgv, expected) => { + expect(findTsxPreload(execArgv)).toBe(expected); + }); + + it.each([ + [["--import", "./setup.ts"]], + [["--import=file:///app/node_modules/tsx-plugin/index.js"]], + [["--max-old-space-size=4096"]], + ])("ignores %j", (execArgv) => { + expect(findTsxPreload(execArgv)).toBeUndefined(); + }); +}); + +describe("getTsxPackageDir", () => { + it("returns the tsx package of a preloaded file", () => { + expect( + getTsxPackageDir( + "file:///app/node_modules/.pnpm/tsx@4.23.12/node_modules/tsx/dist/loader.mjs", + ), + ).toBe("/app/node_modules/.pnpm/tsx@4.23.12/node_modules/tsx"); + }); +}); + +describe("tsx loader", () => { + it.each(["names.cts", "names.mts"])( + "transforms %s without keepNames and minifyWhitespace", + (fixture) => { + const output = runNode([ + `--require=${TSX_LOADER}`, + "--import", + "tsx", + fixture, + ]); + + expect(output).not.toContain("__name"); + expect(output).toContain("const increment = (n) => n + 1;\n"); + }, + ); + + it("starts esbuild's service without --ping", () => { + const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "codspeed-esbuild-")); + const argsFile = path.join(stubDir, "args"); + const esbuild = createRequire(TSX_PACKAGE_JSON).resolve( + "esbuild/bin/esbuild", + ); + // esbuild's bin script reads ESBUILD_BINARY_PATH too, and would run the stub again + const stub = path.join(stubDir, "esbuild"); + fs.writeFileSync( + stub, + `#!/bin/sh\necho "$@" >> "${argsFile}"\nunset ESBUILD_BINARY_PATH\nexec "${esbuild}" "$@"\n`, + { mode: 0o755 }, + ); + + let calls: string[]; + try { + for (const fixture of ["names.cts", "names.mts"]) { + runNode([`--require=${TSX_LOADER}`, "--import", "tsx", fixture], { + ESBUILD_BINARY_PATH: stub, + }); + } + calls = fs.readFileSync(argsFile, "utf8").trim().split("\n"); + } finally { + fs.rmSync(stubDir, { recursive: true }); + } + + expect(calls.some((call) => call.includes("--service="))).toBe(true); + expect(calls.filter((call) => call.includes("--ping"))).toEqual([]); + }); +}); + +describe("tsx CLI", () => { + it("reruns the script with the node flags CodSpeed needs", () => { + const tsxCli = path.join(path.dirname(TSX_PACKAGE_JSON), "dist", "cli.mjs"); + + const output = runNode([tsxCli, "introspect.mts"], { + CODSPEED_ENV: "true", + CODSPEED_RUNNER_MODE: "simulation", + }); + + const execArgvs = output + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); + expect(execArgvs).toHaveLength(1); + expect(execArgvs[0]).toEqual( + expect.arrayContaining(["--no-opt", `--require=${TSX_LOADER}`]), + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6110070b..41339b2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -298,6 +298,9 @@ importers: prebuildify: specifier: ^6.0.1 version: 6.0.1 + tsx: + specifier: ^4.23.12 + version: 4.23.12 packages/playwright-plugin: dependencies: @@ -3960,13 +3963,13 @@ packages: git-raw-commits@2.0.11: resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} engines: {node: '>=10'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-raw-commits@3.0.0: resolution: {integrity: sha512-b5OHmZ3vAgGrDn/X0kS+9qCfNKWe4K/jFnhwzVWWg0/k5eLa3060tZShrRg8Dja5kPc+YjS0Gc6y7cRr44Lpjw==} engines: {node: '>=14'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-remote-origin-url@2.0.0: @@ -3976,7 +3979,7 @@ packages: git-semver-tags@5.0.1: resolution: {integrity: sha512-hIvOeZwRbQ+7YEUmCkHqo8FOLQZCEn18yevLHADlFPZY02KJGsu5FZt9YW/lybfK2uhWFI7Qg/07LekJiTv7iA==} engines: {node: '>=14'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-up@7.0.0: From 9fab634f5845bf484440615bd39785854460d7c2 Mon Sep 17 00:00:00 2001 From: Adrien Cacciaguerra Date: Sat, 17 Oct 2026 07:16:32 +0000 Subject: [PATCH 9/9] ci: run the tsx loader tests where tsx registers its hooks off-thread tsx registers its module hooks off-thread before Node 22.22.3 and 24.11.1, and Node only has `module.registerHooks` from 22.15, so the latest 22, 24 and 26 releases never exercise those loader paths. Refs COD-3900 Co-Authored-By: Claude Opus 5.5 --- .github/actions/test-native-abi/action.yml | 10 ++++++---- .github/workflows/ci.yml | 8 ++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/actions/test-native-abi/action.yml b/.github/actions/test-native-abi/action.yml index 5cd8bf34..cc830929 100644 --- a/.github/actions/test-native-abi/action.yml +++ b/.github/actions/test-native-abi/action.yml @@ -1,15 +1,16 @@ name: Test Native ABI description: > - Set up the given Node.js major and run the core package's integ tests - in-band, without rebuilding, to catch native addon ABI mismatches and - V8-version-specific natives-syntax behavior. The flags mirror what the + Set up the given Node.js version and run the core package's integ tests + in-band, without rebuilding, to catch native addon ABI mismatches, + V8-version-specific natives-syntax behavior and Node-version-specific + module hooks in the tsx loader. The flags mirror what the runner passes in a real benchmark process; they cannot go through NODE_OPTIONS (--allow-natives-syntax is rejected there), so jest runs in-band under a flagged node instead of forking workers. inputs: node-version: - description: Node.js major version to test under + description: Node.js version to test under required: true runs: @@ -25,3 +26,4 @@ runs: "$(node -p 'require.resolve("jest/bin/jest")')" -c jest.config.integ.js --runInBand tests/index.integ.test.ts tests/optimization.integ.test.ts + tests/tsx.integ.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bd82c02..39169b5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,14 @@ jobs: - uses: ./.github/actions/test-native-abi with: node-version: "26" + # tsx registers its module hooks off-thread before Node 22.22.3 and + # 24.11.1, and Node only has `module.registerHooks` from 22.15 + - uses: ./.github/actions/test-native-abi + with: + node-version: "22.14.0" + - uses: ./.github/actions/test-native-abi + with: + node-version: "24.10.0" list-examples: runs-on: "ubuntu-latest"