Skip to content

feat(cli,core): let a tool run the oxnode watch watcher with its own loader - #872

Merged
Brooooooklyn merged 3 commits into
mainfrom
feat/embeddable-watch
Oct 21, 2026
Merged

Brooooooklyn merged 3 commits into
mainfrom
feat/embeddable-watch

Conversation

@Brooooooklyn

Copy link
Copy Markdown
Member

Closes #871.

A tool with its own loader built on the oxc-node binding (Vite+'s vpx watch) can now run the oxnode watch watcher.

 your CLI                                        the app (your loader)
 runWatch(argv, options)  ── node <loaderArgs> <node options> <entry> ──▶  installSignalAck()
   @oxc-node/cli/watch                                                     watchSink(), withFlush()
                          ◀── IPC { "watch:import": [url] } ──────────────   @oxc-node/core/watch-report
                          ◀── "I" on the OXNODE_SIGNAL_ACK socket ─────────   @oxc-node/core/signal-ack

Watcher side: @oxc-node/cli/watch

runWatch(argv, { loaderArgs, ignoreDirs, name }). All options are optional, and with none oxnode watch behaves and prints exactly as before (the default --help text is byte-for-byte the same).

Option Default
loaderArgs ["--enable-source-maps", "--import", <@oxc-node/core/register>], resolved only when not given
ignoreDirs the folder of @oxc-node/core/register when loaderArgs is not given, else none (real paths count too)
name "oxnode watch", used in errors, usage and --help; its first word tags log lines ([vpx])
  • dist/watch.js is a second rolldown entry. It reaches only a shared chunk that imports node: modules: no clipanion, no binding, and it starts nothing on import. A test walks the chunks to keep it that way.
  • WatchCommand (clipanion) moved into src/index.ts, so src/watch has no clipanion import.
  • Hand-written types in packages/cli/watch.d.ts. ./dist/* stays importable.

App side: @oxc-node/core/signal-ack, @oxc-node/core/watch-report

  • installSignalAck() is unchanged.
  • flush, withFlush and createRelay now take the binding's takeWatchDependencies as an argument (take), so the entry loads no binding of its own. register.mjs and esm.mjs pass theirs.
  • Both entries have import and default, so a CommonJS --require preload can load them.
  • Added to package.json and to the build-time rewrite in reexport-oxc-runtime.mjs.

Docs

README, "Embedding the watcher": both sides, with code, plus the protocol for a loader that does not use these entries (start args and env, the IPC message, the "oxc-node": true key, the ack byte).

Tests

  • watch-embed.spec.ts (new):
    • runs runWatch through @oxc-node/cli/watch with a custom registerHooks loader built from the new core entries;
    • restarts on a dependency edit and on a tsconfig.json edit (the second one only works through take), under [vpx], with the loader folder skipped;
    • errors and --help under the tool's name;
    • bundle guard; importing starts nothing; the entries load no binding and work with require().
  • watch-args.spec.ts, watch-units.spec.ts: names in the errors, help text and log tag.
  • Full suite passes in normal and OXC_TRANSFORM_ALL mode. Checked by hand on Node 22.18, 24.12 and 24.22.

Open questions for the maintainers

  1. Under a name other than oxnode watch, the hints that point to oxnode --watch (for --test, and the help footer) are left out, not rewritten. Should there be a nodeWatch option for a tool's own text?
  2. Is the log tag taken from the first word of name enough, or should there be a label option?
  3. Should the protocol in the README (env names such as OXNODE_SIGNAL_ACK, the "oxc-node" key, the ack byte) be called stable under semver?
  4. The help body still says "Run with oxc-node", which is true for a loader built on the binding. Is that wording fine for vpx?

🤖 Generated with Claude Code

Brooooooklyn and others added 2 commits October 20, 2026 22:44
…loader

runWatch(argv, { loaderArgs, ignoreDirs, name }) is exported as
@oxc-node/cli/watch, an entry that starts nothing and loads neither
clipanion nor the binding. The child side is public as
@oxc-node/core/signal-ack and @oxc-node/core/watch-report, which now take
the binding's takeWatchDependencies as an argument. The README documents
the watch protocol.

Closes #871

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ries

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T14:59:09.669111Z 500326b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 500326bdeb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/core/package.json Resolved
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn merged commit e47f28e into main Oct 21, 2026
67 checks passed
@Brooooooklyn
Brooooooklyn deleted the feat/embeddable-watch branch October 21, 2026 03:44
fengmk2 added a commit to voidzero-dev/vite-plus that referenced this pull request Oct 21, 2026
## Summary

Implements #3141 except the `vp migrate` rule, which moves to a
follow-up that migrates tsx scripts as a whole.

`vpx --watch` now runs oxc-node's watcher (`oxnode watch`,
oxc-project/oxc-node#861) with the vpx script loader instead of Node.js'
own watch mode, so it works like `tsx watch`:

```bash
vpx --watch ./src/server.ts --port 3000
vpx --watch --include "./config/*.json" --exclude "src/**/*.test.ts" ./src/server.ts
vpx --watch --inspect --clear-screen=false ./src/index.ts
```

It takes `--include`, `--exclude`, `--clear-screen`, `--kill-signal`,
`--kill-timeout`, `--rerun-on-enter`, and `--poll`, plus `tsx watch`'s
`--ignore`, `--no-cache`, and `CHOKIDAR_USEPOLLING` and the Node.js
watch mode options `--watch-preserve-output` and `--watch-kill-signal`,
so scripts written for `tsx watch` or `node --watch` move over. Return
reruns the script, Ctrl+C stops it, and `vpx --watch --help` lists the
options. `vpx watch` stays package mode, so it still runs the npm
package `watch`.

It reruns the script in cases `tsx watch` misses:

| Change | `tsx watch` (4.23.15) | `vpx --watch` |
| --- | --- | --- |
| An imported file | rerun | rerun |
| A tsconfig edit, including a file it `extends` (a new `paths` mapping)
| no rerun | rerun |
| `import('./later.js')` fails, then `later.ts` is created | no rerun |
rerun |
| An `--env-file` file | no rerun | rerun |

## Changes

- **Detection.** Both CLIs take `--watch` out of the Node.js options and
run the watcher with the rest. `--watch-path`, which would start
Node.js' watch mode, also selects the watcher, which rejects it with a
hint to use `--include`, as it rejects `--test` and `--eval`. Detection
knows which watcher options take a value (`--include`, `--exclude`,
`--ignore`, `--kill-signal`, `--kill-timeout`, in the case table both
CLIs share), so `vpx --watch --exclude ./gen.ts ./main.ts` runs
`main.ts`.
- **Watcher.** `dist/script-watch.js` runs `runWatch`
(`@oxc-node/cli/watch`, oxc-project/oxc-node#872) as `vpx --watch`, with
the loader flags of its own `dist/`, so the watcher and the loader
always come from one `vite-plus`. That package's folder is ignored,
since its loader files and binding show up in the module graph. The
bundle imports only `node:` modules and loads no binding. oxc-node
itself is unchanged.
- **Running it.** The global `vpx` runs `node <dist>/script-watch.js
<args>` through the `node` shim, so the watcher gets the Node.js `vp
node` selects and, on Unix, the PID of `vpx`. It uses the project's
`vite-plus` when its `dist/` has the watch entry, else the global
install, as for scripts. The project-local bin runs the watcher in its
own process, with `node_modules/.bin` first on `PATH`.
- **tsconfig.** `--tsconfig`, as a `vpx` option or among the Node.js
options, reaches the runs as `VP_SCRIPT_TSCONFIG`, as for a script, so
it wins over a `TS_NODE_PROJECT` that an `--env-file` sets
(oxc-project/oxc-node#876 affects `oxnode watch --tsconfig` only).
- **Loader reports.** `oxc_node.rs` forwards `enableWatchReport` and
`takeWatchDependencies`. Under the watcher, the preload turns recording
on before the first transform or resolve, and the hooks and the CommonJS
transform send what the binding recorded, the tsconfig files read and
the paths a failed resolve looked for, as `{ "watch:import": [url] }`.
On the `module.register()` path (older 22.x and 24.11.0),
`script-register.ts` passes the hooks thread a port and relays its
reports, as upstream's `register.mjs` does. Without a watcher, nothing
is opened or wrapped.
- **Signals.** When the watcher is not the terminal's foreground job (a
pipe, `vp run`, a tool), a Ctrl+C reaches the script once: the preload
installs `@oxc-node/core/signal-ack`, which tells the watcher the script
already has the `SIGINT`, so it does not send a second one. A `SIGINT`
sent to the `vpx` PID alone is passed on. The ack loads `node:net`,
which adds under 1 ms to a run whose stdout and stderr are files (45.6
against 44.8 ms for a hello script on Node.js 22.18), and nothing with a
terminal or a pipe, where Node.js loads `node:net` anyway.
- **From the vendored source.** The watcher and the loader's
`@oxc-node/core/watch-report` and `/signal-ack` are imported from
`oxc-node/` and bundled, so they match the binding at the pinned hash.
Type checking uses upstream's `watch.d.ts` for the watcher.
- **Docs.** Both help texts, the Watch Mode section of the vpx guide,
the RFC, `BUNDLING.md`, and the `AGENTS.md` shipped to projects.

## Behavior change

`vpx --watch` no longer runs Node.js' watch mode. Through `vpx`,
Node.js' `--watch-path` (use `--include`) and `--test --watch` are
errors now.

## Known limitation

A restart stops the script's process only, so a server the script
started in a subprocess can keep its port (oxc-project/oxc-node#875).
`tsx watch` and `node --watch` behave the same way.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose the oxnode watch watcher as an entry an embedder can run with its own loader

1 participant