Skip to content

Commit 00420ad

Browse files
wan9chiclaude
andcommitted
feat(client): napi binding
Adds the JS surface that lets spawned Node tools talk to the runner over the IPC introduced in the previous PR: - `crates/vite_task_client_napi` — napi binding that exposes the Rust `Client` to JS as a `RunnerClient` returned from `load()`. - `packages/vite-task-client` — JS wrapper with `ignoreInput`, `ignoreOutput`, `disableCache`, `getEnv`, and `getEnvs`. Types live in `index.js` as JSDoc; `index.d.ts` is generated via `pnpm build-vite-task-client-types` and a CI staleness check fails the build if the committed `.d.ts` drifts from the source. The package is consumable as a no-op outside the runner: when `VP_RUN_NODE_CLIENT_PATH` isn't set, `load()` returns `null` and every exported function becomes a no-op (or returns `undefined`/`{}`). That means the wrapper is safe to add as a regular dependency from a tool that wants to opt in to runner-aware behavior without requiring its users to run under `vp`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b73e92f commit 00420ad

6 files changed

Lines changed: 274 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 95 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ libc = "0.2.185"
8686
libtest-mimic = "0.8.2"
8787
memmap2 = "0.9.7"
8888
monostate = "1.0.2"
89+
napi = "3"
90+
napi-build = "2"
91+
napi-derive = "3"
8992
native_str = { path = "crates/native_str" }
9093
nix = { version = "0.31.2", features = ["dir", "signal"] }
9194
ntapi = "0.4.1"
@@ -151,6 +154,7 @@ vite_str = { path = "crates/vite_str" }
151154
vite_task = { path = "crates/vite_task" }
152155
vite_task_bin = { path = "crates/vite_task_bin" }
153156
vite_task_client = { path = "crates/vite_task_client" }
157+
vite_task_client_napi = { path = "crates/vite_task_client_napi", artifact = "cdylib", target = "target" }
154158
vite_task_graph = { path = "crates/vite_task_graph" }
155159
vite_task_ipc_shared = { path = "crates/vite_task_ipc_shared" }
156160
vite_task_plan = { path = "crates/vite_task_plan" }
@@ -172,6 +176,7 @@ ignored = [
172176
# These are artifact dependencies. They are not directly `use`d in Rust code.
173177
"fspy_preload_unix",
174178
"fspy_preload_windows",
179+
"vite_task_client_napi",
175180
# Registered in the workspace dependency table so downstream PRs in the
176181
# runner-aware-tools stack can pick it up via `workspace = true` without
177182
# touching this file. No in-tree consumer in this PR.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[package]
2+
name = "vite_task_client_napi"
3+
version = "0.1.0"
4+
authors.workspace = true
5+
edition.workspace = true
6+
license.workspace = true
7+
rust-version.workspace = true
8+
9+
[lib]
10+
crate-type = ["cdylib"]
11+
test = false
12+
doctest = false
13+
14+
[dependencies]
15+
napi = { workspace = true, features = ["napi6"] }
16+
napi-derive = { workspace = true }
17+
vite_str = { workspace = true }
18+
vite_task_client = { workspace = true }
19+
20+
[build-dependencies]
21+
napi-build = { workspace = true }
22+
23+
[lints]
24+
workspace = true
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# vite_task_client_napi
2+
3+
Node addon that lets JS/TS tools running inside a `vp run` task talk to the runner over IPC via `vite_task_client`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
extern crate napi_build;
2+
3+
fn main() {
4+
napi_build::setup();
5+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
//! Node addon that exposes a `load()` factory which returns a
2+
//! `RunnerClient` JS class instance bound to the runner's IPC connection.
3+
//! Not intended to be published directly — the runner hands the compiled
4+
//! `.node` file to child processes via the `VP_RUN_NODE_CLIENT_PATH` env
5+
//! var, and the JS wrapper in `@voidzero-dev/vite-task-client`
6+
//! `require()`s it lazily.
7+
//!
8+
//! The factory shape (`load() -> RunnerClient`, rather than methods
9+
//! exported at the top level) is a deliberate layer of indirection so
10+
//! the addon can evolve over time: a future wrapper can pass an options
11+
//! argument (e.g. a version field) and receive a differently-shaped
12+
//! addon, without breaking older addons that ignore the argument.
13+
//!
14+
//! `load()` is callable only inside a runner-spawned task: when the IPC
15+
//! env is absent or the connection refuses, `load()` throws and the JS
16+
//! wrapper falls into no-op mode.
17+
18+
// The napi boundary forces std `String` through function signatures; clippy's
19+
// blanket bans on disallowed types / needless-pass-by-value / missing Errors
20+
// sections are all about pure-Rust call sites and don't apply here (JS never
21+
// reads rustdoc). `disallowed_macros` is allowed because `napi-derive` expands
22+
// to `std::format!` inside `check_status!`, and the macro output isn't ours
23+
// to rewrite.
24+
#![expect(
25+
clippy::disallowed_macros,
26+
clippy::disallowed_types,
27+
clippy::missing_errors_doc,
28+
clippy::needless_pass_by_value,
29+
reason = "napi bindings require owned std String + std::format! at the JS boundary"
30+
)]
31+
32+
use std::{collections::HashMap, ffi::OsStr};
33+
34+
use napi::{Error, Result};
35+
use napi_derive::napi;
36+
use vite_task_client::Client;
37+
38+
/// Options for [`RunnerClient::get_env`] and [`RunnerClient::get_envs`].
39+
///
40+
/// Modeled as a JS plain object rather than a positional boolean so future
41+
/// knobs (e.g. a `default` value) can be added without an ABI break on the
42+
/// JS wrapper side.
43+
///
44+
/// Every field is optional so the napi addon — the cross-version API
45+
/// stability boundary between the runner-shipped `.node` and the
46+
/// separately-npm-published JS wrapper — can fill in defaults and let old
47+
/// wrappers keep working against new runners (and vice versa).
48+
#[napi(object)]
49+
pub struct GetEnvOptions {
50+
/// Whether the runner should record this env as a cache-key dependency.
51+
/// Defaults to `true`.
52+
pub tracked: Option<bool>,
53+
}
54+
55+
/// Handle returned by [`load`]. Holds the IPC connection and exposes the
56+
/// runner-side operations as instance methods.
57+
#[napi]
58+
pub struct RunnerClient {
59+
client: Client,
60+
}
61+
62+
#[napi]
63+
impl RunnerClient {
64+
#[napi]
65+
pub fn ignore_input(&self, path: String) -> Result<()> {
66+
self.client
67+
.ignore_input(OsStr::new(&path))
68+
.map_err(|err| err_string(vite_str::format!("{err}")))
69+
}
70+
71+
#[napi]
72+
pub fn ignore_output(&self, path: String) -> Result<()> {
73+
self.client
74+
.ignore_output(OsStr::new(&path))
75+
.map_err(|err| err_string(vite_str::format!("{err}")))
76+
}
77+
78+
#[napi]
79+
pub fn disable_cache(&self) -> Result<()> {
80+
self.client.disable_cache().map_err(|err| err_string(vite_str::format!("{err}")))
81+
}
82+
83+
#[napi]
84+
pub fn get_env(&self, name: String, options: Option<GetEnvOptions>) -> Result<Option<String>> {
85+
let tracked = options.and_then(|o| o.tracked).unwrap_or(true);
86+
let value = self
87+
.client
88+
.get_env(OsStr::new(&name), tracked)
89+
.map_err(|err| err_string(vite_str::format!("{err}")))?;
90+
value.map_or(Ok(None), |value| {
91+
value.to_str().map(|s| Some(s.to_owned())).ok_or_else(|| {
92+
err_string(vite_str::format!("env value for {name} is not valid UTF-8"))
93+
})
94+
})
95+
}
96+
97+
#[napi]
98+
pub fn get_envs(
99+
&self,
100+
pattern: String,
101+
options: Option<GetEnvOptions>,
102+
) -> Result<HashMap<String, String>> {
103+
let tracked = options.and_then(|o| o.tracked).unwrap_or(true);
104+
let matches = self
105+
.client
106+
.get_envs(&pattern, tracked)
107+
.map_err(|err| err_string(vite_str::format!("{err}")))?;
108+
// Entries whose name or value contains non-UTF-8 bytes can't cross
109+
// the JS boundary as `String`. Unlike `get_env` (which errors out),
110+
// bulk fetch drops them silently — the caller has no way to know
111+
// which one is bad, and a partial match-set is usually still useful.
112+
Ok(matches
113+
.into_iter()
114+
.filter_map(|(k, v)| Some((k.to_str()?.to_owned(), v.to_str()?.to_owned())))
115+
.collect())
116+
}
117+
}
118+
119+
/// Connect to the runner and return a [`RunnerClient`]. Throws when the
120+
/// IPC env is missing or the connection fails.
121+
#[napi]
122+
pub fn load() -> Result<RunnerClient> {
123+
let client = Client::from_envs(std::env::vars_os())
124+
.map_err(|err| {
125+
err_string(vite_str::format!("vp run client: failed to connect to runner IPC: {err}"))
126+
})?
127+
.ok_or_else(|| {
128+
err_static(
129+
"vp run client: runner IPC env is not set; this module is only usable \
130+
inside a `vp run` task",
131+
)
132+
})?;
133+
Ok(RunnerClient { client })
134+
}
135+
136+
fn err_static(msg: &'static str) -> Error {
137+
Error::from_reason(msg)
138+
}
139+
140+
fn err_string(msg: vite_str::Str) -> Error {
141+
Error::from_reason(msg.as_str())
142+
}

0 commit comments

Comments
 (0)