Skip to main content

dnx_core/
hooks.rs

1//! Hook system for `.pnpmfile.cjs` compatibility.
2//!
3//! Launches a long-lived Node.js child process that loads the user's
4//! `.pnpmfile.cjs` and communicates via JSON-over-stdin/stdout IPC.
5//! This amortizes the ~30ms Node.js startup cost across all packages.
6
7use crate::errors::{DnxError, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::io::{BufRead, BufReader, Write};
11use std::path::{Path, PathBuf};
12use std::process::{Child, Command, Stdio};
13use tracing::{debug, warn};
14
15// ---------------------------------------------------------------------------
16// Embedded JS worker script
17// ---------------------------------------------------------------------------
18
19const WORKER_SCRIPT: &str = r#"
20'use strict';
21const path = require('path');
22const readline = require('readline');
23
24const pnpmfilePath = process.argv[2];
25let hooks;
26try {
27    hooks = require(path.resolve(pnpmfilePath));
28} catch (e) {
29    process.stderr.write('dnx-hook: failed to load ' + pnpmfilePath + ': ' + e.message + '\n');
30    process.exit(1);
31}
32
33const context = {
34    log: function(msg) {
35        process.stderr.write('dnx-hook: ' + msg + '\n');
36    }
37};
38
39const rl = readline.createInterface({ input: process.stdin, terminal: false });
40
41rl.on('line', async (line) => {
42    let request;
43    try {
44        request = JSON.parse(line);
45    } catch (e) {
46        process.stdout.write(JSON.stringify({ error: 'Invalid JSON: ' + e.message }) + '\n');
47        return;
48    }
49
50    try {
51        if (request.type === 'readPackage') {
52            if (hooks.hooks && typeof hooks.hooks.readPackage === 'function') {
53                let result = hooks.hooks.readPackage(request.pkg, context);
54                if (result && typeof result.then === 'function') {
55                    result = await result;
56                }
57                process.stdout.write(JSON.stringify({ pkg: result || request.pkg }) + '\n');
58            } else {
59                process.stdout.write(JSON.stringify({ pkg: request.pkg }) + '\n');
60            }
61        } else if (request.type === 'afterAllResolved') {
62            if (hooks.hooks && typeof hooks.hooks.afterAllResolved === 'function') {
63                let result = hooks.hooks.afterAllResolved(request.lockfile, context);
64                if (result && typeof result.then === 'function') {
65                    result = await result;
66                }
67                process.stdout.write(JSON.stringify({ lockfile: result || request.lockfile }) + '\n');
68            } else {
69                process.stdout.write(JSON.stringify({ lockfile: request.lockfile }) + '\n');
70            }
71        } else if (request.type === 'ping') {
72            process.stdout.write(JSON.stringify({ pong: true }) + '\n');
73        } else {
74            process.stdout.write(JSON.stringify({ error: 'Unknown request type' }) + '\n');
75        }
76    } catch (e) {
77        process.stdout.write(JSON.stringify({ error: e.message }) + '\n');
78    }
79});
80
81rl.on('close', () => process.exit(0));
82"#;
83
84// ---------------------------------------------------------------------------
85// Public types
86// ---------------------------------------------------------------------------
87
88/// Serializable package metadata sent to/from hooks.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct HookPackage {
91    pub name: String,
92    pub version: String,
93    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
94    pub dependencies: HashMap<String, String>,
95    #[serde(
96        rename = "devDependencies",
97        default,
98        skip_serializing_if = "HashMap::is_empty"
99    )]
100    pub dev_dependencies: HashMap<String, String>,
101    #[serde(
102        rename = "peerDependencies",
103        default,
104        skip_serializing_if = "HashMap::is_empty"
105    )]
106    pub peer_dependencies: HashMap<String, String>,
107    #[serde(
108        rename = "optionalDependencies",
109        default,
110        skip_serializing_if = "HashMap::is_empty"
111    )]
112    pub optional_dependencies: HashMap<String, String>,
113}
114
115/// Hook system — either active (with a running Node.js worker) or no-op.
116pub enum Hooks {
117    /// Hooks are active — `.pnpmfile.cjs` is loaded in a child process.
118    Active(HookRunner),
119    /// No hooks configured — all calls are no-ops.
120    Noop,
121}
122
123impl Hooks {
124    /// Detect and initialize hooks for a project.
125    ///
126    /// - If `pnpmfile_config` is `Some("false")`, hooks are disabled.
127    /// - If `pnpmfile_config` is `Some(path)`, that path is used.
128    /// - If `pnpmfile_config` is `None`, auto-detects `.pnpmfile.cjs` in `project_root`.
129    pub fn detect(project_root: &Path, pnpmfile_config: Option<&str>) -> Self {
130        match pnpmfile_config {
131            Some("false") => {
132                debug!("Hooks disabled by configuration");
133                Hooks::Noop
134            }
135            Some(path) => {
136                let pnpmfile_path = if Path::new(path).is_absolute() {
137                    PathBuf::from(path)
138                } else {
139                    project_root.join(path)
140                };
141                if pnpmfile_path.exists() {
142                    match HookRunner::new(&pnpmfile_path) {
143                        Ok(runner) => {
144                            debug!("Hooks loaded from {}", pnpmfile_path.display());
145                            Hooks::Active(runner)
146                        }
147                        Err(e) => {
148                            warn!("Failed to start hook runner: {}", e);
149                            Hooks::Noop
150                        }
151                    }
152                } else {
153                    warn!("Configured pnpmfile not found: {}", pnpmfile_path.display());
154                    Hooks::Noop
155                }
156            }
157            None => {
158                let default_path = project_root.join(".pnpmfile.cjs");
159                if default_path.exists() {
160                    match HookRunner::new(&default_path) {
161                        Ok(runner) => {
162                            debug!("Hooks loaded from .pnpmfile.cjs");
163                            Hooks::Active(runner)
164                        }
165                        Err(e) => {
166                            warn!("Failed to start hook runner: {}", e);
167                            Hooks::Noop
168                        }
169                    }
170                } else {
171                    Hooks::Noop
172                }
173            }
174        }
175    }
176
177    /// Run `readPackage` hook on a package's metadata.
178    /// Returns the (possibly modified) package metadata.
179    pub fn read_package(&mut self, pkg: HookPackage) -> Result<HookPackage> {
180        match self {
181            Hooks::Active(runner) => runner.read_package(pkg),
182            Hooks::Noop => Ok(pkg),
183        }
184    }
185
186    /// Run `afterAllResolved` hook on the lockfile content.
187    /// Returns the (possibly modified) lockfile string.
188    pub fn after_all_resolved(&mut self, lockfile: &str) -> Result<String> {
189        match self {
190            Hooks::Active(runner) => runner.after_all_resolved(lockfile),
191            Hooks::Noop => Ok(lockfile.to_string()),
192        }
193    }
194
195    /// Returns true if hooks are active.
196    pub fn is_active(&self) -> bool {
197        matches!(self, Hooks::Active(_))
198    }
199}
200
201// ---------------------------------------------------------------------------
202// HookRunner — manages the Node.js child process
203// ---------------------------------------------------------------------------
204
205pub struct HookRunner {
206    child: Child,
207    stdin: Option<std::process::ChildStdin>,
208    reader: BufReader<std::process::ChildStdout>,
209}
210
211/// IPC request types
212#[derive(Serialize)]
213#[serde(tag = "type")]
214enum HookRequest {
215    #[serde(rename = "readPackage")]
216    ReadPackage { pkg: Box<HookPackage> },
217    #[serde(rename = "afterAllResolved")]
218    AfterAllResolved { lockfile: String },
219    #[serde(rename = "ping")]
220    Ping,
221}
222
223/// IPC response types
224#[derive(Deserialize)]
225struct ReadPackageResponse {
226    pkg: Option<HookPackage>,
227    error: Option<String>,
228}
229
230#[derive(Deserialize)]
231struct AfterAllResolvedResponse {
232    lockfile: Option<String>,
233    error: Option<String>,
234}
235
236#[derive(Deserialize)]
237struct PingResponse {
238    pong: Option<bool>,
239    error: Option<String>,
240}
241
242impl HookRunner {
243    /// Spawn a Node.js worker process that loads the given `.pnpmfile.cjs`.
244    pub fn new(pnpmfile_path: &Path) -> Result<Self> {
245        // Write worker script to a temp file
246        let temp_dir = std::env::temp_dir();
247        let worker_path = temp_dir.join("dnx-hook-worker.js");
248        std::fs::write(&worker_path, WORKER_SCRIPT)
249            .map_err(|e| DnxError::Hook(format!("Failed to write hook worker script: {}", e)))?;
250
251        let mut child = Command::new("node")
252            .arg(&worker_path)
253            .arg(pnpmfile_path)
254            .stdin(Stdio::piped())
255            .stdout(Stdio::piped())
256            .stderr(Stdio::inherit()) // Hook logs go to stderr
257            .spawn()
258            .map_err(|e| {
259                DnxError::Hook(format!(
260                    "Failed to spawn Node.js for hooks (is Node.js installed?): {}",
261                    e
262                ))
263            })?;
264
265        let stdin = child
266            .stdin
267            .take()
268            .ok_or_else(|| DnxError::Hook("Failed to capture hook worker stdin".to_string()))?;
269        let stdout = child
270            .stdout
271            .take()
272            .ok_or_else(|| DnxError::Hook("Failed to capture hook worker stdout".to_string()))?;
273        let reader = BufReader::new(stdout);
274
275        let mut runner = Self {
276            child,
277            stdin: Some(stdin),
278            reader,
279        };
280
281        // Verify the worker is alive with a ping
282        runner.ping()?;
283
284        Ok(runner)
285    }
286
287    fn ping(&mut self) -> Result<()> {
288        let request = serde_json::to_string(&HookRequest::Ping)
289            .map_err(|e| DnxError::Hook(format!("Failed to serialize ping: {}", e)))?;
290
291        self.send_line(&request)?;
292        let response_line = self.read_line()?;
293
294        let response: PingResponse = serde_json::from_str(&response_line)
295            .map_err(|e| DnxError::Hook(format!("Failed to parse ping response: {}", e)))?;
296
297        if let Some(err) = response.error {
298            return Err(DnxError::Hook(format!("Hook worker error: {}", err)));
299        }
300
301        if response.pong != Some(true) {
302            return Err(DnxError::Hook(
303                "Hook worker did not respond to ping".to_string(),
304            ));
305        }
306
307        Ok(())
308    }
309
310    fn read_package(&mut self, pkg: HookPackage) -> Result<HookPackage> {
311        let request = serde_json::to_string(&HookRequest::ReadPackage {
312            pkg: Box::new(pkg.clone()),
313        })
314        .map_err(|e| DnxError::Hook(format!("Failed to serialize readPackage: {}", e)))?;
315
316        self.send_line(&request)?;
317        let response_line = self.read_line()?;
318
319        let response: ReadPackageResponse = serde_json::from_str(&response_line)
320            .map_err(|e| DnxError::Hook(format!("Failed to parse readPackage response: {}", e)))?;
321
322        if let Some(err) = response.error {
323            return Err(DnxError::Hook(format!("readPackage hook error: {}", err)));
324        }
325
326        Ok(response.pkg.unwrap_or(pkg))
327    }
328
329    fn after_all_resolved(&mut self, lockfile: &str) -> Result<String> {
330        let request = serde_json::to_string(&HookRequest::AfterAllResolved {
331            lockfile: lockfile.to_string(),
332        })
333        .map_err(|e| DnxError::Hook(format!("Failed to serialize afterAllResolved: {}", e)))?;
334
335        self.send_line(&request)?;
336        let response_line = self.read_line()?;
337
338        let response: AfterAllResolvedResponse =
339            serde_json::from_str(&response_line).map_err(|e| {
340                DnxError::Hook(format!("Failed to parse afterAllResolved response: {}", e))
341            })?;
342
343        if let Some(err) = response.error {
344            return Err(DnxError::Hook(format!(
345                "afterAllResolved hook error: {}",
346                err
347            )));
348        }
349
350        Ok(response.lockfile.unwrap_or_else(|| lockfile.to_string()))
351    }
352
353    fn send_line(&mut self, line: &str) -> Result<()> {
354        let stdin = self
355            .stdin
356            .as_mut()
357            .ok_or_else(|| DnxError::Hook("Hook worker stdin is closed".to_string()))?;
358        writeln!(stdin, "{}", line)
359            .map_err(|e| DnxError::Hook(format!("Failed to write to hook worker: {}", e)))?;
360        stdin
361            .flush()
362            .map_err(|e| DnxError::Hook(format!("Failed to flush hook worker stdin: {}", e)))?;
363        Ok(())
364    }
365
366    fn read_line(&mut self) -> Result<String> {
367        let mut line = String::new();
368        self.reader
369            .read_line(&mut line)
370            .map_err(|e| DnxError::Hook(format!("Failed to read from hook worker: {}", e)))?;
371        if line.is_empty() {
372            return Err(DnxError::Hook(
373                "Hook worker process exited unexpectedly".to_string(),
374            ));
375        }
376        Ok(line.trim().to_string())
377    }
378}
379
380impl Drop for HookRunner {
381    fn drop(&mut self) {
382        // Close stdin to signal the worker to exit
383        self.stdin.take();
384
385        // Wait up to 2 seconds for the child to exit gracefully.
386        // If it's stuck (e.g. after Ctrl+C), kill it to avoid orphans.
387        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
388        loop {
389            match self.child.try_wait() {
390                Ok(Some(_)) => break, // exited
391                Ok(None) => {
392                    // still running
393                    if std::time::Instant::now() >= deadline {
394                        warn!("Hook worker did not exit in 2s, killing");
395                        let _ = self.child.kill();
396                        let _ = self.child.wait();
397                        break;
398                    }
399                    std::thread::sleep(std::time::Duration::from_millis(50));
400                }
401                Err(_) => break, // error querying status
402            }
403        }
404    }
405}