From ddbd9adcf2bf659c148df2ebb9d77301d080b3c4 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 23 Oct 2026 15:27:32 +0900 Subject: [PATCH 01/10] feat(cli): add public TypeScript preload --- .../action.yml | 1 + .../command_script_import/binding-guard.cjs | 9 + .../command_script_import/package.json | 9 + .../command_script_import/snapshots.toml | 50 ++ .../snapshots/script_import_native_watch.md | 9 + .../script_import_node22_async.global.md | 24 + .../script_import_node22_async.local.md | 24 + .../snapshots/script_import_node22_sync.md | 22 + .../snapshots/script_import_node24_async.md | 22 + .../snapshots/script_import_node24_sync.md | 22 + .../snapshots/script_import_node26_sync.md | 22 + .../script_import_unsupported_node.md | 9 + .../command_script_import/src/argv.ts | 8 + .../command_script_import/src/child.ts | 3 + .../command_script_import/src/children.mjs | 18 + .../command_script_import/src/cluster.mjs | 12 + .../command_script_import/src/common.cts | 5 + .../src/config-fields.cts | 9 + .../src/config-parent-check.mts | 1 + .../src/config-worker.mts | 14 + .../src/config-workers.mjs | 64 +++ .../command_script_import/src/config.ts | 8 + .../command_script_import/src/dedup.mjs | 16 + .../src/dependency-error.ts | 2 + .../command_script_import/src/error.ts | 7 + .../src/internal-host.mjs | 13 + .../src/internal-loader.mjs | 13 + .../src/javascript-view.jsx | 4 + .../command_script_import/src/main.cts | 16 + .../command_script_import/src/main.ts | 30 ++ .../command_script_import/src/module.mts | 2 + .../command_script_import/src/nested.cts | 2 + .../command_script_import/src/remapped.ts | 2 + .../command_script_import/src/setup.ts | 2 + .../command_script_import/src/spawn.mjs | 12 + .../command_script_import/src/value.ts | 2 + .../command_script_import/src/view.tsx | 4 + .../command_script_import/src/worker-host.mjs | 17 + .../command_script_import/src/worker.cts | 4 + .../command_script_import/src/worker.mts | 5 + .../command_script_import/tests/enum.test.ts | 6 + .../command_script_import/tsconfig.json | 15 + .../verify-unsupported.mjs | 15 + .../command_script_import/verify-watch.mjs | 50 ++ .../fixtures/command_script_import/verify.mjs | 170 +++++++ .../migration_tsx_already_vite_plus.md | 6 +- .../snapshots/migration_tsx_kept.md | 6 +- .../snapshots/migration_tsx_scripts.md | 6 +- .../.fixture/tsx/package.json | 4 + .../migration_tsx_preload/package.json | 17 + .../migration_tsx_preload/scripts/seed.ts | 2 + .../migration_tsx_preload/snapshots.toml | 12 + .../snapshots/migration_tsx_preload.md | 94 ++++ .../migration_tsx_preload/tsconfig.json | 5 + docs/guide/migrate-rules.md | 26 +- docs/guide/vpx.md | 38 +- packages/cli/BUNDLING.md | 3 +- packages/cli/package.json | 8 +- .../cli/src/migration/__tests__/tsx.spec.ts | 251 +++++++++- packages/cli/src/migration/bin.ts | 2 +- packages/cli/src/migration/migrator/tsx.ts | 200 ++++++-- packages/cli/src/register.ts | 17 + packages/cli/src/script-hooks.ts | 25 + packages/cli/src/script-preload.ts | 10 +- packages/cli/tsdown.config.ts | 2 + .../patches/oxc-node-loader-context.patch | 457 ++++++++++++++++++ rfcs/script-import-loader.md | 170 +++++++ rfcs/vpx-script-execution.md | 22 +- 68 files changed, 2070 insertions(+), 87 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/binding-guard.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_native_watch.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node22_async.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node22_async.local.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node22_sync.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node24_async.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node24_sync.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node26_sync.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_unsupported_node.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/argv.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/child.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/children.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/cluster.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/common.cts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-fields.cts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-parent-check.mts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-worker.mts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-workers.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/dedup.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/dependency-error.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/error.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/internal-host.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/internal-loader.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/javascript-view.jsx create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/main.cts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/main.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/module.mts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/nested.cts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/remapped.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/setup.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/spawn.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/value.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/view.tsx create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/worker-host.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/worker.cts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/worker.mts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/tests/enum.test.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/tsconfig.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/verify-unsupported.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/verify-watch.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/verify.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/.fixture/tsx/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/scripts/seed.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/snapshots/migration_tsx_preload.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/tsconfig.json create mode 100644 packages/cli/src/register.ts create mode 100644 packages/tools/patches/oxc-node-loader-context.patch create mode 100644 rfcs/script-import-loader.md diff --git a/.github/actions/compute-native-cache-input-hash/action.yml b/.github/actions/compute-native-cache-input-hash/action.yml index 445bb83df5..3769d07e7b 100644 --- a/.github/actions/compute-native-cache-input-hash/action.yml +++ b/.github/actions/compute-native-cache-input-hash/action.yml @@ -31,6 +31,7 @@ runs: hashFiles( 'packages/tools/.upstream-versions.json', 'packages/tools/src/patch-oxc-node.ts', + 'packages/tools/patches/oxc-node-loader-context.patch', 'packages/tools/src/cargo-toml.ts', 'rust-toolchain.toml', 'Cargo.lock', diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/binding-guard.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/binding-guard.cjs new file mode 100644 index 0000000000..d954506ca3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/binding-guard.cjs @@ -0,0 +1,9 @@ +// An unsupported runtime must stop before loading a native binding. +const Module = require('node:module'); +const original = Module._load; +Module._load = function (request, ...args) { + if (/binding[\\/]index\.(?:cjs|js)$/.test(request) || request.endsWith('.node')) { + throw new Error('NATIVE_BINDING_LOADED_BEFORE_VERSION_CHECK'); + } + return original.call(this, request, ...args); +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/package.json new file mode 100644 index 0000000000..e455992a16 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/package.json @@ -0,0 +1,9 @@ +{ + "name": "command-script-import", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": "22.18.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots.toml new file mode 100644 index 0000000000..cf0dfb02cf --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots.toml @@ -0,0 +1,50 @@ +[[case]] +name = "script_import_node22_async" +vp = ["local", "global"] +env = { EXPECTED_NODE_VERSION = "22.18.0" } +comment = "The public installed package preload works without tsx on the asynchronous hook path." +steps = [ + ["node", "verify.mjs"], + { argv = ["vpx", "--import=vite-plus/register", "./src/dedup.mjs"], comment = "The CLI's existing flags and the public entry share one registration" }, +] + +[[case]] +name = "script_import_node22_sync" +vp = "global" +env = { VP_NODE_VERSION = "22.23.2", EXPECTED_NODE_VERSION = "22.23.2" } +comment = "The complete preload contract also runs on Node.js 22 synchronous hooks." +steps = [["node", "verify.mjs"], ["vpx", "--import=vite-plus/register", "./src/dedup.mjs"]] + +[[case]] +name = "script_import_node24_async" +vp = "global" +env = { VP_NODE_VERSION = "24.11.0", EXPECTED_NODE_VERSION = "24.11.0" } +comment = "The earliest supported Node.js 24 also uses asynchronous hooks, including workers with independent configs." +steps = [["node", "verify.mjs"], ["vpx", "--import=vite-plus/register", "./src/dedup.mjs"]] + +[[case]] +name = "script_import_node24_sync" +vp = "global" +env = { VP_NODE_VERSION = "24.12.0", EXPECTED_NODE_VERSION = "24.12.0" } +comment = "The complete preload contract runs on Node.js 24 synchronous hooks." +steps = [["node", "verify.mjs"], ["vpx", "--import=vite-plus/register", "./src/dedup.mjs"]] + +[[case]] +name = "script_import_node26_sync" +vp = "global" +env = { VP_NODE_VERSION = "26.5.0", EXPECTED_NODE_VERSION = "26.5.0" } +comment = "The complete preload contract runs on Node.js 26 synchronous hooks." +steps = [["node", "verify.mjs"], ["vpx", "--import=vite-plus/register", "./src/dedup.mjs"]] + +[[case]] +name = "script_import_native_watch" +vp = "global" +comment = "A public preload preserves Node.js native watch mode." +steps = [["node", "verify-watch.mjs"]] + +[[case]] +name = "script_import_unsupported_node" +vp = "global" +env = { VP_NODE_VERSION = "20.19.0" } +comment = "Unsupported Node.js reaches the version diagnostic before loading native code." +steps = [["node", "verify-unsupported.mjs"]] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_native_watch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_native_watch.md new file mode 100644 index 0000000000..c5692e29a9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_native_watch.md @@ -0,0 +1,9 @@ +# script_import_native_watch + +A public preload preserves Node.js native watch mode. + +## `node verify-watch.mjs` + +``` +native node --watch reloads an imported TypeScript dependency +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node22_async.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node22_async.global.md new file mode 100644 index 0000000000..4be5269269 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node22_async.global.md @@ -0,0 +1,24 @@ +# script_import_node22_async + +The public installed package preload works without tsx on the asynchronous hook path. + +## `node verify.mjs` + +``` +public package export: both flag forms, resolved URL, NODE_OPTIONS, ESM/CJS +subsequent TypeScript preload, argv, execArgv, PID, runtime, and exit status +eval/stdin imports and requires, dynamic registration, node:test and discovery +fork, ESM/CJS workers, spawned Node.js, and inherited custom tsconfig +worker-specific configs, explicit worker preload, cluster, and internal loader thread +repeated flags, repeated dynamic imports, and NODE_OPTIONS/argv overlap +config paths with spaces, explicit/compat/upstream precedence, unchanged environment +source maps identify TypeScript lines in application and dependency errors +``` + +## `vpx --import=vite-plus/register ./src/dedup.mjs` + +The CLI's existing flags and the public entry share one registration + +``` +registration stays single across repeated and mixed preloads +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node22_async.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node22_async.local.md new file mode 100644 index 0000000000..4be5269269 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node22_async.local.md @@ -0,0 +1,24 @@ +# script_import_node22_async + +The public installed package preload works without tsx on the asynchronous hook path. + +## `node verify.mjs` + +``` +public package export: both flag forms, resolved URL, NODE_OPTIONS, ESM/CJS +subsequent TypeScript preload, argv, execArgv, PID, runtime, and exit status +eval/stdin imports and requires, dynamic registration, node:test and discovery +fork, ESM/CJS workers, spawned Node.js, and inherited custom tsconfig +worker-specific configs, explicit worker preload, cluster, and internal loader thread +repeated flags, repeated dynamic imports, and NODE_OPTIONS/argv overlap +config paths with spaces, explicit/compat/upstream precedence, unchanged environment +source maps identify TypeScript lines in application and dependency errors +``` + +## `vpx --import=vite-plus/register ./src/dedup.mjs` + +The CLI's existing flags and the public entry share one registration + +``` +registration stays single across repeated and mixed preloads +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node22_sync.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node22_sync.md new file mode 100644 index 0000000000..3376185b39 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node22_sync.md @@ -0,0 +1,22 @@ +# script_import_node22_sync + +The complete preload contract also runs on Node.js 22 synchronous hooks. + +## `node verify.mjs` + +``` +public package export: both flag forms, resolved URL, NODE_OPTIONS, ESM/CJS +subsequent TypeScript preload, argv, execArgv, PID, runtime, and exit status +eval/stdin imports and requires, dynamic registration, node:test and discovery +fork, ESM/CJS workers, spawned Node.js, and inherited custom tsconfig +worker-specific configs, explicit worker preload, cluster, and internal loader thread +repeated flags, repeated dynamic imports, and NODE_OPTIONS/argv overlap +config paths with spaces, explicit/compat/upstream precedence, unchanged environment +source maps identify TypeScript lines in application and dependency errors +``` + +## `vpx --import=vite-plus/register ./src/dedup.mjs` + +``` +registration stays single across repeated and mixed preloads +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node24_async.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node24_async.md new file mode 100644 index 0000000000..85b15cdb56 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node24_async.md @@ -0,0 +1,22 @@ +# script_import_node24_async + +The earliest supported Node.js 24 also uses asynchronous hooks, including workers with independent configs. + +## `node verify.mjs` + +``` +public package export: both flag forms, resolved URL, NODE_OPTIONS, ESM/CJS +subsequent TypeScript preload, argv, execArgv, PID, runtime, and exit status +eval/stdin imports and requires, dynamic registration, node:test and discovery +fork, ESM/CJS workers, spawned Node.js, and inherited custom tsconfig +worker-specific configs, explicit worker preload, cluster, and internal loader thread +repeated flags, repeated dynamic imports, and NODE_OPTIONS/argv overlap +config paths with spaces, explicit/compat/upstream precedence, unchanged environment +source maps identify TypeScript lines in application and dependency errors +``` + +## `vpx --import=vite-plus/register ./src/dedup.mjs` + +``` +registration stays single across repeated and mixed preloads +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node24_sync.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node24_sync.md new file mode 100644 index 0000000000..0040e0535a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node24_sync.md @@ -0,0 +1,22 @@ +# script_import_node24_sync + +The complete preload contract runs on Node.js 24 synchronous hooks. + +## `node verify.mjs` + +``` +public package export: both flag forms, resolved URL, NODE_OPTIONS, ESM/CJS +subsequent TypeScript preload, argv, execArgv, PID, runtime, and exit status +eval/stdin imports and requires, dynamic registration, node:test and discovery +fork, ESM/CJS workers, spawned Node.js, and inherited custom tsconfig +worker-specific configs, explicit worker preload, cluster, and internal loader thread +repeated flags, repeated dynamic imports, and NODE_OPTIONS/argv overlap +config paths with spaces, explicit/compat/upstream precedence, unchanged environment +source maps identify TypeScript lines in application and dependency errors +``` + +## `vpx --import=vite-plus/register ./src/dedup.mjs` + +``` +registration stays single across repeated and mixed preloads +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node26_sync.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node26_sync.md new file mode 100644 index 0000000000..63a305cf68 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_node26_sync.md @@ -0,0 +1,22 @@ +# script_import_node26_sync + +The complete preload contract runs on Node.js 26 synchronous hooks. + +## `node verify.mjs` + +``` +public package export: both flag forms, resolved URL, NODE_OPTIONS, ESM/CJS +subsequent TypeScript preload, argv, execArgv, PID, runtime, and exit status +eval/stdin imports and requires, dynamic registration, node:test and discovery +fork, ESM/CJS workers, spawned Node.js, and inherited custom tsconfig +worker-specific configs, explicit worker preload, cluster, and internal loader thread +repeated flags, repeated dynamic imports, and NODE_OPTIONS/argv overlap +config paths with spaces, explicit/compat/upstream precedence, unchanged environment +source maps identify TypeScript lines in application and dependency errors +``` + +## `vpx --import=vite-plus/register ./src/dedup.mjs` + +``` +registration stays single across repeated and mixed preloads +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_unsupported_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_unsupported_node.md new file mode 100644 index 0000000000..d12c274a93 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots/script_import_unsupported_node.md @@ -0,0 +1,9 @@ +# script_import_unsupported_node + +Unsupported Node.js reaches the version diagnostic before loading native code. + +## `node verify-unsupported.mjs` + +``` +unsupported Node.js fails before binding and application loading +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/argv.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/argv.ts new file mode 100644 index 0000000000..1730258b90 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/argv.ts @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +assert.equal(process.execPath, process.env.EXPECTED_EXEC_PATH); +assert.equal(process.ppid, Number(process.env.EXPECTED_PARENT_PID)); +assert.equal(process.argv[1], path.resolve('src/argv.ts')); +assert.equal((globalThis as any).setup, 'loaded'); +console.log(JSON.stringify({ args: process.argv.slice(2), execArgv: process.execArgv })); +process.exitCode = 7; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/child.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/child.ts new file mode 100644 index 0000000000..cd3b494a81 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/child.ts @@ -0,0 +1,3 @@ +import { value } from '@value'; +enum Result { Value = 'fork' } +process.send!(`${Result.Value}: ${value}`, () => process.disconnect()); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/children.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/children.mjs new file mode 100644 index 0000000000..98d9902850 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/children.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { once } from 'node:events'; +import { Worker } from 'node:worker_threads'; + +async function collect(child, expected) { + const signal = AbortSignal.timeout(15000); + const exited = once(child, 'exit', { signal }); + const [message] = await once(child, 'message', { signal }); + assert.equal(message, expected); + const [code] = await exited; + assert.equal(code, 0); +} +const expected = process.env.EXPECTED_CONFIG_VALUE ?? 'default'; +await collect(fork(new URL('./child.ts', import.meta.url)), `fork: ${expected}`); +await collect(new Worker(new URL('./worker.mts', import.meta.url)), `esm worker: cts: ${expected}`); +await collect(new Worker(new URL('./worker.cts', import.meta.url)), 'cjs worker: cts'); +console.log('fork and ESM/CJS workers inherit registration'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/cluster.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/cluster.mjs new file mode 100644 index 0000000000..49c179d408 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/cluster.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import cluster from 'node:cluster'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; + +cluster.setupPrimary({ exec: fileURLToPath(new URL('./child.ts', import.meta.url)) }); +const child = cluster.fork(); +const exited = once(child, 'exit'); +const [message] = await once(child, 'message'); +assert.equal(message, 'fork: compat'); +assert.equal((await exited)[0], 0); +console.log('cluster inherits registration and custom tsconfig'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/common.cts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/common.cts new file mode 100644 index 0000000000..16d3520789 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/common.cts @@ -0,0 +1,5 @@ +const assert = require('node:assert/strict'); +enum Format { Value = 'cts' } +const nested: string = require('./nested.cts'); +assert.equal(nested, 'nested'); +module.exports = { format: Format.Value }; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-fields.cts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-fields.cts new file mode 100644 index 0000000000..17ef0793bd --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-fields.cts @@ -0,0 +1,9 @@ +let writes: number = 0; +class Base { + set field(_value: number) { writes += 1; } +} +class Derived extends Base { + field = 1; +} +new Derived(); +module.exports = { writes }; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-parent-check.mts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-parent-check.mts new file mode 100644 index 0000000000..e1ac29af16 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-parent-check.mts @@ -0,0 +1 @@ +export { value } from '@value'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-worker.mts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-worker.mts new file mode 100644 index 0000000000..9ed1135325 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-worker.mts @@ -0,0 +1,14 @@ +import { once } from 'node:events'; +import { createRequire } from 'node:module'; +import { parentPort } from 'node:worker_threads'; +import { value as initial } from '@value'; + +enum Stage { Ready = 'ready', Result = 'result' } +const run = once(parentPort!, 'message'); +parentPort!.postMessage({ stage: Stage.Ready, initial }); +await run; +// The other worker has registered before either worker resolves or transforms these. +const { value } = await import('./config-parent-check.mts'); +const { writes } = createRequire(import.meta.url)('./config-fields.cts'); +parentPort!.postMessage({ stage: Stage.Result, value, writes }); +parentPort!.close(); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-workers.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-workers.mjs new file mode 100644 index 0000000000..d0fbf9f53f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config-workers.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { createRequire } from 'node:module'; +import { Worker } from 'node:worker_threads'; + +const require = createRequire(import.meta.url); +const selectors = ['VP_SCRIPT_TSCONFIG', 'TSX_TSCONFIG_PATH', 'TS_NODE_PROJECT', 'OXC_TSCONFIG_PATH']; +const original = selectors.map(key => process.env[key]); +const one = 'configs with spaces/worker-one.json'; +const two = 'configs with spaces/worker-two.json'; +let checks = 0; +async function checkParent() { + const { value } = await import(`./config-parent-check.mts?check=${checks++}`); + delete require.cache[require.resolve('./config-fields.cts')]; + assert.equal(value, 'parent'); + assert.equal(require('./config-fields.cts').writes, 0); + assert.deepEqual(selectors.map(key => process.env[key]), original); +} + +async function pair(configurations) { + let readyCount = 0; + let release; + const gate = new Promise(resolve => { release = resolve; }); + const tasks = configurations.map(async ({ env: overrides, value, writes }) => { + const env = { ...process.env }; + for (const key of selectors) delete env[key]; + Object.assign(env, overrides); + const worker = new Worker(new URL('./config-worker.mts', import.meta.url), { env }); + const signal = AbortSignal.timeout(15000); + try { + const [ready] = await once(worker, 'message', { signal }); + assert.deepEqual(ready, { stage: 'ready', initial: value }); + if (++readyCount === configurations.length) release(); + await gate; + const result = once(worker, 'message', { signal }); + worker.postMessage('run'); + assert.deepEqual((await result)[0], { stage: 'result', value, writes }); + } finally { + await worker.terminate(); + } + }); + // Attach rejection handlers immediately, including when a worker fails before the gate. + const completed = Promise.all(tasks); + await Promise.race([gate, completed]); + await Promise.all([completed, checkParent()]); + await checkParent(); +} + +await checkParent(); +await pair([ + { env: { TSX_TSCONFIG_PATH: one }, value: 'worker-one', writes: 1 }, + { env: { TSX_TSCONFIG_PATH: two }, value: 'worker-two', writes: 0 }, +]); +// Worker environments differ from the main thread's OS environment. In particular, +// no missing variable in a worker may fall back to the parent's native getenv(). +await pair([ + { env: { TS_NODE_PROJECT: one, OXC_TSCONFIG_PATH: two }, value: 'worker-one', writes: 1 }, + { env: { OXC_TSCONFIG_PATH: two }, value: 'worker-two', writes: 0 }, +]); +await pair([ + { env: { VP_SCRIPT_TSCONFIG: one, TSX_TSCONFIG_PATH: two }, value: 'worker-one', writes: 1 }, + { env: {}, value: 'default', writes: 0 }, +]); +console.log('concurrent worker configs isolate ESM aliases and CJS transforms from the parent'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config.ts new file mode 100644 index 0000000000..f3911873e4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/config.ts @@ -0,0 +1,8 @@ +import { value } from '@value'; +console.log(JSON.stringify({ + value, + explicit: process.env.VP_SCRIPT_TSCONFIG ?? null, + compat: process.env.TSX_TSCONFIG_PATH ?? null, + upstream: process.env.OXC_TSCONFIG_PATH ?? null, + tsnode: process.env.TS_NODE_PROJECT ?? null, +})); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/dedup.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/dedup.mjs new file mode 100644 index 0000000000..b60549a2a5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/dedup.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import Module from 'node:module'; + +const transform = Module._extensions['.ts']; +const resolve = Module._resolveFilename; +assert.equal(typeof transform, 'function'); +await import('vite-plus/register'); +// Re-evaluate the public wrapper, while retaining the emitted hook identities. +const again = new URL(import.meta.resolve('vite-plus/register')); +again.searchParams.set('again', '1'); +await import(again.href); +assert.equal(Module._extensions['.ts'], transform); +assert.equal(Module._resolveFilename, resolve); +const { format } = await import('./module.mts'); +assert.equal(format, 'mts'); +console.log('registration stays single across repeated and mixed preloads'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/dependency-error.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/dependency-error.ts new file mode 100644 index 0000000000..0399633fc2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/dependency-error.ts @@ -0,0 +1,2 @@ +import { fail } from 'fixture-ts-import/error'; +fail(); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/error.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/error.ts new file mode 100644 index 0000000000..fda19bce35 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/error.ts @@ -0,0 +1,7 @@ +enum Failure { Message = 'mapped failure' } +class Broken { + constructor(public message: string) { + throw new Error(message); + } +} +new Broken(Failure.Message); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/internal-host.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/internal-host.mjs new file mode 100644 index 0000000000..3ef8696157 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/internal-host.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { register } from 'node:module'; +import { MessageChannel } from 'node:worker_threads'; + +const { port1, port2 } = new MessageChannel(); +const message = once(port1, 'message', { signal: AbortSignal.timeout(15000) }); +register(new URL('./internal-loader.mjs', import.meta.url), { + parentURL: import.meta.url, data: { port: port2 }, transferList: [port2], +}); +assert.deepEqual((await message)[0], { internal: true, unchanged: true }); +port1.close(); +console.log('the internal loader thread skips public registration'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/internal-loader.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/internal-loader.mjs new file mode 100644 index 0000000000..4a89bdff16 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/internal-loader.mjs @@ -0,0 +1,13 @@ +import Module from 'node:module'; +import { isInternalThread } from 'node:worker_threads'; + +export async function initialize({ port }) { + const transform = Module._extensions['.ts']; + const resolve = Module._resolveFilename; + await import('vite-plus/register'); + port.postMessage({ + internal: isInternalThread, + unchanged: Module._extensions['.ts'] === transform && Module._resolveFilename === resolve, + }); + port.close(); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/javascript-view.jsx b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/javascript-view.jsx new file mode 100644 index 0000000000..661fc2ee73 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/javascript-view.jsx @@ -0,0 +1,4 @@ +function h(tag, props, ...children) { + return { tag, children }; +} +export const view =
jsx
; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/main.cts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/main.cts new file mode 100644 index 0000000000..c6da42ddb0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/main.cts @@ -0,0 +1,16 @@ +const assert = require('node:assert/strict'); +const common = require('./common.cts'); +const dependency: string = require('fixture-ts-require'); +const decorated: string[] = []; +function record(_target: object, key: string) { decorated.push(key); } +class Counter { + field = 2; + constructor(public initial: number) {} + @record + total() { return this.initial + this.field; } +} +assert.deepEqual(common, { format: 'cts' }); +assert.equal(dependency, 'cjs dependency'); +assert.equal(new Counter(3).total(), 5); +assert.deepEqual(decorated, ['total']); +console.log('cjs syntax, nested require, helpers, and dependencies'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/main.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/main.ts new file mode 100644 index 0000000000..5151d9c92a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/main.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { value } from '@value'; +import { value as remapped } from './remapped.js'; +import { format } from './module.mts'; +import common from './common.cts'; +import { view } from './view.tsx'; +import { view as jsx } from './javascript-view.jsx'; +import { kind } from 'fixture-ts-import'; +import dependency from 'fixture-ts-require'; + +namespace Options { export const count = 3; } +const decorated: string[] = []; +function record(_target: object, key: string) { decorated.push(key); } +class Counter { + field = 2; + constructor(public initial: number) {} + @record + total() { return this.initial + this.field; } +} +assert.equal(value, 'default'); +assert.equal(remapped, 'remapped'); +assert.equal(format, 'mts'); +assert.deepEqual(common, { format: 'cts' }); +assert.deepEqual(view, { tag: 'section', children: ['tsx'] }); +assert.deepEqual(jsx, { tag: 'section', children: ['jsx'] }); +assert.equal(kind, 'esm dependency'); +assert.equal(dependency, 'cjs dependency'); +assert.equal(new Counter(Options.count).total(), 5); +assert.deepEqual(decorated, ['total']); +console.log('esm syntax, resolution, JSX, helpers, and dependencies'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/module.mts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/module.mts new file mode 100644 index 0000000000..b619caf0f5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/module.mts @@ -0,0 +1,2 @@ +enum Format { Value = 'mts' } +export const format: Format = await Promise.resolve(Format.Value); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/nested.cts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/nested.cts new file mode 100644 index 0000000000..f6dc2278ca --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/nested.cts @@ -0,0 +1,2 @@ +enum Value { Current = 'nested' } +module.exports = Value.Current; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/remapped.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/remapped.ts new file mode 100644 index 0000000000..bc2cfa2634 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/remapped.ts @@ -0,0 +1,2 @@ +export enum Value { Current = 'remapped' } +export const value: Value = Value.Current; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/setup.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/setup.ts new file mode 100644 index 0000000000..a16c228f87 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/setup.ts @@ -0,0 +1,2 @@ +enum Setup { Value = 'loaded' } +(globalThis as any).setup = Setup.Value; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/spawn.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/spawn.mjs new file mode 100644 index 0000000000..7e8a3514f3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/spawn.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +assert.deepEqual(process.execArgv, []); +const child = spawnSync(process.execPath, ['./src/main.ts'], { + encoding: 'utf8', + timeout: 15000, +}); +assert.equal(child.error, undefined); +assert.equal(child.status, 0, child.stderr); +assert.equal(child.stdout.trim(), 'esm syntax, resolution, JSX, helpers, and dependencies'); +console.log('spawn inherits NODE_OPTIONS registration'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/value.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/value.ts new file mode 100644 index 0000000000..97214aaa78 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/value.ts @@ -0,0 +1,2 @@ +export enum Value { Current = 'default' } +export const value: Value = Value.Current; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/view.tsx b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/view.tsx new file mode 100644 index 0000000000..ee2d34fdee --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/view.tsx @@ -0,0 +1,4 @@ +function h(tag: string, props: object | null, ...children: unknown[]) { + return { tag, children }; +} +export const view =
tsx
; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/worker-host.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/worker-host.mjs new file mode 100644 index 0000000000..813469d9bb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/worker-host.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +const register = pathToFileURL(createRequire(import.meta.url).resolve('vite-plus/register')).href; +const worker = new Worker(new URL('./worker.mts', import.meta.url), { + execArgv: ['--import', register], +}); +try { + const [message] = await once(worker, 'message', { signal: AbortSignal.timeout(15000) }); + assert.equal(message, 'esm worker: cts: default'); + console.log('a plain JavaScript host registers its TypeScript worker'); +} finally { + await worker.terminate(); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/worker.cts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/worker.cts new file mode 100644 index 0000000000..061206ad74 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/worker.cts @@ -0,0 +1,4 @@ +const { parentPort } = require('node:worker_threads'); +const common = require('./common.cts'); +enum Result { Value = 'cjs worker' } +parentPort.postMessage(`${Result.Value}: ${common.format}`); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/worker.mts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/worker.mts new file mode 100644 index 0000000000..7471baca9b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/src/worker.mts @@ -0,0 +1,5 @@ +import { parentPort } from 'node:worker_threads'; +import common from './common.cts'; +import { value } from '@value'; +enum Result { Value = 'esm worker' } +parentPort!.postMessage(`${Result.Value}: ${common.format}: ${value}`); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/tests/enum.test.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/tests/enum.test.ts new file mode 100644 index 0000000000..43190dc102 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/tests/enum.test.ts @@ -0,0 +1,6 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +enum Result { Value = 'node:test' } +test('preload reaches the test process', () => { + assert.equal(Result.Value, 'node:test'); +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/tsconfig.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/tsconfig.json new file mode 100644 index 0000000000..4805b9707d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "experimentalDecorators": true, + "allowJs": true, + "jsx": "react", + "jsxFactory": "h", + "baseUrl": ".", + "paths": { + "@value": ["./src/value.ts"] + } + }, + "include": ["src", "tests"] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/verify-unsupported.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/verify-unsupported.mjs new file mode 100644 index 0000000000..a8a32df2ca --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/verify-unsupported.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +assert.equal(Number(process.versions.node.split('.')[0]), 20); +const child = spawnSync(process.execPath, [ + '--require', './binding-guard.cjs', + '--import=vite-plus/register', + '--eval', 'throw new Error("APPLICATION_RAN");', +], { encoding: 'utf8', timeout: 15000 }); +assert.equal(child.error, undefined); +assert.equal(child.status, 1, child.stderr); +assert.equal(child.stdout, ''); +assert.match(child.stderr, /requires Node\.js/); +assert.doesNotMatch(child.stderr, /NATIVE_BINDING_LOADED|APPLICATION_RAN|SyntaxError/); +console.log('unsupported Node.js fails before binding and application loading'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/verify-watch.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/verify-watch.mjs new file mode 100644 index 0000000000..2459b38bc3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/verify-watch.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { writeFileSync } from 'node:fs'; +import { writeFile, unlink } from 'node:fs/promises'; + +const entry = 'src/watch-entry.ts'; +const dependency = 'src/watch-dependency.ts'; +await writeFile(entry, "import { value } from './watch-dependency.ts'; console.log('watch-value:' + value);\n"); +await writeFile(dependency, "export enum Value { Current = 'first' }\nexport const value = Value.Current;\n"); +const child = spawn(process.execPath, [ + '--watch', '--watch-preserve-output', '--import=vite-plus/register', entry, +], { stdio: ['ignore', 'pipe', 'pipe'] }); +let output = ''; +let edited = false; +const exited = once(child, 'exit'); +let timer; +let rewrite; +try { + await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error(`native watch timed out:\n${output}`)), 20000); + child.once('error', reject); + child.once('exit', (code, signal) => reject(new Error(`native watch exited ${code}/${signal}:\n${output}`))); + child.stderr.on('data', chunk => { output += chunk; }); + child.stdout.on('data', chunk => { + output += chunk; + if (!edited && output.includes('watch-value:first')) { + edited = true; + // The child can print before the parent receives its dependency report. + // Repeat the edit until the real watcher observes it, as the vpx fixture does. + const edit = () => { + try { + writeFileSync(dependency, "export enum Value { Current = 'second' }\nexport const value = Value.Current;\n"); + } catch (error) { reject(error); } + }; + edit(); + rewrite = setInterval(edit, 500); + } + if (output.includes('watch-value:second')) resolve(); + }); + }); + assert.equal(edited, true); + console.log('native node --watch reloads an imported TypeScript dependency'); +} finally { + clearTimeout(timer); + clearInterval(rewrite); + child.kill(); + await exited; + await Promise.all([unlink(entry), unlink(dependency)]); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/verify.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/verify.mjs new file mode 100644 index 0000000000..84a1036164 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/verify.mjs @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +if (process.env.EXPECTED_NODE_VERSION) { + assert.equal(process.versions.node, process.env.EXPECTED_NODE_VERSION); +} +const require = createRequire(import.meta.url); +assert.throws(() => require.resolve('tsx'), { code: 'MODULE_NOT_FOUND' }); +const registerPath = require.resolve('vite-plus/register'); +assert.match(registerPath.replaceAll('\\', '/'), /\/dist\/register\.js$/); +assert.equal(import.meta.resolve('vite-plus/register'), pathToFileURL(registerPath).href); + +function write(file, contents) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, contents); +} + +// Local packages exercise installed TypeScript without a network dependency. +write('node_modules/fixture-ts-import/package.json', JSON.stringify({ + name: 'fixture-ts-import', type: 'module', + exports: { '.': './index.ts', './error': './error.ts' }, +})); +write('node_modules/fixture-ts-import/index.ts', + "export enum Kind { Value = 'esm dependency' }\nexport const kind: Kind = Kind.Value;\n"); +write('node_modules/fixture-ts-import/error.ts', + "enum Failure { Value = 'dependency failure' }\nexport function fail() {\n throw new Error(Failure.Value);\n}\n"); +write('node_modules/fixture-ts-require/package.json', JSON.stringify({ + name: 'fixture-ts-require', main: './index.cts', +})); +write('node_modules/fixture-ts-require/index.cts', + "enum Kind { Value = 'cjs dependency' }\nmodule.exports = Kind.Value;\n"); + +const configs = {}; +for (const name of ['explicit', 'compat', 'upstream', 'parent', 'worker-one', 'worker-two']) { + configs[name] = `configs with spaces/${name}.json`; + write(`src/config-${name}.ts`, + `export enum Value { Current = '${name}' }\nexport const value = Value.Current;\n`); + write(configs[name], JSON.stringify({ + extends: '../tsconfig.json', + compilerOptions: { + baseUrl: '..', paths: { '@value': [`./src/config-${name}.ts`] }, + useDefineForClassFields: name !== 'worker-one', + }, + })); +} +write('src/name #%.ts', "enum Value { Current = 'encoded filename' }\nconsole.log(Value.Current);\n"); + +const baseEnv = { ...process.env }; +for (const key of ['NODE_OPTIONS', 'VP_SCRIPT_TSCONFIG', 'TSX_TSCONFIG_PATH', 'OXC_TSCONFIG_PATH', 'TS_NODE_PROJECT']) { + delete baseEnv[key]; +} +function run(args, { env = {}, input, cwd, status = 0 } = {}) { + const child = spawnSync(process.execPath, args, { + env: { ...baseEnv, ...env }, input, cwd, encoding: 'utf8', timeout: 30000, + }); + assert.equal(child.error, undefined, `node ${JSON.stringify(args)}: ${child.error}`); + assert.equal(child.status, status, + `node ${JSON.stringify(args)}\nstdout: ${child.stdout}\nstderr: ${child.stderr}`); + return { out: child.stdout.trim(), err: child.stderr.replaceAll('\\', '/') }; +} + +const esm = 'esm syntax, resolution, JSX, helpers, and dependencies'; +const cjs = 'cjs syntax, nested require, helpers, and dependencies'; +assert.equal(run(['--import=vite-plus/register', './src/main.ts']).out, esm); +assert.equal(run(['--import', 'vite-plus/register', './src/main.cts']).out, cjs); +assert.equal(run(['--import', pathToFileURL(registerPath).href, './src/name #%.ts']).out, 'encoded filename'); +// A file URL is portable; Node also accepts an absolute POSIX path. +if (process.platform !== 'win32') { + assert.equal(run(['--import', registerPath, './src/name #%.ts']).out, 'encoded filename'); +} +assert.equal(run(['./src/main.ts'], { env: { NODE_OPTIONS: '--import=vite-plus/register' } }).out, esm); +assert.equal(run(['./src/main.cts'], { env: { NODE_OPTIONS: '--import vite-plus/register' } }).out, cjs); +console.log('public package export: both flag forms, resolved URL, NODE_OPTIONS, ESM/CJS'); + +const preloads = ['--import=vite-plus/register', '--import', './src/setup.ts']; +const args = ['one', '--', '--flag', 'space argument']; +const argv = run([...preloads, './src/argv.ts', ...args], { + status: 7, + env: { EXPECTED_EXEC_PATH: process.execPath, EXPECTED_PARENT_PID: String(process.pid) }, +}); +assert.deepEqual(JSON.parse(argv.out), { args, execArgv: preloads }); +assert.deepEqual(JSON.parse(run(['--import', './src/setup.ts', './src/argv.ts', ...args], { + status: 7, + env: { + NODE_OPTIONS: '--import=vite-plus/register', + EXPECTED_EXEC_PATH: process.execPath, EXPECTED_PARENT_PID: String(process.pid), + }, +}).out), { args, execArgv: ['--import', './src/setup.ts'] }); +console.log('subsequent TypeScript preload, argv, execArgv, PID, runtime, and exit status'); + +const esmEval = "const { format } = await import('./src/module.mts'); console.log(format);"; +const cjsEval = "console.log(require('./src/common.cts').format);"; +assert.equal(run(['--import=vite-plus/register', '--input-type=module', '--eval', esmEval]).out, 'mts'); +assert.equal(run(['--import=vite-plus/register', '--eval', cjsEval]).out, 'cts'); +assert.equal(run(['--import=vite-plus/register', '--input-type=module', '-'], { input: esmEval }).out, 'mts'); +assert.equal(run(['--import=vite-plus/register', '-'], { input: cjsEval }).out, 'cts'); +assert.equal(run(['--input-type=module', '--eval', + "await import('vite-plus/register'); const { format } = await import('./src/module.mts'); console.log(format);", +]).out, 'mts'); +for (const options of [ + { args: ['./tests/enum.test.ts'] }, + { args: [], cwd: path.resolve('tests') }, +]) { + const test = run(['--import=vite-plus/register', '--test', '--test-reporter=tap', ...options.args], options); + assert.match(test.out, /# tests 1\b/); + assert.match(test.out, /# fail 0\b/); +} +console.log('eval/stdin imports and requires, dynamic registration, node:test and discovery'); + +const children = 'fork and ESM/CJS workers inherit registration'; +assert.equal(run(['--import=vite-plus/register', './src/children.mjs']).out, children); +assert.equal(run(['./src/children.mjs'], { env: { NODE_OPTIONS: '--import=vite-plus/register' } }).out, children); +assert.equal(run(['./src/spawn.mjs'], { env: { NODE_OPTIONS: '--import=vite-plus/register' } }).out, + 'spawn inherits NODE_OPTIONS registration'); +assert.equal(run(['--import=vite-plus/register', './src/children.mjs'], { + env: { TSX_TSCONFIG_PATH: configs.compat, EXPECTED_CONFIG_VALUE: 'compat' }, +}).out, children); +console.log('fork, ESM/CJS workers, spawned Node.js, and inherited custom tsconfig'); + +assert.equal(run(['--import=vite-plus/register', './src/config-workers.mjs'], { + env: { + TSX_TSCONFIG_PATH: configs.parent, + TS_NODE_PROJECT: configs.parent, + OXC_TSCONFIG_PATH: configs.parent, + }, +}).out, 'concurrent worker configs isolate ESM aliases and CJS transforms from the parent'); +assert.equal(run(['./src/worker-host.mjs']).out, 'a plain JavaScript host registers its TypeScript worker'); +assert.equal(run(['--import=vite-plus/register', './src/cluster.mjs'], { + env: { TSX_TSCONFIG_PATH: configs.compat }, +}).out, 'cluster inherits registration and custom tsconfig'); +assert.equal(run(['./src/internal-host.mjs']).out, 'the internal loader thread skips public registration'); +console.log('worker-specific configs, explicit worker preload, cluster, and internal loader thread'); + +const dedup = 'registration stays single across repeated and mixed preloads'; +assert.equal(run(['--import=vite-plus/register', '--import', 'vite-plus/register', './src/dedup.mjs']).out, dedup); +assert.equal(run(['--import=vite-plus/register', './src/dedup.mjs'], { + env: { NODE_OPTIONS: '--import=vite-plus/register' }, +}).out, dedup); +console.log('repeated flags, repeated dynamic imports, and NODE_OPTIONS/argv overlap'); + +for (const [value, env] of [ + ['default', {}], + ['compat', { TSX_TSCONFIG_PATH: configs.compat }], + ['upstream', { OXC_TSCONFIG_PATH: configs.upstream }], + ['upstream', { TS_NODE_PROJECT: configs.upstream }], + ['compat', { TS_NODE_PROJECT: configs.compat, OXC_TSCONFIG_PATH: configs.upstream }], + ['compat', { TSX_TSCONFIG_PATH: configs.compat, OXC_TSCONFIG_PATH: configs.upstream }], + ['compat', { VP_SCRIPT_TSCONFIG: '', TSX_TSCONFIG_PATH: configs.compat }], + ['explicit', { VP_SCRIPT_TSCONFIG: configs.explicit, TSX_TSCONFIG_PATH: configs.compat, + OXC_TSCONFIG_PATH: configs.upstream, TS_NODE_PROJECT: configs.upstream }], +]) { + const actual = JSON.parse(run(['--import=vite-plus/register', './src/config.ts'], { env }).out); + assert.deepEqual(actual, { value, + explicit: env.VP_SCRIPT_TSCONFIG ?? null, compat: env.TSX_TSCONFIG_PATH ?? null, + upstream: env.OXC_TSCONFIG_PATH ?? null, tsnode: env.TS_NODE_PROJECT ?? null, + }); +} +console.log('config paths with spaces, explicit/compat/upstream precedence, unchanged environment'); + +const mapped = run(['--import=vite-plus/register', './src/error.ts'], { status: 1 }); +assert.match(mapped.err, /Error: mapped failure/); +assert.match(mapped.err, /at new Broken \([^\n]*\/src\/error\.ts:4:\d+\)/); +const dependencyMapped = run(['--import=vite-plus/register', './src/dependency-error.ts'], { status: 1 }); +assert.match(dependencyMapped.err, /Error: dependency failure/); +assert.match(dependencyMapped.err, /at fail \([^\n]*\/node_modules\/fixture-ts-import\/error\.ts:3:\d+\)/); +console.log('source maps identify TypeScript lines in application and dependency errors'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_already_vite_plus.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_already_vite_plus.md index 924cf81287..c2fa29fee6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_already_vite_plus.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_already_vite_plus.md @@ -29,12 +29,12 @@ VITE+ - The Unified Toolchain for the Web VITE+ - The Unified Toolchain for the Web ▲ No package manager is declared in package.json; using pnpm for this migration without adding a pin. Run `vp env pin` to declare it explicitly. -● tsx scripts detected. Auto-migrating to vpx... +● tsx scripts detected. Auto-migrating to Vite+... ◇ Updated . to Vite+ • Node pnpm • Dependencies: vite → -• tsx scripts migrated to `vpx` +• tsx scripts migrated to Vite+ ``` ## `vpt print-file package.json` @@ -49,7 +49,7 @@ scripts run vpx, and the tsx devDependency is gone }, "name": "migration-tsx", "scripts": { - "bench": "vpx ./scripts/bench.ts", + "bench": "node --import vite-plus/register ./scripts/bench.ts", "dev": "vpx --watch --clear-screen=false src/server.ts", "seed": "cross-env NODE_ENV=development vpx scripts/seed.ts", "start": "concurrently \"vpx --watch src/server.ts\" \"vp dev\"" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md index 83ca0a570a..3a1e8331d9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md @@ -15,11 +15,11 @@ scripts vpx cannot run keep tsx, with a warning for each ``` VITE+ - The Unified Toolchain for the Web -● tsx scripts detected. Auto-migrating to vpx... +● tsx scripts detected. Auto-migrating to Vite+... ◇ Migrated . to Vite+ • Node pnpm • 1 config update applied -• tsx scripts migrated to `vpx` +• tsx scripts migrated to Vite+ ! Warnings: - Kept tsx in package.json scripts "calc": `vpx` does not run TypeScript passed to --eval or --print. - Kept tsx in package.json scripts "repl": `vpx` does not start a TypeScript REPL. @@ -39,7 +39,7 @@ the other scripts run vpx, and the tsx devDependency stays }, "name": "migration-tsx", "scripts": { - "bench": "vpx ./scripts/bench.ts", + "bench": "node --import vite-plus/register ./scripts/bench.ts", "calc": "tsx -e 'console.log(1 + 1)'", "dev": "vpx --watch --clear-screen=false src/server.ts", "repl": "tsx", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_scripts.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_scripts.md index 6bbb17a93e..fdcc90f780 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_scripts.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_scripts.md @@ -9,11 +9,11 @@ tsx scripts should migrate to vpx automatically ``` VITE+ - The Unified Toolchain for the Web -● tsx scripts detected. Auto-migrating to vpx... +● tsx scripts detected. Auto-migrating to Vite+... ◇ Migrated . to Vite+ • Node pnpm • 1 config update applied -• tsx scripts migrated to `vpx` +• tsx scripts migrated to Vite+ ``` ## `vpt print-file package.json` @@ -27,7 +27,7 @@ scripts run vpx, and the tsx devDependency is gone "scripts": { "dev": "vpx --watch --clear-screen=false src/server.ts", "seed": "cross-env NODE_ENV=development vpx scripts/seed.ts", - "bench": "vpx ./scripts/bench.ts", + "bench": "node --import vite-plus/register ./scripts/bench.ts", "start": "concurrently \"vpx --watch src/server.ts\" \"vp dev\"" }, "devDependencies": { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/.fixture/tsx/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/.fixture/tsx/package.json new file mode 100644 index 0000000000..e900a90163 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/.fixture/tsx/package.json @@ -0,0 +1,4 @@ +{ + "name": "tsx", + "version": "4.20.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/package.json new file mode 100644 index 0000000000..27eea0adc6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/package.json @@ -0,0 +1,17 @@ +{ + "name": "migration-tsx-preload", + "type": "module", + "scripts": { + "seed": "node --import=tsx ./scripts/seed.ts", + "eval": "node --import tsx --eval \"import('./scripts/seed.ts')\"", + "test": "node --import tsx --test", + "watch": "node --import=tsx --watch --test ./scripts/seed.ts", + "tool": "cross-env NODE_OPTIONS=\"--trace-warnings --import tsx\" mocha", + "config": "TSX_TSCONFIG_PATH=tsconfig.json node --import tsx ./scripts/seed.ts", + "launcher": "NODE_OPTIONS=--import=tsx concurrently \"node ./scripts/seed.ts\" \"node --test\"" + }, + "devDependencies": { + "tsx": "file:.fixture/tsx", + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/scripts/seed.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/scripts/seed.ts new file mode 100644 index 0000000000..81130916e2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/scripts/seed.ts @@ -0,0 +1,2 @@ +const message: string = 'TypeScript preload works'; +console.log(message); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/snapshots.toml new file mode 100644 index 0000000000..87daf044f5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/snapshots.toml @@ -0,0 +1,12 @@ +[[case]] +name = "migration_tsx_preload" +vp = "local" +comment = "Plain tsx preloads keep Node.js and its flags. The local tsx stub keeps installation offline." +steps = [ + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "Migrate preload flags and literal NODE_OPTIONS assignments.", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "All commands keep Node.js or the tool launcher, and tsx is removed.", continue-on-failure = true }, + { argv = ["vp", "run", "seed"], comment = "The migrated file command resolves the installed preload.", continue-on-failure = true }, + { argv = ["vp", "run", "eval"], comment = "The migrated eval command keeps native Node.js behavior.", continue-on-failure = true }, + { argv = ["vp", "migrate", "--full", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "A second migration finds no remaining tsx use.", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "The scripts are unchanged on the second run.", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/snapshots/migration_tsx_preload.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/snapshots/migration_tsx_preload.md new file mode 100644 index 0000000000..282df84862 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/snapshots/migration_tsx_preload.md @@ -0,0 +1,94 @@ +# migration_tsx_preload + +Plain tsx preloads keep Node.js and its flags. The local tsx stub keeps installation offline. + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +Migrate preload flags and literal NODE_OPTIONS assignments. + +``` +VITE+ - The Unified Toolchain for the Web + +● tsx scripts detected. Auto-migrating to Vite+... +◇ Migrated . to Vite+ +• Node pnpm +• 1 config update applied +• tsx scripts migrated to Vite+ +``` + +## `vpt print-file package.json` + +All commands keep Node.js or the tool launcher, and tsx is removed. + +``` +{ + "name": "migration-tsx-preload", + "type": "module", + "scripts": { + "seed": "node --import=vite-plus/register ./scripts/seed.ts", + "eval": "node --import vite-plus/register --eval \"import('./scripts/seed.ts')\"", + "test": "node --import vite-plus/register --test", + "watch": "node --import=vite-plus/register --watch --test ./scripts/seed.ts", + "tool": "cross-env NODE_OPTIONS=\"--trace-warnings --import vite-plus/register\" mocha", + "config": "TSX_TSCONFIG_PATH=tsconfig.json node --import vite-plus/register ./scripts/seed.ts", + "launcher": "NODE_OPTIONS=--import=vite-plus/register concurrently \"node ./scripts/seed.ts\" \"node --test\"" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + } +} +``` + +## `vp run seed` + +The migrated file command resolves the installed preload. + +``` +$ node --import=vite-plus/register ./scripts/seed.ts ⊘ cache disabled +TypeScript preload works +``` + +## `vp run eval` + +The migrated eval command keeps native Node.js behavior. + +``` +$ node --import vite-plus/register --eval "import('./scripts/seed.ts')" ⊘ cache disabled +TypeScript preload works +``` + +## `vp migrate --full --no-interactive --no-hooks --no-agent --no-editor` + +A second migration finds no remaining tsx use. + +``` +VITE+ - The Unified Toolchain for the Web + +▲ No package manager is declared in package.json; using pnpm for this migration without adding a pin. Run `vp env pin` to declare it explicitly. +This project is already using Vite+! Happy coding! +``` + +## `vpt print-file package.json` + +The scripts are unchanged on the second run. + +``` +{ + "name": "migration-tsx-preload", + "type": "module", + "scripts": { + "seed": "node --import=vite-plus/register ./scripts/seed.ts", + "eval": "node --import vite-plus/register --eval \"import('./scripts/seed.ts')\"", + "test": "node --import vite-plus/register --test", + "watch": "node --import=vite-plus/register --watch --test ./scripts/seed.ts", + "tool": "cross-env NODE_OPTIONS=\"--trace-warnings --import vite-plus/register\" mocha", + "config": "TSX_TSCONFIG_PATH=tsconfig.json node --import vite-plus/register ./scripts/seed.ts", + "launcher": "NODE_OPTIONS=--import=vite-plus/register concurrently \"node ./scripts/seed.ts\" \"node --test\"" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/tsconfig.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/tsconfig.json new file mode 100644 index 0000000000..678ff85dad --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "target": "ESNext" + } +} diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 503c2f401e..bc8f63147c 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -278,21 +278,29 @@ Unrelated `bunx` commands and other package-executor forms remain unchanged. When a `package.json` script, a lint-staged task, or a `#!/usr/bin/env tsx` shebang runs [tsx](https://tsx.is), migration offers to move it to [`vpx`](/guide/vpx), Vite+'s TypeScript script runner. It rewrites scripts and lint-staged tasks in `package.json` and JSON lint-staged files, and `#!/usr/bin/env tsx` shebangs in script files: -| Before | After | -| -------------------------------------------------------------------------- | ------------------------------------------ | -| `tsx src/seed.ts --rows 10` | `vpx src/seed.ts --rows 10` | -| `tsx watch --include "*.json" src/a.ts` | `vpx --watch --include "*.json" src/a.ts` | -| `npx tsx a.ts`, `pnpm exec tsx a.ts`, `pnpm dlx tsx a.ts`, `bunx tsx a.ts` | `vpx a.ts` | -| `node --import tsx ./a.ts` | `vpx ./a.ts` | -| `cross-env NODE_ENV=dev tsx a.ts` | `cross-env NODE_ENV=dev vpx a.ts` | -| `concurrently "tsx watch a.ts" "vp dev"` | `concurrently "vpx --watch a.ts" "vp dev"` | +| Before | After | +| -------------------------------------------------------------------------- | -------------------------------------------------- | +| `tsx src/seed.ts --rows 10` | `vpx src/seed.ts --rows 10` | +| `tsx watch --include "*.json" src/a.ts` | `vpx --watch --include "*.json" src/a.ts` | +| `npx tsx a.ts`, `pnpm exec tsx a.ts`, `pnpm dlx tsx a.ts`, `bunx tsx a.ts` | `vpx a.ts` | +| `node --import tsx ./a.ts` | `node --import vite-plus/register ./a.ts` | +| `node --import=tsx --test` | `node --import=vite-plus/register --test` | +| `NODE_OPTIONS='--import tsx' mocha` | `NODE_OPTIONS='--import vite-plus/register' mocha` | +| `cross-env NODE_ENV=dev tsx a.ts` | `cross-env NODE_ENV=dev vpx a.ts` | +| `concurrently "tsx watch a.ts" "vp dev"` | `concurrently "vpx --watch a.ts" "vp dev"` | `tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. A quoted command is rewritten only for programs that run it, such as `concurrently`, `nodemon --exec`, `sh -c`, and `cross-env-shell`; a quoted argument of other programs, as in `echo "tsx a.ts"`, is data. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). -Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files or in watch mode, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead) or load tsx through `NODE_OPTIONS`, and commands with pipes, variables, or subshells. It also reads the scripts a command runs, including local `--require` and `--import` modules, and keeps tsx when one does not exist yet, as when an earlier command generates it, and when `vpx` would fail on one: a `require()` of `./a.js` that only tsx resolves to `a.ts`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check skips globs and scripts after a `cd` or another shell state change, and reads only the files a command names, not the modules they import. It does not look for what else `vpx` handles differently, such as standard decorators, tsconfig `paths` in `require()`, or a tsconfig `module` of `node16` or `nodenext`; `vpx` reports those when the script runs. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. +The plain `node --import tsx` and `node --import=tsx` forms keep Node.js, its options, wrappers, and arguments. They use the [`vite-plus/register` preload](/guide/vpx#nodejs-preload), so Node.js retains its eval, test discovery, and native watch behavior. Literal `NODE_OPTIONS` assignments also migrate, including assignments through `cross-env` and known command launchers. The preload accepts `TSX_TSCONFIG_PATH` as a fallback. Assignments that also load a legacy tsx hook, or reach a command that still needs tsx, stay unchanged. + +For the tsx executable and legacy loader forms such as `--loader tsx/esm` and `-r tsx/cjs`, migration keeps the existing `vpx` rules. It keeps tsx, with a warning, for `--eval`, `--print`, or `--check`, the REPL, `--test` without files or in watch mode, an entry without a file extension, or commands that load tsx through `NODE_OPTIONS`. Commands with pipes, variables, or subshells also stay unchanged. `vpx` accepts `TSX_TSCONFIG_PATH` as a fallback too; an explicit `--tsconfig` takes priority. + +Both migration paths read the scripts a command names, including local `--require` and `--import` modules. They keep tsx when a script does not exist yet, as when an earlier command generates it, or when the Vite+ loader would fail on it: a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `.cts` file with `import` or `export`, or a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check skips globs and scripts after a `cd` or another shell state change, and reads only the files a command names, not the modules they import. It does not look for other compiler differences, such as standard decorators, tsconfig `paths` in `require()`, or a tsconfig `module` of `node16` or `nodenext`; the loader reports those when the script runs. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since the replacement comes from the `vite-plus` devDependency. Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api` (in source, build output, or a `package.json` `imports` alias), no shebang, no tool setting such as `--import=tsx` in `package.json` or a JSON or JavaScript config, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx, and a package's dependency stays while a nested package under it does. When tsx stays, the warning names what still uses it. +Tool configurations and source files need manual edits. For example, change `nodeArguments: ['--import=tsx']` to `nodeArguments: ['--import=vite-plus/register']`, or Mocha's `"import": "tsx"` setting to `"import": "vite-plus/register"`. Keep tsx if the project uses its programmatic APIs. + See [Coming from tsx](/guide/vpx#coming-from-tsx) for the other differences. ## Continuous Integration Rules diff --git a/docs/guide/vpx.md b/docs/guide/vpx.md index 5f3fdcf4a4..e94a531033 100644 --- a/docs/guide/vpx.md +++ b/docs/guide/vpx.md @@ -103,6 +103,38 @@ Scripts can use `vpx` as their interpreter. Make the file executable and keep it The `vpx` bin of the `vite-plus` package runs scripts on its own, so `"seed": "vpx ./scripts/seed.ts"` works in `package.json` scripts without the global CLI. It runs them on the Node.js that started it, which inside `vp run` and package manager scripts is the project's version. +### Node.js Preload + +If a tool starts Node.js itself, replace `--import=tsx` with `--import=vite-plus/register`: + +```bash +node --import=vite-plus/register ./scripts/seed.ts +node --import vite-plus/register --test ./test/example.test.ts +NODE_OPTIONS='--import=vite-plus/register' node ./scripts/seed.ts +``` + +Install `vite-plus` in the project first. The preload resolves from the project and runs on the Node.js that started the command; it requires the same Node.js versions as Vite+. It provides the same TypeScript transforms, tsconfig resolution, and source maps as `vpx`. + +Tools with a Node.js argument setting can replace `["--import", "tsx"]` with `["--import", "vite-plus/register"]`. For example, an AVA configuration can use: + +```json +{ + "ava": { + "nodeArguments": ["--import=vite-plus/register"] + } +} +``` + +Keep the tool's existing TypeScript file patterns and other settings. A JavaScript launcher can also run `await import('vite-plus/register')` before dynamically importing a TypeScript file. A static TypeScript import in that launcher is resolved before registration, so use `--import` for static imports. + +`fork()` and workers inherit the preload through Node.js arguments. `NODE_OPTIONS` also passes it to spawned Node.js processes. Replacing a child's arguments or environment follows Node.js's normal inheritance rules. + +To select a tsconfig, set `VP_SCRIPT_TSCONFIG=tsconfig.scripts.json`. The existing `TSX_TSCONFIG_PATH` variable also works, with `VP_SCRIPT_TSCONFIG` taking precedence. Otherwise the [usual tsconfig discovery](#tsconfig) applies. Each worker selects its config from its own environment, so workers can use different configs without changing their parent. + +Node.js owns its flags, test discovery, and native watch mode. Eval and stdin can import TypeScript files, but their input text is not transformed. All `--require` preloads run before `--import`: put a TypeScript setup file after the loader as `--import ./setup.ts`. Repeated imports of `vite-plus/register` are harmless, including when a tool already runs under `vpx`. + +Remove the old tsx preload when you add this one. The [compiler differences from tsx](#coming-from-tsx) also apply to the public preload. + ### Watch Mode ```bash @@ -132,7 +164,7 @@ It watches every file the script imports or requires (in worker threads too, on `vpx` reads compiler options from a tsconfig but does not type-check with it. Each file uses the nearest `tsconfig.json` above it that includes it through `files`, `include`, `exclude`, or project `references`, as `tsc` does, and `extends` is followed. Files in `node_modules` use no tsconfig, and neither does a file that no tsconfig includes, so it gets the defaults below. Such a file still resolves `paths` aliases through the nearest `tsconfig.json` above it, as tsx does: with a root tsconfig whose `include` is `["src"]`, a script in `scripts/` can import the same aliases as `src/`. Because the config depends on the file's location, not on the working directory, `vpx` compiles a file the same way wherever it runs from. -`--tsconfig ` applies one config to every file instead, including files that config does not include. Without `--tsconfig`, an `OXC_TSCONFIG_PATH` or `TS_NODE_PROJECT` environment variable does the same. If it names a file that does not exist, or a tsconfig fails to parse, `vpx` prints a warning and runs without that config. +`--tsconfig ` applies one config to every file instead, including files that config does not include. Without `--tsconfig`, `TSX_TSCONFIG_PATH`, `TS_NODE_PROJECT`, or `OXC_TSCONFIG_PATH` can select a config, in that order. If it names a file that does not exist, or a tsconfig fails to parse, `vpx` prints a warning and runs without that config. A file that no tsconfig applies to runs as if its `compilerOptions` were empty: @@ -193,8 +225,8 @@ JSX that depends on a compiler plugin is not supported. `vpx` compiles JSX only - **Node.js version**: `vpx` needs Node.js `^22.18.0 || ^24.11.0 || >=26.0.0`, while tsx also runs on older releases. - **Watch mode**: `tsx watch` becomes `vpx --watch`, which takes tsx watch's options (see [Watch Mode](#watch-mode)). It also reruns the script on tsconfig edits and when a file a failed import looked for is created, which tsx watch misses. - **Eval, REPL, and `--check`**: `vpx --eval`, the REPL, and `vpx --check` do not transform TypeScript. -- **`node --import tsx`**: `vpx` has no equivalent for tools that must start `node` themselves. -- **Other loaders**: remove `--import tsx` from `NODE_OPTIONS`. Two loaders registered together compete for the same files. +- **`node --import tsx`**: replace it with [`node --import vite-plus/register`](#nodejs-preload), including in tool settings and `NODE_OPTIONS`. +- **Other loaders**: replace the tsx preload instead of adding a second loader. Two loaders registered together compete for the same files. ## `vp exec` diff --git a/packages/cli/BUNDLING.md b/packages/cli/BUNDLING.md index 6c29cac777..abbcc27a87 100644 --- a/packages/cli/BUNDLING.md +++ b/packages/cli/BUNDLING.md @@ -313,7 +313,8 @@ A binding built by `pnpm build` contains: - `sync-remote` clones it into `oxc-node/` at the hash in `packages/tools/.upstream-versions.json`; CI checks it out in `.github/actions/clone`. - The root `Cargo.toml` links it with `default-features = false`, so it registers no NAPI exports and declares no global allocator, and excludes `oxc-node/` from the workspace, so upstream code is not held to workspace lints. `binding/src/oxc_node.rs` forwards the functions the loader uses under an `oxcNode` namespace, so nothing collides with Rolldown's `transform`. `packages/tools/src/patch-oxc-node.ts` sets oxc-node's `oxc` dependency to the workspace version and exposes its frozen tsconfig APIs to embedders while keeping their NAPI registration disabled. -- `src/script-preload.ts` (the `--require` entry), `src/script-register.ts` (the `--import` entry), and `src/script-esm-hooks.ts` are adapted from oxc-node's `register.mjs` and `esm.mjs`. They load the hooks from this binding, pass `vpx --tsconfig` through `setTsconfigPath()`, and resolve oxc-node's runtime helpers from `@oxc-project/runtime`, a `vite-plus` dependency. Off-thread ESM hooks receive the preload's frozen tsconfig and dependency policy because each thread has a separate loader context. +- `src/script-preload.ts` (the `--require` entry), `src/script-register.ts` (the `--import` entry), and `src/script-esm-hooks.ts` are adapted from oxc-node's `register.mjs` and `esm.mjs`. They load the hooks from this binding, pass `vpx --tsconfig` through `initializeLoader()`, and resolve oxc-node's runtime helpers from `@oxc-project/runtime`, a `vite-plus` dependency. Off-thread ESM hooks receive the preload's frozen tsconfig and dependency policy because each thread has a separate loader context. +- `src/register.ts` exposes `vite-plus/register` for `node --import`. It loads the emitted `script-preload.cjs` and then `script-register.js` by runtime path, so Node.js caches the same registrations used by `vpx`. Keep those paths external to the bundle and list `dist/register.js` in the package's `sideEffects`. The entry skips Node.js internal loader threads to avoid recursively registering a loader through `NODE_OPTIONS`. See the [preload RFC](../../rfcs/script-import-loader.md). - oxc-node's `packages/core/watch-report.mjs` and `signal-ack.mjs` (`@oxc-node/core/watch-report` and `/signal-ack`) are imported from the vendored source and bundled into the loader, so watch mode gets the reports in the format the binding records them. - `src/script-watch.ts` (`dist/script-watch.js`, `vpx --watch`) bundles oxc-node's watcher, `packages/cli/src/watch` (`@oxc-node/cli/watch`), from the vendored source the same way, and runs it with the loader entries next to it. It imports upstream's `watch.d.ts` for types, and a `tsdown.config.ts` plugin swaps in the sources, so type checking never reaches code written against upstream's `@types/node`. - `pirates`, the CommonJS hook, is bundled into `dist`. diff --git a/packages/cli/package.json b/packages/cli/package.json index 756478a312..08ad130be4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -32,7 +32,9 @@ "templates" ], "type": "module", - "sideEffects": false, + "sideEffects": [ + "./dist/register.js" + ], "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -85,6 +87,10 @@ "types": "./dist/oxlint-plugin.d.ts" }, "./package.json": "./package.json", + "./register": { + "types": "./dist/register.d.ts", + "default": "./dist/register.js" + }, "./pack": { "types": "./dist/pack.d.ts", "import": "./dist/pack.js" diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index e66c8f88be..94590e5543 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -42,11 +43,94 @@ describe('rewriteTsxCommand', () => { ['NODE_ENV=test tsx ./a.ts', 'NODE_ENV=test vpx ./a.ts'], ['cross-env NODE_ENV=production tsx ./a.ts', 'cross-env NODE_ENV=production vpx ./a.ts'], ['tsx ./a.ts && tsx watch ./b.ts; vp build', 'vpx ./a.ts && vpx --watch ./b.ts; vp build'], - ['node --import tsx ./a.ts', 'vpx ./a.ts'], - ['node --import=tsx --inspect ./a.ts', 'vpx --inspect ./a.ts'], + ['node --import tsx ./a.ts', 'node --import vite-plus/register ./a.ts'], + ['node --import=tsx --inspect ./a.ts', 'node --import=vite-plus/register --inspect ./a.ts'], ['node --loader tsx/esm --watch ./a.ts', 'vpx --watch ./a.ts'], ['node -r tsx/cjs ./a.cts', 'vpx ./a.cts'], - ['node --import tsx --import ./setup.ts ./a.ts', 'vpx --import ./setup.ts ./a.ts'], + [ + 'node --import tsx --import ./setup.ts ./a.ts', + 'node --import vite-plus/register --import ./setup.ts ./a.ts', + ], + ['node --import tsx', 'node --import vite-plus/register'], + ['node --import tsx -e 1', 'node --import vite-plus/register -e 1'], + ['node --import tsx --print 1', 'node --import vite-plus/register --print 1'], + ['node --import tsx --check ./a.ts', 'node --import vite-plus/register --check ./a.ts'], + ['node --import tsx --test', 'node --import vite-plus/register --test'], + [ + 'node --import tsx --watch --test ./a.test.ts', + 'node --import vite-plus/register --watch --test ./a.test.ts', + ], + [ + 'node --import tsx --watch-path src ./a.ts', + 'node --import vite-plus/register --watch-path src ./a.ts', + ], + ['node --import tsx src/index', 'node --import vite-plus/register src/index'], + [ + 'node --import "tsx" --import=\'tsx\' -- ./a.ts', + 'node --import "vite-plus/register" --import=vite-plus/register -- ./a.ts', + ], + [ + 'pnpm exec node --inspect --import=tsx ./a.ts', + 'pnpm exec node --inspect --import=vite-plus/register ./a.ts', + ], + [ + 'TSX_TSCONFIG_PATH=cli.json node --import tsx ./a.ts', + 'TSX_TSCONFIG_PATH=cli.json node --import vite-plus/register ./a.ts', + ], + ['TSX_TSCONFIG_PATH=cli.json tsx ./a.ts', 'TSX_TSCONFIG_PATH=cli.json vpx ./a.ts'], + [ + 'TSX_TSCONFIG_PATH=cli.json tsx --tsconfig other.json ./a.ts', + 'TSX_TSCONFIG_PATH=cli.json vpx --tsconfig other.json ./a.ts', + ], + [ + 'cross-env-shell TSX_TSCONFIG_PATH=cli.json "tsx ./a.ts"', + 'cross-env-shell TSX_TSCONFIG_PATH=cli.json "vpx ./a.ts"', + ], + [ + 'TSX_TSCONFIG_PATH=cli.json concurrently "tsx ./a.ts"', + 'TSX_TSCONFIG_PATH=cli.json concurrently "vpx ./a.ts"', + ], + [ + "NODE_OPTIONS='--import tsx' node ./a.ts", + "NODE_OPTIONS='--import vite-plus/register' node ./a.ts", + ], + ['NODE_OPTIONS=--import=tsx mocha', 'NODE_OPTIONS=--import=vite-plus/register mocha'], + [ + 'cross-env NODE_OPTIONS="--trace-warnings --import=tsx" mocha --reporter spec', + 'cross-env NODE_OPTIONS="--trace-warnings --import=vite-plus/register" mocha --reporter spec', + ], + [ + 'cross-env NODE_OPTIONS="--import "tsx mocha', + 'cross-env NODE_OPTIONS="--import vite-plus/register" mocha', + ], + [ + 'cross-env NODE_OPTIONS=--import" tsx" mocha', + 'cross-env NODE_OPTIONS="--import vite-plus/register" mocha', + ], + [ + 'cross-env node_options=--import=tsx node ./a.ts', + 'cross-env node_options=--import=vite-plus/register node ./a.ts', + ], + [ + 'TSX_TSCONFIG_PATH=cli.json NODE_OPTIONS=--import=tsx node ./a.ts', + 'TSX_TSCONFIG_PATH=cli.json NODE_OPTIONS=--import=vite-plus/register node ./a.ts', + ], + [ + 'NODE_OPTIONS=--import=tsx node --import tsx ./a.ts', + 'NODE_OPTIONS=--import=vite-plus/register node --import vite-plus/register ./a.ts', + ], + [ + 'NODE_OPTIONS=--import=tsx concurrently "node ./a.ts" "node --test"', + 'NODE_OPTIONS=--import=vite-plus/register concurrently "node ./a.ts" "node --test"', + ], + [ + 'TSX_TSCONFIG_PATH=cli.json concurrently "node --import tsx ./a.ts"', + 'TSX_TSCONFIG_PATH=cli.json concurrently "node --import vite-plus/register ./a.ts"', + ], + [ + 'cross-env-shell NODE_OPTIONS=--import=tsx "node --import tsx ./a.ts"', + 'cross-env-shell NODE_OPTIONS=--import=vite-plus/register "node --import vite-plus/register ./a.ts"', + ], [ 'concurrently "tsx watch src/api.ts" "vp dev"', 'concurrently "vpx --watch src/api.ts" "vp dev"', @@ -92,18 +176,10 @@ describe('rewriteTsxCommand', () => { ['tsx $ENTRY', 'shell syntax'], ['tsx %ENTRY%.ts', 'shell syntax'], ['.\\node_modules\\.bin\\tsx.cmd scripts/a.ts', 'shell syntax'], - ['node --import tsx', 'REPL'], - ['node --import tsx -e 1', '--eval or --print'], - ['node --import tsx --watch-path src ./a.ts', '--watch-path'], - ["NODE_OPTIONS='--import tsx' node ./a.ts", '`NODE_OPTIONS` loads tsx'], // A quoted argument of a program that does not run commands is data. ['echo "tsx scripts/build.ts"', 'does not rewrite this use of tsx'], // Only the value of `-c` is a command; the next argument is the shell's `$0`. [`sh -c 'echo "$0"' 'tsx a.ts'`, 'does not rewrite this use of tsx'], - ['TSX_TSCONFIG_PATH=cli.json tsx ./a.ts', 'TSX_TSCONFIG_PATH'], - ['TSX_TSCONFIG_PATH=cli.json node --import tsx ./a.ts', 'TSX_TSCONFIG_PATH'], - ['cross-env-shell TSX_TSCONFIG_PATH=cli.json "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], - ['TSX_TSCONFIG_PATH=cli.json concurrently "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], ['NODE_OPTIONS=--import=tsx tsx ./a.ts', 'NODE_OPTIONS'], // Windows reads environment variable names in any case. ['cross-env node_options=--import=tsx tsx ./a.ts', 'NODE_OPTIONS'], @@ -112,7 +188,20 @@ describe('rewriteTsxCommand', () => { ['export TSX_TSCONFIG_PATH=cli.json && tsx ./a.ts', 'shell syntax'], ['tsx watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], ['tsx --watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], - ['node --import tsx --watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], + [ + 'NODE_OPTIONS=--import=tsx concurrently "tsx ./a.ts" "node --import tsx ./b.ts"', + 'NODE_OPTIONS', + ], + ['NODE_OPTIONS=--loader=tsx/esm node --import tsx ./a.ts', 'NODE_OPTIONS'], + ['NODE_OPTIONS="--import=tsx --require=tsx/cjs" node ./a.ts', 'NODE_OPTIONS'], + ['NODE_OPTIONS="--import=tsx" node -r tsx/cjs ./a.cts', 'NODE_OPTIONS'], + ['NODE_OPTIONS="--import=\'tsx\'" node --import tsx ./a.ts', 'NODE_OPTIONS'], + ['NODE_OPTIONS=--import=tsx runner --require tsx/cjs', 'does not rewrite this use of tsx'], + ['NODE_OPTIONS="$NODE_OPTIONS --import tsx" node ./a.ts', 'shell syntax'], + ['node ./a.ts --import tsx', 'does not rewrite this use of tsx'], + ['node --eval "--import tsx"', 'does not rewrite this use of tsx'], + ['node --title "--import tsx" ./a.ts', 'does not rewrite this use of tsx'], + ['echo "NODE_OPTIONS=--import=tsx"', 'does not rewrite this use of tsx'], ])('keeps %s', (command, reason) => { const result = rewriteTsxCommand(command); expect(result.command).toBe(command); @@ -125,6 +214,62 @@ describe('rewriteTsxCommand', () => { expect(result.kept).toEqual([expect.stringContaining('--eval')]); }); + it + .skipIf(process.platform === 'win32') + .each([ + 'NODE_OPTIONS="--import "tsx', + "NODE_OPTIONS=--import' tsx'", + `NODE_OPTIONS='--import "tsx"'`, + ])('preserves shell assignment semantics for %s', (assignment) => { + const command = `${assignment} sh -c 'printenv NODE_OPTIONS'`; + const result = rewriteTsxCommand(command); + expect(result.kept).toEqual([]); + expect(execFileSync('sh', ['-c', result.command], { encoding: 'utf8' }).trim()).toBe( + assignment.includes('"tsx"') + ? '--import "vite-plus/register"' + : '--import vite-plus/register', + ); + expect(rewriteTsxCommand(result.command).command).toBe(result.command); + }); + + it.skipIf(process.platform === 'win32')( + 'does not split NODE_OPTIONS data at an unquoted tab', + () => { + const command = `NODE_OPTIONS='--title=a\t--import=tsx' node --eval 'process.stdout.write(process.title)'`; + const result = rewriteTsxCommand(command); + expect(result.command).toBe(command); + expect(execFileSync('sh', ['-c', result.command], { encoding: 'utf8' })).toBe( + 'a\t--import=tsx', + ); + }, + ); + + it.each([ + [ + 'node --import tsx ./a.ts --import tsx', + 'node --import vite-plus/register ./a.ts --import tsx', + ], + [ + 'node --import tsx -- ./a.ts --import tsx', + 'node --import vite-plus/register -- ./a.ts --import tsx', + ], + [ + 'node --title "--import tsx" --import tsx ./a.ts', + 'node --title "--import tsx" --import vite-plus/register ./a.ts', + ], + [ + 'node --import tsx --eval "--import tsx"', + 'node --import vite-plus/register --eval "--import tsx"', + ], + [ + 'NODE_OPTIONS=\'--import tsx --title "--import tsx"\' node ./a.ts', + 'NODE_OPTIONS=\'--import vite-plus/register --title "--import tsx"\' node ./a.ts', + ], + ])('only replaces preload modules in %s', (command, expected) => { + expect(rewriteTsxCommand(command).command).toBe(expected); + expect(rewriteTsxCommand(expected).command).toBe(expected); + }); + describe('with the scripts it runs', () => { let dir: string; @@ -244,6 +389,22 @@ describe('rewriteTsxCommand', () => { it.each([ ['tsx esm.cts', '`esm.cts` uses import or export'], ['node --import tsx ./esm.cts', '`./esm.cts` uses import or export'], + ['node --import tsx --import ./esm.cts ./plain.ts', '`./esm.cts` uses import or export'], + ['NODE_OPTIONS=--import=tsx node ./esm.cts', '`./esm.cts` uses import or export'], + [ + 'NODE_OPTIONS="--import tsx --import ./esm.cts" node ./plain.ts', + '`./esm.cts` uses import or export', + ], + ['NODE_OPTIONS="--import tsx --import ./esm.cts" mocha', '`./esm.cts` uses import or export'], + [ + 'NODE_OPTIONS=--import=tsx concurrently "node ./esm.cts" "node --import tsx ./plain.ts"', + '`./esm.cts` uses import or export', + ], + [ + 'node --import tsx --test plain.ts dirname.ts', + '`dirname.ts` mixes import or export with CommonJS globals', + ], + ['node --import tsx generated.ts', '`generated.ts` does not exist yet'], ['tsx watch dirname.ts', '`dirname.ts` mixes import or export with CommonJS globals'], [ 'tsx --test plain.ts dirname.ts', @@ -301,6 +462,18 @@ describe('rewriteTsxCommand', () => { ['tsx import-equals.cts', 'vpx import-equals.cts'], ['tsx importer.ts', 'vpx importer.ts'], ['tsx --test plain.ts', 'vpx --test plain.ts'], + [ + 'node --import tsx --eval 1 generated.ts', + 'node --import vite-plus/register --eval 1 generated.ts', + ], + [ + 'NODE_OPTIONS=--import=tsx node --eval 1 generated.ts', + 'NODE_OPTIONS=--import=vite-plus/register node --eval 1 generated.ts', + ], + [ + 'node --import tsx --test tests/*.test.ts', + 'node --import vite-plus/register --test tests/*.test.ts', + ], // A glob, and a script after a shell state change, are not checked. ['tsx --test tests/*.test.ts', 'vpx --test tests/*.test.ts'], ['cd tools && tsx only.ts', 'cd tools && vpx only.ts'], @@ -408,6 +581,60 @@ describe('migrateTsxToVpx', () => { }); }); + it('migrates a project that only uses preloads and removes the unused dependency', () => { + write('package.json', { + type: 'module', + scripts: { + seed: 'node --import=tsx scripts/seed.ts', + test: 'node --import tsx --test', + mocha: 'cross-env NODE_OPTIONS="--import tsx" mocha', + }, + devDependencies: { tsx: '^4.20.0' }, + }); + write('.lintstagedrc.json', { '*.ts': 'node --import tsx scripts/check.ts' }); + const report = createMigrationReport(); + + expect(detectTsxProject(root)).toBe(true); + expect(migrateTsxToVpx(root, undefined, report)).toEqual({ + changed: true, + dependencyRemoved: true, + }); + expect(readJson('package.json').scripts).toEqual({ + seed: 'node --import=vite-plus/register scripts/seed.ts', + test: 'node --import vite-plus/register --test', + mocha: 'cross-env NODE_OPTIONS="--import vite-plus/register" mocha', + }); + expect(readJson('.lintstagedrc.json')).toEqual({ + '*.ts': 'node --import vite-plus/register scripts/check.ts', + }); + expect(readJson('package.json').devDependencies).toEqual({}); + expect(report.warnings).toEqual([]); + expect(detectTsxProject(root)).toBe(false); + expect(migrateTsxToVpx(root)).toEqual({ changed: false, dependencyRemoved: false }); + }); + + it('retains tsx for tool configuration that still needs a manual preload edit', () => { + write('package.json', { + scripts: { seed: 'node --import=tsx scripts/seed.ts' }, + ava: { nodeArguments: ['--import=tsx'] }, + devDependencies: { tsx: '^4.20.0' }, + }); + const report = createMigrationReport(); + + expect(migrateTsxToVpx(root, undefined, report)).toEqual({ + changed: true, + dependencyRemoved: false, + }); + expect(readJson('package.json').scripts.seed).toBe( + 'node --import=vite-plus/register scripts/seed.ts', + ); + expect(readJson('package.json').ava).toEqual({ nodeArguments: ['--import=tsx'] }); + expect(readJson('package.json').devDependencies).toEqual({ tsx: '^4.20.0' }); + expect(report.warnings).toContain( + 'Kept the tsx devDependency in package.json: `ava` in package.json still uses tsx.', + ); + }); + it.each([ ['a kept script', 'package.json', undefined, '`scripts` in package.json still uses tsx'], [ diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 29cc62b1a2..a9defc251a 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -582,7 +582,7 @@ function showMigrationSummary(options: { log(`${styleText('gray', '•')} tsup config migrated to tsdown (\`vp pack\`)`); } if (report.tsxMigrated) { - log(`${styleText('gray', '•')} tsx scripts migrated to \`vpx\``); + log(`${styleText('gray', '•')} tsx scripts migrated to Vite+`); } if (report.nodeVersionFileMigrated) { log(`${styleText('gray', '•')} Node version manager file migrated to .node-version`); diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 67f1865830..4649260abf 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -57,8 +57,6 @@ const KEEP_WATCH_PATH = '`--watch-path` has no `vpx` equivalent; use `--include` const KEEP_SHELL = 'the command uses shell syntax that vp migrate does not rewrite, such as pipes, variables, or subshells'; const KEEP_OTHER = 'vp migrate does not rewrite this use of tsx'; -const KEEP_TSX_TSCONFIG = - '`TSX_TSCONFIG_PATH` selects the tsconfig for tsx only; pass it to `vpx` as `--tsconfig`'; const KEEP_NODE_OPTIONS = '`NODE_OPTIONS` loads tsx, whose hooks would compete with those of `vpx`; remove it first'; const KEEP_WATCH_TEST = '`vpx --watch` does not run `--test`'; @@ -242,13 +240,21 @@ interface Rewrite { /** * Rewrite the tsx invocations in a command to `vpx`: `tsx a.ts` becomes `vpx a.ts`, * `tsx watch a.ts` becomes `vpx --watch a.ts` (`vpx watch` runs the npm package `watch`), - * and `node --import tsx a.ts` becomes `vpx a.ts`. Package-manager wrappers such as + * and `node --import tsx a.ts` keeps Node.js with `vite-plus/register`. Wrappers such as * `npx tsx` go away with tsx, since `vpx` is a bin of vite-plus. A form `vpx` cannot run * stays as it is, with the reason in `kept`. With `cwd`, the directory the command runs * in, the scripts it runs are checked for syntax `vpx` runs differently. */ export function rewriteTsxCommand(command: string, cwd?: string): TsxCommandRewrite { - if (!TSX_COMMAND.test(command)) { + return rewriteCommand(command, cwd, []); +} + +function rewriteCommand( + command: string, + cwd: string | undefined, + inherited: readonly string[], +): TsxCommandRewrite { + if (!TSX_COMMAND.test(command) && !envKeepReason(inherited)) { return { command, kept: [] }; } const segments = parseLiteralCommands(command, true); @@ -264,19 +270,20 @@ export function rewriteTsxCommand(command: string, cwd?: string): TsxCommandRewr rewrite.cwd = undefined; continue; } - rewriteSegment(rewrite, tokens); - } - let rewritten = command; - for (const edit of rewrite.edits.toSorted((a, b) => b.start - a.start)) { - rewritten = rewritten.slice(0, edit.start) + edit.text + rewritten.slice(edit.end); + rewriteSegment(rewrite, tokens, inherited); } + const rewritten = applyEdits(command, rewrite.edits); if (!rewrite.kept.length && TSX_COMMAND.test(rewritten)) { rewrite.kept.push(KEEP_OTHER); } return { command: rewritten, kept: rewrite.kept }; } -function rewriteSegment(rewrite: Rewrite, tokens: LiteralCommandToken[]): void { +function rewriteSegment( + rewrite: Rewrite, + tokens: LiteralCommandToken[], + inherited: readonly string[], +): void { const values = tokens.map(({ value }) => value); // Environment assignments and cross-env stay in front of the command. let first = 0; @@ -295,27 +302,118 @@ function rewriteSegment(rewrite: Rewrite, tokens: LiteralCommandToken[]): void { if (first >= values.length) { return; } - // tsx reads TSX_TSCONFIG_PATH, where `vpx` takes `--tsconfig`, and tsx hooks loaded - // through NODE_OPTIONS would compete with those of `vpx`. Both reach the commands a - // launcher runs. - const envReason = envKeepReason(values.slice(0, first)); + // tsx hooks loaded through NODE_OPTIONS would compete with those of `vpx`. + // Environment assignments also reach the commands a launcher runs. + const assignments = [...inherited, ...values.slice(0, first)]; + const envReason = envKeepReason(assignments); + const environment = scanEnvironmentOptions(assignments); + environment.keepReason ??= environment.preloads + .map((script) => scriptKeepReason(rewrite.cwd, script)) + .find((reason) => reason !== undefined); + const editStart = rewrite.edits.length; + const keptStart = rewrite.kept.length; const start = first + getCommandStartIndex(values.slice(first)); const launcher = COMMAND_LAUNCHERS.get(values[start]); if (values[start] === 'tsx') { rewriteTsx(rewrite, tokens, first, start, envReason); } else if (values[start] === 'node') { - rewriteNodeLoader(rewrite, tokens, first, start, envReason); + rewriteNodeLoader(rewrite, tokens, first, start, envReason, environment); } else if (shell) { for (const token of tokens.slice(start)) { - rewriteQuotedCommand(rewrite, token, envReason); + rewriteQuotedCommand(rewrite, token, assignments); } } else if (launcher) { for (let index = start + 1; index < tokens.length; index++) { if (launcher(values[index - 1])) { - rewriteQuotedCommand(rewrite, tokens[index], envReason); + rewriteQuotedCommand(rewrite, tokens[index], assignments); + } + } + } else if (values.slice(start).some((value) => TSX_COMMAND.test(value))) { + rewrite.kept.push(KEEP_OTHER); + } + if (environment.replacements.length) { + // All children inherit this assignment. Keep it and their edits together if one + // still needs tsx, rather than installing both loaders in the same process. + if (environment.keepReason && rewrite.kept.length === keptStart) { + rewrite.kept.push(environment.keepReason); + } + if (rewrite.kept.length !== keptStart) { + rewrite.edits.length = editStart; + } else { + for (const replacement of environment.replacements) { + const index = replacement.index - inherited.length; + if (index >= 0) { + const token = tokens[index]; + rewrite.edits.push(literalEdit(rewrite.command, token, replacement.value)); + } + } + } + } +} + +function applyEdits(command: string, edits: readonly Edit[]): string { + for (const edit of edits.toSorted((a, b) => b.start - a.start)) { + command = command.slice(0, edit.start) + edit.text + command.slice(edit.end); + } + return command; +} + +/** Preserve the surrounding quote style when replacing one complete shell token. */ +function literalEdit(command: string, token: LiteralCommandToken, value: string): Edit { + const raw = command.slice(token.start, token.end); + const assignment = /^([A-Za-z_]\w*=)/.exec(raw); + const prefix = assignment?.[1] ?? ''; + const quote = raw[prefix.length]; + const content = value.slice(prefix.length); + let text: string; + if (quote === '"' && (prefix !== '' || raw.endsWith(quote))) { + text = `${prefix}"${content.replaceAll(/[\\"$`]/g, '\\$&')}"`; + } else if (quote === "'" && (prefix !== '' || raw.endsWith(quote))) { + text = `${prefix}'${content.replaceAll("'", "'\\''")}'`; + } else { + text = `${prefix}${/^[\w./=:-]+$/.test(content) ? content : `"${content.replaceAll(/[\\"$`]/g, '\\$&')}"`}`; + } + return { start: token.start, end: token.end, text }; +} + +interface EnvironmentOptions { + replacements: { index: number; value: string }[]; + preloads: string[]; + keepReason?: string; +} + +/** NODE_OPTIONS uses double quotes and spaces, not shell single quotes or tab separators. */ +function scanEnvironmentOptions(assignments: readonly string[]): EnvironmentOptions { + const result: EnvironmentOptions = { replacements: [], preloads: [] }; + for (const [index, assignment] of assignments.entries()) { + if (!/^NODE_OPTIONS=/i.test(assignment)) { + continue; + } + const value = assignment.slice(13); + const shellOnlySyntax = value.includes("'") || value.replaceAll(/"[^"]*"/g, '').includes('\t'); + const segments = shellOnlySyntax ? undefined : parseLiteralCommands(value, false); + if (!segments || segments.length !== 1) { + if (TSX_COMMAND.test(value)) { + result.keepReason = KEEP_NODE_OPTIONS; } + continue; + } + const scanned = scanOptions(segments[0], 0, false); + result.preloads.push(...scanned.preloads); + if (scanned.loaders !== scanned.imports.length || scanned.entryIndex !== segments[0].length) { + if (TSX_COMMAND.test(value)) { + result.keepReason = KEEP_NODE_OPTIONS; + } + continue; + } + if (scanned.imports.length) { + result.replacements.push({ + index, + value: `${assignment.slice(0, 13)}${applyEdits(value, importEdits(value, scanned))}`, + }); } } + return result; } /** @@ -323,9 +421,6 @@ function rewriteSegment(rewrite: Rewrite, tokens: LiteralCommandToken[]): void { * Names match in any case, as on Windows. */ function envKeepReason(assignments: readonly string[]): string | undefined { - if (assignments.some((value) => /^TSX_TSCONFIG_PATH=/i.test(value))) { - return KEEP_TSX_TSCONFIG; - } if ( assignments.some((value) => /^NODE_OPTIONS=/i.test(value) && TSX_COMMAND.test(value.slice(13))) ) { @@ -345,7 +440,7 @@ function optionName(arg: string): string { /** Remove `tokens[index]` with the space before it. */ function removal(tokens: LiteralCommandToken[], index: number): Edit { - return { start: tokens[index - 1].end, end: tokens[index].end, text: '' }; + return { start: tokens[index - 1]?.end ?? tokens[index].start, end: tokens[index].end, text: '' }; } /** @@ -490,8 +585,30 @@ function rewriteNodeLoader( first: number, start: number, keepReason: string | undefined, + environment: EnvironmentOptions, ): void { const scanned = scanOptions(tokens, start + 1, false); + if ( + scanned.loaders === scanned.imports.length && + (scanned.imports.length || environment.replacements.length) + ) { + // Node.js keeps control of eval, test discovery, and native watch mode. Only the + // compiler checks apply, including other preloads named in NODE_OPTIONS. + const scripts = scanned.inlineCode + ? [] + : tokens.slice(scanned.entryIndex).map(({ value }) => value); + const reason = + environment.keepReason ?? + [...scanned.preloads, ...(scanned.test ? scripts : scripts.slice(0, 1))] + .map((script) => scriptKeepReason(rewrite.cwd, script)) + .find((reason) => reason !== undefined); + if (reason) { + rewrite.kept.push(reason); + } else { + rewrite.edits.push(...importEdits(rewrite.command, scanned)); + } + return; + } if (!scanned.loaders) { // `NODE_OPTIONS='--import tsx' node a.ts` loads tsx without a flag of its own. if (keepReason === KEEP_NODE_OPTIONS) { @@ -517,11 +634,18 @@ interface ScannedOptions { /** Removals of tsx loader flags, and for tsx, of `--no-cache`. */ removals: Edit[]; loaders: number; + /** Plain tsx --import arguments to replace without changing the Node.js command. */ + imports: { token: LiteralCommandToken; value: string }[]; /** Local `--require` and `--import` modules, which load before the scripts. */ preloads: string[]; keepReason?: string; test: boolean; watch: boolean; + inlineCode: boolean; +} + +function importEdits(command: string, scanned: ScannedOptions): Edit[] { + return scanned.imports.map(({ token, value }) => literalEdit(command, token, value)); } /** Read the options of `tsx` (`tsx` true) or `node` from `tokens[index]` up to the entry. */ @@ -530,9 +654,11 @@ function scanOptions(tokens: LiteralCommandToken[], index: number, tsx: boolean) entryIndex: tokens.length, removals: [], loaders: 0, + imports: [], preloads: [], test: false, watch: false, + inlineCode: false, }; for (; index < tokens.length; index++) { const { value } = tokens[index]; @@ -556,6 +682,14 @@ function scanOptions(tokens: LiteralCommandToken[], index: number, tsx: boolean) end: tokens[inline ? index : index + 1].end, }); scanned.loaders++; + if (name === '--import' && loader === 'tsx') { + scanned.imports.push({ + token: tokens[inline ? index : index + 1], + value: inline + ? `${value.slice(0, value.indexOf('=') + 1)}vite-plus/register` + : 'vite-plus/register', + }); + } } else if (loader !== undefined && (loader.startsWith('.') || path.isAbsolute(loader))) { scanned.preloads.push(loader); } @@ -567,6 +701,7 @@ function scanOptions(tokens: LiteralCommandToken[], index: number, tsx: boolean) scanned.keepReason ??= KEEP_OPTIONS.get(name); scanned.test ||= name === '--test'; scanned.watch ||= name === '--watch'; + scanned.inlineCode ||= ['-e', '-p', '-pe', '--eval', '--print', '--run'].includes(name); if ( !inline && (NODE_OPTIONS_WITH_VALUE.has(name) || @@ -600,33 +735,24 @@ function runReason( } /** - * Rewrite a command that a launcher runs, given as one quoted argument. `inherited` keeps - * tsx for it, as when the launcher passes on `TSX_TSCONFIG_PATH`. + * Rewrite a command that a launcher runs, including the environment it inherits. */ function rewriteQuotedCommand( rewrite: Rewrite, token: LiteralCommandToken, - inherited: string | undefined, + inherited: readonly string[], ): void { const raw = rewrite.command.slice(token.start, token.end); const quote = raw[0]; if ((quote !== '"' && quote !== "'") || raw.length < 2 || !raw.endsWith(quote)) { return; } - const inner = rewriteTsxCommand(token.value, rewrite.cwd); + const inner = rewriteCommand(token.value, rewrite.cwd, inherited); rewrite.kept.push(...inner.kept); if (inner.command === token.value) { return; } - if (inherited) { - rewrite.kept.push(inherited); - } else if (!inner.command.includes(quote)) { - rewrite.edits.push({ - start: token.start, - end: token.end, - text: `${quote}${inner.command}${quote}`, - }); - } + rewrite.edits.push(literalEdit(rewrite.command, token, inner.command)); } /** The commands in a scripts or lint-staged record. */ @@ -944,10 +1070,10 @@ export async function confirmTsxMigration(interactive: boolean): Promise` becomes `vpx `, `tsx watch` becomes `vpx --watch`, and the tsx dependency is removed once nothing uses it.", + '`tsx ` becomes `vpx `, `tsx watch` becomes `vpx --watch`, and `node --import tsx` uses `vite-plus/register`. The tsx dependency is removed once nothing uses it.', ), initialValue: true, }); @@ -956,6 +1082,6 @@ export async function confirmTsxMigration(interactive: boolean): Promise process.env[name]); + holder[SCRIPT_CONFIG] = { + path: source ? process.env[source] : undefined, + source: source === 'TS_NODE_PROJECT' || source === 'OXC_TSCONFIG_PATH' ? source : undefined, + }; + } + return holder[SCRIPT_CONFIG]; +} /** * Where this thread reports the files watch mode restarts on, or `null` when nobody is diff --git a/packages/cli/src/script-preload.ts b/packages/cli/src/script-preload.ts index 8f52bee8dc..9166e3c73e 100644 --- a/packages/cli/src/script-preload.ts +++ b/packages/cli/src/script-preload.ts @@ -30,6 +30,7 @@ import { resolveWithOxcNode, routeHelperRequires, SUPPORTED_NODE_RANGE, + threadScriptConfig, threadWatchSink, withFlush, withRequireAsyncModuleCode, @@ -60,11 +61,10 @@ if (watch) { oxcNode.enableWatchReport(); } -// `vpx --tsconfig` pins one config for every file. It must be set before the first -// transform or resolve. Each thread has its own loader context; script-register.ts -// forwards the effective selection to the off-thread hooks. Without an override, -// each file uses its nearest tsconfig. -oxcNode.setTsconfigPath(process.env.VP_SCRIPT_TSCONFIG); +// Pin this thread's config before its first transform or resolve. The ESM entry +// passes the same choice to the off-thread hooks on older Node.js releases. +const tsconfig = threadScriptConfig(); +oxcNode.initializeLoader(tsconfig.path, tsconfig.source); // Dependencies run as published, except TypeScript, which Node.js refuses to strip // under `node_modules`. Set the same way, before the first transform or resolve. oxcNode.setTransformDeps('typescript'); diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index a9b7a3977a..46f8b0dfa7 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -90,6 +90,8 @@ export default defineConfig([ // (the `--require` preload is built as CommonJS below). 'script-register': './src/script-register.ts', 'script-esm-hooks': './src/script-esm-hooks.ts', + // Public Node.js preload; reuses the emitted loader entries above/below. + register: './src/register.ts', // `vpx --watch`: oxc-node's watcher with that loader. 'script-watch': './src/script-watch.ts', // Project-local `vpx` bin; kept apart from bin.js so it loads no binding. diff --git a/packages/tools/patches/oxc-node-loader-context.patch b/packages/tools/patches/oxc-node-loader-context.patch new file mode 100644 index 0000000000..e28f8a700d --- /dev/null +++ b/packages/tools/patches/oxc-node-loader-context.patch @@ -0,0 +1,457 @@ +diff --git a/packages/core/esm.mjs b/packages/core/esm.mjs +index 9b8f581..c13757f 100644 +--- a/packages/core/esm.mjs ++++ b/packages/core/esm.mjs +@@ -4,7 +4,6 @@ import { pathToFileURL } from "node:url"; + import { isMainThread, MessageChannel } from "node:worker_threads"; + + import { +- __napiBindingTarget, + createResolve, + enableWatchReport, + initTracing, +@@ -148,11 +147,8 @@ function loadHook(url, context, nextLoad) { + * Receives the `data` that `register.mjs` passes to `module.register()`. Absent when + * this module is registered some other way. + * +- * Only a WASI copy needs the replay: it starts with no state of its own. A native +- * worker thread shares the main thread's binding, so `data` would be a snapshot from +- * `module.register()` time — replaying it would overwrite a `setTsconfigPath` / +- * `setTransformDeps` made since, and a stale mode would throw once a different one +- * is already in use. ++ * Both native loader threads and WASI copies start with their own loader context. ++ * Apply the registering thread's configuration before the first resolve or load. + * + * The watch port is taken first, on every binding: a native one shares its queue + * with the main thread but still needs someone to drain it after these hooks run. +@@ -171,9 +167,6 @@ function initialize(data) { + // A WASI copy has a recorder of its own; on native this repeats the main thread. + enableWatchReport(); + } +- if (__napiBindingTarget === "native") { +- return; +- } + if (data?.tsconfigPath != null) { + setTsconfigPath(data.tsconfigPath); + } +diff --git a/src/lib.rs b/src/lib.rs +index 9c97478..87203c5 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,6 +1,6 @@ + use std::{ + borrow::Cow, +- cell::OnceCell, ++ cell::{OnceCell, RefCell}, + collections::{HashMap, HashSet}, + env, + ffi::OsStr, +@@ -160,21 +160,6 @@ enum TsconfigSource { + /// Extensions that TypeScript only treats as program inputs when `allowJs` is on. + const JS_EXTENSIONS: [&str; 4] = ["js", "jsx", "mjs", "cjs"]; + +-/// The nearest-ancestor `tsconfig.json` per importer directory, memoized. +-/// +-/// [`TsconfigSource::nearest`] runs once per (importer, specifier) edge, and +-/// its ancestor walk would otherwise repeat the same `stat` syscalls for every +-/// edge out of the same directory. Everything around it is already amortized +-/// for the process — oxc_resolver caches per-directory discovery, per-file +-/// results, file metadata and parsed configs — so memoize the terminal +-/// outcome the same way, with one exception: a config that does not parse is +-/// never memoized, because discovery's `get_or_try_init` does not cache parse +-/// errors either, and a corrected `tsconfig.json` must be picked up on the +-/// next resolution. A config *created* mid-process stays invisible, matching +-/// discovery's cached `None`s. +-static NEAREST_TSCONFIG: LazyLock>>>> = +- LazyLock::new(|| Mutex::new(HashMap::new())); +- + /// The outcome of [`TsconfigSource::walk_nearest`]. + enum Nearest { + /// A usable config answers for the tree. +@@ -418,7 +403,7 @@ impl TsconfigSource { + /// ownership) nor for specifiers that name a concrete target + /// ([`is_bare_specifier`], see [`Self::for_importer`]). + /// +- /// The outcome is memoized per importer directory in [`NEAREST_TSCONFIG`]; ++ /// The outcome is memoized per importer directory in the loader context; + /// `resolve_tsconfig` parses, builds and caches the config, so repeated + /// imports from the same tree cost one parse and one walk. A broken config + /// is handled exactly as [`Self::discover`] handles it, through +@@ -438,8 +423,9 @@ impl TsconfigSource { + // The walk depends only on the directory's ancestor chain (`skip(1)` + // drops the file name), so one entry covers every importer in it. + let directory = path.parent()?; ++ let context = loader_context(); + if let Some(cached) = +- NEAREST_TSCONFIG.lock().unwrap_or_else(PoisonError::into_inner).get(directory) ++ context.nearest_tsconfig.lock().unwrap_or_else(PoisonError::into_inner).get(directory) + { + return cached.clone(); + } +@@ -451,7 +437,8 @@ impl TsconfigSource { + Nearest::Found(tsconfig) => Some(tsconfig), + Nearest::NotFound | Nearest::Unusable => None, + }; +- NEAREST_TSCONFIG ++ context ++ .nearest_tsconfig + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(directory.to_path_buf(), outcome.clone()); +@@ -700,7 +687,53 @@ impl FileSystem for WatchFs { + /// file system and cache, so every condition resolver records too. + type Resolver = ResolverGeneric; + +-static RESOLVER_AND_TSCONFIG: OnceLock<(Resolver, TsconfigSource)> = OnceLock::new(); ++/// One JavaScript thread's loader state. Node.js workers have separate environments ++/// and can select different tsconfigs, even though they share this native library. ++#[derive(Default)] ++struct LoaderContext { ++ resolver_and_tsconfig: OnceLock<(Resolver, TsconfigSource)>, ++ tsconfig_override: Mutex, ++ transform_deps: Mutex, ++ /// Each export-condition/extension-order variant shares this context's base ++ /// resolver caches. Keeping them here prevents one Worker's first resolution ++ /// from choosing the tsconfig for other workers. ++ condition_resolvers: Mutex>, ++ /// Memoize ancestor walks per importer directory, like the resolver's own ++ /// discovery cache. Invalid configs are not cached, so a corrected file is read ++ /// again; a newly created config stays invisible for this context's lifetime. ++ nearest_tsconfig: Mutex>>>, ++} ++ ++thread_local! { ++ static LOADER_CONTEXT: RefCell> = ++ RefCell::new(Arc::new(LoaderContext::default())); ++} ++ ++fn loader_context() -> Arc { ++ LOADER_CONTEXT.with(|context| Arc::clone(&context.borrow())) ++} ++ ++// The public asynchronous transformer computes on a libuv pool thread. Carry the ++// caller's context into that job and restore the pool thread's prior context even ++// when the transform returns an error or panics. ++#[cfg(feature = "napi")] ++struct LoaderContextGuard(Arc); ++ ++#[cfg(feature = "napi")] ++impl LoaderContextGuard { ++ fn enter(context: Arc) -> Self { ++ Self(LOADER_CONTEXT.with(|current| current.replace(context))) ++ } ++} ++ ++#[cfg(feature = "napi")] ++impl Drop for LoaderContextGuard { ++ fn drop(&mut self) { ++ LOADER_CONTEXT.with(|current| { ++ current.replace(Arc::clone(&self.0)); ++ }); ++ } ++} + + /// The tsconfig named through [`set_tsconfig_path`], and what [`init_resolver`] + /// made of it once it has read it. +@@ -710,14 +743,20 @@ static RESOLVER_AND_TSCONFIG: OnceLock<(Resolver, TsconfigSource)> = OnceLock::n + /// never accepted and then silently ignored. + struct TsconfigOverride { + path: Option, ++ /// The loader passes the choice from this JavaScript thread's `process.env`. ++ /// Unlike Rust's process-wide environment, that can differ in each Worker. ++ use_process_env: bool, + /// Set once [`init_resolver`] has read the override: the working directory + /// it resolved relative paths against, and the explicit config it settled + /// on, from this override or from the environment. + consumed: Option<(PathBuf, Option)>, + } + +-static TSCONFIG_OVERRIDE: Mutex = +- Mutex::new(TsconfigOverride { path: None, consumed: None }); ++impl Default for TsconfigOverride { ++ fn default() -> Self { ++ Self { path: None, use_process_env: true, consumed: None } ++ } ++} + + /// Pin one `tsconfig.json` for every file, as `OXC_TSCONFIG_PATH` does, but + /// without touching `process.env` — so the user's script and the processes it +@@ -729,27 +768,41 @@ static TSCONFIG_OVERRIDE: Mutex = + /// `undefined` or an empty string clears the override and goes back to the + /// environment variables, then to discovery. + /// +-/// The resolver and its tsconfig are shared by the whole process and created +-/// on the first transform or resolve, so this has to run before that. Calling ++/// The resolver and its tsconfig belong to the current loader context and are ++/// created on its first transform or resolve, so this has to run before that. Calling + /// it afterwards throws, unless it names the config already in use, whether + /// that came from here or from an environment variable. + #[cfg_attr(feature = "napi", napi)] + pub fn set_tsconfig_path(path: Option) -> Result<()> { ++ set_tsconfig_override(path, true) ++} ++ ++/// Initialize a loader from its JavaScript environment, already resolved by its ++/// wrapper. `None` selects discovery, without reading the parent process's env. ++pub fn initialize_loader(path: Option) -> Result<()> { ++ set_tsconfig_override(path, false) ++} ++ ++fn set_tsconfig_override(path: Option, use_process_env: bool) -> Result<()> { + // Kept as given: a relative path is joined to the loader's working directory + // in `init_resolver`, exactly like the environment variables. The native + // current directory is not that directory under WASI. + let path = path.filter(|path| !path.is_empty()).map(PathBuf::from); +- let mut tsconfig_override = TSCONFIG_OVERRIDE.lock().unwrap_or_else(PoisonError::into_inner); ++ let context = loader_context(); ++ let mut tsconfig_override = ++ context.tsconfig_override.lock().unwrap_or_else(PoisonError::into_inner); + if let Some((cwd, active)) = &tsconfig_override.consumed { + // Naming the config already in use is fine, wherever it came from. + let same = match &path { + Some(path) => active.as_deref() == Some(&*absolute_tsconfig_path(cwd, path.clone())), +- None => tsconfig_override.path.is_none(), ++ None if use_process_env => tsconfig_override.path.is_none(), ++ None => active.is_none(), + }; + if same { + // Remember it, so `getTsconfigPath()` reports the accepted path even + // when the environment chose it first. + tsconfig_override.path = path; ++ tsconfig_override.use_process_env = use_process_env; + return Ok(()); + } + return Err(Error::new( +@@ -758,17 +811,19 @@ pub fn set_tsconfig_path(path: Option) -> Result<()> { + )); + } + tsconfig_override.path = path; ++ tsconfig_override.use_process_env = use_process_env; + Ok(()) + } + + /// The path last passed to [`set_tsconfig_path`], exactly as given, or `null`. + /// +-/// `register.mjs` hands it to the `module.register()` hook thread. Under WASI +-/// that thread instantiates its own copy of the binding, which starts out with +-/// no override of its own. ++/// A loader must pass this to its `module.register()` hook thread, whose context ++/// starts without an override. This also applies to a separate WASI binding. + #[cfg_attr(feature = "napi", napi)] + pub fn get_tsconfig_path() -> Option { +- let tsconfig_override = TSCONFIG_OVERRIDE.lock().unwrap_or_else(PoisonError::into_inner); ++ let context = loader_context(); ++ let tsconfig_override = ++ context.tsconfig_override.lock().unwrap_or_else(PoisonError::into_inner); + tsconfig_override.path.as_ref().map(|path| path.to_string_lossy().into_owned()) + } + +@@ -784,7 +839,7 @@ pub fn get_tsconfig_path() -> Option { + /// over the environment variable, as [`set_tsconfig_path`] does over its + /// variables. + /// +-/// The mode is read once per process, on the first transform or resolve, so ++/// The mode is read once per loader context, on the first transform or resolve, so + /// this has to run before that. Calling it afterwards throws, unless it names + /// the mode already in use, whether that came from here or from the + /// environment variable. On WASI the deadline is earlier: the +@@ -811,7 +866,8 @@ pub fn set_transform_deps(mode: Option) -> Result<()> { + } + }), + }; +- let mut deps = TRANSFORM_DEPS.lock().unwrap_or_else(PoisonError::into_inner); ++ let context = loader_context(); ++ let mut deps = context.transform_deps.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(consumed) = deps.consumed { + // Naming the mode already in use is fine, wherever it came from; + // anything else — `null` asking for the variable's answer included — +@@ -840,12 +896,12 @@ pub fn set_transform_deps(mode: Option) -> Result<()> { + /// send a wrapper that forwards the answer to another binding back to the + /// environment variable instead. + /// +-/// `register.mjs` hands it to the `module.register()` hook thread. Under WASI +-/// that thread instantiates its own copy of the binding, which starts out with +-/// no override of its own. ++/// A loader must pass this to its `module.register()` hook thread, whose context ++/// starts without an override. This also applies to a separate WASI binding. + #[cfg_attr(feature = "napi", napi)] + pub fn get_transform_deps() -> Option { +- let deps = TRANSFORM_DEPS.lock().unwrap_or_else(PoisonError::into_inner); ++ let context = loader_context(); ++ let deps = context.transform_deps.lock().unwrap_or_else(PoisonError::into_inner); + deps.mode.map(|mode| { + match mode { + DepsMode::Off => "off", +@@ -877,7 +933,8 @@ pub fn transforms_dep(path: String) -> bool { + /// the deadline on every `module.register()` runtime. + #[cfg_attr(feature = "napi", napi)] + pub fn freeze_transform_deps() -> String { +- let mut deps = TRANSFORM_DEPS.lock().unwrap_or_else(PoisonError::into_inner); ++ let context = loader_context(); ++ let mut deps = context.transform_deps.lock().unwrap_or_else(PoisonError::into_inner); + if deps.consumed.is_none() { + deps.consumed = Some(deps.mode.unwrap_or_else(deps_mode_from_env)); + } +@@ -993,14 +1050,6 @@ fn preserve_symlinks(env: &Env) -> PreserveSymlinks { + }) + } + +-/// Resolvers for the export conditions, and the [`ExtensionOrder`], resolve +-/// requests have asked for so far, each cloned from the base [`RESOLVER_AND_TSCONFIG`] resolver so they all +-/// share its caches. Conditions are part of every request, so the base resolver +-/// must not keep whichever set its first caller happened to pass: a `require()` +-/// of a TypeScript file initialises it with none, and a later `import()` would +-/// then pick a package's `default` export over `import`. +-static CONDITION_RESOLVERS: Mutex> = Mutex::new(Vec::new()); +- + /// A resolver for one set of export conditions, one [`ExtensionOrder`] and + /// one answer to whether symlinks resolve to their real path. + type ConditionResolver = (Vec, ExtensionOrder, bool, Arc); +@@ -1023,7 +1072,8 @@ fn resolver_for_conditions( + order: ExtensionOrder, + symlinks: bool, + ) -> Arc { +- let mut resolvers = CONDITION_RESOLVERS.lock().unwrap_or_else(PoisonError::into_inner); ++ let context = loader_context(); ++ let mut resolvers = context.condition_resolvers.lock().unwrap_or_else(PoisonError::into_inner); + if let Some((.., resolver)) = resolvers + .iter() + .find(|(names, o, s, _)| names == conditions && *o == order && *s == symlinks) +@@ -1138,6 +1188,7 @@ fn deps_mode_from_env() -> DepsMode { + /// Both live behind one lock so that a call racing the resolver's + /// initialisation either lands before it reads the mode or is rejected — + /// never accepted and then silently ignored. ++#[derive(Default)] + struct TransformDeps { + mode: Option, + /// The mode in effect once [`init_resolver`] has run: the override, or +@@ -1145,13 +1196,11 @@ struct TransformDeps { + consumed: Option, + } + +-static TRANSFORM_DEPS: Mutex = +- Mutex::new(TransformDeps { mode: None, consumed: None }); +- + /// Which dependencies get transformed: [`set_transform_deps`]'s choice, or + /// `OXC_TRANSFORM_ALL`'s. + fn deps_mode() -> DepsMode { +- let deps = TRANSFORM_DEPS.lock().unwrap_or_else(PoisonError::into_inner); ++ let context = loader_context(); ++ let deps = context.transform_deps.lock().unwrap_or_else(PoisonError::into_inner); + deps.mode.or(deps.consumed).unwrap_or_else(deps_mode_from_env) + } + +@@ -1524,7 +1573,9 @@ pub fn transform_module_runner( + let cwd = env::current_dir()?; + let src_path = Path::new(&path); + let lookup_path = tsconfig_lookup_path(&cwd, src_path).into_owned(); +- let (resolver, tsconfig_source) = RESOLVER_AND_TSCONFIG.get_or_init(|| init_resolver(cwd)); ++ let context = loader_context(); ++ let (resolver, tsconfig_source) = ++ context.resolver_and_tsconfig.get_or_init(|| init_resolver(cwd)); + let resolved_tsconfig = tsconfig_source.for_path(resolver, &lookup_path); + let is_es_module = is_module_file(src_path); + let mut runner = ModuleRunner { sync: sync.unwrap_or(false), ..Default::default() }; +@@ -1560,6 +1611,7 @@ pub fn transform_async( + + #[cfg(feature = "napi")] + pub struct TransformTask { ++ context: Arc, + cwd: String, + helper_module_name: Option, + path: String, +@@ -1573,11 +1625,14 @@ impl Task for TransformTask { + type JsValue = Output; + + fn compute(&mut self) -> Result { ++ let _context = LoaderContextGuard::enter(Arc::clone(&self.context)); + let src_path = Path::new(&self.path); + let cwd = PathBuf::from(&self.cwd); + // Worked out before `cwd` is moved into the initialiser. + let lookup_path = tsconfig_lookup_path(&cwd, src_path).into_owned(); +- let (resolver, tsconfig_source) = RESOLVER_AND_TSCONFIG.get_or_init(|| init_resolver(cwd)); ++ let context = loader_context(); ++ let (resolver, tsconfig_source) = ++ context.resolver_and_tsconfig.get_or_init(|| init_resolver(cwd)); + let resolved_tsconfig = tsconfig_source.for_path(resolver, &lookup_path); + oxc_transform( + src_path, +@@ -1632,7 +1687,9 @@ impl OxcTransformer { + let src_path = Path::new(&path); + // Worked out before `cwd` is moved into the initialiser. + let lookup_path = tsconfig_lookup_path(&cwd, src_path).into_owned(); +- let (resolver, tsconfig_source) = RESOLVER_AND_TSCONFIG.get_or_init(|| init_resolver(cwd)); ++ let context = loader_context(); ++ let (resolver, tsconfig_source) = ++ context.resolver_and_tsconfig.get_or_init(|| init_resolver(cwd)); + let resolved_tsconfig = tsconfig_source.for_path(resolver, &lookup_path); + oxc_transform( + src_path, +@@ -1655,6 +1712,7 @@ impl OxcTransformer { + source: Either3, + ) -> AsyncTask { + AsyncTask::new(TransformTask { ++ context: loader_context(), + path, + source, + cwd: self.cwd.clone(), +@@ -1851,8 +1909,9 @@ fn is_module_file(path: &Path) -> bool { + + /// The `type` of the nearest `package.json`, once the resolver is set up. + fn package_type(path: &Path) -> Option { ++ let context = loader_context(); + let (Some((resolver, _)), Some(directory), Some(name)) = ( +- RESOLVER_AND_TSCONFIG.get(), ++ context.resolver_and_tsconfig.get(), + path.parent(), + path.file_name().and_then(|name| name.to_str()), + ) else { +@@ -2555,8 +2614,9 @@ pub fn create_resolve<'env>( + #[cfg(not(target_family = "wasm"))] + let cwd = env::current_dir()?; + ++ let context_state = loader_context(); + let (base_resolver, tsconfig_source) = +- RESOLVER_AND_TSCONFIG.get_or_init(|| init_resolver(cwd.clone())); ++ context_state.resolver_and_tsconfig.get_or_init(|| init_resolver(cwd.clone())); + + // A `file:` URL is an absolute path in every form Node.js hands over, + // UNC `file://server/…` included. Schemes are case-insensitive, so +@@ -2954,7 +3014,9 @@ pub fn load<'env>( + } + + let loaded = next_load.call((url.clone(), Some(context)).into())?; +- let (resolver, tsconfig_source) = RESOLVER_AND_TSCONFIG ++ let context_state = loader_context(); ++ let (resolver, tsconfig_source) = context_state ++ .resolver_and_tsconfig + .get() + .ok_or_else(|| Error::new(Status::GenericFailure, "Failed to get resolver and tsconfig"))?; + +@@ -3612,11 +3674,15 @@ fn init_resolver(cwd: PathBuf) -> (Resolver, TsconfigSource) { + // An explicitly requested config always wins over discovery, and one passed + // through the API wins over the environment. + let explicit_tsconfig_path = { ++ let context = loader_context(); + let mut tsconfig_override = +- TSCONFIG_OVERRIDE.lock().unwrap_or_else(PoisonError::into_inner); ++ context.tsconfig_override.lock().unwrap_or_else(PoisonError::into_inner); + tracing::debug!(api_tsconfig = ?tsconfig_override.path); + let explicit_tsconfig = + tsconfig_override.path.clone().map(|path| (path, "setTsconfigPath()")).or_else(|| { ++ if !tsconfig_override.use_process_env { ++ return None; ++ } + let explicit_tsconfig = non_empty_env("TS_NODE_PROJECT") + .map(|path| (path, "TS_NODE_PROJECT")) + .or_else(|| { +@@ -3637,7 +3703,8 @@ fn init_resolver(cwd: PathBuf) -> (Resolver, TsconfigSource) { + // a later `setTransformDeps` call can never silently change behaviour. A + // mode already frozen at `register()` stays frozen: env changes between + // registration and now must not move the deadline back. +- let mut deps = TRANSFORM_DEPS.lock().unwrap_or_else(PoisonError::into_inner); ++ let context = loader_context(); ++ let mut deps = context.transform_deps.lock().unwrap_or_else(PoisonError::into_inner); + if deps.consumed.is_none() { + deps.consumed = Some(deps.mode.unwrap_or_else(deps_mode_from_env)); + } diff --git a/rfcs/script-import-loader.md b/rfcs/script-import-loader.md new file mode 100644 index 0000000000..31f7cafe03 --- /dev/null +++ b/rfcs/script-import-loader.md @@ -0,0 +1,170 @@ +# RFC: Preload the Vite+ TypeScript Loader in Node.js + +- Status: Implemented +- Related: [Run TypeScript Scripts with `vpx`](./vpx-script-execution.md) + +## Problem + +`vpx` runs TypeScript files, but a test runner, debugger, worker host, or other tool +can own the Node.js command. These integrations still need `node --import=tsx` or +`NODE_OPTIONS=--import=tsx`. Replacing the executable with `vpx` does not give them +a module to preload. The original script execution RFC deferred this public entry. + +## Proposal + +Export `vite-plus/register` as a side-effectful ESM preload from the existing +`vite-plus` package: + +```sh +node --import=vite-plus/register ./scripts/seed.ts +node --import vite-plus/register --test ./test/example.test.ts +NODE_OPTIONS='--import=vite-plus/register' node ./scripts/seed.ts +``` + +Tools that accept Node.js arguments can use `["--import", "vite-plus/register"]`. +An ESM launcher can use `await import('vite-plus/register')` before it dynamically +imports TypeScript. The module exports no functions. Static imports are linked +before module evaluation, so importing the preload beside a static TypeScript +import is not a registration API. + +The project must install `vite-plus`, and the Node.js process must meet its +`engines.node` range. Node.js resolves the package from the working directory for +`--import`, including `NODE_OPTIONS`. The global installation is not a fallback. +The preload does not select a runtime, alter `PATH`, rewrite arguments, spawn a +process, or replace Node.js test discovery, watch mode, eval, or stdin behavior. +Files imported from eval or stdin are transformed; the input text itself is not. +This is the same distinction as tsx's preload, rather than the tsx executable. + +### Public Name + +`register` describes the module's side effect: install loading hooks into the +current Node.js process. It follows the entry proposed by the script execution +RFC and the convention used by `@oxc-node/core/register` and `ts-node/register`. + +The package name alone, `--import vite-plus`, would overload the existing root +configuration API with process-wide loader registration. `vite-plus/loader` names +the purpose but can suggest Node.js's older `--loader` interface. +`vite-plus/node` is less specific about the operation. `vite-plus/tsx` makes the +migration familiar but suggests the tsx implementation or exact compiler semantics. +Use one public name so documentation and tool configurations converge on the same +entry. A separate `vpx` npm package would also conflict with the original goal of +shipping the loader inside `vite-plus`. + +## Registration + +The public entry loads the **existing emitted** `script-preload.cjs`, then the +existing `script-register.js`. The former installs the CommonJS transform hook, +source maps, runtime-helper resolution, and synchronous ESM hooks on supported +Node.js releases. The latter registers asynchronous ESM hooks on older supported +releases. Registration completes before the next Node.js `--import` or entry file. + +Both imports must retain their emitted file identities. Bundling another copy of +the preload would install a second CommonJS hook when `vpx` and the public preload +are combined. Node.js module caching must make repeated preloads and an explicit +dynamic import harmless, including a public import mixed with the CLI's existing +two flags. The public entry is listed in `sideEffects`, so bundlers do not remove +an intentional side-effect import. + +The CLI continues to pass its existing two flags. Its `--require` entry runs before +user `--require` modules. With a public `--import` alone, Node.js runs all +`--require` modules first; the preload cannot retroactively transform those files. +Put a TypeScript setup file in a subsequent `--import`, as with `node --import tsx`. +No public CommonJS-only entry or legacy `--loader` API is added. + +Node.js inherits `--import` in `process.execArgv` for `fork()` and workers. A +`NODE_OPTIONS` preload also reaches spawned Node.js processes through their +environment. Explicit replacement of `execArgv` or the environment follows Node.js +rules. Registration occurs independently in each JavaScript thread; the loader +worker must not recursively register itself. + +## Configuration and Compatibility + +The public entry uses the same transforms, resolution, dependency policy, source +maps, and errors as `vpx`. It supports TypeScript, TSX/JSX, ESM and CommonJS files, +tsconfig paths for imports, TypeScript dependencies, and runtime helpers without +installing tsx. The existing [tsx differences](../docs/guide/vpx.md#coming-from-tsx) +remain visible: this entry does not change the compiler's module-format policy, +CommonJS resolution, JSX defaults, or supported decorator syntax. The acceptance +claim is complete replacement of the **preload integration surface**, not identical +output for every program accepted by tsx. + +`VP_SCRIPT_TSCONFIG` explicitly selects a config, including the value set by +`vpx --tsconfig`. `TSX_TSCONFIG_PATH` is accepted as a compatibility fallback so an +integration can replace its preload without renaming its existing config variable. +Without either, the existing `OXC_TSCONFIG_PATH` / `TS_NODE_PROJECT` behavior and +per-file discovery apply. Configuration is established before either hook path +resolves or transforms application files. The loader does not change environment +variables; children inherit the original values. + +Each JavaScript thread owns its native resolver, config selection, dependency +policy, and resolution caches. Concurrent workers can select different configs or +clear their parent's config variables. Reading Rust's process environment is not +sufficient: Node.js workers have their own `process.env` values. The preload +captures the effective config in JavaScript, and `initializeLoader` explicitly +selects that path or automatic discovery. The asynchronous ESM hook receives the +same selection through `module.register` data before its first resolve. + +The pinned oxc-node source currently stores these values process-wide. A checked-in +source patch changes that storage to a loader context per thread; the vendoring +preparation step applies it idempotently and rejects upstream drift. Async native +transform tasks carry their caller's context onto the task thread. This patch is +needed until equivalent isolation is available upstream; copying or reloading the +native addon per worker is not an acceptable substitute. + +Do not load tsx and this preload together. They both claim TypeScript files, and +their registration order is not a supported compatibility mechanism. + +## Migration + +For literal `node --import tsx` and `node --import=tsx` commands, `vp migrate` +replaces the module with `vite-plus/register` and preserves the Node.js executable, +other flags, wrappers, and arguments. It also rewrites literal `--import tsx` +values in `NODE_OPTIONS` assignments in supported script commands. Existing +source checks keep commands whose known syntax differs from the Vite+ loader. +The direct Node.js path does not inherit `vpx` CLI restrictions on eval, test +discovery, or native watch mode. + +The existing rules for `tsx ` and legacy `tsx/esm`, `tsx/cjs`, and `--loader` +forms remain, except that `TSX_TSCONFIG_PATH` no longer prevents CLI migration: +the shared loader now supports it too. Arbitrary JavaScript configuration, source imports, CI, and shell +files are not edited automatically. The documentation shows the corresponding +manual replacement. The dependency scan continues to retain tsx if any remaining +use needs it, including its programmatic APIs or runtime dependencies. + +## Validation + +Use packed or installed package resolution, not only imports of source files. +CLI snapshot fixtures cover the asynchronous hook path on Node.js 22.18 and +24.11.0 and the synchronous path on Node.js 22, 24, and 26: + +1. Both `--import` spellings, a tool's argument array, and `NODE_OPTIONS`. +2. ESM and CommonJS entries and imports, `.ts`, `.mts`, `.cts`, JSX, enums, + decorators, tsconfig aliases, `.js` to `.ts`, and TypeScript dependencies. +3. `--test`, eval/stdin importing a TypeScript file, and a subsequent TypeScript + `--import`; argument and exit-status preservation. +4. `fork()`, ESM/CommonJS workers, and spawned children through `NODE_OPTIONS`; + concurrent workers with distinct configs and workers that clear parent config + variables, without changing the parent's resolution. +5. Repeated registration and registration combined with `vpx`. +6. Explicit tsconfig selection, `TSX_TSCONFIG_PATH`, precedence, and source-map + stack locations on errors. +7. An unsupported Node.js version fails before the native binding loads. +8. Migration preserves command structure, keeps unsupported uses and dependencies, + removes tsx when unused, and makes no further changes on a second run. + +Run the existing loader and migration suites as regressions, the TypeScript unit +suite, `vp check`, and a full package build. Review generated snapshot output. + +The installed-package cases live in +[`command_script_import`](../crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_script_import/snapshots.toml). +The migration integration case lives in +[`migration_tsx_preload`](../crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx_preload/snapshots.toml), +alongside the existing loader and migration regression fixtures. Unit tests in +`packages/cli/src/migration/__tests__/tsx.spec.ts` cover shell quoting, inherited +options, and dependency retention. + +## References + +- [Node.js `--import`](https://nodejs.org/api/cli.html#--importmodule) +- [Node.js module customization hooks](https://nodejs.org/api/module.html#customization-hooks) +- [tsx as a Node.js enhancement](https://tsx.is/dev-api/node-cli) diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index 33471ef022..eff020d412 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -2,7 +2,7 @@ - Status: Implemented in [#2936](https://github.com/voidzero-dev/vite-plus/pull/2936); CI covers Linux, macOS, and Windows on Node.js 22, 24, and 26 - Related: [`vpx` command](./vpx-command.md), [JavaScript runtime management](./js-runtime.md), [CLI bundling](../packages/cli/BUNDLING.md), [Core binding resolution](./core-binding-resolution.md) -- Upstream: [oxc-node](https://github.com/oxc-project/oxc-node) at `ce22828`, a `main` commit after v0.1.7 with fixes not yet released, vendored unpatched and compiled into the Vite+ native binding, as Rolldown is +- Upstream: [oxc-node](https://github.com/oxc-project/oxc-node) at `ce22828`, a `main` commit after v0.1.7 with fixes not yet released, vendored and compiled into the Vite+ native binding, as Rolldown is - Prior art: [tsx](https://github.com/privatenumber/tsx) 4.23.15 ## Summary @@ -39,7 +39,7 @@ Repositories use `tsx` to run `scripts/*.ts`. Vite+ manages the Node.js version ## Non-Goals -Type checking (`vp check`); a TypeScript REPL or TypeScript in `-e`/`-p` or `--check`; a programmatic API or public `node --import vite-plus/…` entry (see [Future Enhancements](#future-enhancements)); an on-disk transform cache; `target` downleveling; Bun and Deno. +Type checking (`vp check`); a TypeScript REPL or TypeScript in `-e`/`-p` or `--check`; a programmatic API (see [Future Enhancements](#future-enhancements)); an on-disk transform cache; `target` downleveling; Bun and Deno. The public `node --import vite-plus/register` entry is specified in the [preload RFC](./script-import-loader.md). ## Command Syntax @@ -137,7 +137,7 @@ On Unix, `exec_tool` replaces the `vpx` process with `node` through `execve`; no Two entries in `vite-plus/dist`, passed together: -1. `--require ` (CommonJS, from `packages/cli/src/script-preload.ts`). It runs before any user `--require` or `--import`, so `--require ./setup.ts` preloads work. It checks the Node.js version and exits with a Vite+ error outside `^22.18.0 || ^24.11.0 || >=26.0.0` (the `engines.node` range), loads the native binding through `packages/cli/binding/index.cjs`, calls `setTsconfigPath()` and `setTransformDeps('typescript')`, turns on source maps, installs the pirates CommonJS hook, and on Node.js 22.22.3+, 24.11.1+, and 26 registers the in-thread ESM hooks with `module.registerHooks()`. +1. `--require ` (CommonJS, from `packages/cli/src/script-preload.ts`). It runs before any user `--require` or `--import`, so `--require ./setup.ts` preloads work. It checks the Node.js version and exits with a Vite+ error outside `^22.18.0 || ^24.11.0 || >=26.0.0` (the `engines.node` range), loads the native binding through `packages/cli/binding/index.cjs`, calls `initializeLoader()` and `setTransformDeps('typescript')`, turns on source maps, installs the pirates CommonJS hook, and on Node.js 22.22.3+, 24.11.1+, and 26 registers the in-thread ESM hooks with `module.registerHooks()`. 2. `--import ` (ESM). On older 22.x and 24.x it registers the off-thread hooks (`dist/script-esm-hooks.js`) with `module.register()`, which Node.js 22 cannot call while a `--require` is loading. On every version, an `--import` also routes the entry point through the ESM loader, where the hooks decide each file's module format. The in-thread hooks start about twice as fast as a hooks thread, and Node.js 25.9+ deprecates `module.register()` (DEP0205). They need nodejs/node#59011 (22.19, 24.5) and nodejs/node#59929 (22.22.3, 24.11.1); tsx switches at the same releases. Upstream oxc-node waits for 26.2 for nodejs/node#62920, which only affects load hooks that return CommonJS source; these hooks do that only when another hook supplied it (see [Known Limitations](#known-limitations)). @@ -153,7 +153,7 @@ The loader is resolved from the project's `vite-plus` first (`JsExecutor::resolv `vpx --watch