diff --git a/.lazy.lua b/.lazy.lua index db04e7e..e895836 100644 --- a/.lazy.lua +++ b/.lazy.lua @@ -3,5 +3,16 @@ vim.lsp.config("biome", { on_dir(vim.fn.getcwd()) end }) +vim.lsp.config("rust_analyzer", { + settings = { + ["rust-analyzer"] = { + cargo = { + features = { + "inspector" + } + } + } + } +}) return {} diff --git a/Cargo.lock b/Cargo.lock index 214d49d..c9f0b66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,12 +47,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.101" @@ -123,6 +167,12 @@ dependencies = [ "backtrace", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -384,6 +434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -392,8 +443,22 @@ version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -420,6 +485,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "combine" version = "4.6.7" @@ -962,6 +1033,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fastwebsockets" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "305d3ba574508e27190906d11707dad683e0494e6b85eae9b044cb2734a5e422" +dependencies = [ + "base64 0.21.7", + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "pin-project", + "rand 0.8.5", + "sha1", + "simdutf8", + "thiserror 1.0.69", + "tokio", + "utf-8", +] + [[package]] name = "fd-lock" version = "4.0.4" @@ -1315,6 +1406,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -1328,6 +1425,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1358,7 +1456,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1578,6 +1676,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -1783,9 +1887,9 @@ dependencies = [ [[package]] name = "md5" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" [[package]] name = "memchr" @@ -1918,17 +2022,23 @@ name = "nix-js" version = "0.1.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bzip2", + "clap", "criterion", "deno_core", "deno_error", "derive_more", "dirs", "ere", + "fastwebsockets", "flate2", "hashbrown 0.16.1", "hex", + "http", + "http-body-util", + "hyper", + "hyper-util", "itertools 0.14.0", "md5", "miette", @@ -1957,6 +2067,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "uuid", "xz2", ] @@ -2075,6 +2186,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -2367,6 +2484,8 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -2376,10 +2495,20 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2492,7 +2621,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -2965,6 +3094,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -3064,6 +3199,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72abeda133c49d7bddece6c154728f83eec8172380c80ab7096da9487e20d27c" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.27.2" @@ -3634,6 +3775,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-ranges" version = "1.0.5" @@ -3658,6 +3805,7 @@ version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ + "getrandom 0.4.1", "js-sys", "wasm-bindgen", ] diff --git a/Justfile b/Justfile index 1813f92..5b148dc 100644 --- a/Justfile +++ b/Justfile @@ -1,15 +1,23 @@ [no-exit-message] @repl: - cargo run --bin repl + cargo run -- repl [no-exit-message] @eval expr: - cargo run --bin eval -- '{{expr}}' + cargo run -- eval '{{expr}}' [no-exit-message] @replr: - cargo run --bin repl --release + cargo run --release -- repl [no-exit-message] @evalr expr: - cargo run --bin eval --release -- '{{expr}}' + cargo run --release -- eval '{{expr}}' + +[no-exit-message] +@repli: + cargo run --release --features inspector -- --inspect-brk 127.0.0.1:9229 repl + +[no-exit-message] +@evali expr: + cargo run --release --features inspector -- --inspect-brk 127.0.0.1:9229 eval '{{expr}}' diff --git a/nix-js/Cargo.toml b/nix-js/Cargo.toml index 6b794ed..c9e933f 100644 --- a/nix-js/Cargo.toml +++ b/nix-js/Cargo.toml @@ -14,6 +14,9 @@ nix-compat = { git = "https://git.snix.dev/snix/snix.git", version = "0.1.0", fe anyhow = "1.0" rustyline = "17.0" +# CLI +clap = { version = "4", features = ["derive"] } + # Logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } @@ -63,6 +66,17 @@ ere = "0.2.4" num_enum = "0.7.5" tap = "1.0.1" +# Inspector (optional) +fastwebsockets = { version = "0.10", features = ["upgrade"], optional = true } +hyper = { version = "1", features = ["http1", "server"], optional = true } +hyper-util = { version = "0.1", features = ["tokio"], optional = true } +http-body-util = { version = "0.1", optional = true } +http = { version = "1", optional = true } +uuid = { version = "1", features = ["v4"], optional = true } + +[features] +inspector = ["dep:fastwebsockets", "dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:http", "dep:uuid"] + [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } diff --git a/nix-js/src/bin/eval.rs b/nix-js/src/bin/eval.rs deleted file mode 100644 index a544cca..0000000 --- a/nix-js/src/bin/eval.rs +++ /dev/null @@ -1,26 +0,0 @@ -use anyhow::Result; -use nix_js::{context::Context, error::Source}; -use std::process::exit; - -fn main() -> Result<()> { - nix_js::logging::init_logging(); - - let mut args = std::env::args(); - if args.len() != 2 { - eprintln!("Usage: {} expr", args.next().unwrap()); - exit(1); - } - args.next(); - let expr = args.next().unwrap(); - let src = Source::new_eval(expr)?; - match Context::new()?.eval(src) { - Ok(value) => { - println!("{value}"); - Ok(()) - } - Err(err) => { - eprintln!("{:?}", miette::Report::new(*err)); - exit(1); - } - } -} diff --git a/nix-js/src/codegen.rs b/nix-js/src/codegen.rs index 3705c89..ca565df 100644 --- a/nix-js/src/codegen.rs +++ b/nix-js/src/codegen.rs @@ -36,9 +36,7 @@ pub(crate) fn compile(expr: &Ir, ctx: &impl CodegenContext) -> String { } code!(&mut buf, ctx; - "Nix.builtins.storeDir=" - quoted(ctx.get_store_dir()) - ";const __currentDir=" + "const __currentDir=" quoted(&ctx.get_current_dir().display().to_string()) ";const __with=null;return " expr @@ -57,9 +55,7 @@ pub(crate) fn compile_scoped(expr: &Ir, ctx: &impl CodegenContext) -> String { } code!(&mut buf, ctx; - "Nix.builtins.storeDir=" - quoted(ctx.get_store_dir()) - ";const __currentDir=" + "const __currentDir=" quoted(&ctx.get_current_dir().display().to_string()) ";return " expr diff --git a/nix-js/src/context.rs b/nix-js/src/context.rs index 33e3542..3ced860 100644 --- a/nix-js/src/context.rs +++ b/nix-js/src/context.rs @@ -47,6 +47,8 @@ fn handle_parse_error<'a>( pub struct Context { ctx: Ctx, runtime: Runtime, + #[cfg(feature = "inspector")] + _inspector_server: Option, } macro_rules! eval { @@ -66,15 +68,51 @@ macro_rules! eval { impl Context { pub fn new() -> Result { let ctx = Ctx::new()?; + #[cfg(feature = "inspector")] + let runtime = Runtime::new(Default::default())?; + #[cfg(not(feature = "inspector"))] let runtime = Runtime::new()?; - let mut context = Self { ctx, runtime }; - context.init_derivation()?; + let mut context = Self { + ctx, + runtime, + #[cfg(feature = "inspector")] + _inspector_server: None, + }; + context.init()?; Ok(context) } - fn init_derivation(&mut self) -> Result<()> { + #[cfg(feature = "inspector")] + pub fn new_with_inspector(addr: std::net::SocketAddr, wait_for_session: bool) -> Result { + use crate::runtime::InspectorOptions; + + let ctx = Ctx::new()?; + let runtime = Runtime::new(InspectorOptions { + enable: true, + wait: wait_for_session, + })?; + + let server = crate::runtime::inspector::InspectorServer::new(addr, "nix-js") + .map_err(|e| Error::internal(e.to_string()))?; + server.register_inspector("nix-js".to_string(), runtime.inspector(), wait_for_session); + + let mut context = Self { + ctx, + runtime, + _inspector_server: Some(server), + }; + context.init()?; + Ok(context) + } + + #[cfg(feature = "inspector")] + pub fn wait_for_inspector_disconnect(&mut self) { + self.runtime.wait_for_inspector_disconnect(); + } + + fn init(&mut self) -> Result<()> { const DERIVATION_NIX: &str = include_str!("runtime/corepkgs/derivation.nix"); let source = Source::new_virtual( "".into(), @@ -82,7 +120,7 @@ impl Context { ); let code = self.ctx.compile(source, None)?; self.runtime - .eval(format!("Nix.builtins.derivation = {}", code), &mut self.ctx)?; + .eval(format!("Nix.builtins.derivation = {};Nix.builtins.storeDir=\"{}\"", code, self.get_store_dir()), &mut self.ctx)?; Ok(()) } diff --git a/nix-js/src/derivation.rs b/nix-js/src/derivation.rs index 4f80227..7b18530 100644 --- a/nix-js/src/derivation.rs +++ b/nix-js/src/derivation.rs @@ -83,10 +83,7 @@ impl DerivationData { ) } - pub fn generate_aterm_modulo( - &self, - input_drv_hashes: &BTreeMap, - ) -> String { + pub fn generate_aterm_modulo(&self, input_drv_hashes: &BTreeMap) -> String { let mut output_entries = Vec::new(); for (name, info) in &self.outputs { output_entries.push(format!( diff --git a/nix-js/src/bin/repl.rs b/nix-js/src/main.rs similarity index 53% rename from nix-js/src/bin/repl.rs rename to nix-js/src/main.rs index 6951e45..6b1c3f4 100644 --- a/nix-js/src/bin/repl.rs +++ b/nix-js/src/main.rs @@ -1,15 +1,73 @@ +use std::process::exit; + use anyhow::Result; +use clap::{Parser, Subcommand}; use hashbrown::HashSet; use nix_js::context::Context; use nix_js::error::Source; use rustyline::DefaultEditor; use rustyline::error::ReadlineError; -fn main() -> Result<()> { - nix_js::logging::init_logging(); +#[derive(Parser)] +#[command(name = "nix-js", about = "Nix expression evaluator")] +struct Cli { + #[cfg(feature = "inspector")] + #[arg(long, value_name = "HOST:PORT", num_args = 0..=1, default_missing_value = "127.0.0.1:9229")] + inspect: Option, + #[cfg(feature = "inspector")] + #[arg(long, value_name = "HOST:PORT", num_args = 0..=1, default_missing_value = "127.0.0.1:9229")] + inspect_brk: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + Eval { expr: String }, + Repl, +} + +fn create_context(#[cfg(feature = "inspector")] cli: &Cli) -> Result { + #[cfg(feature = "inspector")] + { + let (addr_str, wait) = if let Some(ref addr) = cli.inspect_brk { + (Some(addr.as_str()), true) + } else if let Some(ref addr) = cli.inspect { + (Some(addr.as_str()), false) + } else { + (None, false) + }; + + if let Some(addr_str) = addr_str { + let addr: std::net::SocketAddr = addr_str + .parse() + .map_err(|e| anyhow::anyhow!("invalid inspector address '{}': {}", addr_str, e))?; + return Ok(Context::new_with_inspector(addr, wait)?); + } + } + Ok(Context::new()?) +} + +fn run_eval(context: &mut Context, expr: String) -> Result<()> { + let src = Source::new_eval(expr)?; + match context.eval(src) { + Ok(value) => { + println!("{value}"); + } + Err(err) => { + eprintln!("{:?}", miette::Report::new(*err)); + exit(1); + } + }; + #[cfg(feature = "inspector")] + context.wait_for_inspector_disconnect(); + Ok(()) +} + +fn run_repl(context: &mut Context) -> Result<()> { let mut rl = DefaultEditor::new()?; - let mut context = Context::new()?; let mut scope = HashSet::new(); const RE: ere::Regex<3> = ere::compile_regex!("^[ \t]*([a-zA-Z_][a-zA-Z0-9_'-]*)[ \t]*(.*)$"); loop { @@ -61,3 +119,19 @@ fn main() -> Result<()> { } Ok(()) } + +fn main() -> Result<()> { + nix_js::logging::init_logging(); + + let cli = Cli::parse(); + + let mut context = create_context( + #[cfg(feature = "inspector")] + &cli, + )?; + + match cli.command { + Command::Eval { expr } => run_eval(&mut context, expr), + Command::Repl => run_repl(&mut context), + } +} diff --git a/nix-js/src/runtime.rs b/nix-js/src/runtime.rs index 533231a..493b36e 100644 --- a/nix-js/src/runtime.rs +++ b/nix-js/src/runtime.rs @@ -2,12 +2,16 @@ use std::borrow::Cow; use std::marker::PhantomData; use std::path::Path; +#[cfg(feature = "inspector")] +use deno_core::PollEventLoopOptions; use deno_core::{Extension, ExtensionFileSource, JsRuntime, OpState, RuntimeOptions, v8}; use crate::error::{Error, Result, Source}; use crate::store::DaemonStore; use crate::value::{AttrSet, List, Symbol, Value}; +#[cfg(feature = "inspector")] +pub(crate) mod inspector; mod ops; use ops::*; @@ -46,40 +50,28 @@ fn runtime_extension() -> Extension { let mut ops = vec![ op_import::(), op_scoped_import::(), - op_resolve_path(), - op_read_file(), op_read_file_type(), op_read_dir(), op_path_exists(), op_walk_dir(), - op_make_placeholder(), op_store_path::(), - op_convert_hash(), op_hash_string(), op_hash_file(), op_parse_hash(), - op_add_path::(), op_add_filtered_path::(), - op_decode_span::(), - op_to_file::(), - op_copy_path_to_store::(), - op_get_env(), - op_match(), op_split(), - op_from_json(), op_from_toml(), - op_finalize_derivation::(), ]; ops.extend(crate::fetcher::register_ops::()); @@ -122,6 +114,9 @@ pub(crate) use private::NixRuntimeError; pub(crate) struct Runtime { js_runtime: JsRuntime, + rt: tokio::runtime::Runtime, + #[cfg(feature = "inspector")] + wait_for_inspector: bool, is_thunk_symbol: v8::Global, primop_metadata_symbol: v8::Global, has_context_symbol: v8::Global, @@ -130,14 +125,21 @@ pub(crate) struct Runtime { _marker: PhantomData, } +#[cfg(feature = "inspector")] +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct InspectorOptions { + pub(crate) enable: bool, + pub(crate) wait: bool, +} + impl Runtime { - pub(crate) fn new() -> Result { + pub(crate) fn new( + #[cfg(feature = "inspector")] inspector_options: InspectorOptions, + ) -> Result { use std::sync::Once; - // Initialize V8 once static INIT: Once = Once::new(); INIT.call_once(|| { - // First flag is always not recognized assert_eq!( deno_core::v8_set_flags(vec!["".into(), format!("--stack-size={}", 8 * 1024)]), [""] @@ -147,6 +149,9 @@ impl Runtime { let mut js_runtime = JsRuntime::new(RuntimeOptions { extensions: vec![runtime_extension::()], + #[cfg(feature = "inspector")] + inspector: inspector_options.enable, + is_main: true, ..Default::default() }); @@ -166,6 +171,12 @@ impl Runtime { Ok(Self { js_runtime, + rt: tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime"), + #[cfg(feature = "inspector")] + wait_for_inspector: inspector_options.wait, is_thunk_symbol, primop_metadata_symbol, has_context_symbol, @@ -175,10 +186,32 @@ impl Runtime { }) } + #[cfg(feature = "inspector")] + pub(crate) fn inspector(&self) -> std::rc::Rc { + self.js_runtime.inspector() + } + + #[cfg(feature = "inspector")] + pub(crate) fn wait_for_inspector_disconnect(&mut self) { + let _ = self.rt + .block_on(self.js_runtime.run_event_loop(PollEventLoopOptions { + wait_for_inspector: true, + ..Default::default() + })); + } + pub(crate) fn eval(&mut self, script: String, ctx: &mut Ctx) -> Result { let ctx: &'static mut Ctx = unsafe { &mut *(ctx as *mut Ctx) }; self.js_runtime.op_state().borrow_mut().put(ctx); + #[cfg(feature = "inspector")] + if self.wait_for_inspector { + self.js_runtime + .inspector() + .wait_for_session_and_break_on_next_statement(); + } else { + self.js_runtime.inspector().wait_for_session(); + } let global_value = self .js_runtime .execute_script("", script) @@ -189,6 +222,22 @@ impl Runtime { crate::error::parse_js_error(error, ctx) })?; + let global_value = self + .rt + .block_on(self.js_runtime.resolve(global_value)) + .map_err(|error| { + let op_state = self.js_runtime.op_state(); + let op_state_borrow = op_state.borrow(); + let ctx: &Ctx = op_state_borrow.get_ctx(); + + crate::error::parse_js_error(error, ctx) + })?; + #[cfg(feature = "inspector")] + { + let _ = self + .rt + .block_on(self.js_runtime.run_event_loop(Default::default())); + } // Retrieve scope from JsRuntime deno_core::scope!(scope, self.js_runtime); diff --git a/nix-js/src/runtime/inspector.rs b/nix-js/src/runtime/inspector.rs new file mode 100644 index 0000000..0e87bd6 --- /dev/null +++ b/nix-js/src/runtime/inspector.rs @@ -0,0 +1,491 @@ +// Copyright 2018-2025 the Deno authors. MIT license. + +// Alias for the future `!` type. +use core::convert::Infallible as Never; +use deno_core::InspectorMsg; +use deno_core::InspectorSessionChannels; +use deno_core::InspectorSessionKind; +use deno_core::InspectorSessionProxy; +use deno_core::JsRuntimeInspector; +use deno_core::anyhow::Context; +use deno_core::futures::channel::mpsc; +use deno_core::futures::channel::mpsc::UnboundedReceiver; +use deno_core::futures::channel::mpsc::UnboundedSender; +use deno_core::futures::channel::oneshot; +use deno_core::futures::prelude::*; +use deno_core::futures::stream::StreamExt; +use deno_core::serde_json::Value; +use deno_core::serde_json::json; +use deno_core::unsync::spawn; +use deno_core::url::Url; +use fastwebsockets::Frame; +use fastwebsockets::OpCode; +use fastwebsockets::WebSocket; +use hyper::body::Bytes; +use hyper_util::rt::TokioIo; +use std::cell::RefCell; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::pin::pin; +use std::process; +use std::rc::Rc; +use std::task::Poll; +use std::thread; +use tokio::net::TcpListener; +use tokio::sync::broadcast; +use uuid::Uuid; + +/// Websocket server that is used to proxy connections from +/// devtools to the inspector. +pub struct InspectorServer { + pub host: SocketAddr, + register_inspector_tx: UnboundedSender, + shutdown_server_tx: Option>, + thread_handle: Option>, +} + +impl InspectorServer { + pub fn new(host: SocketAddr, name: &'static str) -> Result { + let (register_inspector_tx, register_inspector_rx) = mpsc::unbounded::(); + + let (shutdown_server_tx, shutdown_server_rx) = broadcast::channel(1); + + let tcp_listener = std::net::TcpListener::bind(host) + .with_context(|| format!("Failed to bind inspector server socket at {}", host))?; + tcp_listener.set_nonblocking(true)?; + + let thread_handle = thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime"); + let local = tokio::task::LocalSet::new(); + local.block_on( + &rt, + server( + tcp_listener, + register_inspector_rx, + shutdown_server_rx, + name, + ), + ) + }); + + Ok(Self { + host, + register_inspector_tx, + shutdown_server_tx: Some(shutdown_server_tx), + thread_handle: Some(thread_handle), + }) + } + + pub fn register_inspector( + &self, + module_url: String, + inspector: Rc, + wait_for_session: bool, + ) { + let session_sender = inspector.get_session_sender(); + let deregister_rx = inspector.add_deregister_handler(); + + let info = InspectorInfo::new( + self.host, + session_sender, + deregister_rx, + module_url, + wait_for_session, + ); + self.register_inspector_tx + .unbounded_send(info) + .expect("unreachable"); + } +} + +impl Drop for InspectorServer { + fn drop(&mut self) { + if let Some(shutdown_server_tx) = self.shutdown_server_tx.take() { + shutdown_server_tx + .send(()) + .expect("unable to send shutdown signal"); + } + + if let Some(thread_handle) = self.thread_handle.take() { + thread_handle.join().expect("unable to join thread"); + } + } +} + +fn handle_ws_request( + req: http::Request, + inspector_map_rc: Rc>>, +) -> http::Result>>> { + let (parts, body) = req.into_parts(); + let req = http::Request::from_parts(parts, ()); + + let maybe_uuid = req + .uri() + .path() + .strip_prefix("/ws/") + .and_then(|s| Uuid::parse_str(s).ok()); + + let Some(uuid) = maybe_uuid else { + return http::Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .body(Box::new(Bytes::from("Malformed inspector UUID").into())); + }; + + // run in a block to not hold borrow to `inspector_map` for too long + let new_session_tx = { + let inspector_map = inspector_map_rc.borrow(); + let maybe_inspector_info = inspector_map.get(&uuid); + + let Some(info) = maybe_inspector_info else { + return http::Response::builder() + .status(http::StatusCode::NOT_FOUND) + .body(Box::new(Bytes::from("Invalid inspector UUID").into())); + }; + info.new_session_tx.clone() + }; + let (parts, _) = req.into_parts(); + let mut req = http::Request::from_parts(parts, body); + + let Ok((resp, upgrade_fut)) = fastwebsockets::upgrade::upgrade(&mut req) else { + return http::Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .body(Box::new( + Bytes::from("Not a valid Websocket Request").into(), + )); + }; + + // spawn a task that will wait for websocket connection and then pump messages between + // the socket and inspector proxy + spawn(async move { + let websocket = match upgrade_fut.await { + Ok(w) => w, + Err(err) => { + eprintln!( + "Inspector server failed to upgrade to WS connection: {:?}", + err + ); + return; + } + }; + + // The 'outbound' channel carries messages sent to the websocket. + let (outbound_tx, outbound_rx) = mpsc::unbounded(); + // The 'inbound' channel carries messages received from the websocket. + let (inbound_tx, inbound_rx) = mpsc::unbounded(); + + let inspector_session_proxy = InspectorSessionProxy { + channels: InspectorSessionChannels::Regular { + tx: outbound_tx, + rx: inbound_rx, + }, + kind: InspectorSessionKind::NonBlocking { + wait_for_disconnect: true, + }, + }; + + eprintln!("Debugger session started."); + let _ = new_session_tx.unbounded_send(inspector_session_proxy); + pump_websocket_messages(websocket, inbound_tx, outbound_rx).await; + }); + + let (parts, _body) = resp.into_parts(); + let resp = http::Response::from_parts(parts, Box::new(http_body_util::Full::new(Bytes::new()))); + Ok(resp) +} + +fn handle_json_request( + inspector_map: Rc>>, + host: Option, +) -> http::Result>>> { + let data = inspector_map + .borrow() + .values() + .map(move |info| info.get_json_metadata(&host)) + .collect::>(); + let body: http_body_util::Full = + Bytes::from(serde_json::to_string(&data).expect("unreachable")).into(); + http::Response::builder() + .status(http::StatusCode::OK) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Box::new(body)) +} + +fn handle_json_version_request( + version_response: Value, +) -> http::Result>>> { + let body = Box::new(http_body_util::Full::from( + serde_json::to_string(&version_response).expect("unreachable"), + )); + + http::Response::builder() + .status(http::StatusCode::OK) + .header(http::header::CONTENT_TYPE, "application/json") + .body(body) +} + +async fn server( + listener: std::net::TcpListener, + register_inspector_rx: UnboundedReceiver, + shutdown_server_rx: broadcast::Receiver<()>, + name: &str, +) { + let inspector_map_ = Rc::new(RefCell::new(HashMap::::new())); + + let inspector_map = Rc::clone(&inspector_map_); + let register_inspector_handler = + listen_for_new_inspectors(register_inspector_rx, inspector_map.clone()).boxed_local(); + + let inspector_map = Rc::clone(&inspector_map_); + let deregister_inspector_handler = future::poll_fn(|cx| { + inspector_map + .borrow_mut() + .retain(|_, info| info.deregister_rx.poll_unpin(cx) == Poll::Pending); + Poll::::Pending + }) + .boxed_local(); + + let json_version_response = json!({ + "Browser": name, + "Protocol-Version": "1.3", + "V8-Version": deno_core::v8::VERSION_STRING, + }); + + // Create the server manually so it can use the Local Executor + let listener = match TcpListener::from_std(listener) { + Ok(l) => l, + Err(err) => { + eprintln!("Cannot create async listener from std listener: {:?}", err); + return; + } + }; + + let server_handler = async move { + loop { + let mut rx = shutdown_server_rx.resubscribe(); + let mut shutdown_rx = pin!(rx.recv()); + let mut accept = pin!(listener.accept()); + + let stream = tokio::select! { + accept_result = &mut accept => { + match accept_result { + Ok((s, _)) => s, + Err(err) => { + eprintln!("Failed to accept inspector connection: {:?}", err); + continue; + } + } + }, + + _ = &mut shutdown_rx => { + break; + } + }; + let io = TokioIo::new(stream); + + let inspector_map = Rc::clone(&inspector_map_); + let json_version_response = json_version_response.clone(); + let mut shutdown_server_rx = shutdown_server_rx.resubscribe(); + + let service = + hyper::service::service_fn(move |req: http::Request| { + future::ready({ + // If the host header can make a valid URL, use it + let host = req + .headers() + .get("host") + .and_then(|host| host.to_str().ok()) + .and_then(|host| Url::parse(&format!("http://{host}")).ok()) + .and_then(|url| match (url.host(), url.port()) { + (Some(host), Some(port)) => Some(format!("{host}:{port}")), + (Some(host), None) => Some(format!("{host}")), + _ => None, + }); + match (req.method(), req.uri().path()) { + (&http::Method::GET, path) if path.starts_with("/ws/") => { + handle_ws_request(req, Rc::clone(&inspector_map)) + } + (&http::Method::GET, "/json/version") => { + handle_json_version_request(json_version_response.clone()) + } + (&http::Method::GET, "/json") => { + handle_json_request(Rc::clone(&inspector_map), host) + } + (&http::Method::GET, "/json/list") => { + handle_json_request(Rc::clone(&inspector_map), host) + } + _ => http::Response::builder() + .status(http::StatusCode::NOT_FOUND) + .body(Box::new(http_body_util::Full::new(Bytes::from( + "Not Found", + )))), + } + }) + }); + + deno_core::unsync::spawn(async move { + let server = hyper::server::conn::http1::Builder::new(); + + let mut conn = pin!(server.serve_connection(io, service).with_upgrades()); + let mut shutdown_rx = pin!(shutdown_server_rx.recv()); + + tokio::select! { + result = conn.as_mut() => { + if let Err(err) = result { + eprintln!("Failed to serve connection: {:?}", err); + } + }, + _ = &mut shutdown_rx => { + conn.as_mut().graceful_shutdown(); + let _ = conn.await; + } + } + }); + } + } + .boxed_local(); + + tokio::select! { + _ = register_inspector_handler => {}, + _ = deregister_inspector_handler => unreachable!(), + _ = server_handler => {}, + } +} + +async fn listen_for_new_inspectors( + mut register_inspector_rx: UnboundedReceiver, + inspector_map: Rc>>, +) { + while let Some(info) = register_inspector_rx.next().await { + eprintln!( + "Debugger listening on {}", + info.get_websocket_debugger_url(&info.host.to_string()) + ); + eprintln!("Visit chrome://inspect to connect to the debugger."); + if info.wait_for_session { + eprintln!("nix-js is waiting for debugger to connect."); + } + if inspector_map.borrow_mut().insert(info.uuid, info).is_some() { + panic!("Inspector UUID already in map"); + } + } +} + +/// The pump future takes care of forwarding messages between the websocket +/// and channels. It resolves when either side disconnects, ignoring any +/// errors. +/// +/// The future proxies messages sent and received on a WebSocket +/// to a UnboundedSender/UnboundedReceiver pair. We need these "unbounded" channel ends to sidestep +/// Tokio's task budget, which causes issues when JsRuntimeInspector::poll_sessions() +/// needs to block the thread because JavaScript execution is paused. +/// +/// This works because UnboundedSender/UnboundedReceiver are implemented in the +/// 'futures' crate, therefore they can't participate in Tokio's cooperative +/// task yielding. +async fn pump_websocket_messages( + mut websocket: WebSocket>, + inbound_tx: UnboundedSender, + mut outbound_rx: UnboundedReceiver, +) { + 'pump: loop { + tokio::select! { + Some(msg) = outbound_rx.next() => { + let msg = Frame::text(msg.content.into_bytes().into()); + let _ = websocket.write_frame(msg).await; + } + Ok(msg) = websocket.read_frame() => { + match msg.opcode { + OpCode::Text => { + if let Ok(s) = String::from_utf8(msg.payload.to_vec()) { + let _ = inbound_tx.unbounded_send(s); + } + } + OpCode::Close => { + // Users don't care if there was an error coming from debugger, + // just about the fact that debugger did disconnect. + eprintln!("Debugger session ended"); + break 'pump; + } + _ => { + // Ignore other messages. + } + } + } + else => { + break 'pump; + } + } + } +} + +/// Inspector information that is sent from the isolate thread to the server +/// thread when a new inspector is created. +pub struct InspectorInfo { + pub host: SocketAddr, + pub uuid: Uuid, + pub thread_name: Option, + pub new_session_tx: UnboundedSender, + pub deregister_rx: oneshot::Receiver<()>, + pub url: String, + pub wait_for_session: bool, +} + +impl InspectorInfo { + pub fn new( + host: SocketAddr, + new_session_tx: mpsc::UnboundedSender, + deregister_rx: oneshot::Receiver<()>, + url: String, + wait_for_session: bool, + ) -> Self { + Self { + host, + uuid: Uuid::new_v4(), + thread_name: thread::current().name().map(|n| n.to_owned()), + new_session_tx, + deregister_rx, + url, + wait_for_session, + } + } + + fn get_json_metadata(&self, host: &Option) -> Value { + let host_listen = format!("{}", self.host); + let host = host.as_ref().unwrap_or(&host_listen); + json!({ + "description": "nix-js", + "devtoolsFrontendUrl": self.get_frontend_url(host), + "faviconUrl": "https://deno.land/favicon.ico", + "id": self.uuid.to_string(), + "title": self.get_title(), + "type": "node", + "url": self.url.to_string(), + "webSocketDebuggerUrl": self.get_websocket_debugger_url(host), + }) + } + + pub fn get_websocket_debugger_url(&self, host: &str) -> String { + format!("ws://{}/ws/{}", host, &self.uuid) + } + + fn get_frontend_url(&self, host: &str) -> String { + format!( + "devtools://devtools/bundled/js_app.html?ws={}/ws/{}&experiments=true&v8only=true", + host, &self.uuid + ) + } + + fn get_title(&self) -> String { + format!( + "nix-js{} [pid: {}]", + self.thread_name + .as_ref() + .map(|n| format!(" - {n}")) + .unwrap_or_default(), + process::id(), + ) + } +} diff --git a/nix-js/src/runtime/ops.rs b/nix-js/src/runtime/ops.rs index 43f796f..34bc11c 100644 --- a/nix-js/src/runtime/ops.rs +++ b/nix-js/src/runtime/ops.rs @@ -909,8 +909,7 @@ pub(super) fn op_finalize_derivation( let (input_drvs, input_srcs) = extract_input_drvs_and_srcs(&input.context).map_err(NixRuntimeError::from)?; - let env: std::collections::BTreeMap = - input.env.into_iter().collect(); + let env: std::collections::BTreeMap = input.env.into_iter().collect(); let drv_path; let output_paths: Vec<(String, String)>; @@ -1008,19 +1007,16 @@ pub(super) fn op_finalize_derivation( { let cache = state.borrow::(); for (dep_drv_path, output_names) in &input_drvs { - let cached_hash = - cache.cache.get(dep_drv_path).ok_or_else(|| { - NixRuntimeError::from(format!( - "Missing modulo hash for input derivation: {}", - dep_drv_path - )) - })?; + let cached_hash = cache.cache.get(dep_drv_path).ok_or_else(|| { + NixRuntimeError::from(format!( + "Missing modulo hash for input derivation: {}", + dep_drv_path + )) + })?; let mut sorted_outs: Vec<&String> = output_names.iter().collect(); sorted_outs.sort(); - let outputs_csv: Vec<&str> = - sorted_outs.iter().map(|s| s.as_str()).collect(); - input_drv_hashes - .insert(cached_hash.clone(), outputs_csv.join(",")); + let outputs_csv: Vec<&str> = sorted_outs.iter().map(|s| s.as_str()).collect(); + input_drv_hashes.insert(cached_hash.clone(), outputs_csv.join(",")); } } @@ -1070,8 +1066,7 @@ pub(super) fn op_finalize_derivation( .map_err(|e| NixRuntimeError::from(format!("failed to write derivation: {}", e)))?; let final_aterm_modulo = final_drv.generate_aterm_modulo(&input_drv_hashes); - let cached_modulo_hash = - crate::nix_utils::sha256_hex(final_aterm_modulo.as_bytes()); + let cached_modulo_hash = crate::nix_utils::sha256_hex(final_aterm_modulo.as_bytes()); let cache = state.borrow_mut::(); cache.cache.insert(drv_path.clone(), cached_modulo_hash); @@ -1215,14 +1210,10 @@ pub(super) fn op_convert_hash( use base64::Engine as _; Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) } - "sri" => Ok(format!( - "{}-{}", - hash.algo(), - { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD.encode(bytes) - } - )), + "sri" => Ok(format!("{}-{}", hash.algo(), { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(bytes) + })), _ => Err(NixRuntimeError::from(format!( "unknown hash format '{}'", input.to_format diff --git a/nix-js/src/string_context.rs b/nix-js/src/string_context.rs index 5808c6a..d353152 100644 --- a/nix-js/src/string_context.rs +++ b/nix-js/src/string_context.rs @@ -32,9 +32,7 @@ impl StringContextElem { pub type InputDrvs = BTreeMap>; pub type Srcs = BTreeSet; -pub fn extract_input_drvs_and_srcs( - context: &[String], -) -> Result<(InputDrvs, Srcs), String> { +pub fn extract_input_drvs_and_srcs(context: &[String]) -> Result<(InputDrvs, Srcs), String> { let mut input_drvs: BTreeMap> = BTreeMap::new(); let mut input_srcs: BTreeSet = BTreeSet::new();