|
| 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