Skip to content

Commit 4ba6a19

Browse files
committed
feat(client): add Node addon and JS wrapper for runner IPC
Splits Step 4 of the runner-aware-tools plan: replaces the napi crate stub with real module-level functions, adds the `@voidzero-dev/vite-task-client` JS package that lazily loads the addon and falls back to no-ops outside a `vp run` task, and renames the runner env vars to the `VP_RUN_` prefix (`VITE_TASK_IPC_NAME` -> `VP_RUN_IPC_NAME`, new `VP_RUN_NODE_CLIENT_PATH`). `vite_task_client::Client` now uses `&self` with `RefCell`s so the napi layer can store it as per-env instance data without a Mutex. Adds an end-to-end `#[ignore]` test driving the addon from Node against a live `vite_task_server`.
1 parent 51dc466 commit 4ba6a19

12 files changed

Lines changed: 319 additions & 36 deletions

File tree

Cargo.lock

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

crates/vite_task_client/src/lib.rs

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::{
2+
cell::RefCell,
23
ffi::OsStr,
34
io::{self, Read, Write},
45
sync::Arc,
@@ -8,11 +9,10 @@ use interprocess::local_socket::{Stream, prelude::*};
89
use native_str::NativeStr;
910
use vite_path::AbsolutePath;
1011
use vite_task_ipc_shared::{GetEnvResponse, IPC_ENV_NAME, Request};
11-
use wincode::{SchemaRead, config::DefaultConfig};
1212

1313
pub struct Client {
14-
stream: Stream,
15-
scratch: Vec<u8>,
14+
stream: RefCell<Stream>,
15+
scratch: RefCell<Vec<u8>>,
1616
}
1717

1818
impl Client {
@@ -31,7 +31,7 @@ impl Client {
3131
for (name, value) in envs {
3232
if name.as_ref() == IPC_ENV_NAME {
3333
let stream = Stream::connect(resolve_name(value.as_ref())?)?;
34-
return Ok(Some(Self { stream, scratch: Vec::new() }));
34+
return Ok(Some(Self::from_stream(stream)));
3535
}
3636
}
3737
Ok(None)
@@ -44,15 +44,19 @@ impl Client {
4444
/// Returns an error if the connection cannot be established.
4545
pub fn from_name(name: &OsStr) -> io::Result<Self> {
4646
let stream = Stream::connect(resolve_name(name)?)?;
47-
Ok(Self { stream, scratch: Vec::new() })
47+
Ok(Self::from_stream(stream))
48+
}
49+
50+
const fn from_stream(stream: Stream) -> Self {
51+
Self { stream: RefCell::new(stream), scratch: RefCell::new(Vec::new()) }
4852
}
4953

5054
/// `path` can be a file or a directory; for a directory, all files inside it are ignored.
5155
///
5256
/// # Errors
5357
///
5458
/// Returns an error if the request fails to send.
55-
pub fn ignore_input(&mut self, path: &AbsolutePath) -> io::Result<()> {
59+
pub fn ignore_input(&self, path: &AbsolutePath) -> io::Result<()> {
5660
let ns = path_to_native_str(path);
5761
self.send(&Request::IgnoreInput(&ns))
5862
}
@@ -62,15 +66,15 @@ impl Client {
6266
/// # Errors
6367
///
6468
/// Returns an error if the request fails to send.
65-
pub fn ignore_output(&mut self, path: &AbsolutePath) -> io::Result<()> {
69+
pub fn ignore_output(&self, path: &AbsolutePath) -> io::Result<()> {
6670
let ns = path_to_native_str(path);
6771
self.send(&Request::IgnoreOutput(&ns))
6872
}
6973

7074
/// # Errors
7175
///
7276
/// Returns an error if the request fails to send.
73-
pub fn disable_cache(&mut self) -> io::Result<()> {
77+
pub fn disable_cache(&self) -> io::Result<()> {
7478
self.send(&Request::DisableCache)
7579
}
7680

@@ -79,38 +83,42 @@ impl Client {
7983
///
8084
/// # Errors
8185
///
82-
/// Returns an error if the request or response fails, or the response id mismatches.
83-
pub fn get_env(&mut self, name: &OsStr, tracked: bool) -> io::Result<Option<Arc<OsStr>>> {
86+
/// Returns an error if the request or response fails.
87+
pub fn get_env(&self, name: &OsStr, tracked: bool) -> io::Result<Option<Arc<OsStr>>> {
8488
let name = Box::<NativeStr>::from(name);
8589

8690
self.send(&Request::GetEnv { name: &name, tracked })?;
87-
let get_env_response = self.recv::<GetEnvResponse>()?;
88-
89-
Ok(get_env_response
90-
.env_value
91-
.map(|env_value| Arc::<OsStr>::from(env_value.to_cow_os_str().as_ref())))
91+
self.recv_with(|bytes| {
92+
let response: GetEnvResponse<'_> = wincode::deserialize_exact(bytes)
93+
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
94+
Ok(response
95+
.env_value
96+
.map(|env_value| Arc::<OsStr>::from(env_value.to_cow_os_str().as_ref())))
97+
})
9298
}
9399

94-
fn send(&mut self, request: &Request<'_>) -> io::Result<()> {
100+
fn send(&self, request: &Request<'_>) -> io::Result<()> {
95101
let bytes = wincode::serialize(request)
96102
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
97103
let len = u32::try_from(bytes.len())
98104
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "request too large"))?;
99-
self.stream.write_all(&len.to_le_bytes())?;
100-
self.stream.write_all(&bytes)?;
101-
self.stream.flush()?;
105+
let mut stream = self.stream.borrow_mut();
106+
stream.write_all(&len.to_le_bytes())?;
107+
stream.write_all(&bytes)?;
108+
stream.flush()?;
102109
Ok(())
103110
}
104111

105-
fn recv<'a, T: SchemaRead<'a, DefaultConfig, Dst = T>>(&'a mut self) -> io::Result<T> {
112+
fn recv_with<T>(&self, extract: impl FnOnce(&[u8]) -> io::Result<T>) -> io::Result<T> {
113+
let mut stream = self.stream.borrow_mut();
114+
let mut scratch = self.scratch.borrow_mut();
106115
let mut len_bytes = [0u8; 4];
107-
self.stream.read_exact(&mut len_bytes)?;
116+
stream.read_exact(&mut len_bytes)?;
108117
let len = u32::from_le_bytes(len_bytes) as usize;
109-
self.scratch.clear();
110-
self.scratch.resize(len, 0);
111-
self.stream.read_exact(&mut self.scratch)?;
112-
wincode::deserialize_exact(&self.scratch)
113-
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
118+
scratch.clear();
119+
scratch.resize(len, 0);
120+
stream.read_exact(&mut scratch)?;
121+
extract(&scratch)
114122
}
115123
}
116124

crates/vite_task_client_napi/Cargo.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,19 @@ rust-version.workspace = true
1010
crate-type = ["cdylib"]
1111

1212
[dependencies]
13-
napi = { workspace = true }
13+
napi = { workspace = true, features = ["napi6"] }
1414
napi-derive = { workspace = true }
15+
vite_path = { workspace = true }
16+
vite_str = { workspace = true }
1517
vite_task_client = { workspace = true }
1618

1719
[build-dependencies]
1820
napi-build = { workspace = true }
1921

22+
[dev-dependencies]
23+
rustc-hash = { workspace = true }
24+
tokio = { workspace = true, features = ["rt", "macros"] }
25+
vite_task_server = { workspace = true }
26+
2027
[lints]
2128
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: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,93 @@
1-
// NAPI bindings are implemented in step 4 of the runner-aware tools plan.
2-
// This stub exists to keep the crate compiling while the underlying
3-
// `vite_task_client` API is evolving.
1+
//! Node addon that exposes module-level functions for tools to talk to a
2+
//! `vp run` runner over IPC. Not intended to be published directly — the
3+
//! runner hands the compiled `.node` file to child processes via the
4+
//! `VP_RUN_NODE_CLIENT_PATH` env var, and the JS wrapper in
5+
//! `@voidzero-dev/vite-task-client` `require()`s it lazily.
6+
//!
7+
//! The module is loadable **only** inside a runner-spawned task: when
8+
//! module-init runs outside that context the registration fails, the JS
9+
//! `require()` throws, and the wrapper falls into no-op mode.
410
11+
// The napi boundary forces std `String` through function signatures; clippy's
12+
// blanket bans on disallowed types / needless-pass-by-value / missing Errors
13+
// sections are all about pure-Rust call sites and don't apply here (JS never
14+
// reads rustdoc).
15+
#![expect(
16+
clippy::disallowed_types,
17+
clippy::missing_errors_doc,
18+
clippy::needless_pass_by_value,
19+
reason = "napi bindings require owned std String at the JS boundary"
20+
)]
21+
22+
use std::ffi::OsStr;
23+
24+
use napi::{Env, Error, Result};
525
use napi_derive::napi;
26+
use vite_path::AbsolutePath;
27+
use vite_task_client::Client;
28+
29+
struct State {
30+
client: Client,
31+
}
32+
33+
#[napi(module_exports)]
34+
pub fn init(env: Env) -> Result<()> {
35+
let client = Client::from_envs(std::env::vars_os())
36+
.map_err(|err| {
37+
err_string(vite_str::format!("vp run client: failed to connect to runner IPC: {err}"))
38+
})?
39+
.ok_or_else(|| {
40+
err_static(
41+
"vp run client: runner IPC env is not set; this module is only usable \
42+
inside a `vp run` task",
43+
)
44+
})?;
45+
env.set_instance_data(State { client }, (), |_| {})?;
46+
Ok(())
47+
}
48+
49+
fn client(env: Env) -> Result<&'static Client> {
50+
env.get_instance_data::<State>()?
51+
.map(|state| &state.client as &Client)
52+
.ok_or_else(|| err_static("vp run client: module state missing"))
53+
}
54+
55+
fn err_static(msg: &'static str) -> Error {
56+
Error::from_reason(msg)
57+
}
58+
59+
fn err_string(msg: vite_str::Str) -> Error {
60+
Error::from_reason(msg.as_str())
61+
}
62+
63+
#[napi]
64+
pub fn ignore_input(env: Env, path: String) -> Result<()> {
65+
let abs = AbsolutePath::new(&path)
66+
.ok_or_else(|| err_string(vite_str::format!("path must be absolute: {path}")))?;
67+
client(env)?.ignore_input(abs).map_err(|err| err_string(vite_str::format!("{err}")))
68+
}
69+
70+
#[napi]
71+
pub fn ignore_output(env: Env, path: String) -> Result<()> {
72+
let abs = AbsolutePath::new(&path)
73+
.ok_or_else(|| err_string(vite_str::format!("path must be absolute: {path}")))?;
74+
client(env)?.ignore_output(abs).map_err(|err| err_string(vite_str::format!("{err}")))
75+
}
76+
77+
#[napi]
78+
pub fn disable_cache(env: Env) -> Result<()> {
79+
client(env)?.disable_cache().map_err(|err| err_string(vite_str::format!("{err}")))
80+
}
681

782
#[napi]
8-
pub const STUB: u32 = 0;
83+
pub fn get_env(env: Env, name: String, tracked: bool) -> Result<Option<String>> {
84+
let value = client(env)?
85+
.get_env(OsStr::new(&name), tracked)
86+
.map_err(|err| err_string(vite_str::format!("{err}")))?;
87+
value.map_or(Ok(None), |value| {
88+
value
89+
.to_str()
90+
.map(|s| Some(s.to_owned()))
91+
.ok_or_else(|| err_string(vite_str::format!("env value for {name} is not valid UTF-8")))
92+
})
93+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//! End-to-end test for the node addon. Requires Node.js on PATH and the
2+
//! `@voidzero-dev/vite-task-client` JS package + the built `.node` addon
3+
//! on disk.
4+
//!
5+
//! Expected env vars:
6+
//! - `VP_RUN_NODE_CLIENT_ADDON_PATH`: absolute path to the built `.node` dylib
7+
//! (e.g. `target/release/libvite_task_client_napi.so` copied or symlinked
8+
//! as `.node`).
9+
//! - `VP_RUN_NODE_CLIENT_JS_PATH`: absolute path to
10+
//! `packages/vite-task-client/index.js`.
11+
//!
12+
//! Both must be set or the test is silently skipped.
13+
14+
use std::{
15+
ffi::{OsStr, OsString},
16+
process::Command,
17+
sync::Arc,
18+
};
19+
20+
use rustc_hash::FxHashMap;
21+
use tokio::runtime::Builder;
22+
use vite_task_server::{Recorder, ServerHandle, serve};
23+
24+
#[test]
25+
#[ignore = "requires built .node addon and Node.js on PATH"]
26+
fn addon_round_trip() {
27+
let addon_path = std::env::var_os("VP_RUN_NODE_CLIENT_ADDON_PATH")
28+
.expect("VP_RUN_NODE_CLIENT_ADDON_PATH must point at the built .node addon");
29+
let js_path = std::env::var_os("VP_RUN_NODE_CLIENT_JS_PATH")
30+
.expect("VP_RUN_NODE_CLIENT_JS_PATH must point at packages/vite-task-client/index.js");
31+
32+
let mut envs: FxHashMap<Arc<OsStr>, Arc<OsStr>> = FxHashMap::default();
33+
envs.insert(
34+
Arc::<OsStr>::from(OsStr::new("NODE_ENV")),
35+
Arc::<OsStr>::from(OsStr::new("production")),
36+
);
37+
38+
let rt = Builder::new_current_thread().enable_all().build().unwrap();
39+
let reports = rt.block_on(async move {
40+
let recorder = Recorder::new(envs);
41+
let (ipc_envs, ServerHandle { driver, stop_accepting }) =
42+
serve(recorder).expect("bind server");
43+
let (ipc_name, ipc_value) = ipc_envs.into_iter().next().expect("one IPC env");
44+
let ipc_name: OsString = ipc_name.to_os_string();
45+
46+
let child = tokio::task::spawn_blocking(move || {
47+
let js_path_str = js_path.to_str().expect("JS path is utf-8");
48+
let script = vite_str::format!(
49+
"\
50+
import({js_path_literal:?}).then(m => {{\n\
51+
m.ignoreInput('/tmp/vp_run_test_in.txt');\n\
52+
m.ignoreOutput('/tmp/vp_run_test_out.txt');\n\
53+
m.disableCache();\n\
54+
m.fetchEnv('NODE_ENV');\n\
55+
if (process.env.NODE_ENV !== 'production') {{\n\
56+
console.error('expected production, got ' + process.env.NODE_ENV);\n\
57+
process.exit(1);\n\
58+
}}\n\
59+
m.fetchEnv('MISSING');\n\
60+
if (process.env.MISSING !== undefined) {{\n\
61+
console.error('MISSING should be undefined');\n\
62+
process.exit(1);\n\
63+
}}\n\
64+
}});\n",
65+
js_path_literal = js_path_str
66+
);
67+
let status = Command::new("node")
68+
.arg("--input-type=module")
69+
.arg("-e")
70+
.arg(script.as_str())
71+
.env::<&OsStr, &OsStr>(&ipc_name, &ipc_value)
72+
.env("VP_RUN_NODE_CLIENT_PATH", &addon_path)
73+
.status()
74+
.expect("spawn node");
75+
stop_accepting.signal();
76+
assert!(status.success(), "node exited with {status}");
77+
});
78+
79+
let (recorder, child_result) = tokio::join!(driver, child);
80+
child_result.expect("node runner panicked");
81+
recorder.into_reports()
82+
});
83+
84+
assert!(reports.cache_disabled, "disableCache should propagate");
85+
assert_eq!(reports.ignored_inputs.len(), 1, "ignoreInput should record one path");
86+
assert_eq!(reports.ignored_outputs.len(), 1, "ignoreOutput should record one path");
87+
88+
let node_env = reports.env_records.get(OsStr::new("NODE_ENV")).expect("NODE_ENV recorded");
89+
assert!(node_env.tracked);
90+
assert_eq!(node_env.value.as_deref(), Some(OsStr::new("production")));
91+
92+
let missing = reports.env_records.get(OsStr::new("MISSING")).expect("MISSING recorded");
93+
assert!(missing.tracked);
94+
assert!(missing.value.is_none());
95+
}

crates/vite_task_ipc_shared/src/lib.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
use native_str::NativeStr;
22
use wincode::{SchemaRead, SchemaWrite};
33

4-
pub const IPC_ENV_NAME: &str = "VITE_TASK_IPC_NAME";
4+
pub const IPC_ENV_NAME: &str = "VP_RUN_IPC_NAME";
5+
6+
/// Path to the Node client module that JS/TS tools `require()` to talk to
7+
/// the runner.
8+
///
9+
/// Implementation-detail leakage (`napi`, `.node`, `addon`) is intentionally
10+
/// kept out of the name: from the consumer's point of view this is just a
11+
/// path they can `require()`. The `NODE_` scope reserves room for a future
12+
/// C-ABI client library advertised via its own env var for non-Node
13+
/// consumers.
14+
pub const NODE_CLIENT_PATH_ENV_NAME: &str = "VP_RUN_NODE_CLIENT_PATH";
515

616
#[derive(Debug, SchemaWrite, SchemaRead)]
717
pub enum Request<'a> {

0 commit comments

Comments
 (0)