Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -187,5 +187,27 @@ name = "vpx_script_watch"
vp = ["local", "global"]
skip-platforms = ["windows"]
cwd = "watch"
comment = "Node.js --watch restarts when an imported .ts file changes."
steps = [["node", "verify-watch.mjs"]]
comment = "Node.js --watch restarts when an imported .ts file changes, and on a tsconfig edit, which the loader reports."
steps = [
["node", "verify-watch.mjs", "dependency", "--watch"],
["node", "verify-watch.mjs", "tsconfig", "--watch"],
]

[[case]]
name = "vpx_script_watch_created"
vp = ["local", "global"]
# Node.js 22 and 24 on Linux watch each reported file itself, which fails for one that
# does not exist yet.
skip-platforms = ["windows", "linux"]
cwd = "watch"
comment = "Node.js --watch restarts when the file a failed import looked for is created, which the loader reports."
steps = [["node", "verify-watch.mjs", "created", "--watch"]]

[[case]]
name = "vpx_script_watch_node24"
vp = ["local", "global"]
skip-platforms = ["windows"]
cwd = "watch"
env = { VP_NODE_VERSION = "24.12.0" }
comment = "On the in-thread module.registerHooks() path, the hooks report the tsconfig files they read too."
steps = [["node", "verify-watch.mjs", "tsconfig", "--watch"]]
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
# vpx_script_watch

Node.js --watch restarts when an imported .ts file changes.
Node.js --watch restarts when an imported .ts file changes, and on a tsconfig edit, which the loader reports.

## `node verify-watch.mjs`
## `node verify-watch.mjs dependency --watch`

```
vpx --watch restarted after an imported .ts file changed
```

## `node verify-watch.mjs tsconfig --watch`

```
vpx --watch restarted after a tsconfig paths edit
```
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
# vpx_script_watch

Node.js --watch restarts when an imported .ts file changes.
Node.js --watch restarts when an imported .ts file changes, and on a tsconfig edit, which the loader reports.

## `node verify-watch.mjs`
## `node verify-watch.mjs dependency --watch`

```
vpx --watch restarted after an imported .ts file changed
```

## `node verify-watch.mjs tsconfig --watch`

```
vpx --watch restarted after a tsconfig paths edit
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# vpx_script_watch_created

Node.js --watch restarts when the file a failed import looked for is created, which the loader reports.

## `node verify-watch.mjs created --watch`

```
vpx --watch restarted after the file an import looked for was created
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# vpx_script_watch_created

Node.js --watch restarts when the file a failed import looked for is created, which the loader reports.

## `node verify-watch.mjs created --watch`

```
vpx --watch restarted after the file an import looked for was created
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# vpx_script_watch_node24

On the in-thread module.registerHooks() path, the hooks report the tsconfig files they read too.

## `node verify-watch.mjs tsconfig --watch`

```
vpx --watch restarted after a tsconfig paths edit
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# vpx_script_watch_node24

On the in-thread module.registerHooks() path, the hooks report the tsconfig files they read too.

## `node verify-watch.mjs tsconfig --watch`

