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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,11 @@ jobs:
- name: Deduplicate dependencies
run: pnpm dedupe --check

- name: Check vite-task-client types are not stale
run: |
pnpm build-vite-task-client-types
git diff --exit-code packages/vite-task-client/index.d.ts
done:
runs-on: namespace-profile-linux-x64-default
if: always()
Expand Down
3 changes: 2 additions & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"ignorePatterns": [
"crates/fspy_detours_sys/detours",
"crates/vite_task_graph/run-config.ts",
"**/fixtures/*/snapshots"
"**/fixtures/*/snapshots",
"packages/vite-task-client/index.d.ts"
]
}
97 changes: 95 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ libc = "0.2.185"
libtest-mimic = "0.8.2"
memmap2 = "0.9.7"
monostate = "1.0.2"
napi = "3"
napi-build = "2"
napi-derive = "3"
native_str = { path = "crates/native_str" }
nix = { version = "0.31.2", features = ["dir", "signal"] }
ntapi = "0.4.1"
Expand Down Expand Up @@ -150,6 +153,7 @@ vite_str = { path = "crates/vite_str" }
vite_task = { path = "crates/vite_task" }
vite_task_bin = { path = "crates/vite_task_bin" }
vite_task_client = { path = "crates/vite_task_client" }
vite_task_client_napi = { path = "crates/vite_task_client_napi", artifact = "cdylib", target = "target" }
vite_task_graph = { path = "crates/vite_task_graph" }
vite_task_ipc_shared = { path = "crates/vite_task_ipc_shared" }
vite_task_plan = { path = "crates/vite_task_plan" }
Expand All @@ -171,6 +175,7 @@ ignored = [
# These are artifact dependencies. They are not directly `use`d in Rust code.
"fspy_preload_unix",
"fspy_preload_windows",
"vite_task_client_napi",
# Registered in the workspace dependency table so downstream PRs in the
# runner-aware-tools stack can pick it up via `workspace = true` without
# touching this file. No in-tree consumer in this PR.
Expand Down
24 changes: 24 additions & 0 deletions crates/vite_task_client_napi/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[package]
name = "vite_task_client_napi"
version = "0.1.0"
authors.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true

[lib]
crate-type = ["cdylib"]
test = false
doctest = false

[dependencies]
napi = { workspace = true, features = ["napi6"] }
napi-derive = { workspace = true }
vite_str = { workspace = true }
vite_task_client = { workspace = true }

[build-dependencies]
napi-build = { workspace = true }

[lints]
workspace = true
3 changes: 3 additions & 0 deletions crates/vite_task_client_napi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# vite_task_client_napi

Node addon that lets JS/TS tools running inside a `vp run` task talk to the runner over IPC via `vite_task_client`.
5 changes: 5 additions & 0 deletions crates/vite_task_client_napi/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
extern crate napi_build;

fn main() {
napi_build::setup();
}
142 changes: 142 additions & 0 deletions crates/vite_task_client_napi/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//! Node addon that exposes a `load()` factory which returns a
//! `RunnerClient` JS class instance bound to the runner's IPC connection.
//! Not intended to be published directly — the runner hands the compiled
//! `.node` file to child processes via the `VP_RUN_NODE_CLIENT_PATH` env
//! var, and the JS wrapper in `@voidzero-dev/vite-task-client`
//! `require()`s it lazily.
//!
//! The factory shape (`load() -> RunnerClient`, rather than methods
//! exported at the top level) is a deliberate layer of indirection so
//! the addon can evolve over time: a future wrapper can pass an options
//! argument (e.g. a version field) and receive a differently-shaped
//! addon, without breaking older addons that ignore the argument.
//!
//! `load()` is callable only inside a runner-spawned task: when the IPC
//! env is absent or the connection refuses, `load()` throws and the JS
//! wrapper falls into no-op mode.

// The napi boundary forces std `String` through function signatures; clippy's
// blanket bans on disallowed types / needless-pass-by-value / missing Errors
// sections are all about pure-Rust call sites and don't apply here (JS never
// reads rustdoc). `disallowed_macros` is allowed because `napi-derive` expands
// to `std::format!` inside `check_status!`, and the macro output isn't ours
// to rewrite.
#![expect(
clippy::disallowed_macros,
clippy::disallowed_types,
clippy::missing_errors_doc,
clippy::needless_pass_by_value,
reason = "napi bindings require owned std String + std::format! at the JS boundary"
)]

use std::{collections::HashMap, ffi::OsStr};

use napi::{Error, Result};
use napi_derive::napi;
use vite_task_client::Client;

/// Options for [`RunnerClient::get_env`] and [`RunnerClient::get_envs`].
///
/// Modeled as a JS plain object rather than a positional boolean so future
/// knobs (e.g. a `default` value) can be added without an ABI break on the
/// JS wrapper side.
///
/// Every field is optional so the napi addon — the cross-version API
/// stability boundary between the runner-shipped `.node` and the
/// separately-npm-published JS wrapper — can fill in defaults and let old
/// wrappers keep working against new runners (and vice versa).
#[napi(object)]
pub struct GetEnvOptions {
/// Whether the runner should record this env as a cache-key dependency.
/// Defaults to `true`.
pub tracked: Option<bool>,
}

/// Handle returned by [`load`]. Holds the IPC connection and exposes the
/// runner-side operations as instance methods.
#[napi]
pub struct RunnerClient {
client: Client,
}

#[napi]
impl RunnerClient {
#[napi]
pub fn ignore_input(&self, path: String) -> Result<()> {
self.client
.ignore_input(OsStr::new(&path))
.map_err(|err| err_string(vite_str::format!("{err}")))
}

#[napi]
pub fn ignore_output(&self, path: String) -> Result<()> {
self.client
.ignore_output(OsStr::new(&path))
.map_err(|err| err_string(vite_str::format!("{err}")))
}

#[napi]
pub fn disable_cache(&self) -> Result<()> {
self.client.disable_cache().map_err(|err| err_string(vite_str::format!("{err}")))
}

#[napi]
pub fn get_env(&self, name: String, options: Option<GetEnvOptions>) -> Result<Option<String>> {
let tracked = options.and_then(|o| o.tracked).unwrap_or(true);
let value = self
.client
.get_env(OsStr::new(&name), tracked)
.map_err(|err| err_string(vite_str::format!("{err}")))?;
value.map_or(Ok(None), |value| {
value.to_str().map(|s| Some(s.to_owned())).ok_or_else(|| {
err_string(vite_str::format!("env value for {name} is not valid UTF-8"))
})
})
}

#[napi]
pub fn get_envs(
&self,
pattern: String,
options: Option<GetEnvOptions>,
) -> Result<HashMap<String, String>> {
let tracked = options.and_then(|o| o.tracked).unwrap_or(true);
let matches = self
.client
.get_envs(&pattern, tracked)
.map_err(|err| err_string(vite_str::format!("{err}")))?;
// Entries whose name or value contains non-UTF-8 bytes can't cross
// the JS boundary as `String`. Unlike `get_env` (which errors out),
// bulk fetch drops them silently — the caller has no way to know
// which one is bad, and a partial match-set is usually still useful.
Ok(matches
.into_iter()
.filter_map(|(k, v)| Some((k.to_str()?.to_owned(), v.to_str()?.to_owned())))
.collect())
}
}

/// Connect to the runner and return a [`RunnerClient`]. Throws when the
/// IPC env is missing or the connection fails.
#[napi]
pub fn load() -> Result<RunnerClient> {
let client = Client::from_envs(std::env::vars_os())
.map_err(|err| {
err_string(vite_str::format!("vp run client: failed to connect to runner IPC: {err}"))
})?
.ok_or_else(|| {
err_static(
"vp run client: runner IPC env is not set; this module is only usable \
inside a `vp run` task",
)
})?;
Ok(RunnerClient { client })
}

fn err_static(msg: &'static str) -> Error {
Error::from_reason(msg)
}

fn err_string(msg: vite_str::Str) -> Error {
Error::from_reason(msg.as_str())
}
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@
"license": "MIT",
"type": "module",
"scripts": {
"prepare": "husky"
"prepare": "husky",
"build-vite-task-client-types": "tsc -p packages/vite-task-client/tsconfig.json"
},
"devDependencies": {
"@tsconfig/strictest": "catalog:",
"@types/node": "catalog:",
"husky": "catalog:",
"lint-staged": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:"
"oxlint-tsgolint": "catalog:",
"typescript": "catalog:"
},
"lint-staged": {
"*": "oxfmt --no-error-on-unmatched-pattern",
Expand Down
3 changes: 3 additions & 0 deletions packages/vite-task-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @voidzero-dev/vite-task-client

Node client that lets JS/TS tools report ignored inputs/outputs, fetch tracked env values, and opt out of caching when running inside a `vp run` task.
Loading
Loading