```
vpx --watch restarted after a tsconfig paths edit
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const later = await import('./later.js').then(
(module: { value: string }) => module.value,
() => 'missing',
);
console.log('later', later);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const name: string = 'a';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const name: string = 'b';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { name } from '@lib/name';

console.log('name', name);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "compilerOptions": { "paths": { "@lib/*": ["./a/*"] } } }
Original file line number Diff line number Diff line change
@@ -1,8 +1,47 @@
// Drives `vpx --watch`: edit an imported .ts file and wait for the restart.
// Drives a watch mode: change a file the script uses and wait for the restart.
//
// node verify-watch.mjs <scenario> <vpx args before the entry...>
import { spawn } from 'node:child_process';
import { writeFile } from 'node:fs/promises';
import { rm, writeFile } from 'node:fs/promises';

const child = spawn('vpx', ['--watch', './main.ts'], { stdio: ['ignore', 'pipe', 'pipe'] });
const SCENARIOS = {
// An imported .ts file changes.
dependency: {
cwd: '.',
first: 'value first',
file: 'dep.ts',
edit: "export const value: string = 'second';\n",
restore: "export const value: string = 'first';\n",
second: 'value second',
done: 'restarted after an imported .ts file changed',
},
// The tsconfig maps `@lib/*` to another folder.
tsconfig: {
cwd: 'paths',
first: 'name a',
file: 'tsconfig.json',
edit: '{ "compilerOptions": { "paths": { "@lib/*": ["./b/*"] } } }\n',
restore: '{ "compilerOptions": { "paths": { "@lib/*": ["./a/*"] } } }\n',
second: 'name b',
done: 'restarted after a tsconfig paths edit',
},
// The file that `import('./later.js')` looked for is created.
created: {
cwd: 'later',
first: 'later missing',
file: 'later.ts',
edit: "export const value: string = 'found';\n",
restore: undefined,
second: 'later found',
done: 'restarted after the file an import looked for was created',
},
};

const [name, ...vpxArgs] = process.argv.slice(2);
const scenario = SCENARIOS[name];
process.chdir(scenario.cwd);

const child = spawn('vpx', [...vpxArgs, './main.ts'], { stdio: ['ignore', 'pipe', 'pipe'] });
let logs = '';
const waiters = [];
const onData = (data) => {
Expand All @@ -29,24 +68,26 @@ function waitFor(text) {
}

try {
await waitFor('value first');
// Node.js watches dep.ts only once the child reports it, which can be after its first
// output, and macOS drops a change made before the watcher is ready. Keep rewriting
// the file until the restart shows up.
const restarted = waitFor('value second');
const rewrite = () => writeFile('dep.ts', "export const value: string = 'second';\n");
await waitFor(scenario.first);
// The watcher watches a file only once the child reports it, which can be after its
// first output, and macOS drops a change made before the watcher is ready. Keep
// rewriting the file until the restart shows up.
const restarted = waitFor(scenario.second);
const rewrite = () => writeFile(scenario.file, scenario.edit);
await rewrite();
const timer = setInterval(() => rewrite().catch(() => {}), 500);
try {
await restarted;
} finally {
clearInterval(timer);
}
console.log('vpx --watch restarted after an imported .ts file changed');
console.log(`vpx ${vpxArgs.join(' ')} ${scenario.done}`);
} catch (error) {
console.error(logs);
throw error;
} finally {
await writeFile('dep.ts', "export const value: string = 'first';\n");
await (scenario.restore === undefined
? rm(scenario.file, { force: true })
: writeFile(scenario.file, scenario.restore));
child.kill();
}
2 changes: 1 addition & 1 deletion docs/guide/vpx.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ Most scripts that run with tsx run with `vpx` unchanged. These differences can n
- **`require()` resolution**: tsx maps `require('./a.js')` to `a.ts` and applies tsconfig `paths` to `require()`. `vpx` resolves `require()` as Node.js does, so write the `.ts` extension, and use `paths` aliases only with `import`.
- **Signals**: tsx turns `SIGINT` and `SIGTERM` into exit codes 130 and 143 and runs `process.on('exit')` handlers first. Under `vpx` the signal ends the script, as it does under Node.js, so clean up in a `process.on('SIGINT')` or `process.on('SIGTERM')` handler.
- **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 is Node.js `--watch`. It has no `--include`, `--exclude`, or `--clear-screen` options and no rerun on Enter; use `--watch-path` to watch more files. Node.js waits for a script that ignores `SIGTERM` to exit before it restarts, where tsx kills it after a few seconds; pass `--watch-kill-signal=SIGKILL` to restart at once.
- **Watch mode**: `tsx watch` becomes `vpx --watch`, which is Node.js `--watch`. It restarts when an imported file or a tsconfig that `vpx` read changes, and when a file that a failed import looked for is created (on Linux, from Node.js 26). It has no `--include`, `--exclude`, or `--clear-screen` options and no rerun on Enter; use `--watch-path` to watch more files. Node.js waits for a script that ignores `SIGTERM` to exit before it restarts, where tsx kills it after a few seconds; pass `--watch-kill-signal=SIGKILL` to restart at once.
- **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.
Expand Down
1 change: 1 addition & 0 deletions packages/cli/BUNDLING.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ 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 source stays unpatched. 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` only sets oxc-node's `oxc` dependency to the workspace version.
- `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.
- 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.
- `pirates`, the CommonJS hook, is bundled into `dist`.

### Export Chain
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/binding/index.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -4234,6 +4234,7 @@ export declare namespace oxcNode {
arg1?: ResolveContext | undefined | null,
) => ResolveFnOutput | Promise<ResolveFnOutput>,
): ResolveFnOutput | Promise<ResolveFnOutput>;
export function enableWatchReport(): void;
export function load(
url: string,
context: LoadContext,
Expand All @@ -4245,6 +4246,7 @@ export declare namespace oxcNode {
): LoadFnOutput | Promise<LoadFnOutput>;
export function setTransformDeps(mode?: string | undefined | null): void;
export function setTsconfigPath(path?: string | undefined | null): void;
export function takeWatchDependencies(): Array<string>;
/** A file transformed for the CommonJS loader. */
export interface TransformOutput {
source: string;
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/binding/src/oxc_node.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! The `oxcNode` namespace: thin forwarders to the vendored oxc-node, which the binding
//! links with `default-features = false`, so it registers no NAPI exports of its own.
//! Only what the `vpx <script>` loader in `packages/cli/src/script-*.ts` uses is exported.
//! `enableWatchReport` and `takeWatchDependencies` feed watch mode the tsconfig files
//! oxc-node read and the paths a failed resolve looked for.

use ::oxc_node::{LoadContext, LoadFnOutput, OxcResolveOptions, ResolveContext, ResolveFnOutput};
use napi::{
Expand Down Expand Up @@ -32,6 +34,16 @@ pub fn transforms_dep(path: String) -> bool {
::oxc_node::transforms_dep(path)
}

#[napi(namespace = "oxcNode")]
pub fn enable_watch_report() {
::oxc_node::enable_watch_report();
}

#[napi(namespace = "oxcNode")]
pub fn take_watch_dependencies() -> Vec<String> {
::oxc_node::take_watch_dependencies()
}

#[napi(namespace = "oxcNode")]
#[allow(clippy::type_complexity)]
pub fn create_resolve<'env>(
Expand Down
32 changes: 27 additions & 5 deletions packages/cli/src/script-esm-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
* Adapted from oxc-node's `packages/core/esm.mjs`.
*/

import type { LoadHook, ResolveHook } from 'node:module';
import { isMainThread, MessageChannel } from 'node:worker_threads';
import type { InitializeHook, LoadHook, ResolveHook } from 'node:module';
import { isMainThread, type MessagePort, MessageChannel } from 'node:worker_threads';

import { HELPER_MODULE_NAME, loadOxcNode, resolveWithOxcNode } from './script-hooks.ts';
import { HELPER_MODULE_NAME, loadOxcNode, resolveWithOxcNode, withFlush } from './script-hooks.ts';

const oxcNode = loadOxcNode();

Expand All @@ -16,13 +16,35 @@ if (!isMainThread) {
new MessageChannel().port1.ref();
}

export const resolve: ResolveHook = (specifier, context, nextResolve) =>
const resolveHook: ResolveHook = (specifier, context, nextResolve) =>
resolveWithOxcNode(oxcNode, specifier, context, nextResolve);

export const load: LoadHook = (url, context, nextLoad) =>
const loadHook: LoadHook = (url, context, nextLoad) =>
oxcNode.load(
url,
context as never,
nextLoad as never,
HELPER_MODULE_NAME,
) as ReturnType<LoadHook>;

/** Under watch mode, the hooks wrapped to report what each call read or looked for. */
let watchHooks: { resolve: ResolveHook; load: LoadHook } | undefined;

/** Receives the port `script-register.ts` relays watch reports through. */
export const initialize: InitializeHook<{ watchPort?: MessagePort } | undefined> = (data) => {
const port = data?.watchPort;
if (port) {
port.unref();
const sink = (message: unknown) => port.postMessage(message);
watchHooks = {
resolve: withFlush(resolveHook, sink, oxcNode.takeWatchDependencies),
load: withFlush(loadHook, sink, oxcNode.takeWatchDependencies),
};
}
};

export const resolve: ResolveHook = (specifier, context, nextResolve) =>
(watchHooks?.resolve ?? resolveHook)(specifier, context, nextResolve);

export const load: LoadHook = (url, context, nextLoad) =>
(watchHooks?.load ?? loadHook)(url, context, nextLoad);
22 changes: 22 additions & 0 deletions packages/cli/src/script-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

import { createRequire } from 'node:module';

import { type WatchSink, watchSink } from '../../../oxc-node/packages/core/watch-report.mjs';

// oxc-node's watch reporting for a loader of its own (`@oxc-node/core/watch-report`),
// bundled from the vendored source, so it matches the binding. `script-preload.ts` also
// imports `@oxc-node/core/signal-ack` this way.
export { createRelay, flush, withFlush } from '../../../oxc-node/packages/core/watch-report.mjs';

type OxcNodeBinding = typeof import('../binding/index.js').oxcNode;

/**
Expand All @@ -18,6 +25,21 @@ export function loadOxcNode(): OxcNodeBinding {
return binding.oxcNode;
}

const WATCH_SINK = Symbol.for('vite-plus:script-watch-sink');

/**
* Where this thread reports the files watch mode restarts on, or `null` when nobody is
* watching (see oxc-node's `watchSink()`). It opens a channel, so it runs once per thread:
* the preload and the `--import` entry are separate bundles and share it here.
*/
export function threadWatchSink(): WatchSink | null {
const holder = globalThis as { [WATCH_SINK]?: WatchSink | null };
if (holder[WATCH_SINK] === undefined) {
holder[WATCH_SINK] = watchSink();
}
return holder[WATCH_SINK];
}

/** Must match `engines.node` in package.json; `__tests__/vpx-script.spec.ts` checks it. */
export const SUPPORTED_NODE_RANGE = '^22.18.0 || ^24.11.0 || >=26.0.0';

Expand Down
Loading
Loading