diff --git a/Cargo.lock b/Cargo.lock index 39fb2b4..ee42e7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,6 +173,18 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -635,6 +647,26 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "const-oid" version = "0.9.6" @@ -927,6 +959,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -1366,6 +1399,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1680,6 +1722,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "image" version = "0.25.10" @@ -2061,7 +2113,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nexide" -version = "0.1.7" +version = "0.1.29" dependencies = [ "aes", "aes-gcm", @@ -2082,24 +2134,34 @@ dependencies = [ "flate2", "hex", "hickory-resolver", + "hkdf", "hmac", + "http-body", "http-body-util", "hyper", "hyper-util", + "if-addrs", "image", "libc", "libloading", "md-5", "p256", + "p384", + "p521", + "parking_lot", "pbkdf2", + "pem", + "pkcs1", "pkcs8", "rand 0.9.4", "rand_core 0.6.4", + "rayon", "reqwest", "rsa", "rustls", "rustls-pemfile", "scrypt", + "sec1", "serde", "serde_json", "sha1", @@ -2111,6 +2173,7 @@ dependencies = [ "tikv-jemallocator", "tokio", "tokio-rustls", + "tokio-stream", "tower", "tower-http", "tracing", @@ -2118,11 +2181,12 @@ dependencies = [ "url", "v8", "webpki-roots", + "x25519-dalek", ] [[package]] name = "nexide-bench" -version = "0.1.7" +version = "0.1.29" dependencies = [ "anyhow", "bollard", @@ -2142,7 +2206,7 @@ dependencies = [ [[package]] name = "nexide-e2e" -version = "0.1.7" +version = "0.1.29" dependencies = [ "anyhow", "nexide", @@ -2333,6 +2397,32 @@ dependencies = [ "sha2", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2391,6 +2481,16 @@ dependencies = [ "sha2", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2433,6 +2533,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "png" version = "0.18.1" @@ -3539,6 +3645,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags", "bytes", "futures-core", @@ -4359,6 +4466,18 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "y4m" version = "0.8.0" @@ -4434,6 +4553,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" @@ -4489,6 +4622,34 @@ dependencies = [ "serde", ] +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index cecc83f..25494e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.1.7" +version = "0.1.29" edition = "2024" license = "MIT OR Apache-2.0" publish = false @@ -36,6 +36,20 @@ print_stdout = "deny" print_stderr = "deny" module_name_repetitions = "allow" +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = "symbols" +debug = false +incremental = false + +[profile.release-with-debug] +inherits = "release" +strip = "none" +debug = "limited" + [workspace.dependencies] anyhow = "1" thiserror = "2" diff --git a/README.md b/README.md index e51cae5..ae5e4ec 100644 --- a/README.md +++ b/README.md @@ -282,8 +282,8 @@ same instance. | `fs` + `fs/promises` | sandboxed | path sandbox; only configured roots are admitted | | `zlib` | full | gzip, deflate, brotli (sync + async wrappers) | | `crypto` | core | sha1/256/512, md5, HMAC, AES-256-GCM, randomUUID | -| `http` / `https` | server-side | enough for Next.js standalone server entrypoint | -| `http2` | stub | loads + constants; `createServer`/`connect` throw | +| `http` / `https` | server-side | Next.js standalone entrypoint + raw `'upgrade'` event (WebSocket pass-through, `ws`/`socket.io` work) | +| `http2` | client | `connect()` + `session.request()` over h2; `createServer`/`createSecureServer` throw | | `net` / `tls` | client-side | enough for outbound `fetch` and DB drivers | | `dns` / `dns/promises` | full | uses Tokio's resolver via Rust ops | | `diagnostics_channel`| full | `Channel` + `TracingChannel` (undici, OTel, APMs) | @@ -294,7 +294,8 @@ same instance. | `async_hooks` | ALS only | `AsyncLocalStorage` works; full hooks do not | | `perf_hooks` | core | monotonic clock, basic marks | | `timers` / `timers/promises` | full | backed by Tokio | -| `inspector` / `tty` / `v8` / `module` / `constants` | core | enough surface for transitive deps | +| `inspector` | APM probes | `Runtime.evaluate` / `getHeapUsage` / `getHeapStatistics`, `HeapProfiler.collectGarbage`; full DevTools wire protocol N/A | +| `tty` / `v8` / `module` / `constants` | core | enough surface for transitive deps | | Global | Status | Notes | |----------------------|-------------|----------------------------------------------------| @@ -313,8 +314,8 @@ same instance. Nexide is a V8-only Next.js runtime; some Node platform surfaces are intentionally absent or partial. The full list — N-API/native addons, -`http2`, worker threads, inspector, ESM at runtime, source maps, -corporate proxies, log rotation, etc. — lives in +`http2` server, worker threads, full inspector protocol, ESM at runtime, +source maps, corporate proxies, log rotation, etc. — lives in [`docs/known-limitations.md`](docs/known-limitations.md). If you hit something that isn't on that list, open an issue with the diff --git a/crates/nexide-bench/docker/deno/Dockerfile b/crates/nexide-bench/docker/deno/Dockerfile index 447be20..5f36ae0 100644 --- a/crates/nexide-bench/docker/deno/Dockerfile +++ b/crates/nexide-bench/docker/deno/Dockerfile @@ -13,7 +13,7 @@ # Build context: workspace root (only `e2e/next-fixture/` is needed). FROM node:24-bookworm-slim AS next-builder -WORKDIR /build/next-fixture +WORKDIR /build/e2e/next-fixture ENV NEXT_TELEMETRY_DISABLED=1 \ CI=1 COPY e2e/next-fixture/package.json e2e/next-fixture/package-lock.json ./ diff --git a/crates/nexide-bench/docker/nexide/Dockerfile b/crates/nexide-bench/docker/nexide/Dockerfile index 8deecb3..88efeb5 100644 --- a/crates/nexide-bench/docker/nexide/Dockerfile +++ b/crates/nexide-bench/docker/nexide/Dockerfile @@ -21,7 +21,7 @@ RUN cargo build --release -p nexide && \ strip target/release/nexide FROM node:24-bookworm-slim AS next-builder -WORKDIR /build/next-fixture +WORKDIR /build/e2e/next-fixture ENV NEXT_TELEMETRY_DISABLED=1 \ CI=1 COPY e2e/next-fixture/package.json e2e/next-fixture/package-lock.json ./ @@ -35,9 +35,9 @@ ENV NEXT_TELEMETRY_DISABLED=1 COPY e2e/next-fixture/package.json e2e/next-fixture/package-lock.json ./ RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force COPY --from=rust-builder /build/target/release/nexide /usr/local/bin/nexide -COPY --from=next-builder /build/e2e/next-fixture/public /app/e2e/next-fixture/public -COPY --from=next-builder /build/e2e/next-fixture/.next/static /app/e2e/next-fixture/.next/static -COPY --from=next-builder /build/e2e/next-fixture/.next/standalone /app/e2e/next-fixture/.next/standalone +COPY --from=next-builder /build/e2e/next-fixture/public /app/next-fixture/public +COPY --from=next-builder /build/e2e/next-fixture/.next/static /app/next-fixture/.next/static +COPY --from=next-builder /build/e2e/next-fixture/.next/standalone /app/next-fixture/.next/standalone WORKDIR /app ENV HOSTNAME=0.0.0.0 \ PORT=3000 \ diff --git a/crates/nexide-bench/docker/node/Dockerfile b/crates/nexide-bench/docker/node/Dockerfile index 96cf865..bfb5395 100644 --- a/crates/nexide-bench/docker/node/Dockerfile +++ b/crates/nexide-bench/docker/node/Dockerfile @@ -8,7 +8,7 @@ # Build context: workspace root (only `e2e/next-fixture/` is needed). FROM node:24-bookworm-slim AS next-builder -WORKDIR /build/next-fixture +WORKDIR /build/e2e/next-fixture ENV NEXT_TELEMETRY_DISABLED=1 \ CI=1 COPY e2e/next-fixture/package.json e2e/next-fixture/package-lock.json ./ diff --git a/crates/nexide-bench/src/docker.rs b/crates/nexide-bench/src/docker.rs index ab54c28..738ba4c 100644 --- a/crates/nexide-bench/src/docker.rs +++ b/crates/nexide-bench/src/docker.rs @@ -18,15 +18,15 @@ use anyhow::{Context, Result, anyhow, bail}; use bollard::Docker; use bollard::models::{ContainerCreateBody, HostConfig, PortBinding}; use bollard::query_parameters::{ - CreateContainerOptionsBuilder, RemoveContainerOptionsBuilder, StartContainerOptions, - StatsOptionsBuilder, StopContainerOptionsBuilder, + CreateContainerOptionsBuilder, LogsOptionsBuilder, RemoveContainerOptionsBuilder, + StartContainerOptions, StatsOptionsBuilder, StopContainerOptionsBuilder, }; use futures::StreamExt; use tokio::sync::Mutex; use tokio::time::{Instant, sleep}; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; -use crate::load::{LoadSpec, run_load}; +use crate::load::{LoadOutcome, LoadSpec, run_load}; use crate::runner::{BenchResult, RouteSpec}; use crate::sample::SampleStats; use crate::target::TargetKind; @@ -360,6 +360,127 @@ async fn await_ready(host_port: u16, timeout: Duration) -> Result<()> { bail!("container at host:{host_port} never responded within {timeout:?}"); } +/// Snapshot of a container's runtime state plus the tail of its stdout/stderr. +/// +/// Captured when a route returns a catastrophic error rate or readiness +/// fails so the operator can immediately see *why* (OOM kill, panic, +/// non-zero exit) without re-running the bench under `docker logs`. +#[derive(Debug, Default)] +struct ContainerDiagnostics { + status: String, + running: bool, + oom_killed: bool, + exit_code: Option, + error: String, + logs: String, +} + +/// Pull `docker inspect` state + last ~200 log lines for `id`. +/// +/// Best-effort: any individual API failure is swallowed so the caller +/// still gets at least a partial picture (e.g. logs even if inspect +/// raced container removal). Returns a default-filled struct on full +/// failure rather than erroring — the caller is already in an error +/// path and we don't want to mask the original failure. +async fn collect_container_diagnostics(docker: &Docker, id: &str) -> ContainerDiagnostics { + let mut diag = ContainerDiagnostics { + status: "".to_string(), + ..ContainerDiagnostics::default() + }; + + if let Ok(inspect) = docker.inspect_container(id, None).await + && let Some(state) = inspect.state.as_ref() + { + if let Some(s) = state.status { + diag.status = format!("{s:?}"); + } + diag.running = state.running.unwrap_or(false); + diag.oom_killed = state.oom_killed.unwrap_or(false); + diag.exit_code = state.exit_code; + diag.error = state.error.clone().unwrap_or_default(); + } + + let opts = LogsOptionsBuilder::default() + .stdout(true) + .stderr(true) + .tail("200") + .build(); + let mut stream = docker.logs(id, Some(opts)); + let mut buf: Vec = Vec::new(); + while let Some(item) = stream.next().await { + match item { + Ok(out) => buf.extend_from_slice(out.as_ref()), + Err(err) => { + warn!(%err, "logs stream error during diagnostics"); + break; + } + } + // Cap at ~256 KiB so a runaway log stream cannot stall the bench. + if buf.len() > 256 * 1024 { + break; + } + } + diag.logs = String::from_utf8_lossy(&buf).into_owned(); + diag +} + +/// Heuristic: does this `LoadOutcome` indicate the container is dead +/// or so broken that subsequent routes will produce garbage? +/// +/// - `ok == 0` → fully unresponsive (connection refused / hung) +/// - `errors > 10 * ok` → catastrophic degradation (e.g. 1.26M errors +/// vs 9k successes seen on `1cpu-256mb` when the runtime crashed +/// mid-run). Anything more lenient still lets transient errors slip +/// through; anything stricter would flag healthy presets that just +/// happen to hit a couple of refused connections. +fn route_run_failed(load: &LoadOutcome) -> bool { + load.ok == 0 || load.errors > load.ok.saturating_mul(10) +} + +fn format_diagnostics_block(label: &str, diag: &ContainerDiagnostics) -> String { + use std::fmt::Write; + let mut s = String::new(); + let _ = writeln!( + s, + ">>> container status={} running={} oom_killed={} exit_code={:?} error={:?}", + diag.status, diag.running, diag.oom_killed, diag.exit_code, diag.error + ); + let log_tail = if diag.logs.trim().is_empty() { + "" + } else { + diag.logs.as_str() + }; + let _ = writeln!(s, ">>> {label} container logs (tail):\n{log_tail}"); + s +} + +fn eprintln_label_err(label: &str, err: &anyhow::Error) { + error!(target: "nexide_bench::docker::fatal", %label, %err, "container failed"); +} + +fn eprintln_label_load(label: &str, load: &LoadOutcome) { + let p95_ms = load.p95.as_secs_f64() * 1000.0; + error!( + target: "nexide_bench::docker::fatal", + %label, + ok = load.ok, + errors = load.errors, + http_errors = load.http_errors, + transport_errors = load.transport_errors, + timeout_errors = load.timeout_errors, + rps = load.rps, + p95_ms, + "route returned catastrophic error rate" + ); +} + +fn eprintln_diag(label: &str, diag: &ContainerDiagnostics) { + let block = format_diagnostics_block(label, diag); + for line in block.lines() { + error!(target: "nexide_bench::docker::fatal", "{line}"); + } +} + async fn warmup_route(url: &str, duration: Duration) { if duration.is_zero() { return; @@ -392,7 +513,6 @@ fn build_create_body( let exposed_ports = vec![port_key]; let env = match kind { TargetKind::Nexide => Some(vec![ - format!("NEXIDE_POOL_MEMORY_BUDGET_MB={}", preset.memory_mb), "HOSTNAME=0.0.0.0".to_owned(), "PORT=3000".to_owned(), "RUST_LOG=info".to_owned(), @@ -443,7 +563,24 @@ async fn run_cell( .start_container(&id, None::) .await .context("start container")?; - await_ready(host_port, spec.ready_timeout).await?; + if let Err(err) = await_ready(host_port, spec.ready_timeout).await { + let label = format!( + "preset={} runtime={} (readiness)", + preset.label(), + kind.label() + ); + let diag = collect_container_diagnostics(&docker, &id).await; + eprintln_label_err(&label, &err); + eprintln_diag(&label, &diag); + return Err(err.context(format!( + "preset={} runtime={} container failed readiness (running={}, oom_killed={}, exit_code={:?})", + preset.label(), + kind.label(), + diag.running, + diag.oom_killed, + diag.exit_code + ))); + } for route in &spec.routes { let url = format!("http://127.0.0.1:{host_port}{}", route.path); @@ -492,6 +629,32 @@ async fn run_cell( .map(Mutex::into_inner) .unwrap_or_default() .finalize(); + + // Fail-fast on catastrophic error rate: capture container + // state + last log lines BEFORE the cleanup path stops/removes + // the container, then bail so subsequent routes don't generate + // 0-RPS/error-flood rows that hide the real cause. + if route_run_failed(&load) { + let label = format!( + "preset={} runtime={} route={}", + preset.label(), + kind.label(), + route.id + ); + let diag = collect_container_diagnostics(&docker, &id).await; + eprintln_label_load(&label, &load); + eprintln_diag(&label, &diag); + bail!( + "{label} returned ok={} errors={} (running={}, oom_killed={}, exit_code={:?}, status={})", + load.ok, + load.errors, + diag.running, + diag.oom_killed, + diag.exit_code, + diag.status + ); + } + results.push(BenchResult { route: route.id.clone(), runtime: kind, diff --git a/crates/nexide-bench/src/load.rs b/crates/nexide-bench/src/load.rs index a794e7d..601e7f6 100644 --- a/crates/nexide-bench/src/load.rs +++ b/crates/nexide-bench/src/load.rs @@ -29,8 +29,26 @@ pub struct LoadSpec { pub struct LoadOutcome { /// Successful HTTP responses (`2xx`). pub ok: u64, - /// Non-2xx responses + transport-level errors. + /// Non-2xx responses + transport-level errors (sum of the buckets + /// below; kept for backwards-compat with table renderers). pub errors: u64, + /// Responses where the server replied with a non-2xx HTTP status. + /// A high count here means the server is *up* and answering, just + /// rejecting (5xx, 4xx) — typically queue-saturation / handler + /// crash mid-request. + pub http_errors: u64, + /// Transport-level failures: connection refused, RST, EOF before + /// response, or any other I/O error returned by the HTTP client. + /// A high count here means the server is *not accepting* / *not + /// responding* — TCP backlog overflow, listener crash, or runtime + /// hang. When this dominates, the bench should bail and dump + /// `docker logs`. + pub transport_errors: u64, + /// Subset of `transport_errors` where the underlying error was + /// the reqwest 10s per-request timeout. A high count here means + /// the server *accepts* but *never replies* — handler deadlock, + /// dispatcher queue stall, or V8 isolate stuck in GC. + pub timeout_errors: u64, /// Total wall-clock time observed by the harness. pub elapsed: Duration, /// Median latency. @@ -69,7 +87,9 @@ pub async fn run_load(spec: LoadSpec) -> Result { .context("reqwest client")?; let histogram = Arc::new(Mutex::new(Histogram::::new_with_max(60_000_000, 3)?)); let ok = Arc::new(AtomicU64::new(0)); - let err = Arc::new(AtomicU64::new(0)); + let http_err = Arc::new(AtomicU64::new(0)); + let transport_err = Arc::new(AtomicU64::new(0)); + let timeout_err = Arc::new(AtomicU64::new(0)); let deadline = Instant::now() + spec.duration; let mut tasks = Vec::with_capacity(spec.connections); for _ in 0..spec.connections { @@ -77,7 +97,9 @@ pub async fn run_load(spec: LoadSpec) -> Result { let url = spec.url.clone(); let histogram = Arc::clone(&histogram); let ok = Arc::clone(&ok); - let err = Arc::clone(&err); + let http_err = Arc::clone(&http_err); + let transport_err = Arc::clone(&transport_err); + let timeout_err = Arc::clone(&timeout_err); tasks.push(tokio::spawn(async move { while Instant::now() < deadline { let started = Instant::now(); @@ -93,11 +115,14 @@ pub async fn run_load(spec: LoadSpec) -> Result { if status_ok { ok.fetch_add(1, Ordering::Relaxed); } else { - err.fetch_add(1, Ordering::Relaxed); + http_err.fetch_add(1, Ordering::Relaxed); } } - Err(_) => { - err.fetch_add(1, Ordering::Relaxed); + Err(err) => { + if err.is_timeout() { + timeout_err.fetch_add(1, Ordering::Relaxed); + } + transport_err.fetch_add(1, Ordering::Relaxed); } } } @@ -114,7 +139,10 @@ pub async fn run_load(spec: LoadSpec) -> Result { let p99 = Duration::from_micros(h.value_at_quantile(0.99)); drop(h); let ok_total = ok.load(Ordering::Relaxed); - let err_total = err.load(Ordering::Relaxed); + let http_err_total = http_err.load(Ordering::Relaxed); + let transport_err_total = transport_err.load(Ordering::Relaxed); + let timeout_err_total = timeout_err.load(Ordering::Relaxed); + let err_total = http_err_total + transport_err_total; let rps = if elapsed.as_secs_f64() > 0.0 { ok_total as f64 / elapsed.as_secs_f64() } else { @@ -123,6 +151,9 @@ pub async fn run_load(spec: LoadSpec) -> Result { Ok(LoadOutcome { ok: ok_total, errors: err_total, + http_errors: http_err_total, + transport_errors: transport_err_total, + timeout_errors: timeout_err_total, elapsed, p50, p95, diff --git a/crates/nexide-bench/src/report.rs b/crates/nexide-bench/src/report.rs index ad02205..93403d5 100644 --- a/crates/nexide-bench/src/report.rs +++ b/crates/nexide-bench/src/report.rs @@ -42,6 +42,9 @@ fn absolute_table(results: &[BenchResult]) -> Table { "threads", "RPS/CPU%", "errors", + "transport", + "http5xx", + "timeout", ]); for r in results { let rps_per_cpu = if r.sample.cpu_avg > 0.0 { @@ -63,6 +66,9 @@ fn absolute_table(results: &[BenchResult]) -> Table { Cell::new(r.sample.threads_max), Cell::new(format!("{rps_per_cpu:>8.1}")), Cell::new(r.load.errors), + Cell::new(r.load.transport_errors), + Cell::new(r.load.http_errors), + Cell::new(r.load.timeout_errors), ]); } table diff --git a/crates/nexide/Cargo.toml b/crates/nexide/Cargo.toml index 660f274..bbd9006 100644 --- a/crates/nexide/Cargo.toml +++ b/crates/nexide/Cargo.toml @@ -35,12 +35,21 @@ chacha20poly1305 = "0.10" pbkdf2 = { version = "0.12", features = ["simple"] } scrypt = { version = "0.11", default-features = false, features = ["std"] } rsa = { version = "0.9", features = ["sha2"] } -p256 = { version = "0.13", features = ["ecdsa", "pem"] } +p256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } +p384 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } +p521 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } ed25519-dalek = { version = "2", features = ["pem", "pkcs8"] } +x25519-dalek = { version = "2", features = ["static_secrets"] } spki = { version = "0.7", features = ["pem"] } pkcs8 = { version = "0.10", features = ["pem"] } +pkcs1 = { version = "0.7", features = ["pem"] } +sec1 = { version = "0.7", features = ["pem", "pkcs8"] } +hkdf = "0.12" +pem = "3" rand = "0.9" +rand_core = "0.6" hex = "0.4" +http-body = "1" http-body-util = "0.1" hyper = { version = "1", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["tokio"] } @@ -48,8 +57,9 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } +tokio-stream = { version = "0.1" } tower = { version = "0.5", features = ["util"] } -tower-http = { version = "0.6", features = ["fs", "trace", "set-header"] } +tower-http = { version = "0.6", features = ["fs", "trace", "set-header", "compression-br", "compression-gzip", "compression-zstd"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } clap = { workspace = true } @@ -66,7 +76,11 @@ reqwest = { workspace = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "rayon"] } fast_image_resize = { version = "5", features = ["image"] } url = "2" +if-addrs = "0.13" base64 = "0.22" +parking_lot = "0.12" +rayon = "1.10" + [features] default = [] diff --git a/crates/nexide/runtime/polyfills/buffer.js b/crates/nexide/runtime/polyfills/buffer.js index 683dd29..bd1658d 100644 --- a/crates/nexide/runtime/polyfills/buffer.js +++ b/crates/nexide/runtime/polyfills/buffer.js @@ -1,10 +1,4 @@ // `Buffer` polyfill - Node-compatible subclass of Uint8Array. -// -// Idempotent. Implemented in pure JS; uses native TextEncoder/Decoder -// when available, with a small fallback for base64/hex. Only the -// surface required by Next.js standalone + common middleware is -// covered: from/alloc/concat/byteLength/toString/write/equals/compare/ -// indexOf/readUInt*/writeUInt* in both endianesses. ((globalThis) => { "use strict"; @@ -227,17 +221,14 @@ const bytes = encode(value, encodingOrOffset); return Buffer.__wrap(bytes); } - if (value instanceof ArrayBuffer) { - const view = new Uint8Array( - value, - encodingOrOffset || 0, - length === undefined ? undefined : length, - ); - return Buffer.__wrap(view.slice()); + if (value instanceof ArrayBuffer || (typeof SharedArrayBuffer !== "undefined" && value instanceof SharedArrayBuffer)) { + const off = encodingOrOffset === undefined ? 0 : encodingOrOffset >>> 0; + const len = length === undefined ? value.byteLength - off : length >>> 0; + return new Buffer(value, off, len); } if (ArrayBuffer.isView(value)) { const src = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - return Buffer.__wrap(src.slice()); + return Buffer.__wrap(new Uint8Array(src)); } if (Array.isArray(value)) { return Buffer.__wrap(Uint8Array.from(value)); @@ -245,18 +236,16 @@ if (value && typeof value === "object" && Array.isArray(value.data)) { return Buffer.__wrap(Uint8Array.from(value.data)); } + if (value && typeof value[Symbol.iterator] === "function") { + return Buffer.__wrap(Uint8Array.from(value)); + } throw new TypeError("Buffer.from: unsupported source"); } static alloc(size, fill, encoding) { const buf = new Buffer(size); - if (fill !== undefined) { - if (typeof fill === "string") { - const bytes = encode(fill, encoding); - for (let i = 0; i < size; i++) buf[i] = bytes[i % bytes.length]; - } else { - buf.fill(fill); - } + if (fill !== undefined && fill !== 0) { + buf.fill(fill, 0, size, encoding); } return buf; } @@ -269,6 +258,9 @@ if (typeof value === "string") return encode(value, encoding).length; if (ArrayBuffer.isView(value)) return value.byteLength; if (value instanceof ArrayBuffer) return value.byteLength; + if (typeof SharedArrayBuffer !== "undefined" && value instanceof SharedArrayBuffer) { + return value.byteLength; + } throw new TypeError("Buffer.byteLength: unsupported source"); } @@ -281,11 +273,12 @@ const out = new Buffer(total); let offset = 0; for (const b of list) { + if (offset >= total) break; const take = Math.min(b.length, total - offset); out.set(b.subarray(0, take), offset); offset += take; - if (offset >= total) break; } + if (offset < total) out.fill(0, offset, total); return out; } @@ -293,9 +286,33 @@ return obj instanceof Buffer; } + static compare(a, b) { + if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array)) { + throw new TypeError("Buffer.compare: arguments must be Uint8Array/Buffer"); + } + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) { + if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; + } + if (a.length === b.length) return 0; + return a.length < b.length ? -1 : 1; + } + + static copyBytesFrom(view, offset, length) { + if (!ArrayBuffer.isView(view)) { + throw new TypeError("Buffer.copyBytesFrom: view must be a TypedArray"); + } + const elementSize = view.BYTES_PER_ELEMENT || 1; + const o = offset === undefined ? 0 : offset | 0; + const l = length === undefined ? view.length - o : length | 0; + const start = view.byteOffset + o * elementSize; + const byteLen = l * elementSize; + const src = new Uint8Array(view.buffer, start, byteLen); + return Buffer.__wrap(new Uint8Array(src)); + } + static __wrap(uint8) { - const out = new Buffer(uint8.buffer, uint8.byteOffset, uint8.byteLength); - return out; + return new Buffer(uint8.buffer, uint8.byteOffset, uint8.byteLength); } toString(encoding, start, end) { @@ -337,8 +354,76 @@ return this.length < other.length ? -1 : 1; } + fill(value, offset, end, encoding) { + if (typeof offset === "string") { + encoding = offset; + offset = 0; + end = this.length; + } else if (typeof end === "string") { + encoding = end; + end = this.length; + } + const o = offset === undefined ? 0 : offset | 0; + const e = end === undefined ? this.length : end | 0; + if (e <= o) return this; + if (typeof value === "string") { + const bytes = encode(value, encoding); + if (bytes.length === 0) return this; + for (let i = o; i < e; i++) this[i] = bytes[(i - o) % bytes.length]; + return this; + } + if (value instanceof Uint8Array) { + if (value.length === 0) return this; + for (let i = o; i < e; i++) this[i] = value[(i - o) % value.length]; + return this; + } + super.fill(value & 0xff, o, e); + return this; + } + + copy(target, targetStart, sourceStart, sourceEnd) { + const ts = targetStart === undefined ? 0 : targetStart | 0; + const ss = sourceStart === undefined ? 0 : sourceStart | 0; + const se = sourceEnd === undefined ? this.length : sourceEnd | 0; + if (ts >= target.length || ss >= se) return 0; + const tAvail = target.length - ts; + const sAvail = se - ss; + const n = Math.min(tAvail, sAvail); + if (target.buffer === this.buffer && Math.abs(ts - ss) < n) { + const tmp = new Uint8Array(this.buffer, this.byteOffset + ss, n).slice(); + new Uint8Array(target.buffer, target.byteOffset + ts, n).set(tmp); + } else { + new Uint8Array(target.buffer, target.byteOffset + ts, n).set( + new Uint8Array(this.buffer, this.byteOffset + ss, n), + ); + } + return n; + } + + compare(target, targetStart, targetEnd, sourceStart, sourceEnd) { + const ts = targetStart === undefined ? 0 : targetStart | 0; + const te = targetEnd === undefined ? target.length : targetEnd | 0; + const ss = sourceStart === undefined ? 0 : sourceStart | 0; + const se = sourceEnd === undefined ? this.length : sourceEnd | 0; + const a = this.subarray(ss, se); + const b = target.subarray(ts, te); + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) { + if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; + } + if (a.length === b.length) return 0; + return a.length < b.length ? -1 : 1; + } + indexOf(value, byteOffset, encoding) { - const start = byteOffset | 0; + let start; + if (typeof byteOffset === "string") { + encoding = byteOffset; + start = 0; + } else { + start = byteOffset === undefined ? 0 : byteOffset | 0; + if (start < 0) start = Math.max(0, this.length + start); + } const needle = typeof value === "number" ? new Uint8Array([value & 0xff]) : (typeof value === "string" ? encode(value, encoding) : new Uint8Array(value)); @@ -352,10 +437,83 @@ return -1; } + lastIndexOf(value, byteOffset, encoding) { + let start; + if (typeof byteOffset === "string") { + encoding = byteOffset; + start = this.length - 1; + } else { + start = byteOffset === undefined ? this.length - 1 : byteOffset | 0; + if (start < 0) start = this.length + start; + } + const needle = typeof value === "number" + ? new Uint8Array([value & 0xff]) + : (typeof value === "string" ? encode(value, encoding) : new Uint8Array(value)); + if (needle.length === 0) return Math.min(start, this.length); + const last = Math.min(start, this.length - needle.length); + outer: for (let i = last; i >= 0; i--) { + for (let j = 0; j < needle.length; j++) { + if (this[i + j] !== needle[j]) continue outer; + } + return i; + } + return -1; + } + includes(value, byteOffset, encoding) { return this.indexOf(value, byteOffset, encoding) !== -1; } + swap16() { + if ((this.length & 0x1) !== 0) { + throw new RangeError("Buffer size must be a multiple of 16-bits"); + } + for (let i = 0; i < this.length; i += 2) { + const a = this[i]; + this[i] = this[i + 1]; + this[i + 1] = a; + } + return this; + } + + swap32() { + if ((this.length & 0x3) !== 0) { + throw new RangeError("Buffer size must be a multiple of 32-bits"); + } + for (let i = 0; i < this.length; i += 4) { + const a = this[i], b = this[i + 1]; + this[i] = this[i + 3]; + this[i + 1] = this[i + 2]; + this[i + 2] = b; + this[i + 3] = a; + } + return this; + } + + swap64() { + if ((this.length & 0x7) !== 0) { + throw new RangeError("Buffer size must be a multiple of 64-bits"); + } + for (let i = 0; i < this.length; i += 8) { + const a = this[i], b = this[i + 1], c = this[i + 2], d = this[i + 3]; + this[i] = this[i + 7]; + this[i + 1] = this[i + 6]; + this[i + 2] = this[i + 5]; + this[i + 3] = this[i + 4]; + this[i + 4] = d; + this[i + 5] = c; + this[i + 6] = b; + this[i + 7] = a; + } + return this; + } + + toJSON() { + const data = new Array(this.length); + for (let i = 0; i < this.length; i++) data[i] = this[i]; + return { type: "Buffer", data }; + } + readUInt8(off) { return this[off]; } writeUInt8(v, off) { this[off] = v & 0xff; return off + 1; } @@ -388,6 +546,165 @@ this[off + 3] = v & 0xff; return off + 4; } + + readInt8(off) { + const v = this[off]; + return v & 0x80 ? v - 0x100 : v; + } + writeInt8(v, off) { + this[off] = v & 0xff; + return off + 1; + } + readInt16LE(off) { + const v = this[off] | (this[off + 1] << 8); + return v & 0x8000 ? v - 0x10000 : v; + } + readInt16BE(off) { + const v = (this[off] << 8) | this[off + 1]; + return v & 0x8000 ? v - 0x10000 : v; + } + writeInt16LE(v, off) { + this[off] = v & 0xff; + this[off + 1] = (v >> 8) & 0xff; + return off + 2; + } + writeInt16BE(v, off) { + this[off] = (v >> 8) & 0xff; + this[off + 1] = v & 0xff; + return off + 2; + } + readInt32LE(off) { + return ((this[off]) | (this[off + 1] << 8) | (this[off + 2] << 16) | (this[off + 3] << 24)) | 0; + } + readInt32BE(off) { + return ((this[off] << 24) | (this[off + 1] << 16) | (this[off + 2] << 8) | this[off + 3]) | 0; + } + writeInt32LE(v, off) { + this[off] = v & 0xff; + this[off + 1] = (v >>> 8) & 0xff; + this[off + 2] = (v >>> 16) & 0xff; + this[off + 3] = (v >>> 24) & 0xff; + return off + 4; + } + writeInt32BE(v, off) { + this[off] = (v >>> 24) & 0xff; + this[off + 1] = (v >>> 16) & 0xff; + this[off + 2] = (v >>> 8) & 0xff; + this[off + 3] = v & 0xff; + return off + 4; + } + + readUIntLE(off, byteLength) { + let value = 0; + let mul = 1; + for (let i = 0; i < byteLength; i++) { + value += this[off + i] * mul; + mul *= 0x100; + } + return value; + } + readUIntBE(off, byteLength) { + let value = 0; + for (let i = 0; i < byteLength; i++) { + value = value * 0x100 + this[off + i]; + } + return value; + } + readIntLE(off, byteLength) { + let value = this.readUIntLE(off, byteLength); + const sign = 2 ** (8 * byteLength - 1); + if (value >= sign) value -= sign * 2; + return value; + } + readIntBE(off, byteLength) { + let value = this.readUIntBE(off, byteLength); + const sign = 2 ** (8 * byteLength - 1); + if (value >= sign) value -= sign * 2; + return value; + } + writeUIntLE(v, off, byteLength) { + let value = Number(v); + for (let i = 0; i < byteLength; i++) { + this[off + i] = value & 0xff; + value = Math.floor(value / 0x100); + } + return off + byteLength; + } + writeUIntBE(v, off, byteLength) { + let value = Number(v); + for (let i = byteLength - 1; i >= 0; i--) { + this[off + i] = value & 0xff; + value = Math.floor(value / 0x100); + } + return off + byteLength; + } + writeIntLE(v, off, byteLength) { + let value = Number(v); + if (value < 0) value += 2 ** (8 * byteLength); + return this.writeUIntLE(value, off, byteLength); + } + writeIntBE(v, off, byteLength) { + let value = Number(v); + if (value < 0) value += 2 ** (8 * byteLength); + return this.writeUIntBE(value, off, byteLength); + } + + readFloatLE(off) { + return new DataView(this.buffer, this.byteOffset, this.byteLength).getFloat32(off, true); + } + readFloatBE(off) { + return new DataView(this.buffer, this.byteOffset, this.byteLength).getFloat32(off, false); + } + writeFloatLE(v, off) { + new DataView(this.buffer, this.byteOffset, this.byteLength).setFloat32(off, v, true); + return off + 4; + } + writeFloatBE(v, off) { + new DataView(this.buffer, this.byteOffset, this.byteLength).setFloat32(off, v, false); + return off + 4; + } + readDoubleLE(off) { + return new DataView(this.buffer, this.byteOffset, this.byteLength).getFloat64(off, true); + } + readDoubleBE(off) { + return new DataView(this.buffer, this.byteOffset, this.byteLength).getFloat64(off, false); + } + writeDoubleLE(v, off) { + new DataView(this.buffer, this.byteOffset, this.byteLength).setFloat64(off, v, true); + return off + 8; + } + writeDoubleBE(v, off) { + new DataView(this.buffer, this.byteOffset, this.byteLength).setFloat64(off, v, false); + return off + 8; + } + readBigInt64LE(off) { + return new DataView(this.buffer, this.byteOffset, this.byteLength).getBigInt64(off, true); + } + readBigInt64BE(off) { + return new DataView(this.buffer, this.byteOffset, this.byteLength).getBigInt64(off, false); + } + readBigUInt64LE(off) { + return new DataView(this.buffer, this.byteOffset, this.byteLength).getBigUint64(off, true); + } + readBigUInt64BE(off) { + return new DataView(this.buffer, this.byteOffset, this.byteLength).getBigUint64(off, false); + } + writeBigInt64LE(v, off) { + new DataView(this.buffer, this.byteOffset, this.byteLength).setBigInt64(off, BigInt(v), true); + return off + 8; + } + writeBigInt64BE(v, off) { + new DataView(this.buffer, this.byteOffset, this.byteLength).setBigInt64(off, BigInt(v), false); + return off + 8; + } + writeBigUInt64LE(v, off) { + new DataView(this.buffer, this.byteOffset, this.byteLength).setBigUint64(off, BigInt(v), true); + return off + 8; + } + writeBigUInt64BE(v, off) { + new DataView(this.buffer, this.byteOffset, this.byteLength).setBigUint64(off, BigInt(v), false); + return off + 8; + } } Object.defineProperty(Buffer, "__nexideBuffer", { @@ -422,7 +739,11 @@ } }; - for (const name of ["from", "alloc", "allocUnsafe", "allocUnsafeSlow", "byteLength", "concat", "isBuffer", "isEncoding"]) { + Buffer.poolSize = 8192; + Buffer.kMaxLength = 0x7fffffff; + Buffer.INSPECT_MAX_BYTES = 50; + + for (const name of ["from", "alloc", "allocUnsafe", "allocUnsafeSlow", "byteLength", "concat", "isBuffer", "isEncoding", "compare", "copyBytesFrom"]) { const desc = Object.getOwnPropertyDescriptor(Buffer, name); if (desc && !desc.enumerable) { Object.defineProperty(Buffer, name, { ...desc, enumerable: true }); diff --git a/crates/nexide/runtime/polyfills/cjs_loader.js b/crates/nexide/runtime/polyfills/cjs_loader.js index 9b5a061..2040b84 100644 --- a/crates/nexide/runtime/polyfills/cjs_loader.js +++ b/crates/nexide/runtime/polyfills/cjs_loader.js @@ -14,6 +14,7 @@ const ops = Nexide.core.ops; const cache = new Map(); + const moduleStack = []; function dirnameOf(spec) { if (typeof spec !== "string" || spec.length === 0) return ""; @@ -32,12 +33,23 @@ } function compileWrapper(source, specifier) { - const wrapper = - "(function (exports, require, module, __filename, __dirname) {\n" + - source + - "\n})\n//# sourceURL=" + - specifier; - return (0, eval)(wrapper); + let fn; + try { + fn = ops.op_cjs_compile_function(source, specifier); + } catch (err) { + if (err && typeof err.message === "string") { + err.message = err.message + " (compiling " + specifier + ")"; + } + throw err; + } + return function (exports, require, module, __filename, __dirname) { + moduleStack.push(specifier); + try { + return fn(exports, require, module, __filename, __dirname); + } finally { + moduleStack.pop(); + } + }; } function makeRequire(parent) { @@ -100,6 +112,59 @@ } } + function buildNamespace(exports) { + if (exports && typeof exports === "object" && exports.__esModule) { + return exports; + } + const ns = Object.create(null); + if (exports !== null && exports !== undefined) { + if (typeof exports === "object" || typeof exports === "function") { + for (const k of Object.keys(exports)) { + try { ns[k] = exports[k]; } catch (_) {} + } + } + } + ns.default = exports; + return ns; + } + + function dynamicImport(specifier, referrer) { + let parent; + if (typeof referrer === "string" && referrer.length > 0) { + parent = referrer; + } else if (moduleStack.length > 0) { + parent = moduleStack[moduleStack.length - 1]; + } else { + parent = ops.op_cjs_root_parent(); + } + if (typeof ops.op_esm_dynamic_import === "function") { + try { + return ops.op_esm_dynamic_import(specifier, parent); + } catch (err) { + return Promise.reject(tagError(err)); + } + } + try { + const exports = loadModule(parent, specifier); + return Promise.resolve(buildNamespace(exports)); + } catch (err) { + return Promise.reject(tagError(err)); + } + } + + Object.defineProperty(globalThis, "__nexideEsm", { + value: Object.freeze({ + chain: function (evalPromise, namespace) { + return Promise.resolve(evalPromise).then(function () { + return namespace; + }); + }, + }), + enumerable: false, + writable: false, + configurable: false, + }); + Object.defineProperty(globalThis, "__nexideCjs", { value: { load: loadModule, @@ -107,6 +172,7 @@ makeRequire, dirnameOf, basenameOf, + dynamicImport, }, enumerable: false, writable: false, diff --git a/crates/nexide/runtime/polyfills/http_bridge.js b/crates/nexide/runtime/polyfills/http_bridge.js index 757d79b..be09966 100644 --- a/crates/nexide/runtime/polyfills/http_bridge.js +++ b/crates/nexide/runtime/polyfills/http_bridge.js @@ -36,59 +36,83 @@ const stack = []; let nextToken = 1; + // Hot-path optimisation: most Next.js API routes that read only + // `req.method`/`req.url`/`req.headers` never call `.on('data'/'end')`. + // We avoid eagerly allocating the four listener arrays + chunk + // buffer per request and instead allocate them on first `.on()` + // touch. Combined with the deferred watchdog (see `__dispatch`) this + // removes ~6 allocations and 1 hidden-class transition from every + // simple JSON GET handler. function buildIncoming(idx, gen) { const meta = ops.op_nexide_get_meta(idx, gen); + const method = meta[0]; + const url = meta[1]; - let cachedRawHeaders = null; - let cachedHeaders = null; let cachedRawHeadersFlat = null; + let cachedRawHeadersPairs = null; + let cachedHeaders = null; - function rawHeaders() { - if (cachedRawHeaders === null) { - cachedRawHeaders = ops.op_nexide_get_headers(idx, gen); + function rawHeadersFlat() { + if (cachedRawHeadersFlat === null) { + cachedRawHeadersFlat = ops.op_nexide_get_headers(idx, gen); } - return cachedRawHeaders; + return cachedRawHeadersFlat; } - const dataListeners = []; - const endListeners = []; - const errorListeners = []; - const bufferedChunks = []; + let dataListeners = null; + let endListeners = null; + let errorListeners = null; + let bufferedChunks = null; let pumped = false; let ended = false; const incoming = { - method: meta.method, - url: meta.uri, + method, + url, httpVersion: "1.1", get headers() { if (cachedHeaders === null) { cachedHeaders = Object.create(null); - const raw = rawHeaders(); - for (let i = 0; i < raw.length; i++) { - cachedHeaders[raw[i].name] = raw[i].value; + const flat = rawHeadersFlat(); + for (let i = 0; i + 1 < flat.length; i += 2) { + cachedHeaders[flat[i]] = flat[i + 1]; } } return cachedHeaders; }, get rawHeaders() { - if (cachedRawHeadersFlat === null) { - const raw = rawHeaders(); - cachedRawHeadersFlat = new Array(raw.length * 2); - for (let i = 0; i < raw.length; i++) { - cachedRawHeadersFlat[i * 2] = raw[i].name; - cachedRawHeadersFlat[i * 2 + 1] = raw[i].value; + return rawHeadersFlat().slice(); + }, + + // Internal accessor used by `node:http`'s IncomingMessage adapter + // to consume the same flat array as Node's + // `[name, value, name, value, ...]` shape without an extra copy. + get __nexideRawHeadersFlat() { + return rawHeadersFlat(); + }, + + // Internal accessor exposing pair-form + // `[[name, value], [name, value], ...]` for legacy adapters that + // expect that shape. Materialised once and cached. + get __nexideHeaderPairs() { + if (cachedRawHeadersPairs === null) { + const flat = rawHeadersFlat(); + const out = new Array(flat.length >> 1); + for (let i = 0, j = 0; i + 1 < flat.length; i += 2, j++) { + out[j] = [flat[i], flat[i + 1]]; } + cachedRawHeadersPairs = out; } - return cachedRawHeadersFlat; + return cachedRawHeadersPairs; }, on(event, cb) { if (event === "data") { + if (dataListeners === null) dataListeners = []; dataListeners.push(cb); - if (bufferedChunks.length) { + if (bufferedChunks !== null && bufferedChunks.length) { const replay = bufferedChunks.slice(); queueMicrotask(() => { for (const chunk of replay) cb(chunk); @@ -96,6 +120,7 @@ } if (!pumped) queueMicrotask(() => incoming.__pump()); } else if (event === "end") { + if (endListeners === null) endListeners = []; endListeners.push(cb); if (ended) { queueMicrotask(() => cb()); @@ -103,6 +128,7 @@ queueMicrotask(() => incoming.__pump()); } } else if (event === "error") { + if (errorListeners === null) errorListeners = []; errorListeners.push(cb); } return incoming; @@ -140,11 +166,16 @@ const n = ops.op_nexide_read_body(idx, gen, buf); if (n === 0) break; const slice = buf.slice(0, n); + if (bufferedChunks === null) bufferedChunks = []; bufferedChunks.push(slice); - for (const cb of dataListeners.slice()) cb(slice); + if (dataListeners !== null) { + for (const cb of dataListeners.slice()) cb(slice); + } } ended = true; - for (const cb of endListeners.slice()) cb(); + if (endListeners !== null) { + for (const cb of endListeners.slice()) cb(); + } }, }; @@ -256,8 +287,7 @@ ops.op_nexide_send_response( idx, gen, - pendingHead.status, - pendingHead.headers, + { status: pendingHead.status, headers: pendingHead.headers }, body, ); headSent = true; @@ -313,6 +343,21 @@ } }; + function readTimeoutMs() { + try { + const env = (typeof process === "object" && process && process.env) || {}; + const raw = env.NEXIDE_HANDLER_TIMEOUT_MS; + if (raw === undefined || raw === null || raw === "") return 0; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) return 0; + return n | 0; + } catch (_e) { + return 0; + } + } + + const HANDLER_TIMEOUT_MS = readTimeoutMs(); + nexide.__dispatch = function (idx, gen) { const top = stack[stack.length - 1]; if (!top) { @@ -348,7 +393,55 @@ handlerPromise = Promise.resolve(ret); } - return handlerPromise.then( + let timeoutHandle = null; + let timedOut = false; + let settled = false; + + const guarded = HANDLER_TIMEOUT_MS > 0 + ? new Promise((resolve, reject) => { + handlerPromise.then( + (v) => { + settled = true; + if (timeoutHandle) clearTimeout(timeoutHandle); + resolve(v); + }, + (e) => { + settled = true; + if (timeoutHandle) clearTimeout(timeoutHandle); + reject(e); + }, + ); + queueMicrotask(() => { + if (settled) return; + timeoutHandle = setTimeout(() => { + if (res.__isEnded()) { + resolve(undefined); + return; + } + timedOut = true; + const url = (req && typeof req.url === "string") ? req.url : "?"; + const method = (req && typeof req.method === "string") ? req.method : "?"; + try { + ops.op_nexide_log( + 4, + `nexide handler watchdog: ${method} ${url} did not settle within ${HANDLER_TIMEOUT_MS}ms; closing slot`, + ); + } catch (_e) { /* logging op may be gone during shutdown */ } + try { + if (typeof res.statusCode === "number") res.statusCode = 504; + res.end("Gateway Timeout"); + } catch (_e) { /* res may be in inconsistent state */ } + const err = new Error( + `nexide handler watchdog: ${method} ${url} timed out after ${HANDLER_TIMEOUT_MS}ms`, + ); + err.code = "ERR_HANDLER_TIMEOUT"; + reject(err); + }, HANDLER_TIMEOUT_MS); + }); + }) + : handlerPromise; + + return guarded.then( () => { if (!res.__isEnded()) { try { res.end(); } catch { } @@ -358,6 +451,9 @@ if (!res.__isEnded()) { try { res.end(); } catch { } } + if (timedOut) { + return; + } throw err; }, ); diff --git a/crates/nexide/runtime/polyfills/late_globals.js b/crates/nexide/runtime/polyfills/late_globals.js index 1cd6853..2427e38 100644 --- a/crates/nexide/runtime/polyfills/late_globals.js +++ b/crates/nexide/runtime/polyfills/late_globals.js @@ -41,5 +41,10 @@ } } } catch { } + try { + if (globalThis.process && typeof globalThis.process.__startSignalPump === "function") { + globalThis.process.__startSignalPump(); + } + } catch { } } })(); diff --git a/crates/nexide/runtime/polyfills/nexide_bridge.js b/crates/nexide/runtime/polyfills/nexide_bridge.js index f2a1622..e4cdc68 100644 --- a/crates/nexide/runtime/polyfills/nexide_bridge.js +++ b/crates/nexide/runtime/polyfills/nexide_bridge.js @@ -24,12 +24,22 @@ const bridge = { __nexideBridge: true, - /** Returns the request method, URL and remote address. */ + /** + * Returns the request line as a flat 2-element array + * `[method, uri]`. The flat shape is the hot-path layout (no + * v8::Object allocation per request) - consumers index by + * position. + */ getMeta(idx, gen) { return ops.op_nexide_get_meta(idx, gen); }, - /** Returns the request headers as an array of `[name, value]` pairs. */ + /** + * Returns the request headers as a flat array + * `[name, value, name, value, ...]` - the same shape Node's + * `IncomingMessage.rawHeaders` uses. Consumers iterate by stride + * 2. + */ getHeaders(idx, gen) { return ops.op_nexide_get_headers(idx, gen); }, diff --git a/crates/nexide/runtime/polyfills/node/async_hooks.js b/crates/nexide/runtime/polyfills/node/async_hooks.js index 70ca49a..2e2ee7f 100644 --- a/crates/nexide/runtime/polyfills/node/async_hooks.js +++ b/crates/nexide/runtime/polyfills/node/async_hooks.js @@ -1,84 +1,69 @@ -// node:async_hooks - minimal AsyncLocalStorage shim. +// node:async_hooks // -// Next.js relies on `AsyncLocalStorage` for request-scoped state. -// Real Node.js implements it on top of the AsyncWrap machinery. -// nexide's runtime is single-isolate-per-request: each request runs -// to completion on the same microtask scheduler before the next one -// is dispatched, so a process-global slot is sufficient - the JS -// callback runs synchronously inside `run`/`enterWith`, and stores -// nest as a stack. +// nexide's `globalThis.AsyncLocalStorage` is installed at boot from +// `runtime/polyfills/async_local_storage.js` and is backed by V8's +// continuation-preserved-embedder-data (CPED). That global is the +// single source of truth - context propagates correctly across +// `await`, `Promise.then`, `queueMicrotask`, and `setTimeout`. +// +// This module re-exports the global rather than shipping a +// stack-based fallback. A stack-based ALS would silently lose +// context across `await` boundaries, so if the global is somehow +// missing we fail fast at module load instead of handing back a +// broken implementation. +// +// `AsyncResource` mirrors the same idea - it uses +// `AsyncLocalStorage.snapshot()` to capture the current context at +// construction time and replay it inside `runInAsyncScope`. (function () { - class AsyncLocalStorage { - constructor() { - this._stack = []; - } - getStore() { - const len = this._stack.length; - return len === 0 ? undefined : this._stack[len - 1]; - } - run(store, callback, ...args) { - this._stack.push(store); - try { - return callback(...args); - } finally { - this._stack.pop(); - } - } - enterWith(store) { - this._stack.push(store); - } - exit(callback, ...args) { - const saved = this._stack.slice(); - this._stack.length = 0; - try { - return callback(...args); - } finally { - this._stack = saved; - } - } - disable() { - this._stack.length = 0; + "use strict"; + + if (typeof globalThis.AsyncLocalStorage !== "function") { + throw new Error( + "nexide: globalThis.AsyncLocalStorage is unavailable. The CPED-backed " + + "polyfill must be loaded before require('node:async_hooks'). " + + "Check the bootstrap order in crates/nexide/src/engine/v8_engine/bootstrap.rs.", + ); + } + const ALS = globalThis.AsyncLocalStorage; + + class AsyncResource { + constructor(type, _opts) { + this.type = String(type || "AsyncResource"); + this._snapshot = ALS.snapshot(); } - static bind(fn) { - return fn; + runInAsyncScope(fn, thisArg, ...args) { + return this._snapshot.call(thisArg, () => fn.apply(thisArg, args)); } - static snapshot() { - return function runInSnapshot(cb, ...args) { - return cb(...args); + bind(fn, thisArg) { + const snap = this._snapshot; + const bound = function bound(...args) { + return snap.call(thisArg, () => fn.apply(thisArg, args)); }; + bound.asyncResource = this; + return bound; } + static bind(fn, type, thisArg) { + const r = new AsyncResource(type || fn.name || "bound-anonymous-fn"); + return r.bind(fn, thisArg); + } + emitDestroy() { return this; } + asyncId() { return 0; } + triggerAsyncId() { return 0; } } - function executionAsyncId() { - return 0; - } - function triggerAsyncId() { - return 0; - } - - function createHook() { - return { - enable() { return this; }, - disable() { return this; }, - }; - } - - const ALS = globalThis.AsyncLocalStorage || AsyncLocalStorage; - const AR = globalThis.AsyncResource || class AsyncResource { - constructor(type) { this.type = type; } - runInAsyncScope(fn, thisArg, ...args) { - return fn.apply(thisArg, args); - } - bind(fn) { return fn.bind(null); } - static bind(fn) { return fn.bind(null); } - }; + const AR = (typeof globalThis.AsyncResource === "function" + && globalThis.AsyncResource !== AsyncResource) + ? globalThis.AsyncResource + : AsyncResource; module.exports = { AsyncLocalStorage: ALS, AsyncResource: AR, - executionAsyncId, - triggerAsyncId, - createHook, + executionAsyncId: () => 0, + triggerAsyncId: () => 0, + executionAsyncResource: () => ({}), + createHook: () => ({ enable() { return this; }, disable() { return this; } }), }; })(); diff --git a/crates/nexide/runtime/polyfills/node/crypto.js b/crates/nexide/runtime/polyfills/node/crypto.js index dbe609e..eab6d55 100644 --- a/crates/nexide/runtime/polyfills/node/crypto.js +++ b/crates/nexide/runtime/polyfills/node/crypto.js @@ -405,6 +405,515 @@ webcrypto.CryptoKey = CryptoKey; webcrypto.SubtleCrypto = SubtleCrypto; webcrypto.Crypto = Crypto; +const KEY_GATE = Symbol("nexide.crypto.KeyObjectGate"); + +function base64UrlEncode(buf) { + return Buffer.from(buf).toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function base64UrlDecode(s) { + const pad = (4 - (s.length % 4)) % 4; + return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad), "base64"); +} + +function curveToNamed(curve) { + switch (curve) { + case "P-256": case "p-256": case "prime256v1": case "secp256r1": return "prime256v1"; + case "P-384": case "p-384": case "secp384r1": return "secp384r1"; + case "P-521": case "p-521": case "secp521r1": return "secp521r1"; + default: return curve; + } +} + +function namedToJoseCurve(name) { + switch (name) { + case "prime256v1": case "secp256r1": return "P-256"; + case "secp384r1": return "P-384"; + case "secp521r1": return "P-521"; + default: return name; + } +} + +function pemLabelForKind(kind) { + switch (kind) { + case "private-pkcs8": return "PRIVATE KEY"; + case "public-spki": return "PUBLIC KEY"; + case "pkcs1-priv": return "RSA PRIVATE KEY"; + case "pkcs1-pub": return "RSA PUBLIC KEY"; + case "ec-sec1": return "EC PRIVATE KEY"; + default: throw new Error(`unknown kind: ${kind}`); + } +} + +function kindForPemLabel(label) { + switch (label) { + case "PRIVATE KEY": return "private-pkcs8"; + case "PUBLIC KEY": return "public-spki"; + case "RSA PRIVATE KEY": return "pkcs1-priv"; + case "RSA PUBLIC KEY": return "pkcs1-pub"; + case "EC PRIVATE KEY": return "ec-sec1"; + default: return null; + } +} + +class KeyObject { + constructor(gate, fields) { + if (gate !== KEY_GATE) { + const err = new TypeError("Illegal constructor"); + err.code = "ERR_INVALID_THIS"; + throw err; + } + this._type = fields.type; + this._kind = fields.kind || null; + this._der = fields.der || null; + this._info = fields.info || {}; + this._secret = fields.secret || null; + } + + get type() { return this._type; } + + get asymmetricKeyType() { + if (this._type === "secret") return undefined; + return this._info.asymmetricKeyType; + } + + get asymmetricKeyDetails() { + if (this._type === "secret") return undefined; + const details = {}; + if (this._info.modulusLength != null) details.modulusLength = this._info.modulusLength; + if (this._info.publicExponent != null) details.publicExponent = BigInt(this._info.publicExponent); + if (this._info.namedCurve != null) details.namedCurve = this._info.namedCurve; + return details; + } + + get symmetricKeySize() { + return this._type === "secret" && this._secret ? this._secret.length : undefined; + } + + export(options = {}) { + if (this._type === "secret") { + if (options.format === "jwk") { + return { kty: "oct", k: base64UrlEncode(this._secret) }; + } + return Buffer.from(this._secret); + } + const format = options.format || "pem"; + if (format === "jwk") { + const json = ops.op_crypto_der_to_jwk(this._der, this._kind); + return JSON.parse(json); + } + let outKind = this._kind; + if (this._type === "private") { + const t = options.type || "pkcs8"; + if (t === "pkcs8") outKind = "private-pkcs8"; + else if (t === "pkcs1") outKind = "pkcs1-priv"; + else if (t === "sec1") outKind = "ec-sec1"; + else throw new Error(`unsupported private export type: ${t}`); + } else { + const t = options.type || "spki"; + if (t === "spki") outKind = "public-spki"; + else if (t === "pkcs1") outKind = "pkcs1-pub"; + else throw new Error(`unsupported public export type: ${t}`); + } + let der = this._der; + if (outKind !== this._kind) { + const hint = this._info.namedCurve || ""; + der = ops.op_crypto_key_convert(this._kind, outKind, this._der, hint); + } + if (options.cipher || options.passphrase) { + const err = new Error("Encrypted key export with passphrase is not supported in nexide"); + err.code = "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM"; + throw err; + } + if (format === "der") return Buffer.from(der); + if (format === "pem") return ops.op_crypto_pem_encode(pemLabelForKind(outKind), der); + throw new Error(`unsupported export format: ${format}`); + } + + equals(other) { + if (!(other instanceof KeyObject)) return false; + if (this._type !== other._type) return false; + if (this._type === "secret") { + if (!this._secret || !other._secret) return false; + if (this._secret.length !== other._secret.length) return false; + let diff = 0; + for (let i = 0; i < this._secret.length; i++) diff |= this._secret[i] ^ other._secret[i]; + return diff === 0; + } + if (this._kind !== other._kind || this._der.length !== other._der.length) return false; + let diff = 0; + for (let i = 0; i < this._der.length; i++) diff |= this._der[i] ^ other._der[i]; + return diff === 0; + } + + static from(cryptoKey) { + const err = new Error("KeyObject.from(CryptoKey) is not supported"); + err.code = "ERR_METHOD_NOT_IMPLEMENTED"; + throw err; + } +} + +function makeKeyObject(fields) { return new KeyObject(KEY_GATE, fields); } + +function inspectAndBuild(type, kind, der) { + const info = JSON.parse(ops.op_crypto_key_inspect(der, kind)); + return makeKeyObject({ type, kind, der, info }); +} + +function createSecretKey(key, encoding) { + let buf; + if (typeof key === "string") buf = Buffer.from(key, encoding || "utf8"); + else if (key instanceof Uint8Array) buf = Buffer.from(key); + else if (key && typeof key === "object" && key.type === "Buffer" && Array.isArray(key.data)) buf = Buffer.from(key.data); + else throw new TypeError("createSecretKey: key must be Buffer/Uint8Array/string"); + return makeKeyObject({ type: "secret", secret: new Uint8Array(buf) }); +} + +function jwkToKeyObject(jwk, wantPublic) { + if (jwk.kty === "oct") { + return createSecretKey(base64UrlDecode(jwk.k)); + } + const wantKind = wantPublic ? "public-spki" : "private-pkcs8"; + const der = ops.op_crypto_jwk_to_der(JSON.stringify(jwk), wantKind); + return inspectAndBuild(wantPublic ? "public" : "private", wantKind, der); +} + +function normalizeKeyInput(input) { + if (input instanceof KeyObject) return { kind: "keyobject", value: input }; + if (typeof input === "string") return { kind: "pem", value: input }; + if (input instanceof Uint8Array) return { kind: "der", value: input, type: "pkcs8" }; + if (input && typeof input === "object") { + if (input.kty) return { kind: "jwk", value: input }; + if (input.format === "jwk" && input.key) return { kind: "jwk", value: input.key }; + if (typeof input.key === "string") return { kind: "pem", value: input.key }; + if (input.key instanceof Uint8Array || (input.key && input.key.type === "Buffer")) { + const der = input.key instanceof Uint8Array ? input.key : Buffer.from(input.key.data); + return { kind: "der", value: der, type: input.type || "pkcs8" }; + } + } + throw new TypeError("invalid key input"); +} + +function pemToDer(pem) { + const decoded = ops.op_crypto_pem_decode(pem); + return { label: decoded.label, der: decoded.der }; +} + +function createPrivateKey(input) { + const norm = normalizeKeyInput(input); + if (norm.kind === "keyobject") { + if (norm.value._type !== "private") throw new Error("expected private KeyObject"); + return norm.value; + } + if (norm.kind === "jwk") return jwkToKeyObject(norm.value, false); + let der, kind; + if (norm.kind === "pem") { + const { label, der: rawDer } = pemToDer(norm.value); + kind = kindForPemLabel(label); + if (!kind) throw new Error(`unsupported PEM label for private key: ${label}`); + der = rawDer; + } else { + der = norm.value; + if (norm.type === "pkcs1") kind = "pkcs1-priv"; + else if (norm.type === "sec1") kind = "ec-sec1"; + else kind = "private-pkcs8"; + } + if (kind !== "private-pkcs8") { + const hint = ""; + der = ops.op_crypto_key_convert(kind, "private-pkcs8", der, hint); + kind = "private-pkcs8"; + } + return inspectAndBuild("private", kind, der); +} + +function createPublicKey(input) { + if (input instanceof KeyObject && input._type === "private") { + const der = ops.op_crypto_key_convert("private-pkcs8", "public-spki", input._der, ""); + return inspectAndBuild("public", "public-spki", der); + } + const norm = normalizeKeyInput(input); + if (norm.kind === "keyobject") { + if (norm.value._type === "public") return norm.value; + if (norm.value._type === "private") { + const der = ops.op_crypto_key_convert("private-pkcs8", "public-spki", norm.value._der, ""); + return inspectAndBuild("public", "public-spki", der); + } + throw new Error("cannot derive public key from secret KeyObject"); + } + if (norm.kind === "jwk") return jwkToKeyObject(norm.value, true); + let der, kind; + if (norm.kind === "pem") { + const { label, der: rawDer } = pemToDer(norm.value); + kind = kindForPemLabel(label); + if (!kind) throw new Error(`unsupported PEM label for public key: ${label}`); + der = rawDer; + if (kind === "private-pkcs8" || kind === "pkcs1-priv" || kind === "ec-sec1") { + if (kind !== "private-pkcs8") der = ops.op_crypto_key_convert(kind, "private-pkcs8", der, ""); + der = ops.op_crypto_key_convert("private-pkcs8", "public-spki", der, ""); + kind = "public-spki"; + } + } else { + der = norm.value; + if (norm.type === "pkcs1") kind = "pkcs1-pub"; + else kind = "public-spki"; + } + if (kind === "pkcs1-pub") { + der = ops.op_crypto_key_convert("pkcs1-pub", "public-spki", der, ""); + kind = "public-spki"; + } + return inspectAndBuild("public", kind, der); +} + +function generateKeyPairSync(type, options = {}) { + const optsJson = JSON.stringify(options || {}); + const result = ops.op_crypto_generate_key_pair(type, optsJson); + const info = JSON.parse(result.info_json); + const pubKO = makeKeyObject({ type: "public", kind: "public-spki", der: result.publicKey, info }); + const privKO = makeKeyObject({ type: "private", kind: "private-pkcs8", der: result.privateKey, info }); + const pubEnc = options.publicKeyEncoding; + const privEnc = options.privateKeyEncoding; + return { + publicKey: pubEnc ? pubKO.export(pubEnc) : pubKO, + privateKey: privEnc ? privKO.export(privEnc) : privKO, + }; +} + +function generateKeyPair(type, options, callback) { + if (typeof options === "function") { callback = options; options = {}; } + queueMicrotask(() => { + try { + const r = generateKeyPairSync(type, options); + callback(null, r.publicKey, r.privateKey); + } catch (err) { + callback(err); + } + }); +} + +function generateKeySync(type, options = {}) { + let length = options.length; + if (type === "hmac") { + if (length == null) length = 256; + if (length < 8 || length > 65536 || length % 8 !== 0) throw new RangeError("hmac length out of range"); + } else if (type === "aes") { + if (length !== 128 && length !== 192 && length !== 256) throw new RangeError("aes length must be 128/192/256"); + } else { + throw new Error(`generateKey: unsupported type ${type}`); + } + const bytes = Buffer.from(ops.op_crypto_random_bytes(length / 8)); + return createSecretKey(bytes); +} + +function generateKey(type, options, callback) { + if (typeof options === "function") { callback = options; options = {}; } + queueMicrotask(() => { + try { callback(null, generateKeySync(type, options)); } catch (err) { callback(err); } + }); +} + +function rsaPaddingName(p) { + if (p == null || p === 4) return "oaep"; + if (p === 1) return "pkcs1"; + throw new Error(`unsupported RSA padding: ${p}`); +} + +function rsaCryptOptions(input, defaultPadding) { + let key, padding, oaepHash, oaepLabel; + if (input instanceof KeyObject || typeof input === "string" || input instanceof Uint8Array) { + key = input; + padding = defaultPadding; + oaepHash = "sha1"; + oaepLabel = null; + } else if (input && typeof input === "object") { + if (input.kty) { + key = input; + } else { + key = input.key !== undefined ? input.key : input; + } + padding = input.padding != null ? input.padding : defaultPadding; + oaepHash = (input.oaepHash || "sha1").toLowerCase(); + oaepLabel = input.oaepLabel ? asBytes(input.oaepLabel) : null; + } else { + throw new TypeError("invalid RSA key argument"); + } + return { key, padding: rsaPaddingName(padding), oaepHash, oaepLabel }; +} + +function publicEncrypt(input, buffer) { + const { key, padding, oaepHash, oaepLabel } = rsaCryptOptions(input, 4); + const ko = createPublicKey(key); + const data = asBytes(buffer); + const out = ops.op_crypto_rsa_encrypt(ko._der, data, padding, oaepHash, oaepLabel || new Uint8Array(0)); + return Buffer.from(out); +} + +function privateDecrypt(input, buffer) { + const { key, padding, oaepHash, oaepLabel } = rsaCryptOptions(input, 4); + const ko = createPrivateKey(key); + const data = asBytes(buffer); + const out = ops.op_crypto_rsa_decrypt(ko._der, data, padding, oaepHash, oaepLabel || new Uint8Array(0)); + return Buffer.from(out); +} + +function privateEncrypt(_input, _buffer) { + const err = new Error("privateEncrypt is not supported in nexide"); + err.code = "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM"; + throw err; +} + +function publicDecrypt(_input, _buffer) { + const err = new Error("publicDecrypt is not supported in nexide"); + err.code = "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM"; + throw err; +} + +function digestForAlgo(algorithm) { + if (!algorithm) return null; + return normalizeDigestName(algorithm); +} + +function chooseSignAlgo(keyObject, algorithm, padding) { + const akt = keyObject.asymmetricKeyType; + const digest = digestForAlgo(algorithm); + if (akt === "ed25519") return { algo: "ed25519" }; + if (akt === "rsa" || akt === "rsa-pss") { + if (!digest) throw new Error("RSA sign requires a digest algorithm"); + const isPss = padding === 6 || akt === "rsa-pss"; + return { algo: `${isPss ? "rsa-pss-" : "rsa-"}${digest}` }; + } + if (akt === "ec") { + const curve = curveToNamed(keyObject._info.namedCurve); + if (curve === "prime256v1") return { algo: "ecdsa-p256-sha256" }; + if (curve === "secp384r1") return { algo: "ecdsa-p384-sha384" }; + if (curve === "secp521r1") return { algo: "ecdsa-p521-sha512" }; + throw new Error(`unsupported EC curve: ${curve}`); + } + throw new Error(`unsupported asymmetricKeyType: ${akt}`); +} + +function signOneShot(algorithm, data, key) { + const ko = key instanceof KeyObject ? key : createPrivateKey(key); + const padding = (key && typeof key === "object" && !(key instanceof KeyObject)) ? key.padding : null; + const { algo } = chooseSignAlgo(ko, algorithm, padding); + const dsaEncoding = (key && typeof key === "object" && !(key instanceof KeyObject) && key.dsaEncoding) || "der"; + const format = algo.startsWith("ecdsa-") ? dsaEncoding : "der"; + const sig = ops.op_crypto_sign_der(algo, "private-pkcs8", ko._der, asBytes(data), format); + return Buffer.from(sig); +} + +function verifyOneShot(algorithm, data, key, signature) { + const ko = key instanceof KeyObject ? key : createPublicKey(key); + const padding = (key && typeof key === "object" && !(key instanceof KeyObject)) ? key.padding : null; + const { algo } = chooseSignAlgo(ko, algorithm, padding); + const dsaEncoding = (key && typeof key === "object" && !(key instanceof KeyObject) && key.dsaEncoding) || "auto"; + const format = algo.startsWith("ecdsa-") ? dsaEncoding : "der"; + return ops.op_crypto_verify_der(algo, "public-spki", ko._der, asBytes(data), asBytes(signature), format); +} + +function diffieHellman(opts) { + if (!opts || !(opts.privateKey instanceof KeyObject) || !(opts.publicKey instanceof KeyObject)) { + throw new TypeError("diffieHellman requires {privateKey, publicKey} KeyObjects"); + } + const priv = opts.privateKey; + const pub = opts.publicKey; + const akt = priv.asymmetricKeyType; + if (akt !== pub.asymmetricKeyType) { + const err = new Error("diffieHellman: incompatible key types"); + err.code = "ERR_CRYPTO_INCOMPATIBLE_KEY"; + throw err; + } + if (akt === "x25519") { + return Buffer.from(ops.op_crypto_x25519_derive(priv._der, pub._der)); + } + if (akt === "ec") { + const curveJose = namedToJoseCurve(priv._info.namedCurve); + return Buffer.from(ops.op_crypto_ecdh_derive(curveJose, priv._der, pub._der)); + } + throw new Error(`diffieHellman not supported for type: ${akt}`); +} + +class ECDH { + constructor(curve) { + const named = curveToNamed(curve); + if (!["prime256v1", "secp384r1", "secp521r1"].includes(named)) { + throw new Error(`unsupported ECDH curve: ${curve}`); + } + this._curve = named; + this._joseCurve = namedToJoseCurve(named); + this._privDer = null; + this._pubDer = null; + this._privRaw = null; + this._pubRaw = null; + } + generateKeys(encoding, format) { + const r = ops.op_crypto_ecdh_generate(this._joseCurve); + this._privDer = r.privateKey; + this._pubDer = r.publicKey; + this._privRaw = r.privateRaw; + this._pubRaw = r.publicRaw; + return this.getPublicKey(encoding, format); + } + getPublicKey(encoding, _format) { + if (!this._pubRaw) throw new Error("ECDH: keys not generated"); + const buf = Buffer.from(this._pubRaw); + return encoding ? buf.toString(encoding) : buf; + } + getPrivateKey(encoding) { + if (!this._privRaw) throw new Error("ECDH: keys not generated"); + const buf = Buffer.from(this._privRaw); + return encoding ? buf.toString(encoding) : buf; + } + setPrivateKey(priv, encoding) { + const raw = typeof priv === "string" ? Buffer.from(priv, encoding || "hex") : asBytes(priv); + const r = ops.op_crypto_ecdh_from_raw(this._joseCurve, raw); + this._privDer = r.privateKey; + this._pubDer = r.publicKey; + this._privRaw = r.privateRaw; + this._pubRaw = r.publicRaw; + return this; + } + computeSecret(otherPub, inEnc, outEnc) { + if (!this._privRaw) throw new Error("ECDH: keys not generated"); + let pubRaw; + if (typeof otherPub === "string") pubRaw = Buffer.from(otherPub, inEnc || "hex"); + else pubRaw = asBytes(otherPub); + const secret = Buffer.from(ops.op_crypto_ecdh_compute_raw(this._joseCurve, this._privRaw, pubRaw)); + return outEnc ? secret.toString(outEnc) : secret; + } +} + +function createECDH(curve) { return new ECDH(curve); } + +function createDiffieHellman(_a, _b, _c, _d) { + const err = new Error("createDiffieHellman is not supported in nexide"); + err.code = "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM"; + throw err; +} + +function createDiffieHellmanGroup(_name) { + const err = new Error("createDiffieHellmanGroup is not supported in nexide"); + err.code = "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM"; + throw err; +} + +function hkdfSync(digest, ikm, salt, info, keylen) { + const ikmBuf = ikm instanceof KeyObject && ikm._type === "secret" ? ikm._secret : asBytes(ikm); + const out = ops.op_crypto_hkdf( + normalizeDigestName(digest), + ikmBuf, + asBytes(salt || new Uint8Array(0)), + asBytes(info || new Uint8Array(0)), + keylen, + ); + return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength); +} + +function hkdf(digest, ikm, salt, info, keylen, callback) { + queueMicrotask(() => { + try { callback(null, hkdfSync(digest, ikm, salt, info, keylen)); } catch (err) { callback(err); } + }); +} + module.exports = { createHash, createHmac, @@ -430,7 +939,40 @@ module.exports = { Crypto, CryptoKey, SubtleCrypto, - constants: {}, + KeyObject, + createPrivateKey, + createPublicKey, + createSecretKey, + generateKeyPair, + generateKeyPairSync, + generateKey, + generateKeySync, + diffieHellman, + createDiffieHellman, + createDiffieHellmanGroup, + createECDH, + ECDH, + privateDecrypt, + publicEncrypt, + privateEncrypt, + publicDecrypt, + sign: signOneShot, + verify: verifyOneShot, + hkdf, + hkdfSync, + getCipherInfo: () => null, + getCurves: () => ["prime256v1", "secp384r1", "secp521r1"], + getFips: () => 0, + setFips: () => {}, + constants: { + RSA_PKCS1_PADDING: 1, + RSA_NO_PADDING: 3, + RSA_PKCS1_OAEP_PADDING: 4, + RSA_PKCS1_PSS_PADDING: 6, + RSA_PSS_SALTLEN_DIGEST: -1, + RSA_PSS_SALTLEN_MAX_SIGN: -2, + RSA_PSS_SALTLEN_AUTO: -2, + }, getCiphers: () => [ "aes-128-cbc", "aes-192-cbc", "aes-256-cbc", "aes-128-ctr", "aes-256-ctr", @@ -438,4 +980,3 @@ module.exports = { ], getHashes: () => ["sha1", "sha256", "sha384", "sha512", "md5"], }; - diff --git a/crates/nexide/runtime/polyfills/node/events.js b/crates/nexide/runtime/polyfills/node/events.js index af80699..4acdff8 100644 --- a/crates/nexide/runtime/polyfills/node/events.js +++ b/crates/nexide/runtime/polyfills/node/events.js @@ -74,6 +74,17 @@ EventEmitter.prototype._addListener = function _addListener(type, listener, prep this._events = Object.create(null); this._eventsCount = 0; } + // Node fires `newListener` BEFORE the listener is registered so the + // handler observes the pre-add state. We unwrap `once` wrappers so + // the user-visible function is reported, matching upstream behavior + // (see lib/events.js `emit('newListener', ...)`). + if (this._events.newListener !== undefined) { + this.emit("newListener", type, listener.listener ? listener.listener : listener); + if (!this._events) { + this._events = Object.create(null); + this._eventsCount = 0; + } + } const existing = this._events[type]; if (!existing) { this._events[type] = listener; @@ -126,7 +137,9 @@ EventEmitter.prototype.removeListener = function removeListener(type, listener) if (!events) return this; const existing = events[type]; if (!existing) return this; + let removed = null; if (existing === listener || existing.listener === listener) { + removed = existing.listener || existing; delete events[type]; this._eventsCount--; } else if (Array.isArray(existing)) { @@ -138,6 +151,7 @@ EventEmitter.prototype.removeListener = function removeListener(type, listener) } } if (position < 0) return this; + removed = existing[position].listener || existing[position]; if (existing.length === 1) { delete events[type]; this._eventsCount--; @@ -145,6 +159,9 @@ EventEmitter.prototype.removeListener = function removeListener(type, listener) existing.splice(position, 1); } } + if (removed && events.removeListener !== undefined) { + this.emit("removeListener", type, removed); + } return this; }; EventEmitter.prototype.off = function off(type, listener) { diff --git a/crates/nexide/runtime/polyfills/node/fs.js b/crates/nexide/runtime/polyfills/node/fs.js index bc530f9..ee5f1eb 100644 --- a/crates/nexide/runtime/polyfills/node/fs.js +++ b/crates/nexide/runtime/polyfills/node/fs.js @@ -125,6 +125,84 @@ function rmSync(p, opts) { function unlinkSync(p) { fsCall(ops.op_fs_rm, pathStr(p), false); } function copyFileSync(src, dst) { fsCall(ops.op_fs_copy, pathStr(src), pathStr(dst)); } function readlinkSync(p) { return fsCall(ops.op_fs_readlink, pathStr(p)); } +function renameSync(src, dst) { fsCall(ops.op_fs_rename, pathStr(src), pathStr(dst)); } +function chmodSync(p, mode) { fsCall(ops.op_fs_chmod, pathStr(p), Number(mode) >>> 0); } +function symlinkSync(target, linkPath) { fsCall(ops.op_fs_symlink, String(target), pathStr(linkPath)); } +function linkSync(existingPath, newPath) { fsCall(ops.op_fs_link, pathStr(existingPath), pathStr(newPath)); } +function truncateSync(p, len) { fsCall(ops.op_fs_truncate, pathStr(p), Number(len) || 0); } +function utimesSync(p, atime, mtime) { + const toMs = (t) => t instanceof Date ? t.getTime() : Number(t) * 1000; + fsCall(ops.op_fs_utimes, pathStr(p), toMs(atime), toMs(mtime)); +} + +function appendFileSync(p, data, opts) { + const encoding = typeof opts === "string" ? opts : opts && opts.encoding; + fsCall(ops.op_fs_append, pathStr(p), toBuf(data, encoding)); +} + +// Async ops use tokio::fs and don't block the JS pump thread - the +// promise APIs prefer them when available, falling back to the sync +// op via queueMicrotask if the async op isn't installed (older +// embedder builds). +function asyncOr(asyncFnName, syncFn) { + const asyncFn = ops[asyncFnName]; + if (typeof asyncFn !== "function") { + return (...args) => new Promise((resolve, reject) => { + queueMicrotask(() => { + try { resolve(syncFn(...args)); } catch (err) { reject(err); } + }); + }); + } + return (...args) => Promise.resolve(asyncFn(...args)).catch((err) => { + if (err && !err.code) { + const msg = String(err.message || ""); + for (const c of FS_CODES) { + if (msg.startsWith(c)) { err.code = c; break; } + } + } + throw err; + }); +} + +const readFileAsync = asyncOr("op_fs_read_async", (p, opts) => { + const encoding = typeof opts === "string" ? opts : opts && opts.encoding; + return decodeMaybe(ops.op_fs_read(pathStr(p)), encoding); +}); +const writeFileAsync = asyncOr("op_fs_write_async", (p, data, opts) => { + const encoding = typeof opts === "string" ? opts : opts && opts.encoding; + ops.op_fs_write(pathStr(p), toBuf(data, encoding)); +}); +const appendFileAsync = asyncOr("op_fs_append_async", (p, data, opts) => { + const encoding = typeof opts === "string" ? opts : opts && opts.encoding; + ops.op_fs_append(pathStr(p), toBuf(data, encoding)); +}); +const statAsyncRaw = asyncOr("op_fs_stat_async", (p, follow) => + ops.op_fs_stat(pathStr(p), follow)); +const readdirAsyncRaw = asyncOr("op_fs_readdir_async", (p) => + ops.op_fs_readdir(pathStr(p))); +const mkdirAsync = asyncOr("op_fs_mkdir_async", (p, recursive) => + ops.op_fs_mkdir(pathStr(p), recursive)); +const rmAsync = asyncOr("op_fs_rm_async", (p, recursive) => + ops.op_fs_rm(pathStr(p), recursive)); +const copyFileAsync = asyncOr("op_fs_copy_async", (src, dst) => + ops.op_fs_copy(pathStr(src), pathStr(dst))); +const renameAsync = asyncOr("op_fs_rename_async", (src, dst) => + ops.op_fs_rename(pathStr(src), pathStr(dst))); +const realpathAsync = asyncOr("op_fs_realpath_async", (p) => + ops.op_fs_realpath(pathStr(p))); + +function readFileWithEncoding(p, opts) { + const encoding = typeof opts === "string" ? opts : opts && opts.encoding; + return readFileAsync(pathStr(p)).then((bytes) => decodeMaybe(bytes, encoding)); +} +function writeFileWithEncoding(p, data, opts) { + const encoding = typeof opts === "string" ? opts : opts && opts.encoding; + return writeFileAsync(pathStr(p), toBuf(data, encoding)); +} +function appendFileWithEncoding(p, data, opts) { + const encoding = typeof opts === "string" ? opts : opts && opts.encoding; + return appendFileAsync(pathStr(p), toBuf(data, encoding)); +} function notAvailable(name) { const err = new Error( @@ -165,21 +243,53 @@ const promisify = (fn) => (...args) => }); const promises = { - readFile: promisify(readFileSync), - writeFile: promisify(writeFileSync), - stat: promisify(statSync), - lstat: promisify(lstatSync), - readdir: promisify(readdirSync), - mkdir: promisify(mkdirSync), - rm: promisify(rmSync), - unlink: promisify(unlinkSync), - copyFile: promisify(copyFileSync), - readlink: promisify(readlinkSync), - realpath: promisify(realpathSync), - access: promisify((p) => { - if (!existsSync(p)) { - const e = new Error(`ENOENT: ${pathStr(p)}`); e.code = "ENOENT"; throw e; + readFile: readFileWithEncoding, + writeFile: writeFileWithEncoding, + stat: (p) => statAsyncRaw(pathStr(p), true).then(makeStats), + lstat: (p) => statAsyncRaw(pathStr(p), false).then(makeStats), + readdir: (p, opts) => readdirAsyncRaw(pathStr(p)).then((entries) => { + if (opts && opts.withFileTypes) { + return entries.map((e) => ({ + name: e.name, + isFile: () => !e.is_dir && !e.is_symlink, + isDirectory: () => e.is_dir, + isSymbolicLink: () => e.is_symlink, + })); } + return entries.map((e) => e.name); + }), + mkdir: (p, opts) => mkdirAsync( + pathStr(p), + typeof opts === "object" && opts ? Boolean(opts.recursive) : false, + ), + rm: (p, opts) => rmAsync( + pathStr(p), + typeof opts === "object" && opts ? Boolean(opts.recursive) : false, + ), + unlink: (p) => rmAsync(pathStr(p), false), + copyFile: (src, dst) => copyFileAsync(pathStr(src), pathStr(dst)), + readlink: promisify(readlinkSync), + realpath: (p) => realpathAsync(pathStr(p)), + rename: (src, dst) => renameAsync(pathStr(src), pathStr(dst)), + appendFile: appendFileWithEncoding, + chmod: (p, mode) => new Promise((resolve, reject) => { + queueMicrotask(() => { try { chmodSync(p, mode); resolve(); } catch (e) { reject(e); } }); + }), + symlink: (t, l) => new Promise((resolve, reject) => { + queueMicrotask(() => { try { symlinkSync(t, l); resolve(); } catch (e) { reject(e); } }); + }), + link: (e, n) => new Promise((resolve, reject) => { + queueMicrotask(() => { try { linkSync(e, n); resolve(); } catch (err) { reject(err); } }); + }), + truncate: (p, len) => new Promise((resolve, reject) => { + queueMicrotask(() => { try { truncateSync(p, len); resolve(); } catch (e) { reject(e); } }); + }), + utimes: (p, a, m) => new Promise((resolve, reject) => { + queueMicrotask(() => { try { utimesSync(p, a, m); resolve(); } catch (e) { reject(e); } }); + }), + access: (p) => statAsyncRaw(pathStr(p), true).then(() => undefined, (err) => { + if (err && err.code) throw err; + const e = new Error(`ENOENT: ${pathStr(p)}`); e.code = "ENOENT"; throw e; }), }; @@ -214,6 +324,8 @@ const lstat = callback(lstatSync); const readdir = callback(readdirSync); const readFile = callback(readFileSync); const writeFile = callback(writeFileSync); +const rename = callback(renameSync); +const appendFile = callback(appendFileSync); const access = callback((p) => { if (!existsSync(p)) { const err = new Error(`ENOENT: no such file or directory, access '${p}'`); @@ -228,8 +340,16 @@ const exists = (p, cb) => { module.exports = { readFileSync, writeFileSync, existsSync, statSync, lstatSync, realpathSync, readdirSync, mkdirSync, rmSync, unlinkSync, copyFileSync, readlinkSync, + renameSync, appendFileSync, + chmodSync, symlinkSync, linkSync, truncateSync, utimesSync, + chmod: callback(chmodSync), + symlink: callback(symlinkSync), + link: callback(linkSync), + truncate: callback(truncateSync), + utimes: callback(utimesSync), createReadStream, createWriteStream, realpath, stat, lstat, readdir, readFile, writeFile, access, exists, + rename, appendFile, watch: () => notAvailable("fs.watch"), watchFile: () => notAvailable("fs.watchFile"), promises, diff --git a/crates/nexide/runtime/polyfills/node/http.js b/crates/nexide/runtime/polyfills/node/http.js index a44754e..cbaed4d 100644 --- a/crates/nexide/runtime/polyfills/node/http.js +++ b/crates/nexide/runtime/polyfills/node/http.js @@ -28,7 +28,198 @@ // end-of-stream. const EventEmitter = require("node:events"); -const { Readable, Writable } = require("node:stream"); +const { Readable, Writable, Duplex } = require("node:stream"); +const { Buffer } = require("node:buffer"); + +const UPGRADE_SOCKET_ID_HEADER = "x-nexide-upgrade-socket-id"; + +// Node-shaped raw TCP socket exposed to `'upgrade'` event listeners. +// +// Lifecycle: +// +// - **pre-handshake**: the JS upgrade listener (typically the +// `ws` library's `WebSocketServer.handleUpgrade`) writes a +// complete HTTP/1.1 status + headers blob to the socket. We +// accumulate those bytes, parse them once `\r\n\r\n` arrives, +// and commit them as a real `synthRes.writeHead` + `synthRes.end` +// so the Rust shield emits the 101 on the wire. That 101 flush +// is what causes hyper's `OnUpgrade` to resolve and the Rust +// side to attach the upgraded TCP stream into the socket +// registry. +// +// - **post-handshake**: subsequent `socket.write()` calls forward +// bytes through `op_upgrade_socket_write_async`, which queues +// them onto the upgraded stream (with a Rust-side buffer in +// case `attach_upgraded` has not run yet). The reader pump +// continuously calls `op_upgrade_socket_read_async`, parking +// in Rust until the upgrade has resolved. +// +// The Duplex superclass takes care of `'data'` listener +// dispatching, backpressure, `pipe()`, and graceful `end()`. +class UpgradeSocket extends Duplex { + constructor(socketId, synthRes) { + super({ + allowHalfOpen: true, + write: (chunk, enc, cb) => this._write(chunk, enc, cb), + }); + this._socketId = socketId; + this._synthRes = synthRes; + this._state = "pre-handshake"; + this._headBuffer = []; + this._headBufferLen = 0; + this._pumping = false; + this.readable = true; + this.writable = true; + this.encrypted = false; + this.remoteAddress = "127.0.0.1"; + this.remotePort = 0; + this.localAddress = "127.0.0.1"; + this.localPort = 0; + } + + setNoDelay() { return this; } + setKeepAlive() { return this; } + setTimeout(_ms, cb) { + if (typeof cb === "function") this.once("timeout", cb); + return this; + } + ref() { return this; } + unref() { return this; } + address() { return { port: this.localPort, family: "IPv4", address: this.localAddress }; } + + _write(chunk, _enc, cb) { + if (this._state === "destroyed") { + cb(new Error("socket has been destroyed")); + return; + } + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (this._state === "pre-handshake") { + this._handlePreHandshakeWrite(buf, cb); + return; + } + this._sendPostHandshake(buf, cb); + } + + _handlePreHandshakeWrite(buf, cb) { + this._headBuffer.push(buf); + this._headBufferLen += buf.length; + const concat = this._headBufferLen === buf.length + ? buf + : Buffer.concat(this._headBuffer, this._headBufferLen); + const headEnd = concat.indexOf("\r\n\r\n"); + if (headEnd < 0) { + // Soft cap to avoid unbounded buffering on malformed input. + if (this._headBufferLen > 64 * 1024) { + cb(new Error("upgrade handshake exceeds 64 KiB without CRLFCRLF")); + return; + } + cb(); + return; + } + const headBytes = concat.slice(0, headEnd); + const tail = concat.slice(headEnd + 4); + let parsed; + try { + parsed = parseHttpResponseHead(headBytes); + } catch (err) { + cb(err); + return; + } + try { + this._synthRes.writeHead(parsed.status, parsed.headers); + this._synthRes.end(); + } catch (err) { + cb(err); + return; + } + this._state = "post-handshake"; + this._headBuffer = null; + this.emit("handshake-committed"); + this._startReaderPump(); + if (tail.length > 0) { + this._sendPostHandshake(tail, cb); + return; + } + cb(); + } + + _sendPostHandshake(buf, cb) { + Nexide.core.ops + .op_upgrade_socket_write_async(this._socketId, buf) + .then(() => cb(), (err) => cb(err)); + } + + _read() { /* reader pump drives push() */ } + + _startReaderPump() { + if (this._pumping) return; + this._pumping = true; + const loop = async () => { + while (this._state === "post-handshake") { + let chunk; + try { + chunk = await Nexide.core.ops.op_upgrade_socket_read_async(this._socketId); + } catch (err) { + this.destroy(err); + return; + } + if (chunk === null) { + this._state = "ended"; + this.push(null); + return; + } + if (!this.push(Buffer.from(chunk))) { + // backpressure — wait for _read to be called again + await new Promise((resolve) => this.once("drain-resume", resolve)); + } + } + }; + loop().catch((err) => this.destroy(err)); + } + + _final(cb) { + if (this._state === "post-handshake" || this._state === "ended") { + try { Nexide.core.ops.op_upgrade_socket_close(this._socketId); } catch { /* noop */ } + } + this._state = "destroyed"; + cb(); + } + + _destroy(err, cb) { + if (this._state !== "destroyed") { + try { Nexide.core.ops.op_upgrade_socket_close(this._socketId); } catch { /* noop */ } + this._state = "destroyed"; + } + cb(err); + } +} + +function parseHttpResponseHead(buf) { + // Accept either a full status line + headers or just headers. + // `ws` always writes `HTTP/1.1 101 ...` first. We parse the status + // line if present; otherwise default to 101. + const text = buf.toString("latin1"); + const lines = text.split("\r\n"); + let statusCode = 101; + let firstHeaderLine = 0; + if (lines.length > 0 && /^HTTP\//.test(lines[0])) { + const m = /^HTTP\/\d\.\d\s+(\d{3})/.exec(lines[0]); + if (!m) throw new Error("malformed HTTP status line in upgrade write"); + statusCode = Number(m[1]); + firstHeaderLine = 1; + } + const headers = []; + for (let i = firstHeaderLine; i < lines.length; i++) { + const line = lines[i]; + if (line === "") continue; + const idx = line.indexOf(":"); + if (idx < 0) throw new Error(`malformed header in upgrade write: ${line}`); + const name = line.slice(0, idx).trim().toLowerCase(); + const value = line.slice(idx + 1).trim(); + headers.push([name, value]); + } + return { status: statusCode, headers }; +} const STATUS_CODES = { 100: "Continue", @@ -91,7 +282,17 @@ class IncomingMessage extends Readable { this.trailers = Object.create(null); this.rawTrailers = []; this.complete = false; - this.socket = { remoteAddress: undefined, remotePort: undefined }; + this.socket = { + remoteAddress: undefined, + remotePort: undefined, + setTimeout(_ms, cb) { if (typeof cb === "function") this._timeoutCb = cb; return this; }, + setNoDelay(_enable) { return this; }, + setKeepAlive(_enable, _initialDelay) { return this; }, + ref() { return this; }, + unref() { return this; }, + destroy() {}, + end() {}, + }; this.connection = this.socket; synth.on("data", (chunk) => this.push(chunk)); @@ -340,17 +541,100 @@ class Server extends EventEmitter { this._address = { port, family: "IPv4", address: host }; this._listening = true; - const adapter = async (synthReq, synthRes) => { + const adapter = (synthReq, synthRes) => { const req = new IncomingMessage(synthReq); const res = new ServerResponse(synthRes); res.req = req; + const upgradeHeader = req.headers && req.headers.upgrade; + const upgradeSocketIdRaw = req.headers && req.headers[UPGRADE_SOCKET_ID_HEADER]; + const upgradeSocketId = upgradeSocketIdRaw === undefined + ? null + : Number(upgradeSocketIdRaw); + if (upgradeHeader) { + const upgradeListeners = this.listeners("upgrade"); + if (upgradeListeners.length > 0 && upgradeSocketId !== null + && Number.isFinite(upgradeSocketId)) { + // Strip the synthetic header so user code does not see it. + if (req.headers) delete req.headers[UPGRADE_SOCKET_ID_HEADER]; + if (Array.isArray(req.rawHeaders)) { + for (let i = req.rawHeaders.length - 2; i >= 0; i -= 2) { + if (String(req.rawHeaders[i]).toLowerCase() === UPGRADE_SOCKET_ID_HEADER) { + req.rawHeaders.splice(i, 2); + } + } + } + const socket = new UpgradeSocket(upgradeSocketId, synthRes); + req.socket = socket; + req.connection = socket; + // The dispatch promise resolves once the user has either + // committed the 101 (via socket.write of the head) or torn + // the socket down. We resolve when synthRes finishes so the + // Rust shield can flush headers; subsequent socket I/O is + // independent of this promise. + const ready = new Promise((resolve) => { + let settled = false; + const settle = () => { if (!settled) { settled = true; resolve(); } }; + if (typeof synthRes.__isEnded === "function" && synthRes.__isEnded()) { + settle(); + return; + } + socket.once("handshake-committed", settle); + socket.once("close", settle); + socket.once("error", settle); + // Safety net: if no head is written within a long window + // we still let the response complete so hyper sends a + // sane status. + setTimeout(() => { + if (!settled && !(typeof synthRes.__isEnded === "function" && synthRes.__isEnded())) { + try { + res.statusCode = 500; + res.setHeader("Connection", "close"); + res.end("upgrade listener did not commit a handshake"); + } catch { /* noop */ } + settle(); + } + }, 30_000).unref?.(); + }); + for (const fn of upgradeListeners) { + try { fn(req, socket, Buffer.alloc(0)); } catch (e) { this.emit("error", e); } + } + return ready; + } + if (upgradeListeners.length > 0) { + // Listener present but no socket id (e.g. transport that + // does not support hyper upgrade). Best-effort fallback: + // emit with a null socket and let the listener decide. + for (const fn of upgradeListeners) { + try { fn(req, null, Buffer.alloc(0)); } catch (e) { this.emit("error", e); } + } + if (!res._ended && !res._destroyed) { + res.statusCode = 501; + res.setHeader("Connection", "close"); + res.end("Upgrade not supported on this connection"); + } + return Promise.resolve(); + } + if (this.listenerCount("request") === 0) { + res.statusCode = 501; + res.setHeader("Connection", "close"); + res.end("Upgrade not supported by this nexide build"); + return Promise.resolve(); + } + } const listeners = this.listeners("request"); + const pending = []; for (const fn of listeners) { const ret = fn(req, res); - if (ret && typeof ret.then === "function") { - await ret; - } + if (ret && typeof ret.then === "function") pending.push(ret); } + return Promise.all(pending).then( + () => new Promise((resolve) => { + if (res._ended || res._destroyed) { resolve(); return; } + const done = () => resolve(); + res.once("finish", done); + res.once("close", done); + }), + ); }; this._token = globalThis.__nexide.pushHandler(adapter); @@ -467,21 +751,48 @@ class ClientRequest extends Writable { this._headers = this._headers.filter(([k]) => k.toLowerCase() !== lower); } - _write(chunk, _encoding, callback) { - if (chunk === null || chunk === undefined) { - callback(); - return; + setTimeout(_ms, cb) { + if (typeof cb === "function") this.once("timeout", cb); + return this; + } + + setNoDelay(_enable) { return this; } + + setSocketKeepAlive(_enable, _initialDelay) { return this; } + + write(chunk, encoding, callback) { + if (typeof encoding === "function") { callback = encoding; encoding = undefined; } + if (chunk == null) { if (callback) callback(); return true; } + let buf; + if (chunk instanceof Uint8Array) { + buf = chunk; + } else if (chunk instanceof ArrayBuffer) { + buf = new Uint8Array(chunk); + } else if (ArrayBuffer.isView(chunk)) { + buf = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + } else if (typeof chunk === "string") { + buf = new TextEncoder().encode(chunk); + } else { + buf = new TextEncoder().encode(String(chunk)); } - const buf = chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(String(chunk)); this._chunks.push(buf); + if (callback) callback(); + return true; + } + + _write(chunk, _encoding, callback) { + this.write(chunk); callback(); } end(chunk, encoding, callback) { + if (typeof chunk === "function") { callback = chunk; chunk = undefined; encoding = undefined; } + else if (typeof encoding === "function") { callback = encoding; encoding = undefined; } if (chunk !== undefined && chunk !== null) { this.write(chunk, encoding); } - super.end(undefined, undefined, callback); + this._ended = true; + if (callback) callback(); this._sent = true; this._dispatchIfReady(); } diff --git a/crates/nexide/runtime/polyfills/node/http2.js b/crates/nexide/runtime/polyfills/node/http2.js index 189ee15..3dc8008 100644 --- a/crates/nexide/runtime/polyfills/node/http2.js +++ b/crates/nexide/runtime/polyfills/node/http2.js @@ -1,45 +1,397 @@ "use strict"; -// node:http2 - nexide does not implement HTTP/2 server/client. Many -// libraries probe for it via `try { require('http2') } catch {}` and -// silently fall back to HTTP/1.1, so we expose the module surface but -// throw on actual use rather than at require-time. +// node:http2 - client-side compatibility layer. +// +// Real frame-level h2 (multiplexing, server push, SETTINGS frames, +// PING, flow control windows visible to user code) requires a +// dedicated bridge to a JS-side h2 codec. nexide does not have +// that yet. However, the underlying `op_http_request` op is backed +// by reqwest + hyper-h2, which transparently negotiates HTTP/2 via +// ALPN whenever the remote endpoint advertises it. That covers the +// vast majority of real-world `node:http2` client usage: gRPC, +// Apollo, REST-over-h2 clients, server-sent events, etc. +// +// This polyfill therefore implements the *client* surface +// (`connect()`, `session.request()`, the resulting Http2Stream as +// a Duplex) on top of `op_http_request`. Each `request()` call +// becomes one logical HTTP request; reqwest's connection pool +// reuses the same h2 connection across calls to the same authority, +// so multiplexing is preserved at the transport layer even though +// JS sees one stream per request. +// +// Server-side (`createServer`, `createSecureServer`) and advanced +// features (server push, ALTSVC, PRIORITY, raw frame sends) remain +// unsupported and throw `ERR_HTTP2_NOT_SUPPORTED` when invoked. -function unsupported() { - throw new Error("nexide: node:http2 is not implemented in this runtime"); -} +const EventEmitter = require("node:events"); +const { Readable, Duplex } = require("node:stream"); +const { Buffer } = require("node:buffer"); -class Http2Session { constructor() { unsupported(); } } -class ServerHttp2Session extends Http2Session {} -class ClientHttp2Session extends Http2Session {} -class Http2Stream { constructor() { unsupported(); } } -class Http2Server { constructor() { unsupported(); } } -class Http2SecureServer { constructor() { unsupported(); } } +const sensitiveHeaders = Symbol.for("nodejs.http2.sensitiveHeaders"); const constants = Object.freeze({ NGHTTP2_NO_ERROR: 0, NGHTTP2_PROTOCOL_ERROR: 1, NGHTTP2_INTERNAL_ERROR: 2, + NGHTTP2_FLOW_CONTROL_ERROR: 3, + NGHTTP2_SETTINGS_TIMEOUT: 4, + NGHTTP2_STREAM_CLOSED: 5, + NGHTTP2_FRAME_SIZE_ERROR: 6, + NGHTTP2_REFUSED_STREAM: 7, + NGHTTP2_CANCEL: 8, + NGHTTP2_COMPRESSION_ERROR: 9, + NGHTTP2_CONNECT_ERROR: 10, + NGHTTP2_ENHANCE_YOUR_CALM: 11, + NGHTTP2_INADEQUATE_SECURITY: 12, + NGHTTP2_HTTP_1_1_REQUIRED: 13, HTTP2_HEADER_STATUS: ":status", HTTP2_HEADER_METHOD: ":method", HTTP2_HEADER_PATH: ":path", HTTP2_HEADER_AUTHORITY: ":authority", HTTP2_HEADER_SCHEME: ":scheme", + HTTP2_HEADER_CONTENT_LENGTH: "content-length", + HTTP2_HEADER_CONTENT_TYPE: "content-type", + NGHTTP2_SESSION_CLIENT: 0, + NGHTTP2_SESSION_SERVER: 1, }); +function unsupported(feature) { + const err = new Error( + `nexide: node:http2 ${feature} is not implemented. The client ` + + "subset (connect/request/response) is supported on top of the " + + "shared HTTP/2-aware reqwest client; server-side h2 and raw " + + "frame-level APIs require a dedicated codec bridge that is " + + "not yet wired up.", + ); + err.code = "ERR_HTTP2_NOT_SUPPORTED"; + return err; +} + +function normaliseAuthority(authority) { + if (typeof authority === "string") { + if (!/^[a-z][a-z0-9+\-.]*:\/\//i.test(authority)) { + authority = `https://${authority}`; + } + return new URL(authority); + } + if (authority instanceof URL) return authority; + throw new TypeError("authority must be a string or URL"); +} + +class ClientHttp2Session extends EventEmitter { + constructor(authority, options = {}) { + super(); + this._authority = normaliseAuthority(authority); + this._options = options || {}; + this._closed = false; + this._destroyed = false; + this._pendingStreams = new Set(); + this.encrypted = this._authority.protocol === "https:"; + this.alpnProtocol = this.encrypted ? "h2" : "h2c"; + this.connecting = false; + this.closed = false; + this.destroyed = false; + this.type = constants.NGHTTP2_SESSION_CLIENT; + queueMicrotask(() => { + if (!this._destroyed) this.emit("connect", this, /* socket */ null); + }); + } + + request(headers, options) { + if (this._destroyed) { + throw new Error("ClientHttp2Session has been destroyed"); + } + headers = headers || {}; + const method = (headers[":method"] || "GET").toUpperCase(); + const path = headers[":path"] || "/"; + const scheme = headers[":scheme"] || this._authority.protocol.replace(/:$/, ""); + const authority = headers[":authority"] || this._authority.host; + const url = `${scheme}://${authority}${path}`; + + const flatHeaders = []; + for (const [name, value] of Object.entries(headers)) { + if (name.startsWith(":")) continue; + if (Array.isArray(value)) { + for (const v of value) flatHeaders.push([name, String(v)]); + } else if (value !== undefined && value !== null) { + flatHeaders.push([name, String(value)]); + } + } + + const stream = new ClientHttp2Stream(this, { + method, + url, + headers: flatHeaders, + endStream: Boolean(options && options.endStream), + }); + this._pendingStreams.add(stream); + stream.once("close", () => this._pendingStreams.delete(stream)); + return stream; + } + + ping(payload, callback) { + if (typeof payload === "function") { + callback = payload; + payload = undefined; + } + queueMicrotask(() => { + if (callback) callback(null, 0, payload || Buffer.alloc(8)); + }); + return true; + } + + setTimeout(_msecs, callback) { + if (callback) this.on("timeout", callback); + return this; + } + + goaway() { /* no-op: connection lifecycle is managed by reqwest pool */ } + + settings(_settings, callback) { + queueMicrotask(() => { + if (callback) callback(null, {}, 0); + }); + } + + close(callback) { + if (this._closed) { + if (callback) queueMicrotask(callback); + return; + } + this._closed = true; + this.closed = true; + const wait = () => { + if (this._pendingStreams.size === 0) { + this.emit("close"); + if (callback) callback(); + } else { + for (const s of this._pendingStreams) s.once("close", wait); + } + }; + wait(); + } + + destroy(error, _code) { + if (this._destroyed) return; + this._destroyed = true; + this.destroyed = true; + for (const s of this._pendingStreams) s.destroy(error); + this._pendingStreams.clear(); + if (error) this.emit("error", error); + this.emit("close"); + } +} + +class ClientHttp2Stream extends Duplex { + constructor(session, init) { + super({ allowHalfOpen: true }); + this._session = session; + this.session = session; + this._method = init.method; + this._url = init.url; + this._headers = init.headers; + this._chunks = []; + this._writeEnded = false; + this._dispatched = false; + this._incoming = null; + this._closed = false; + this.id = undefined; + this.aborted = false; + this.destroyed = false; + this.closed = false; + this.pending = true; + this.sentHeaders = headersFromArray(init.headers); + this.rstCode = constants.NGHTTP2_NO_ERROR; + if (init.endStream) { + this._writeEnded = true; + queueMicrotask(() => this._dispatchIfReady()); + } + } + + _write(chunk, _enc, cb) { + if (this._dispatched) { + cb(new Error("cannot write to ClientHttp2Stream after request was dispatched")); + return; + } + this._chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + cb(); + } + + _final(cb) { + this._writeEnded = true; + this._dispatchIfReady(); + cb(); + } + + _dispatchIfReady() { + if (this._dispatched || !this._writeEnded) return; + this._dispatched = true; + const total = this._chunks.reduce((a, b) => a + b.length, 0); + let body = null; + if (total > 0) { + body = new Uint8Array(total); + let off = 0; + for (const c of this._chunks) { + body.set(c, off); + off += c.length; + } + } + Nexide.core.ops + .op_http_request({ + method: this._method, + url: this._url, + headers: this._headers, + body, + }) + .then((resp) => this._onResponse(resp), (err) => this._onError(err)); + } + + _onResponse(resp) { + this.pending = false; + const headers = { ":status": resp.status }; + for (const [name, value] of resp.headers) { + const lower = name.toLowerCase(); + if (headers[lower] === undefined) { + headers[lower] = value; + } else if (Array.isArray(headers[lower])) { + headers[lower].push(value); + } else { + headers[lower] = [headers[lower], value]; + } + } + this._bodyId = resp.bodyId; + this.emit("response", headers, /* flags */ 0); + this._pumpResponse(); + } + + async _pumpResponse() { + while (!this._closed) { + let chunk; + try { + chunk = await Nexide.core.ops.op_http_response_read(this._bodyId); + } catch (err) { + this._onError(err); + return; + } + if (chunk === null) { + this._closed = true; + this.push(null); + Nexide.core.ops.op_http_response_close(this._bodyId); + this.emit("end"); + this.emit("close"); + return; + } + this.push(chunk); + } + } + + _onError(err) { + if (this._closed) return; + this._closed = true; + if (this._bodyId !== undefined) { + try { Nexide.core.ops.op_http_response_close(this._bodyId); } catch { /* noop */ } + } + this.emit("error", err); + this.emit("close"); + } + + _read() { /* pump-driven */ } + + close(code) { + this.rstCode = code || constants.NGHTTP2_NO_ERROR; + if (this._closed) return; + this._closed = true; + if (this._bodyId !== undefined) { + try { Nexide.core.ops.op_http_response_close(this._bodyId); } catch { /* noop */ } + } + this.aborted = true; + this.emit("close"); + } + + destroy(err) { + if (this.destroyed) return; + this.destroyed = true; + this.close(constants.NGHTTP2_CANCEL); + if (err) this.emit("error", err); + return super.destroy(err); + } + + setTimeout(_ms, cb) { + if (cb) this.on("timeout", cb); + return this; + } + + sendTrailers(_trailers) { /* not supported via reqwest's high-level client */ } + + priority(_options) { /* h2 priority frames not exposed */ } +} + +function headersFromArray(arr) { + const out = {}; + for (const [name, value] of arr) { + const lower = name.toLowerCase(); + if (out[lower] === undefined) out[lower] = value; + else if (Array.isArray(out[lower])) out[lower].push(value); + else out[lower] = [out[lower], value]; + } + return out; +} + +function connect(authority, options, listener) { + if (typeof options === "function") { + listener = options; + options = undefined; + } + const session = new ClientHttp2Session(authority, options); + if (listener) session.once("connect", listener); + return session; +} + +class Http2Session extends EventEmitter {} +class ServerHttp2Session extends Http2Session { + constructor() { + super(); + throw unsupported("server-side sessions"); + } +} +class Http2Stream extends Readable {} +class Http2Server extends EventEmitter { + constructor() { + super(); + throw unsupported("createServer"); + } +} +class Http2SecureServer extends EventEmitter { + constructor() { + super(); + throw unsupported("createSecureServer"); + } +} + +function createServer() { throw unsupported("createServer"); } +function createSecureServer() { throw unsupported("createSecureServer"); } + module.exports = { constants, - createServer: unsupported, - createSecureServer: unsupported, - connect: unsupported, - getDefaultSettings: () => ({}), + connect, + createServer, + createSecureServer, + getDefaultSettings: () => ({ + headerTableSize: 4096, + enablePush: false, + initialWindowSize: 65535, + maxFrameSize: 16384, + maxConcurrentStreams: 4294967295, + maxHeaderListSize: 65535, + }), getPackedSettings: () => Buffer.alloc(0), getUnpackedSettings: () => ({}), Http2Session, ServerHttp2Session, ClientHttp2Session, Http2Stream, + ClientHttp2Stream, Http2Server, Http2SecureServer, - sensitiveHeaders: Symbol("nodejs.http2.sensitiveHeaders"), + sensitiveHeaders, }; diff --git a/crates/nexide/runtime/polyfills/node/inspector.js b/crates/nexide/runtime/polyfills/node/inspector.js index d72b2d1..1b55ee2 100644 --- a/crates/nexide/runtime/polyfills/node/inspector.js +++ b/crates/nexide/runtime/polyfills/node/inspector.js @@ -1,13 +1,114 @@ -// node:inspector - minimal stub. Next.js consults this for debugger -// hooks, which are no-ops in our runtime. +// node:inspector - lightweight Inspector emulation. +// +// nexide does not embed the V8 Inspector C++ protocol or speak the +// Chrome DevTools wire format, so this module cannot host a real +// debugger or live CPU profiler. What it can do is route a small +// set of high-frequency `Session.post` calls that APM agents +// (Datadog, Sentry, Elastic) issue at startup to inspect runtime +// state, so those agents observe sensible answers instead of empty +// objects: +// +// * `Runtime.evaluate` - executes the supplied expression in the +// current realm via `vm.runInThisContext` and returns a result +// descriptor matching the inspector wire shape. +// * `Runtime.getHeapUsage` / `Runtime.getHeapStatistics` - +// forwards to `process.memoryUsage()` and returns it in the +// shape the inspector emits. +// * `HeapProfiler.collectGarbage` - calls `globalThis.gc()` if +// available (only when `--expose-gc` is set on the CLI). +// * `Profiler.enable`/`disable` - acknowledged but unsupported. +// +// All other methods reject with an `code: -32601` ("Method not +// found") error, mirroring the Inspector protocol. (function () { + "use strict"; const noop = function () {}; + const vm = require("node:vm"); + + function methodNotFound(method) { + const err = new Error(`'${method}' wasn't found`); + err.code = -32601; + return err; + } + + function handle(method, params) { + switch (method) { + case "Runtime.evaluate": { + const expression = params && params.expression; + if (typeof expression !== "string") { + throw new TypeError("Runtime.evaluate requires `params.expression`"); + } + try { + const value = vm.runInThisContext(expression); + return { + result: { + type: typeof value, + value: value === undefined ? null : value, + description: String(value), + }, + }; + } catch (e) { + return { + exceptionDetails: { + text: String(e && e.message), + exception: { type: "object", description: String(e) }, + }, + }; + } + } + case "Runtime.getHeapUsage": + case "Runtime.getHeapStatistics": { + const m = process.memoryUsage(); + return { + usedSize: m.heapUsed, + totalSize: m.heapTotal, + ...m, + }; + } + case "HeapProfiler.collectGarbage": { + if (typeof globalThis.gc === "function") globalThis.gc(); + return {}; + } + case "Profiler.enable": + case "Profiler.disable": + case "Debugger.enable": + case "Debugger.disable": + case "Runtime.enable": + case "Runtime.disable": + return {}; + default: + throw methodNotFound(method); + } + } + class Session { - connect() {} - disconnect() {} - post(_method, _params, cb) { if (typeof cb === 'function') cb(null, {}); } + constructor() { this._connected = false; } + connect() { this._connected = true; } + connectToMainThread() { this._connected = true; } + disconnect() { this._connected = false; } + post(method, params, cb) { + if (typeof params === "function") { cb = params; params = undefined; } + if (!this._connected) { + const err = new Error("inspector session is not connected"); + if (typeof cb === "function") queueMicrotask(() => cb(err)); + return; + } + try { + const result = handle(String(method), params || {}); + if (typeof cb === "function") queueMicrotask(() => cb(null, result)); + } catch (err) { + if (typeof cb === "function") queueMicrotask(() => cb(err)); + } + } + on() { return this; } + once() { return this; } + off() { return this; } + addListener() { return this; } + removeListener() { return this; } + emit() { return false; } } + module.exports = { Session, open: noop, diff --git a/crates/nexide/runtime/polyfills/node/os.js b/crates/nexide/runtime/polyfills/node/os.js index 163c0ee..fe3b8df 100644 --- a/crates/nexide/runtime/polyfills/node/os.js +++ b/crates/nexide/runtime/polyfills/node/os.js @@ -35,7 +35,11 @@ module.exports = { } return out; }, - networkInterfaces() { return {}; }, + networkInterfaces() { + const op = (typeof Nexide !== "undefined" && Nexide.core && Nexide.core.ops + && Nexide.core.ops.op_os_network_interfaces); + return typeof op === "function" ? op() : {}; + }, loadavg() { return [0, 0, 0]; }, userInfo() { return { diff --git a/crates/nexide/runtime/polyfills/node/stream.js b/crates/nexide/runtime/polyfills/node/stream.js index ca86b4d..649731f 100644 --- a/crates/nexide/runtime/polyfills/node/stream.js +++ b/crates/nexide/runtime/polyfills/node/stream.js @@ -1,11 +1,26 @@ "use strict"; -// node:stream - minimal Readable/Writable/Duplex/Transform/PassThrough -// implementation sufficient for the surface Next.js standalone uses -// (mostly Readable.from + pipeline + finished + simple piping). +// node:stream - Readable/Writable/Duplex/Transform/PassThrough plus +// Web Streams interop and a working high-water-mark / cork model. +// +// Coverage was extended to the surface Next.js App Router streaming +// SSR uses: `Readable.toWeb` / `Readable.fromWeb` (so the route +// handler can return a `ReadableStream` to `axum`), +// `writableHighWaterMark` / backpressure on Writable so a slow client +// doesn't grow the buffer unbounded, and `cork` / `uncork` so +// renderers that batch many small writes don't emit a per-byte event. const EventEmitter = require("node:events"); +const DEFAULT_HWM = 16 * 1024; + +function chunkLen(c) { + if (c == null) return 0; + if (typeof c === "string") return c.length; + if (typeof c.byteLength === "number") return c.byteLength; + return 0; +} + class Readable extends EventEmitter { constructor(opts = {}) { super(); @@ -16,9 +31,16 @@ class Readable extends EventEmitter { this._destroyed = false; this._readImpl = opts.read; this._encoding = null; + this._highWaterMark = typeof opts.highWaterMark === "number" + ? opts.highWaterMark : DEFAULT_HWM; + this._bufferedBytes = 0; } - static from(iterable) { - const r = new Readable(); + get readableHighWaterMark() { return this._highWaterMark; } + get readableLength() { return this._bufferedBytes; } + get readableEnded() { return this._ended && this._buffer.length === 0; } + get destroyed() { return this._destroyed; } + static from(iterable, opts) { + const r = new Readable(opts); (async () => { try { for await (const chunk of iterable) { @@ -31,21 +53,111 @@ class Readable extends EventEmitter { })(); return r; } + // Web Streams interop ----------------------------------------------- + static toWeb(node) { + return new globalThis.ReadableStream({ + start(controller) { + node.on("data", (chunk) => { + try { + const buf = typeof chunk === "string" + ? new TextEncoder().encode(chunk) + : (chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)); + controller.enqueue(buf); + } catch (err) { controller.error(err); } + }); + node.once("end", () => { try { controller.close(); } catch (_) {} }); + node.once("error", (err) => { try { controller.error(err); } catch (_) {} }); + }, + cancel(reason) { try { node.destroy(reason); } catch (_) {} }, + }); + } + static fromWeb(web, opts) { + const r = new Readable(opts); + (async () => { + const reader = web.getReader(); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + r.push(value); + } + r.push(null); + } catch (err) { r.destroy(err); } + finally { try { reader.releaseLock(); } catch (_) {} } + })(); + return r; + } setEncoding(enc) { this._encoding = enc; return this; } + on(event, cb) { + super.on(event, cb); + if (event === "data" && this._buffer.length) { + const replay = this._buffer.slice(); + queueMicrotask(() => { + for (const chunk of replay) cb(chunk); + if (this._ended) this.emit("end"); + }); + } else if (event === "end" && this._ended) { + queueMicrotask(() => this.emit("end")); + } + return this; + } + addListener(event, cb) { return this.on(event, cb); } push(chunk) { if (chunk === null) { this._ended = true; this.emit("end"); return false; } this._buffer.push(chunk); + this._bufferedBytes += chunkLen(chunk); this.emit("data", chunk); - return true; + return this._bufferedBytes < this._highWaterMark; + } + unshift(chunk) { + if (chunk == null) return; + this._buffer.unshift(chunk); + this._bufferedBytes += chunkLen(chunk); } read() { if (this._buffer.length === 0) return null; - return this._buffer.shift(); + const chunk = this._buffer.shift(); + this._bufferedBytes -= chunkLen(chunk); + return chunk; + } + resume() { + if (this._destroyed) return this; + this._paused = false; + if (this._buffer.length) { + const replay = this._buffer.slice(); + this._buffer = []; + this._bufferedBytes = 0; + queueMicrotask(() => { + for (const chunk of replay) this.emit("data", chunk); + if (this._ended) this.emit("end"); + }); + } else if (this._ended) { + queueMicrotask(() => this.emit("end")); + } + return this; + } + pause() { + this._paused = true; + return this; + } + isPaused() { + return this._paused === true; } pipe(dest) { - this.on("data", (chunk) => dest.write(chunk)); - this.on("end", () => dest.end()); - this.on("error", (err) => dest.destroy(err)); + if (this._buffer.length) { + const replay = this._buffer.slice(); + this._buffer = []; + this._bufferedBytes = 0; + queueMicrotask(() => { + for (const chunk of replay) dest.write(chunk); + if (this._ended) dest.end(); + }); + } else if (this._ended) { + queueMicrotask(() => dest.end()); + } + super.on("data", (chunk) => dest.write(chunk)); + super.on("end", () => dest.end()); + super.on("error", (err) => dest.destroy(err)); return dest; } destroy(err) { @@ -59,13 +171,19 @@ class Readable extends EventEmitter { const self = this; return { async next() { - if (self._buffer.length) return { value: self._buffer.shift(), done: false }; + if (self._buffer.length) { + const c = self._buffer.shift(); + self._bufferedBytes -= chunkLen(c); + return { value: c, done: false }; + } if (self._ended) return { value: undefined, done: true }; return new Promise((resolve, reject) => { const onData = () => { if (!self._buffer.length) return; cleanup(); - resolve({ value: self._buffer.shift(), done: false }); + const c = self._buffer.shift(); + self._bufferedBytes -= chunkLen(c); + resolve({ value: c, done: false }); }; const onEnd = () => { cleanup(); resolve({ value: undefined, done: true }); }; const onErr = (err) => { cleanup(); reject(err); }; @@ -90,7 +208,58 @@ class Writable extends EventEmitter { this._chunks = []; this._ended = false; this._writeImpl = opts.write; + this._writevImpl = opts.writev; this._finalImpl = opts.final; + this._highWaterMark = typeof opts.highWaterMark === "number" + ? opts.highWaterMark : DEFAULT_HWM; + this._bufferedBytes = 0; + this._corkCount = 0; + this._corkBuffer = []; + this._needDrain = false; + } + get writableHighWaterMark() { return this._highWaterMark; } + get writableLength() { return this._bufferedBytes; } + get writableEnded() { return this._ended; } + get writableFinished() { return this._ended && this._bufferedBytes === 0; } + cork() { this._corkCount++; } + uncork() { + if (this._corkCount === 0) return; + this._corkCount--; + if (this._corkCount === 0 && this._corkBuffer.length) { + const drained = this._corkBuffer.splice(0); + if (this._writevImpl) { + this._writevImpl( + drained.map(([chunk, encoding]) => ({ chunk, encoding })), + (err) => { + for (const [, , cb] of drained) { + if (cb) cb(err); + } + }, + ); + } else { + for (const [chunk, encoding, cb] of drained) { + this._doWrite(chunk, encoding, cb); + } + } + } + } + _doWrite(chunk, encoding, cb) { + const len = chunkLen(chunk); + this._bufferedBytes += len; + const done = (err) => { + this._bufferedBytes = Math.max(0, this._bufferedBytes - len); + if (cb) cb(err); + if (this._needDrain && this._bufferedBytes < this._highWaterMark) { + this._needDrain = false; + this.emit("drain"); + } + }; + if (this._writeImpl) { + this._writeImpl(chunk, encoding, done); + } else { + this._chunks.push(chunk); + done(); + } } write(chunk, encoding, cb) { if (typeof encoding === "function") { cb = encoding; encoding = undefined; } @@ -99,17 +268,25 @@ class Writable extends EventEmitter { if (cb) cb(err); else this.emit("error", err); return false; } - this._chunks.push(chunk); - if (this._writeImpl) { - this._writeImpl(chunk, encoding, cb || (() => {})); - } else if (cb) { - cb(); + if (this._corkCount > 0) { + this._corkBuffer.push([chunk, encoding, cb]); + this._bufferedBytes += chunkLen(chunk); + } else { + this._doWrite(chunk, encoding, cb); } - return true; + const ok = this._bufferedBytes < this._highWaterMark; + if (!ok) this._needDrain = true; + return ok; } end(chunk, encoding, cb) { if (typeof chunk === "function") { cb = chunk; chunk = undefined; } + if (typeof encoding === "function") { cb = encoding; encoding = undefined; } if (chunk !== undefined && chunk !== null) this.write(chunk, encoding); + if (this._corkCount > 0) { + // Implicit uncork on end so a forgotten uncork doesn't deadlock. + this._corkCount = 1; + this.uncork(); + } this._ended = true; const finish = () => { this.emit("finish"); if (cb) cb(); }; if (this._finalImpl) this._finalImpl(finish); else finish(); @@ -120,6 +297,33 @@ class Writable extends EventEmitter { this.emit("close"); return this; } + // Web Streams interop ----------------------------------------------- + static toWeb(node) { + return new globalThis.WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + const ok = node.write(chunk, undefined, (err) => err ? reject(err) : resolve()); + if (ok && !node._writeImpl) resolve(); + }); + }, + close() { + return new Promise((resolve) => node.end(undefined, undefined, resolve)); + }, + abort(reason) { try { node.destroy(reason); } catch (_) {} }, + }); + } + static fromWeb(web, opts) { + const writer = web.getWriter(); + return new Writable({ + ...opts, + write(chunk, _enc, cb) { + writer.write(chunk).then(() => cb && cb(), (err) => cb && cb(err)); + }, + final(cb) { + writer.close().then(() => cb && cb(), (err) => cb && cb(err)); + }, + }); + } } class Duplex extends Readable { @@ -129,12 +333,17 @@ class Duplex extends Readable { } write(chunk, encoding, cb) { return this._writableInner.write(chunk, encoding, cb); } end(...args) { return this._writableInner.end(...args); } + cork() { return this._writableInner.cork(); } + uncork() { return this._writableInner.uncork(); } + get writableHighWaterMark() { return this._writableInner._highWaterMark; } + get writableLength() { return this._writableInner._bufferedBytes; } } class Transform extends Duplex { constructor(opts = {}) { super(opts); this._transform = opts.transform; + this._flush = opts.flush; } write(chunk, encoding, cb) { const done = (err, transformed) => { @@ -146,6 +355,27 @@ class Transform extends Duplex { else done(null, chunk); return true; } + end(chunk, encoding, cb) { + if (typeof chunk === "function") { cb = chunk; chunk = undefined; } + if (chunk !== undefined && chunk !== null) this.write(chunk, encoding); + const finalize = () => { + if (this._flush) { + this._flush((err, tail) => { + if (err) { this.emit("error", err); return; } + if (tail !== undefined && tail !== null) this.push(tail); + this.push(null); + this.emit("finish"); + if (cb) cb(); + }); + } else { + this.push(null); + this.emit("finish"); + if (cb) cb(); + } + }; + queueMicrotask(finalize); + return this; + } } class PassThrough extends Transform { @@ -190,6 +420,22 @@ function finished(stream, cb) { return undefined; } +function addAbortSignal(signal, stream) { + if (!signal) return stream; + const onAbort = () => { + const err = new Error("The operation was aborted"); + err.code = "ABORT_ERR"; + err.name = "AbortError"; + try { stream.destroy(err); } catch (_) {} + }; + if (signal.aborted) { + queueMicrotask(onAbort); + } else if (typeof signal.addEventListener === "function") { + signal.addEventListener("abort", onAbort, { once: true }); + } + return stream; +} + class Stream extends EventEmitter { pipe(dest) { this.on("data", (c) => dest.write && dest.write(c)); this.on("end", () => dest.end && dest.end()); return dest; } } @@ -203,5 +449,6 @@ stream.Transform = Transform; stream.PassThrough = PassThrough; stream.pipeline = pipeline; stream.finished = finished; +stream.addAbortSignal = addAbortSignal; stream.default = stream; module.exports = stream; diff --git a/crates/nexide/runtime/polyfills/node/tls.js b/crates/nexide/runtime/polyfills/node/tls.js index 87d3cc4..a1f29c5 100644 --- a/crates/nexide/runtime/polyfills/node/tls.js +++ b/crates/nexide/runtime/polyfills/node/tls.js @@ -144,11 +144,26 @@ function connect(...args) { cb = args.find((a) => typeof a === "function") || null; opts = { port, host }; } - const host = opts.host || opts.servername || "127.0.0.1"; - const port = opts.port; - if (typeof port !== "number") throw new TypeError("tls.connect: port required"); + const host = opts.servername || opts.host || "127.0.0.1"; const sock = new TLSSocket(); if (cb) sock.once("secureConnect", cb); + if (opts.socket && typeof opts.socket._id === "number" && opts.socket._id > 0) { + const inner = opts.socket; + const innerId = inner._id; + inner._id = 0; + inner._readable = false; + inner._writable = false; + inner._paused = true; + inner.destroyed = true; + ops.op_tls_upgrade(innerId, String(host)).then( + (handle) => sock._adoptHandle(handle), + (err) => { sock.destroyed = true; sock.emit("error", err); }, + ); + return sock; + } + + const port = opts.port; + if (typeof port !== "number") throw new TypeError("tls.connect: port required"); ops.op_tls_connect(String(host), port).then( (handle) => sock._adoptHandle(handle), (err) => { sock.destroyed = true; sock.emit("error", err); }, diff --git a/crates/nexide/runtime/polyfills/node/url.js b/crates/nexide/runtime/polyfills/node/url.js index 04e3742..bd3f059 100644 --- a/crates/nexide/runtime/polyfills/node/url.js +++ b/crates/nexide/runtime/polyfills/node/url.js @@ -1,15 +1,43 @@ "use strict"; -// node:url - self-contained legacy `url` API plus a minimal -// WHATWG-ish URL/URLSearchParams shim. The bare V8 isolate that -// nexide boots does not install the WHATWG URL globals, so this -// module ships the minimum surface that covers the request shapes -// exercised by Next.js standalone. +// node:url - self-contained legacy `url` API plus a WHATWG URL/ +// URLSearchParams shim. The bare V8 isolate that nexide boots does +// not install the WHATWG URL globals, so this module ships the +// minimum surface that covers the request shapes exercised by +// Next.js standalone. When the host exposes `op_url_parse` the +// shim delegates to the Rust `url` crate (full WHATWG: IPv6 hosts, +// IDN, percent normalisation, special vs opaque schemes, …) and +// falls back to the in-process regex parser otherwise. +const ops = (typeof Nexide !== "undefined" && Nexide.core && Nexide.core.ops) || {}; const URL_REGEX = /^([a-z][a-z0-9+\-.]*:)?(?:\/\/((?:([^/?#@]*)@)?([^/?#:]*)(?::(\d+))?))?([^?#]*)(\?[^#]*)?(#.*)?$/i; +function rustParse(input, base) { + if (typeof ops.op_url_parse !== "function") return null; + const arr = ops.op_url_parse(String(input), base === undefined ? null : String(base)); + if (!arr) return null; + return { + href: arr[0], protocol: arr[1], username: arr[2], password: arr[3], + hostname: arr[4] === null ? "" : arr[4], port: arr[5], + pathname: arr[6], search: arr[7], hash: arr[8], origin: arr[9], + }; +} + class NexideURL { constructor(input, base) { + const r = rustParse(input, base); + if (r) { + this.protocol = r.protocol; + this.username = r.username; + this.password = r.password; + this.hostname = r.hostname; + this.port = r.port; + this.pathname = r.pathname; + this.search = r.search; + this.hash = r.hash; + this._origin = r.origin; + return; + } let str = String(input); if (base !== undefined) { str = resolveAgainst(String(base), str); @@ -28,11 +56,19 @@ class NexideURL { this.pathname = m[6] || (this.hostname ? "/" : ""); this.search = m[7] || ""; this.hash = m[8] || ""; + this._origin = null; + } + static canParse(input, base) { + if (typeof ops.op_url_can_parse === "function") { + return ops.op_url_can_parse(String(input), base === undefined ? null : String(base)); + } + try { new NexideURL(input, base); return true; } catch { return false; } } get host() { return this.port ? `${this.hostname}:${this.port}` : this.hostname; } get origin() { + if (this._origin !== null && this._origin !== undefined) return this._origin; return this.hostname ? `${this.protocol}//${this.host}` : "null"; } get href() { diff --git a/crates/nexide/runtime/polyfills/node/zlib.js b/crates/nexide/runtime/polyfills/node/zlib.js index 607eb65..bdeff7a 100644 --- a/crates/nexide/runtime/polyfills/node/zlib.js +++ b/crates/nexide/runtime/polyfills/node/zlib.js @@ -115,20 +115,37 @@ class Gzip extends ZlibTransform { class Gunzip extends ZlibTransform { constructor(options) { super("gunzip", options); } } +class BrotliCompress extends ZlibTransform { + constructor(options) { super("brotli-compress", options); } +} +class BrotliDecompress extends ZlibTransform { + constructor(options) { super("brotli-decompress", options); } +} + +function unzipDecode(input) { + const buf = asBuf(input); + if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) { + return Buffer.from(ops.op_zlib_decode("gzip", buf)); + } + return Buffer.from(ops.op_zlib_decode("deflate", buf)); +} -function brotliStreamingUnavailable() { - const err = new Error( - "Brotli streaming is not available in nexide; use brotliCompress/brotliDecompress", - ); - err.code = "ERR_NOT_AVAILABLE"; - throw err; +class BrotliUnavailable { + constructor() { + throw new Error("BrotliUnavailable is no longer used"); + } } +// Kept exported for ABI stability with previous pre-streaming releases. +void BrotliUnavailable; module.exports = { gzipSync: (i) => syncEncode("gzip", i), gunzipSync: (i) => syncDecode("gzip", i), deflateSync: (i) => syncEncode("deflate", i), inflateSync: (i) => syncDecode("deflate", i), + deflateRawSync: (i) => syncEncode("deflate-raw", i), + inflateRawSync: (i) => syncDecode("deflate-raw", i), + unzipSync: (i) => unzipDecode(i), brotliCompressSync: (i) => syncEncode("brotli", i), brotliDecompressSync: (i) => syncDecode("brotli", i), @@ -136,6 +153,9 @@ module.exports = { gunzip: asyncWrap((i) => syncDecode("gzip", i)), deflate: asyncWrap((i) => syncEncode("deflate", i)), inflate: asyncWrap((i) => syncDecode("deflate", i)), + deflateRaw: asyncWrap((i) => syncEncode("deflate-raw", i)), + inflateRaw: asyncWrap((i) => syncDecode("deflate-raw", i)), + unzip: asyncWrap((i) => unzipDecode(i)), brotliCompress: asyncWrap((i) => syncEncode("brotli", i)), brotliDecompress: asyncWrap((i) => syncDecode("brotli", i)), @@ -145,8 +165,9 @@ module.exports = { createInflateRaw: (opts) => new InflateRaw(opts), createGzip: (opts) => new Gzip(opts), createGunzip: (opts) => new Gunzip(opts), - createBrotliCompress: brotliStreamingUnavailable, - createBrotliDecompress: brotliStreamingUnavailable, + createUnzip: (opts) => new Gunzip(opts), + createBrotliCompress: (opts) => new BrotliCompress(opts), + createBrotliDecompress: (opts) => new BrotliDecompress(opts), Deflate, Inflate, @@ -154,9 +175,92 @@ module.exports = { InflateRaw, Gzip, Gunzip, + Unzip: Gunzip, + BrotliCompress, + BrotliDecompress, constants: { - Z_OK: 0, Z_STREAM_END: 1, Z_NO_FLUSH: 0, Z_FINISH: 4, - BROTLI_OPERATION_PROCESS: 0, BROTLI_OPERATION_FINISH: 2, + Z_NO_FLUSH: 0, + Z_PARTIAL_FLUSH: 1, + Z_SYNC_FLUSH: 2, + Z_FULL_FLUSH: 3, + Z_FINISH: 4, + Z_BLOCK: 5, + Z_TREES: 6, + Z_OK: 0, + Z_STREAM_END: 1, + Z_NEED_DICT: 2, + Z_ERRNO: -1, + Z_STREAM_ERROR: -2, + Z_DATA_ERROR: -3, + Z_MEM_ERROR: -4, + Z_BUF_ERROR: -5, + Z_VERSION_ERROR: -6, + Z_NO_COMPRESSION: 0, + Z_BEST_SPEED: 1, + Z_BEST_COMPRESSION: 9, + Z_DEFAULT_COMPRESSION: -1, + Z_FILTERED: 1, + Z_HUFFMAN_ONLY: 2, + Z_RLE: 3, + Z_FIXED: 4, + Z_DEFAULT_STRATEGY: 0, + Z_BINARY: 0, + Z_TEXT: 1, + Z_UNKNOWN: 2, + Z_DEFLATED: 8, + Z_MIN_WINDOWBITS: 8, + Z_MAX_WINDOWBITS: 15, + Z_DEFAULT_WINDOWBITS: 15, + Z_MIN_CHUNK: 64, + Z_MAX_CHUNK: Infinity, + Z_DEFAULT_CHUNK: 16384, + Z_MIN_MEMLEVEL: 1, + Z_MAX_MEMLEVEL: 9, + Z_DEFAULT_MEMLEVEL: 8, + Z_MIN_LEVEL: -1, + Z_MAX_LEVEL: 9, + Z_DEFAULT_LEVEL: -1, + DEFLATE: 1, + INFLATE: 2, + GZIP: 3, + GUNZIP: 4, + DEFLATERAW: 5, + INFLATERAW: 6, + UNZIP: 7, + BROTLI_DECODE: 8, + BROTLI_ENCODE: 9, + BROTLI_OPERATION_PROCESS: 0, + BROTLI_OPERATION_FLUSH: 1, + BROTLI_OPERATION_FINISH: 2, + BROTLI_OPERATION_EMIT_METADATA: 3, + BROTLI_PARAM_MODE: 0, + BROTLI_MODE_GENERIC: 0, + BROTLI_MODE_TEXT: 1, + BROTLI_MODE_FONT: 2, + BROTLI_DEFAULT_MODE: 0, + BROTLI_PARAM_QUALITY: 1, + BROTLI_MIN_QUALITY: 0, + BROTLI_MAX_QUALITY: 11, + BROTLI_DEFAULT_QUALITY: 11, + BROTLI_PARAM_LGWIN: 2, + BROTLI_MIN_WINDOW_BITS: 10, + BROTLI_MAX_WINDOW_BITS: 24, + BROTLI_LARGE_MAX_WINDOW_BITS: 30, + BROTLI_DEFAULT_WINDOW: 22, + BROTLI_PARAM_LGBLOCK: 3, + BROTLI_MIN_INPUT_BLOCK_BITS: 16, + BROTLI_MAX_INPUT_BLOCK_BITS: 24, + BROTLI_PARAM_DISABLE_LITERAL_CONTEXT_MODELING: 4, + BROTLI_PARAM_SIZE_HINT: 5, + BROTLI_PARAM_LARGE_WINDOW: 6, + BROTLI_PARAM_NPOSTFIX: 7, + BROTLI_PARAM_NDIRECT: 8, + BROTLI_DECODER_RESULT_ERROR: 0, + BROTLI_DECODER_RESULT_SUCCESS: 1, + BROTLI_DECODER_RESULT_NEEDS_MORE_INPUT: 2, + BROTLI_DECODER_RESULT_NEEDS_MORE_OUTPUT: 3, + BROTLI_DECODER_PARAM_DISABLE_RING_BUFFER_REALLOCATION: 0, + BROTLI_DECODER_PARAM_LARGE_WINDOW: 1, }, }; diff --git a/crates/nexide/runtime/polyfills/process.js b/crates/nexide/runtime/polyfills/process.js index 0721255..75c9e83 100644 --- a/crates/nexide/runtime/polyfills/process.js +++ b/crates/nexide/runtime/polyfills/process.js @@ -16,6 +16,95 @@ const meta = ops.op_process_meta(); + const listeners = Object.create(null); + + function getList(event) { + return listeners[event] || (listeners[event] = []); + } + function on(event, fn) { + if (typeof fn !== "function") return process; + getList(event).push({ fn, once: false }); + return process; + } + function once(event, fn) { + if (typeof fn !== "function") return process; + getList(event).push({ fn, once: true }); + return process; + } + function off(event, fn) { + const list = listeners[event]; + if (!list) return process; + listeners[event] = list.filter((e) => e.fn !== fn); + return process; + } + function removeAllListeners(event) { + if (event == null) { + for (const k of Object.keys(listeners)) delete listeners[k]; + } else { + delete listeners[event]; + } + return process; + } + function listenersFor(event) { + const list = listeners[event]; + return list ? list.map((e) => e.fn) : []; + } + function listenerCount(event) { + const list = listeners[event]; + return list ? list.length : 0; + } + function eventNames() { return Object.keys(listeners); } + function emit(event, ...args) { + const list = listeners[event]; + if (!list || list.length === 0) return false; + // Snapshot + filter to support listener removal during dispatch. + const snapshot = list.slice(); + listeners[event] = list.filter((e) => !e.once); + for (const entry of snapshot) { + try { entry.fn(...args); } catch (err) { + // Mirror Node's behaviour: emit 'uncaughtException' and fall + // back to stderr so a buggy SIGTERM handler doesn't poison + // every other listener. + const handlers = listeners["uncaughtException"]; + if (handlers && handlers.length) { + for (const h of handlers.slice()) { + try { h.fn(err); } catch (_) { /* swallow */ } + } + } else { + Nexide.core.print( + `[nexide] uncaught in '${event}' handler: ${err && err.stack || err}\n`, + true, + ); + } + } + } + return true; + } + + // ── Pump signal queue ──────────────────────────────────────────── + // The Rust side records SIGTERM/SIGINT/SIGHUP (Unix) into a + // process-wide queue; we drain it on a 100 ms cadence and emit on + // the EventEmitter above. Cadence is fixed - tighter polling burns + // CPU, slacker polling delays graceful drain inside K8s + // `terminationGracePeriodSeconds`. + let signalPumpStarted = false; + function startSignalPump() { + if (signalPumpStarted) return; + if (typeof ops.op_process_drain_signals !== "function") return; + if (typeof globalThis.setTimeout !== "function") return; + signalPumpStarted = true; + const tick = () => { + try { + const pending = ops.op_process_drain_signals(); + if (pending && pending.length) { + for (const sig of pending) emit(sig, sig); + } + } catch (_) { /* op missing on minimal builds */ } + globalThis.setTimeout(tick, 100); + }; + globalThis.setTimeout(tick, 100); + } + const envHandler = { get(_target, prop) { if (typeof prop !== "string") return undefined; @@ -52,6 +141,8 @@ const noopEmitter = () => process; + const _ = noopEmitter; + const stdout = { write(chunk) { const s = typeof chunk === "string" @@ -245,6 +336,9 @@ const n = Number(code); const safe = Number.isFinite(n) ? Math.trunc(n) : 0; const clamped = safe >= 0 && safe <= 255 ? safe : 1; + // Node fires 'exit' synchronously before the process tears down + // so DB pools, log flushers, etc. get a final chance to run. + try { emit("exit", clamped); } catch (_) { /* swallow */ } ops.op_process_exit(clamped); }, abort, @@ -257,21 +351,29 @@ emitWarning(warning) { Nexide.core.print("(warning) " + String(warning) + "\n", true); }, - on: noopEmitter, - once: noopEmitter, - off: noopEmitter, - addListener: noopEmitter, - removeListener: noopEmitter, - removeAllListeners: noopEmitter, - prependListener: noopEmitter, - prependOnceListener: noopEmitter, - listeners: emptyArray, - rawListeners: emptyArray, - listenerCount: () => 0, - eventNames: emptyArray, + on, + once, + off, + addListener: on, + removeListener: off, + removeAllListeners, + prependListener: (event, fn) => { + if (typeof fn !== "function") return process; + getList(event).unshift({ fn, once: false }); + return process; + }, + prependOnceListener: (event, fn) => { + if (typeof fn !== "function") return process; + getList(event).unshift({ fn, once: true }); + return process; + }, + listeners: listenersFor, + rawListeners: listenersFor, + listenerCount, + eventNames, setMaxListeners: () => process, getMaxListeners: () => 10, - emit: alwaysFalse, + emit, binding() { throw new Error("process.binding is not supported by nexide"); }, @@ -370,6 +472,16 @@ globalThis.global = globalThis; } + // Expose the signal-queue poller; late_globals.js arms it after the + // timers polyfill is in place. + Object.defineProperty(process, "__startSignalPump", { + value: startSignalPump, + enumerable: false, + configurable: false, + writable: false, + }); + startSignalPump(); + if (!globalThis.__nexideErrorTrap) { const _origErr = globalThis.console.error; const formatError = (err, depth) => { diff --git a/crates/nexide/runtime/polyfills/timers.js b/crates/nexide/runtime/polyfills/timers.js index 79ba3fe..24fcb65 100644 --- a/crates/nexide/runtime/polyfills/timers.js +++ b/crates/nexide/runtime/polyfills/timers.js @@ -11,12 +11,24 @@ * * `setImmediate` is a true macrotask: it resolves on the next * event-loop tick after the current microtask queue drains. It - * relies on `op_void_async_deferred`, which Next.js' streaming SSR - * needs to flush chunks between renders. + * uses `op_timer_sleep(0)` so the resume goes through the async- + * completion pump (a real macrotask) — same ordering as Node's + * libuv "check" phase. This is critical for Next.js streaming SSR: + * `createFlightDataInjectionTransformStream` relies on + * `atLeastOneTask()` (a Promise wrapping `setImmediate`) to let + * the upstream HTML transform fully drain its microtask-queued + * chunks BEFORE flight RSC chunks are injected. If `setImmediate` + * resolves on a microtask (e.g. via `op_void_async_deferred`), + * flight `` chunks splice + * into the middle of HTML attribute writes (e.g. + * ``). * - * Cancelled timers are tracked by id in a single `Set`. The - * implementation is idempotent so the file can safely be evaluated - * once per isolate. + * Pending timers are tracked by id in a single `Map`. + * Capturing only the numeric `id` in the host-Promise `.then` keeps + * cancelled callbacks from being retained for the full delay (the + * Rust `tokio::time::sleep` Promise pins its `.then` body until the + * delay elapses, even after `clearTimeout`). The implementation is + * idempotent so the file can safely be evaluated once per isolate. */ ((globalThis) => { @@ -34,7 +46,25 @@ } let nextId = 1; - const cancelled = new Set(); + // `pending` maps live timer ids to their `{ cb, args }` payload. + // + // The Rust-side `op_timer_sleep` Promise keeps the `.then` body + // alive until the requested delay has elapsed - even when the + // user calls `clearTimeout` shortly after scheduling. If we close + // over `cb` and `args` directly inside that `.then`, every + // *cancelled* timer leaks its callback closure (and everything + // it transitively captures - `req`, `res`, response builders for + // the Next.js handler watchdog, etc.) until the underlying timer + // fires. Under load (e.g. 400+ RPS with a 60s watchdog) that + // amounts to tens of thousands of retained closures and tens to + // hundreds of MB of live JS heap before V8 can collect. + // + // Storing payloads in a side-map and only capturing the small + // numeric `id` in the `.then` lets `clearTimeout` evict the heavy + // payload immediately. The Rust timer still resolves at its + // scheduled time, but at that point the map lookup misses and we + // exit without retaining anything. + const pending = new Map(); function nextTimerId() { const id = nextId++; @@ -48,9 +78,11 @@ return Math.floor(n); } - function runOnce(id, cb, args) { - if (cancelled.delete(id)) return; - cb(...args); + function runOnce(id) { + const slot = pending.get(id); + if (slot === undefined) return; + pending.delete(id); + slot.cb(...slot.args); } function makeTimeout(id) { @@ -68,7 +100,7 @@ unref() { return this; }, hasRef() { return true; }, refresh() { return this; }, - [Symbol.dispose]() { cancelled.add(this._id); }, + [Symbol.dispose]() { pending.delete(this._id); }, }; function idOf(t) { @@ -83,13 +115,14 @@ throw new TypeError("setTimeout requires a function"); } const id = nextTimerId(); - opTimerSleep(coerceDelay(ms)).then(() => runOnce(id, cb, args)); + pending.set(id, { cb, args }); + opTimerSleep(coerceDelay(ms)).then(() => runOnce(id)); return makeTimeout(id); }; globalThis.clearTimeout = function clearTimeout(t) { const id = idOf(t); - if (id >= 0) cancelled.add(id); + if (id >= 0) pending.delete(id); }; globalThis.setInterval = function setInterval(cb, ms, ...args) { @@ -98,13 +131,14 @@ } const id = nextTimerId(); const delay = coerceDelay(ms); + pending.set(id, { cb, args }); const tick = () => { - if (cancelled.delete(id)) return; - try { cb(...args); } catch (err) { reportTimerError(err); } - if (cancelled.has(id)) { - cancelled.delete(id); - return; - } + const slot = pending.get(id); + if (slot === undefined) return; + try { slot.cb(...slot.args); } catch (err) { reportTimerError(err); } + // Re-check: the user-provided callback may have called + // `clearInterval` synchronously, which would have evicted us. + if (!pending.has(id)) return; opTimerSleep(delay).then(tick); }; opTimerSleep(delay).then(tick); @@ -113,7 +147,7 @@ globalThis.clearInterval = function clearInterval(t) { const id = idOf(t); - if (id >= 0) cancelled.add(id); + if (id >= 0) pending.delete(id); }; globalThis.setImmediate = function setImmediate(cb, ...args) { @@ -121,15 +155,14 @@ throw new TypeError("setImmediate requires a function"); } const id = nextTimerId(); - opVoidDeferred().then(() => { - runOnce(id, cb, args); - }); + pending.set(id, { cb, args }); + opTimerSleep(0).then(() => runOnce(id)); return makeTimeout(id); }; globalThis.clearImmediate = function clearImmediate(t) { const id = idOf(t); - if (id >= 0) cancelled.add(id); + if (id >= 0) pending.delete(id); }; if (typeof globalThis.queueMicrotask !== "function") { diff --git a/crates/nexide/runtime/polyfills/web_apis.js b/crates/nexide/runtime/polyfills/web_apis.js index 4f1db2f..f0d3911 100644 --- a/crates/nexide/runtime/polyfills/web_apis.js +++ b/crates/nexide/runtime/polyfills/web_apis.js @@ -55,10 +55,46 @@ return new Uint8Array(bytes); } encodeInto(source, dest) { - const enc = this.encode(source); - const n = Math.min(enc.length, dest.length); - dest.set(enc.subarray(0, n)); - return { read: source.length, written: n }; + const s = String(source); + const u = dest instanceof Uint8Array ? dest : new Uint8Array(dest.buffer, dest.byteOffset || 0, dest.byteLength); + const cap = u.length; + let read = 0; + let written = 0; + for (let i = 0; i < s.length; i++) { + let c = s.charCodeAt(i); + let advance = 1; + if (c >= 0xD800 && c <= 0xDBFF && i + 1 < s.length) { + const c2 = s.charCodeAt(i + 1); + if (c2 >= 0xDC00 && c2 <= 0xDFFF) { + c = 0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00); + advance = 2; + } + } + let need; + if (c < 0x80) need = 1; + else if (c < 0x800) need = 2; + else if (c < 0x10000) need = 3; + else need = 4; + if (written + need > cap) break; + if (need === 1) { + u[written++] = c; + } else if (need === 2) { + u[written++] = 0xC0 | (c >> 6); + u[written++] = 0x80 | (c & 0x3F); + } else if (need === 3) { + u[written++] = 0xE0 | (c >> 12); + u[written++] = 0x80 | ((c >> 6) & 0x3F); + u[written++] = 0x80 | (c & 0x3F); + } else { + u[written++] = 0xF0 | (c >> 18); + u[written++] = 0x80 | ((c >> 12) & 0x3F); + u[written++] = 0x80 | ((c >> 6) & 0x3F); + u[written++] = 0x80 | (c & 0x3F); + } + read += advance; + if (advance === 2) i++; + } + return { read: read, written: written }; } } globalThis.TextEncoder = TextEncoder; @@ -85,7 +121,6 @@ } else { u = new Uint8Array(input); } - let bytes; if (this._pending && this._pending.length > 0) { bytes = new Uint8Array(this._pending.length + u.length); @@ -271,6 +306,11 @@ get(k) { return this._map.get(String(k).toLowerCase()) ?? null; } has(k) { return this._map.has(String(k).toLowerCase()); } set(k, v) { this._map.set(String(k).toLowerCase(), String(v)); } + getSetCookie() { + const v = this._map.get("set-cookie"); + if (!v) return []; + return Array.isArray(v) ? v.slice() : String(v).split(/, (?=[^;]+=)/); + } forEach(cb, thisArg) { for (const [k, v] of this._map) cb.call(thisArg, v, k, this); } *entries() { yield* this._map.entries(); } *keys() { yield* this._map.keys(); } @@ -295,15 +335,57 @@ globalThis.AbortController = AbortController; } - function readBody(input) { - if (input == null) return new Uint8Array(0); - if (input instanceof Uint8Array) return input; + function extractBody(input) { + if (input == null) return { bytes: new Uint8Array(0), contentType: null }; + if (input instanceof Uint8Array) return { bytes: input, contentType: null }; if (typeof input === "string") { + return { + bytes: new TextEncoder().encode(input), + contentType: "text/plain;charset=UTF-8", + }; + } + if (typeof globalThis.URLSearchParams !== "undefined" && input instanceof globalThis.URLSearchParams) { + return { + bytes: new TextEncoder().encode(input.toString()), + contentType: "application/x-www-form-urlencoded;charset=UTF-8", + }; + } + if (typeof globalThis.Blob !== "undefined" && input instanceof globalThis.Blob) { + return { bytes: null, blob: input, contentType: input.type || null }; + } + if (input instanceof ArrayBuffer) return { bytes: new Uint8Array(input), contentType: null }; + if (ArrayBuffer.isView(input)) { + return { + bytes: new Uint8Array(input.buffer, input.byteOffset, input.byteLength), + contentType: null, + }; + } + if (typeof globalThis.FormData !== "undefined" && input instanceof globalThis.FormData) { + const boundary = "----nexide" + Math.random().toString(16).slice(2); const enc = new TextEncoder(); - return enc.encode(input); + const parts = []; + for (const [name, value] of input.entries()) { + parts.push(enc.encode(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n`)); + parts.push(enc.encode(typeof value === "string" ? value : String(value))); + parts.push(enc.encode("\r\n")); + } + parts.push(enc.encode(`--${boundary}--\r\n`)); + let total = 0; + for (const p of parts) total += p.byteLength; + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { out.set(p, off); off += p.byteLength; } + return { + bytes: out, + contentType: `multipart/form-data; boundary=${boundary}`, + }; } - if (input.buffer instanceof ArrayBuffer) return new Uint8Array(input.buffer); - return new Uint8Array(0); + return { bytes: new Uint8Array(0), contentType: null }; + } + + function readBody(input) { + const { bytes } = extractBody(input); + return bytes || new Uint8Array(0); } function isNodeReadable(value) { @@ -410,7 +492,15 @@ this.referrer = init.referrer || ""; this.bodyUsed = false; } - get body() { return bodyToReadableStream(this._rawBody); } + get body() { + if (!this._bodyStream) { + Object.defineProperty(this, "_bodyStream", { + value: bodyToReadableStream(this._rawBody), + writable: true, configurable: true, enumerable: false, + }); + } + return this._bodyStream; + } clone() { return new Request(this.url, this); } async arrayBuffer() { return (await consumeBody(this._rawBody)).buffer; } async text() { return new TextDecoder().decode(await consumeBody(this._rawBody)); } @@ -432,7 +522,15 @@ this.url = ""; this.bodyUsed = false; } - get body() { return bodyToReadableStream(this._rawBody); } + get body() { + if (!this._bodyStream) { + Object.defineProperty(this, "_bodyStream", { + value: bodyToReadableStream(this._rawBody), + writable: true, configurable: true, enumerable: false, + }); + } + return this._bodyStream; + } clone() { return new Response(this._rawBody, { status: this.status, statusText: this.statusText, headers: this.headers }); } static error() { const r = new Response(null, { status: 0 }); r.type = "error"; return r; } static redirect(url, status = 302) { return new Response(null, { status, headers: { location: url } }); } @@ -501,7 +599,28 @@ for (const [name, value] of Object.entries(headersIn)) headers.push([String(name), String(value)]); } const rawBody = init.body ?? (input && input._rawBody) ?? null; - const body = await collectBody(rawBody); + let body = null; + let derivedContentType = null; + if (rawBody != null) { + if (rawBody instanceof globalThis.ReadableStream || isNodeReadable(rawBody)) { + body = await collectBody(rawBody); + } else { + const extracted = extractBody(rawBody); + if (extracted.blob) { + body = new Uint8Array(await extracted.blob.arrayBuffer()); + } else { + body = extracted.bytes && extracted.bytes.byteLength > 0 ? extracted.bytes : null; + } + derivedContentType = extracted.contentType; + } + } + if (derivedContentType) { + let hasCt = false; + for (const [n] of headers) { + if (n.toLowerCase() === "content-type") { hasCt = true; break; } + } + if (!hasCt) headers.push(["content-type", derivedContentType]); + } const resp = await httpOps.op_http_request({ method, url, headers, body }); @@ -593,6 +712,7 @@ this._cancelled = false; this._locked = false; this._waiters = []; + this._byteStream = underlying && underlying.type === "bytes"; const wakeWaiters = () => { const waiters = this._waiters; this._waiters = []; @@ -600,13 +720,31 @@ }; const controller = { enqueue: (chunk) => { - if (this._closed || this._cancelled) return; + if (this._closed || this._cancelled) { + return; + } + if (chunk && typeof chunk === "object" && chunk instanceof Uint8Array) { + chunk = new Uint8Array(chunk); + } else if ( + chunk && + typeof chunk === "object" && + chunk.buffer instanceof ArrayBuffer && + typeof chunk.byteLength === "number" + ) { + const u = new Uint8Array( + chunk.buffer, + chunk.byteOffset || 0, + chunk.byteLength, + ); + chunk = new Uint8Array(u); + } this._chunks.push(chunk); wakeWaiters(); }, close: () => { this._closed = true; wakeWaiters(); }, error: (err) => { this._error = err; this._closed = true; wakeWaiters(); }, get desiredSize() { return 1; }, + get byobRequest() { return null; }, }; try { if (typeof underlying.start === "function") { @@ -653,7 +791,8 @@ } if (stream._error) throw stream._error; if (stream._chunks.length > 0) { - return { value: stream._chunks.shift(), done: false }; + const v = stream._chunks.shift(); + return { value: v, done: false }; } return { value: undefined, done: true }; }, diff --git a/crates/nexide/src/diagnostics/contention.rs b/crates/nexide/src/diagnostics/contention.rs new file mode 100644 index 0000000..2d3033e --- /dev/null +++ b/crates/nexide/src/diagnostics/contention.rs @@ -0,0 +1,150 @@ +//! Atomic counters for hot-path lock contention. + +use std::sync::atomic::{AtomicU64, Ordering}; + +pub(crate) static PRERENDER_READ_FAST: AtomicU64 = AtomicU64::new(0); +pub(crate) static PRERENDER_READ_CONTENDED: AtomicU64 = AtomicU64::new(0); +pub(crate) static PRERENDER_WRITE_FAST: AtomicU64 = AtomicU64::new(0); +pub(crate) static PRERENDER_WRITE_CONTENDED: AtomicU64 = AtomicU64::new(0); + +pub(crate) static RAM_CACHE_FAST: AtomicU64 = AtomicU64::new(0); +pub(crate) static RAM_CACHE_CONTENDED: AtomicU64 = AtomicU64::new(0); + +pub(crate) static MEM_CACHE_FAST: AtomicU64 = AtomicU64::new(0); +pub(crate) static MEM_CACHE_CONTENDED: AtomicU64 = AtomicU64::new(0); + +pub(crate) static INFLIGHT_ACQUIRE_FAST: AtomicU64 = AtomicU64::new(0); +pub(crate) static INFLIGHT_ACQUIRE_CONTENDED: AtomicU64 = AtomicU64::new(0); + +#[inline] +pub(crate) fn record_fast(c: &AtomicU64) { + c.fetch_add(1, Ordering::Relaxed); +} + +#[inline] +pub(crate) fn record_contended(c: &AtomicU64) { + c.fetch_add(1, Ordering::Relaxed); +} + +#[derive(Clone, Copy, Default, Debug)] +pub(crate) struct Snapshot { + pub(crate) prerender_read_fast: u64, + pub(crate) prerender_read_contended: u64, + pub(crate) prerender_write_fast: u64, + pub(crate) prerender_write_contended: u64, + pub(crate) ram_cache_fast: u64, + pub(crate) ram_cache_contended: u64, + pub(crate) mem_cache_fast: u64, + pub(crate) mem_cache_contended: u64, + pub(crate) inflight_acquire_fast: u64, + pub(crate) inflight_acquire_contended: u64, +} + +impl Snapshot { + pub(crate) fn current() -> Self { + Self { + prerender_read_fast: PRERENDER_READ_FAST.load(Ordering::Relaxed), + prerender_read_contended: PRERENDER_READ_CONTENDED.load(Ordering::Relaxed), + prerender_write_fast: PRERENDER_WRITE_FAST.load(Ordering::Relaxed), + prerender_write_contended: PRERENDER_WRITE_CONTENDED.load(Ordering::Relaxed), + ram_cache_fast: RAM_CACHE_FAST.load(Ordering::Relaxed), + ram_cache_contended: RAM_CACHE_CONTENDED.load(Ordering::Relaxed), + mem_cache_fast: MEM_CACHE_FAST.load(Ordering::Relaxed), + mem_cache_contended: MEM_CACHE_CONTENDED.load(Ordering::Relaxed), + inflight_acquire_fast: INFLIGHT_ACQUIRE_FAST.load(Ordering::Relaxed), + inflight_acquire_contended: INFLIGHT_ACQUIRE_CONTENDED.load(Ordering::Relaxed), + } + } + + pub(crate) fn delta(&self, prev: &Self) -> Self { + Self { + prerender_read_fast: self + .prerender_read_fast + .saturating_sub(prev.prerender_read_fast), + prerender_read_contended: self + .prerender_read_contended + .saturating_sub(prev.prerender_read_contended), + prerender_write_fast: self + .prerender_write_fast + .saturating_sub(prev.prerender_write_fast), + prerender_write_contended: self + .prerender_write_contended + .saturating_sub(prev.prerender_write_contended), + ram_cache_fast: self.ram_cache_fast.saturating_sub(prev.ram_cache_fast), + ram_cache_contended: self + .ram_cache_contended + .saturating_sub(prev.ram_cache_contended), + mem_cache_fast: self.mem_cache_fast.saturating_sub(prev.mem_cache_fast), + mem_cache_contended: self + .mem_cache_contended + .saturating_sub(prev.mem_cache_contended), + inflight_acquire_fast: self + .inflight_acquire_fast + .saturating_sub(prev.inflight_acquire_fast), + inflight_acquire_contended: self + .inflight_acquire_contended + .saturating_sub(prev.inflight_acquire_contended), + } + } + + pub(crate) fn has_activity(&self) -> bool { + self.prerender_read_fast + | self.prerender_read_contended + | self.prerender_write_fast + | self.prerender_write_contended + | self.ram_cache_fast + | self.ram_cache_contended + | self.mem_cache_fast + | self.mem_cache_contended + | self.inflight_acquire_fast + | self.inflight_acquire_contended + != 0 + } +} + +#[cfg(test)] +pub(crate) fn reset_for_tests() { + for c in [ + &PRERENDER_READ_FAST, + &PRERENDER_READ_CONTENDED, + &PRERENDER_WRITE_FAST, + &PRERENDER_WRITE_CONTENDED, + &RAM_CACHE_FAST, + &RAM_CACHE_CONTENDED, + &MEM_CACHE_FAST, + &MEM_CACHE_CONTENDED, + &INFLIGHT_ACQUIRE_FAST, + &INFLIGHT_ACQUIRE_CONTENDED, + ] { + c.store(0, Ordering::Relaxed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_delta_subtracts_field_by_field() { + reset_for_tests(); + record_fast(&PRERENDER_READ_FAST); + record_fast(&PRERENDER_READ_FAST); + record_contended(&MEM_CACHE_CONTENDED); + let s1 = Snapshot::current(); + record_fast(&PRERENDER_READ_FAST); + record_contended(&MEM_CACHE_CONTENDED); + let s2 = Snapshot::current(); + let d = s2.delta(&s1); + assert_eq!(d.prerender_read_fast, 1); + assert_eq!(d.mem_cache_contended, 1); + assert_eq!(d.ram_cache_fast, 0); + } + + #[test] + fn has_activity_detects_any_nonzero_field() { + let mut s = Snapshot::default(); + assert!(!s.has_activity()); + s.ram_cache_fast = 1; + assert!(s.has_activity()); + } +} diff --git a/crates/nexide/src/diagnostics/mod.rs b/crates/nexide/src/diagnostics/mod.rs new file mode 100644 index 0000000..e4694f1 --- /dev/null +++ b/crates/nexide/src/diagnostics/mod.rs @@ -0,0 +1,41 @@ +//! Lightweight diagnostics: contention counters + periodic logger. + +pub(crate) mod contention; + +use std::time::Duration; + +const LOGGER_INTERVAL: Duration = Duration::from_secs(5); + +/// Spawn a task that logs contention counter deltas every 5s. +/// The handle is intentionally not awaited: cancelling it stops +/// logging. +pub fn spawn_periodic_logger() -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut prev = contention::Snapshot::current(); + let mut ticker = tokio::time::interval(LOGGER_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + ticker.tick().await; + loop { + ticker.tick().await; + let now = contention::Snapshot::current(); + let delta = now.delta(&prev); + if delta.has_activity() { + tracing::info!( + target: "nexide::contention", + prerender_read_fast = delta.prerender_read_fast, + prerender_read_contended = delta.prerender_read_contended, + prerender_write_fast = delta.prerender_write_fast, + prerender_write_contended = delta.prerender_write_contended, + ram_cache_fast = delta.ram_cache_fast, + ram_cache_contended = delta.ram_cache_contended, + mem_cache_fast = delta.mem_cache_fast, + mem_cache_contended = delta.mem_cache_contended, + inflight_acquire_fast = delta.inflight_acquire_fast, + inflight_acquire_contended = delta.inflight_acquire_contended, + "contention sample" + ); + } + prev = now; + } + }) +} diff --git a/crates/nexide/src/dispatch/dispatcher.rs b/crates/nexide/src/dispatch/dispatcher.rs index bdcf88e..8ab7f01 100644 --- a/crates/nexide/src/dispatch/dispatcher.rs +++ b/crates/nexide/src/dispatch/dispatcher.rs @@ -1,6 +1,6 @@ //! Trait + concrete implementation of the cross-thread dispatcher. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -11,7 +11,10 @@ use tokio::sync::{mpsc, oneshot}; use super::errors::DispatchError; use crate::engine::cjs::{FsResolver, ROOT_PARENT, default_registry}; use crate::engine::{BootContext, V8Engine}; -use crate::ops::{HeaderPair, RequestMeta, RequestSlot, ResponsePayload}; +use crate::ops::{ + HeaderPair, OsEnv, ProcessConfig, RequestFailure, RequestMeta, RequestSlot, ResponseHead, + ResponsePayload, StreamTaps, +}; use crate::sandbox_root_for; /// Plain-data view of an HTTP request used to cross thread boundaries. @@ -39,6 +42,20 @@ impl ProtoRequest { } } +/// Response yielded by the streaming dispatch path. +/// +/// `head` is delivered as soon as the JS handler emits status+headers; +/// `body` produces individual chunks (`Bytes`) as they are written by +/// the handler, surfacing `RequestFailure` mid-stream when the +/// handler errors after `head` has already been sent. Closing the +/// channel signals end-of-stream. +pub struct StreamingResponse { + /// Status + headers half (always set before `body` yields). + pub head: ResponseHead, + /// Body chunks as they are produced by the JS handler. + pub body: tokio::sync::mpsc::UnboundedReceiver>, +} + /// Cross-thread dispatcher contract used by the HTTP shield. /// /// Implementors must be `Send + Sync + 'static` so Axum can clone the @@ -57,6 +74,27 @@ pub trait EngineDispatcher: Send + Sync + 'static { /// `504`). async fn dispatch(&self, request: ProtoRequest) -> Result; + /// Streaming variant. Returns the response head as soon as JS + /// emits it and a chunk receiver that yields body bytes as they + /// are produced by the handler. Default implementation falls + /// back to the buffered [`Self::dispatch`] path so existing + /// dispatchers keep working without changes. + async fn dispatch_streaming( + &self, + request: ProtoRequest, + ) -> Result { + let payload = self.dispatch(request).await?; + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + if !payload.body.is_empty() { + let _ = tx.send(Ok(payload.body)); + } + drop(tx); + Ok(StreamingResponse { + head: payload.head, + body: rx, + }) + } + /// Total number of requests dispatched since the worker started /// (Query - telemetry only, no side effects). fn dispatch_count(&self) -> usize; @@ -66,6 +104,12 @@ pub trait EngineDispatcher: Send + Sync + 'static { struct DispatchJob { slot: RequestSlot, reply: oneshot::Sender>, + streaming: Option, +} + +struct StreamingHandshake { + head_tx: oneshot::Sender, + body_tx: tokio::sync::mpsc::UnboundedSender>, } /// Production [`EngineDispatcher`] backed by a dedicated thread that @@ -117,6 +161,7 @@ impl EngineDispatcher for IsolateDispatcher { .send(DispatchJob { slot, reply: reply_tx, + streaming: None, }) .await .map_err(|_| DispatchError::WorkerGone)?; @@ -127,6 +172,57 @@ impl EngineDispatcher for IsolateDispatcher { outcome } + async fn dispatch_streaming( + &self, + request: ProtoRequest, + ) -> Result { + let slot = request.into_slot()?; + let (reply_tx, mut reply_rx) = oneshot::channel(); + let (head_tx, head_rx) = oneshot::channel(); + let (body_tx, body_rx) = tokio::sync::mpsc::unbounded_channel(); + self.job_tx + .send(DispatchJob { + slot, + reply: reply_tx, + streaming: Some(StreamingHandshake { head_tx, body_tx }), + }) + .await + .map_err(|_| DispatchError::WorkerGone)?; + + tokio::select! { + biased; + head = head_rx => { + match head { + Ok(head) => { + self.dispatch_count.fetch_add(1, Ordering::Relaxed); + Ok(StreamingResponse { head, body: body_rx }) + } + Err(_) => { + match (&mut reply_rx).await.map_err(|_| DispatchError::WorkerGone)? { + Ok(_) => Err(DispatchError::WorkerGone), + Err(err) => Err(err), + } + } + } + } + outcome = &mut reply_rx => { + let outcome = outcome.map_err(|_| DispatchError::WorkerGone)?; + match outcome { + Ok(payload) => { + self.dispatch_count.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + if !payload.body.is_empty() { + let _ = tx.send(Ok(payload.body)); + } + drop(tx); + Ok(StreamingResponse { head: payload.head, body: rx }) + } + Err(err) => Err(err), + } + } + } + } + fn dispatch_count(&self) -> usize { self.dispatch_count.load(Ordering::Relaxed) } @@ -167,11 +263,20 @@ async fn run_worker( } }; let project_root = sandbox_root_for(&entrypoint); - let resolver = Arc::new(FsResolver::new(vec![project_root.clone()], registry)); + let entrypoint_dir = entrypoint + .parent() + .map_or_else(|| project_root.clone(), Path::to_path_buf); + let mut resolver_roots: Vec = vec![entrypoint_dir.clone()]; + if entrypoint_dir != project_root { + resolver_roots.push(project_root.clone()); + } + let resolver = Arc::new(FsResolver::new(resolver_roots, registry)); let ctx = BootContext::new() .with_cjs(resolver) .with_cjs_root(ROOT_PARENT) - .with_fs(crate::ops::FsHandle::real(vec![project_root])); + .with_fs(crate::ops::FsHandle::real(vec![project_root])) + .with_process(ProcessConfig::builder(Arc::new(OsEnv)).build()) + .with_heap_limit(crate::effective_heap_limit()); let mut engine = match V8Engine::boot_with(&entrypoint, ctx).await { Ok(engine) => { @@ -210,7 +315,15 @@ async fn run_worker( let Some(job) = job_rx.recv().await else { break "channel closed"; }; - let rx = engine.borrow().enqueue(job.slot); + let rx = match job.streaming { + None => engine.borrow().enqueue(job.slot), + Some(handshake) => { + let (tx, rx) = oneshot::channel(); + let taps = StreamTaps::new(handshake.head_tx, handshake.body_tx); + engine.borrow().enqueue_streaming(job.slot, tx, taps); + rx + } + }; pump_signal.notify_one(); let pump_signal_for_task = pump_signal.clone(); tokio::task::spawn_local(async move { diff --git a/crates/nexide/src/dispatch/mod.rs b/crates/nexide/src/dispatch/mod.rs index 819dc02..d112646 100644 --- a/crates/nexide/src/dispatch/mod.rs +++ b/crates/nexide/src/dispatch/mod.rs @@ -10,5 +10,5 @@ mod dispatcher; mod errors; -pub use dispatcher::{EngineDispatcher, IsolateDispatcher, ProtoRequest}; +pub use dispatcher::{EngineDispatcher, IsolateDispatcher, ProtoRequest, StreamingResponse}; pub use errors::DispatchError; diff --git a/crates/nexide/src/engine/cjs/mod.rs b/crates/nexide/src/engine/cjs/mod.rs index 3eb9042..c1ba1a2 100644 --- a/crates/nexide/src/engine/cjs/mod.rs +++ b/crates/nexide/src/engine/cjs/mod.rs @@ -14,4 +14,4 @@ mod resolver; pub use builtins::{default_registry, register_node_builtins}; pub use errors::CjsError; pub use registry::{BuiltinModule, BuiltinRegistry}; -pub use resolver::{CjsResolver, FsResolver, ROOT_PARENT, Resolved}; +pub use resolver::{CjsResolver, FsResolver, ROOT_PARENT, Resolved, is_esm_path}; diff --git a/crates/nexide/src/engine/cjs/resolver.rs b/crates/nexide/src/engine/cjs/resolver.rs index ad5dd61..6e684f6 100644 --- a/crates/nexide/src/engine/cjs/resolver.rs +++ b/crates/nexide/src/engine/cjs/resolver.rs @@ -14,6 +14,8 @@ use std::sync::Arc; use super::errors::CjsError; use super::registry::BuiltinRegistry; +const LOG_TARGET: &str = "nexide::engine::cjs"; + /// Sentinel parent value used by the JS loader for top-level /// `require` calls. The resolver treats it as the project root. pub const ROOT_PARENT: &str = ""; @@ -86,6 +88,17 @@ pub trait CjsResolver: Send + Sync + 'static { /// configured roots. fn resolve(&self, parent: &str, request: &str) -> Result; + /// Resolves `request` using ESM conditions (`["node", "import", + /// "default"]`). Defaults to [`Self::resolve`] for resolvers that + /// do not distinguish CJS vs ESM exports. + /// + /// # Errors + /// + /// Same as [`Self::resolve`]. + fn resolve_esm(&self, parent: &str, request: &str) -> Result { + self.resolve(parent, request) + } + /// Returns the source of a `node:*` builtin, or `None` if no /// builtin with that name is registered. fn builtin_source(&self, name: &str) -> Option<&'static str>; @@ -101,6 +114,52 @@ pub trait CjsResolver: Send + Sync + 'static { fn is_path_admitted(&self, _path: &Path) -> bool { true } + + /// Returns `true` when `path` should be evaluated as ESM. + /// + /// Default implementation inspects the file extension and the + /// nearest `package.json#type` field. + #[must_use] + fn is_esm_path(&self, path: &Path) -> bool { + is_esm_path(path) + } +} + +/// Walks up from `path`, looking for the closest `package.json`, and +/// reports whether the file should be parsed as ESM. +/// +/// Rules (mirror Node): +/// * `.mjs` / `.mts` → always ESM. +/// * `.cjs` / `.cts` → never ESM. +/// * `.js` / `.jsx` → defer to `package.json#type` (`"module"` ⇒ ESM). +/// * everything else → CJS. +#[must_use] +pub fn is_esm_path(path: &Path) -> bool { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase); + match ext.as_deref() { + Some("mjs" | "mts") => return true, + Some("cjs" | "cts" | "json" | "node") => return false, + Some("js" | "jsx" | "ts" | "tsx") => {} + _ => return false, + } + let mut current = path.parent(); + while let Some(dir) = current { + let pkg = dir.join("package.json"); + if pkg.is_file() + && let Ok(text) = std::fs::read_to_string(&pkg) + && let Ok(json) = serde_json::from_str::(&text) + { + if let Some(t) = json.get("type").and_then(serde_json::Value::as_str) { + return t == "module"; + } + return false; + } + current = dir.parent(); + } + false } /// File-system-backed resolver scoped to a list of project roots. @@ -231,13 +290,18 @@ impl FsResolver { Ok(Self::classify(path)) } - fn resolve_node_modules(&self, base_dir: &Path, request: &str) -> Result { + fn resolve_node_modules( + &self, + base_dir: &Path, + request: &str, + conditions: &[&str], + ) -> Result { let (pkg_name, subpath) = split_package_name(request); let mut current = Some(base_dir.to_path_buf()); while let Some(dir) = current { let pkg_dir = dir.join("node_modules").join(&pkg_name); if pkg_dir.is_dir() - && let Some(found) = Self::resolve_in_package(&pkg_dir, subpath) + && let Some(found) = Self::resolve_in_package(&pkg_dir, subpath, conditions) && self.within_roots(&found) { return Ok(Self::classify(found)); @@ -254,6 +318,7 @@ impl FsResolver { &self, base_dir: &Path, request: &str, + conditions: &[&str], ) -> Result { let mut current = Some(base_dir.to_path_buf()); while let Some(dir) = current { @@ -262,9 +327,9 @@ impl FsResolver { && let Ok(text) = std::fs::read_to_string(&pkg_path) && let Ok(json) = serde_json::from_str::(&text) && let Some(imports) = json.get("imports") - && let Some(rel) = match_exports(imports, request) + && let Some(rel) = match_exports(imports, request, conditions) { - return self.resolve_imports_target(&dir, &rel, request, base_dir); + return self.resolve_imports_target(&dir, &rel, request, base_dir, conditions); } current = dir.parent().map(Path::to_path_buf); } @@ -280,6 +345,7 @@ impl FsResolver { target: &str, request: &str, base_dir: &Path, + conditions: &[&str], ) -> Result { if target.starts_with("./") || target.starts_with("../") { let candidate = pkg_dir.join(target.trim_start_matches("./")); @@ -297,12 +363,12 @@ impl FsResolver { }); } if target.starts_with('#') { - return self.resolve_subpath_imports(pkg_dir, target); + return self.resolve_subpath_imports(pkg_dir, target, conditions); } - self.resolve_node_modules(pkg_dir, target) + self.resolve_node_modules(pkg_dir, target, conditions) } - fn resolve_in_package(pkg_dir: &Path, subpath: &str) -> Option { + fn resolve_in_package(pkg_dir: &Path, subpath: &str, conditions: &[&str]) -> Option { let pkg_path = pkg_dir.join("package.json"); let pkg_json = if pkg_path.is_file() { std::fs::read_to_string(&pkg_path) @@ -320,7 +386,7 @@ impl FsResolver { } else { format!("./{subpath}") }; - if let Some(rel) = match_exports(exports, &key) { + if let Some(rel) = match_exports(exports, &key, conditions) { let candidate = pkg_dir.join(rel.trim_start_matches("./")); if candidate.is_file() { return Some(candidate); @@ -371,10 +437,10 @@ fn split_package_name(request: &str) -> (String, &str) { ) } -fn match_exports(exports: &serde_json::Value, key: &str) -> Option { +fn match_exports(exports: &serde_json::Value, key: &str, conditions: &[&str]) -> Option { if let Some(obj) = exports.as_object() { if let Some(direct) = obj.get(key) - && let Some(s) = pick_condition(direct) + && let Some(s) = pick_condition(direct, conditions) { return Some(s); } @@ -384,7 +450,7 @@ fn match_exports(exports: &serde_json::Value, key: &str) -> Option { let suffix = &suffix[1..]; if key.starts_with(prefix) && key.ends_with(suffix) && key.len() >= pat.len() - 1 { let middle = &key[prefix.len()..key.len() - suffix.len()]; - if let Some(template) = pick_condition(val) { + if let Some(template) = pick_condition(val, conditions) { return Some(template.replacen('*', middle, 1)); } } @@ -400,13 +466,13 @@ fn match_exports(exports: &serde_json::Value, key: &str) -> Option { None } -fn pick_condition(value: &serde_json::Value) -> Option { +fn pick_condition(value: &serde_json::Value, conditions: &[&str]) -> Option { match value { serde_json::Value::String(s) => Some(s.clone()), serde_json::Value::Object(map) => { - for cond in ["require", "node", "default"] { - if let Some(v) = map.get(cond) - && let Some(s) = pick_condition(v) + for cond in conditions { + if let Some(v) = map.get(*cond) + && let Some(s) = pick_condition(v, conditions) { return Some(s); } @@ -417,8 +483,16 @@ fn pick_condition(value: &serde_json::Value) -> Option { } } -impl CjsResolver for FsResolver { - fn resolve(&self, parent: &str, request: &str) -> Result { +const CJS_CONDITIONS: &[&str] = &["require", "node", "default"]; +const ESM_CONDITIONS: &[&str] = &["node", "import", "default"]; + +impl FsResolver { + fn resolve_with( + &self, + parent: &str, + request: &str, + conditions: &[&str], + ) -> Result { if let Some(name) = request.strip_prefix("node:") { if self.registry.contains(name) { return Ok(Resolved::Builtin(name.to_owned())); @@ -442,15 +516,33 @@ impl CjsResolver for FsResolver { let base_dir = self.parent_dir(parent); if request.starts_with('#') { - return self.resolve_subpath_imports(&base_dir, request); + return self.resolve_subpath_imports(&base_dir, request, conditions); } if is_relative { self.resolve_file_path(&base_dir, request) } else { - self.resolve_node_modules(&base_dir, request) + self.resolve_node_modules(&base_dir, request, conditions) } } +} + +impl CjsResolver for FsResolver { + fn resolve(&self, parent: &str, request: &str) -> Result { + let result = self.resolve_with(parent, request, CJS_CONDITIONS); + if tracing::enabled!(target: LOG_TARGET, tracing::Level::DEBUG) { + log_resolution(parent, request, "cjs", &result); + } + result + } + + fn resolve_esm(&self, parent: &str, request: &str) -> Result { + let result = self.resolve_with(parent, request, ESM_CONDITIONS); + if tracing::enabled!(target: LOG_TARGET, tracing::Level::DEBUG) { + log_resolution(parent, request, "esm", &result); + } + result + } fn builtin_source(&self, name: &str) -> Option<&'static str> { self.registry.lookup(name).map(|m| m.source()) @@ -461,6 +553,60 @@ impl CjsResolver for FsResolver { } } +fn log_resolution( + parent: &str, + request: &str, + mode: &'static str, + result: &Result, +) { + match result { + Ok(Resolved::Builtin(name)) => tracing::trace!( + target: LOG_TARGET, + parent, + request, + mode, + kind = "builtin", + name = %name, + "resolved", + ), + Ok(Resolved::File(path)) => tracing::trace!( + target: LOG_TARGET, + parent, + request, + mode, + kind = "file", + path = %path.display(), + "resolved", + ), + Ok(Resolved::Json(path)) => tracing::trace!( + target: LOG_TARGET, + parent, + request, + mode, + kind = "json", + path = %path.display(), + "resolved", + ), + Ok(Resolved::Native(path)) => tracing::trace!( + target: LOG_TARGET, + parent, + request, + mode, + kind = "native", + path = %path.display(), + "resolved", + ), + Err(err) => tracing::debug!( + target: LOG_TARGET, + parent, + request, + mode, + error = %err, + "resolution failed", + ), + } +} + #[cfg(test)] mod tests { use super::*; @@ -589,6 +735,21 @@ mod tests { assert!(matches!(r, Resolved::File(p) if p.ends_with("index.js"))); } + #[test] + fn root_resolves_relative_to_first_root_when_node_modules_live_in_subdir() { + let sandbox = tmp_root(); + let app = sandbox.path().join("web-ui"); + let pkg = app.join("node_modules").join("firebase-admin"); + fs::create_dir_all(&pkg).expect("mkdirs"); + fs::write(pkg.join("index.js"), "module.exports = 'fb';").expect("entry"); + let resolver = FsResolver::new( + vec![app.clone(), sandbox.path().to_path_buf()], + registry_with(&[]), + ); + let r = resolver.resolve(ROOT_PARENT, "firebase-admin").expect("ok"); + assert!(matches!(r, Resolved::File(p) if p.ends_with("index.js"))); + } + #[test] fn resolves_subpath_imports_with_conditions() { let dir = tmp_root(); diff --git a/crates/nexide/src/engine/code_cache.rs b/crates/nexide/src/engine/code_cache.rs new file mode 100644 index 0000000..5a502cc --- /dev/null +++ b/crates/nexide/src/engine/code_cache.rs @@ -0,0 +1,642 @@ +//! Persistent V8 bytecode cache for user-bundle compile sites. +//! +//! Stores serialized [`v8::CachedData`] keyed by +//! `SHA-256(source) || cached_data_version_tag()`. Pairs with the +//! aggressive idle-GC pump that flushes V8's internal code: after a +//! `MemoryPressureLevel::Critical` notification V8 may re-parse and +//! re-bytecode-gen on the next request; with the cache hot, that work +//! collapses into a `ConsumeCodeCache` deserialise. +//! +//! ## Storage layout +//! +//! ```text +//! ${NEXIDE_CACHE_DIR:-/tmp/.nexide-cache}/ +//! v1//.bin +//! ``` +//! +//! - `v1/` is the on-disk schema version owned by nexide. Bumped when +//! the file format changes (currently the raw V8 cached-data blob). +//! - `/` partitions by V8's bytecode-ABI tag so a +//! `rusty_v8` upgrade silently invalidates everything from the +//! previous generation - we never ship a stale blob to a new V8. +//! - File names are content-addressed: identical sources produced by +//! parallel workers collapse to the same path. Atomic rename +//! (`.tmp.` → ``) makes concurrent writes safe. +//! +//! ## Failure model +//! +//! Every cache operation degrades to a no-op on error - reads return +//! `None`, writes log and drop. V8 itself silently falls back to a +//! fresh compile when cached bytes are rejected, so nothing on the +//! hot path can break correctness. +//! +//! ## Concurrency +//! +//! [`CodeCache`] is `Send + Sync` and cheap to clone (`Arc` inside). +//! Stores are dispatched onto Tokio's blocking pool via +//! `tokio::task::spawn_blocking`; the calling V8 thread never blocks +//! on disk. When no Tokio runtime is available (e.g. unit tests), +//! [`Self::store`] falls back to a synchronous write so behaviour +//! stays observable. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; + +use sha2::{Digest, Sha256}; + +const LOG_TARGET: &str = "nexide::engine::code_cache"; + +const SCHEMA_VERSION: &str = "v1"; +const FILE_EXT: &str = "bin"; + +const DEFAULT_QUOTA_MB: u64 = 256; + +const ENV_CACHE_DIR: &str = "NEXIDE_CACHE_DIR"; +const ENV_KILL_SWITCH: &str = "NEXIDE_CODE_CACHE"; +const ENV_QUOTA_MB: &str = "NEXIDE_CACHE_MAX_MB"; + +/// Per-cache atomic counters. Cheap to read and stable across clones +/// because [`CodeCache`] holds an [`Arc`]. +#[derive(Debug, Default)] +pub struct CacheMetrics { + /// Successful `lookup` calls that produced bytes. + pub hits: AtomicU64, + /// `lookup` calls that found nothing on disk. + pub misses: AtomicU64, + /// V8 reported `CachedData::rejected()` after a hit - source was + /// found but bytecode was stale. + pub rejects: AtomicU64, + /// Successful `store` writes (including overwrites). + pub writes: AtomicU64, + /// `store` calls whose write path returned an I/O error. + pub write_errors: AtomicU64, + /// Cumulative bytes written through `store`. + pub bytes_cached: AtomicU64, +} + +impl CacheMetrics { + fn record_hit(&self) { + self.hits.fetch_add(1, Ordering::Relaxed); + } + fn record_miss(&self) { + self.misses.fetch_add(1, Ordering::Relaxed); + } + /// Logged when V8 marks a freshly loaded cache entry as + /// `rejected` (typically a tag that survived disk but lost an + /// internal V8 invariant). + pub fn record_reject(&self) { + self.rejects.fetch_add(1, Ordering::Relaxed); + } + fn record_write(&self, bytes: u64) { + self.writes.fetch_add(1, Ordering::Relaxed); + self.bytes_cached.fetch_add(bytes, Ordering::Relaxed); + } + fn record_write_error(&self) { + self.write_errors.fetch_add(1, Ordering::Relaxed); + } + + /// Snapshot of all counters. Useful for tracing summaries on + /// shutdown and for the bench harness. + #[must_use] + pub fn snapshot(&self) -> CacheMetricsSnapshot { + CacheMetricsSnapshot { + hits: self.hits.load(Ordering::Relaxed), + misses: self.misses.load(Ordering::Relaxed), + rejects: self.rejects.load(Ordering::Relaxed), + writes: self.writes.load(Ordering::Relaxed), + write_errors: self.write_errors.load(Ordering::Relaxed), + bytes_cached: self.bytes_cached.load(Ordering::Relaxed), + } + } +} + +/// Plain-old-data snapshot returned by [`CacheMetrics::snapshot`]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[allow(missing_docs)] +pub struct CacheMetricsSnapshot { + pub hits: u64, + pub misses: u64, + pub rejects: u64, + pub writes: u64, + pub write_errors: u64, + pub bytes_cached: u64, +} + +impl CacheMetricsSnapshot { + /// Hit ratio in `[0.0, 1.0]`. Returns `0.0` when no lookups have + /// been recorded so callers can format unconditionally. + #[must_use] + pub fn hit_ratio(&self) -> f64 { + let total = self.hits + self.misses; + if total == 0 { + 0.0 + } else { + self.hits as f64 / total as f64 + } + } +} + +/// Process-wide V8 bytecode cache. +/// +/// Cheap to clone: state lives in an [`Arc`]. One instance per +/// runtime is enough; share it across every isolate via the engine +/// handle. +#[derive(Debug, Clone)] +pub struct CodeCache { + inner: Arc, +} + +#[derive(Debug)] +struct Inner { + root: PathBuf, + enabled: bool, + v8_tag_override: Option, + v8_tag: OnceLock, + quota_bytes: u64, + metrics: Arc, +} + +impl Inner { + fn resolve_tag(&self) -> u32 { + if let Some(tag) = self.v8_tag_override { + return tag; + } + *self + .v8_tag + .get_or_init(v8::script_compiler::cached_data_version_tag) + } +} + +impl CodeCache { + /// Builds a cache from the environment. + /// + /// - `NEXIDE_CODE_CACHE=0|false|off|no` → fully disabled. + /// - `NEXIDE_CACHE_DIR` overrides the storage root (default + /// `${TMPDIR}/.nexide-cache`). + /// - `NEXIDE_CACHE_MAX_MB` caps eviction quota (default 256 MB). + /// + /// Best-effort `mkdir -p` is performed up front; failure here + /// does *not* disable the cache - subsequent reads/writes will + /// just fail individually and degrade to no-ops. + #[must_use] + pub fn from_env() -> Self { + let kill_switch = std::env::var(ENV_KILL_SWITCH) + .ok() + .map(|raw| { + matches!( + raw.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" + ) + }) + .unwrap_or(false); + if kill_switch { + return Self::disabled(); + } + + let root = std::env::var(ENV_CACHE_DIR) + .ok() + .filter(|s| !s.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(default_cache_root); + + let quota_bytes = std::env::var(ENV_QUOTA_MB) + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|&mb| mb > 0) + .unwrap_or(DEFAULT_QUOTA_MB) + .saturating_mul(1024 * 1024); + + let v8_tag_cell: OnceLock = OnceLock::new(); + + if let Err(err) = std::fs::create_dir_all(root.join(SCHEMA_VERSION)) { + tracing::warn!( + target: LOG_TARGET, + path = %root.display(), + error = %err, + "code cache: mkdir failed - operations will degrade to no-op" + ); + } + + Self { + inner: Arc::new(Inner { + root, + enabled: true, + v8_tag_override: None, + v8_tag: v8_tag_cell, + quota_bytes, + metrics: Arc::new(CacheMetrics::default()), + }), + } + } + + /// Builds a cache pinned to `root` with a custom V8 tag. Test-only + /// constructor: lets tests assert tag-segregation without + /// depending on the real V8 ABI tag. + #[must_use] + #[doc(hidden)] + pub fn with_root(root: PathBuf, v8_tag: u32, quota_bytes: u64) -> Self { + let dir = root.join(SCHEMA_VERSION).join(format!("{v8_tag:08x}")); + let _ = std::fs::create_dir_all(&dir); + Self { + inner: Arc::new(Inner { + root, + enabled: true, + v8_tag_override: Some(v8_tag), + v8_tag: OnceLock::new(), + quota_bytes, + metrics: Arc::new(CacheMetrics::default()), + }), + } + } + + /// Builds a cache pinned to `root` that resolves the V8 ABI tag + /// lazily on first use, exactly like [`Self::from_env`] but with a + /// caller-supplied storage root. Test-only. + #[must_use] + #[doc(hidden)] + pub fn with_root_lazy(root: PathBuf, quota_bytes: u64) -> Self { + let _ = std::fs::create_dir_all(root.join(SCHEMA_VERSION)); + Self { + inner: Arc::new(Inner { + root, + enabled: true, + v8_tag_override: None, + v8_tag: OnceLock::new(), + quota_bytes, + metrics: Arc::new(CacheMetrics::default()), + }), + } + } + + /// Builds a permanently-disabled cache. All operations are + /// no-ops; metrics still tick (`misses` only) so dashboards stay + /// consistent across deployments. + #[must_use] + pub fn disabled() -> Self { + Self { + inner: Arc::new(Inner { + root: PathBuf::new(), + enabled: false, + v8_tag_override: Some(0), + v8_tag: OnceLock::new(), + quota_bytes: 0, + metrics: Arc::new(CacheMetrics::default()), + }), + } + } + + /// Returns `true` when the cache will read/write the filesystem. + #[must_use] + pub fn is_enabled(&self) -> bool { + self.inner.enabled + } + + /// Per-cache shared metrics handle. + #[must_use] + pub fn metrics(&self) -> Arc { + Arc::clone(&self.inner.metrics) + } + + /// V8 bytecode-ABI tag used to partition the cache. + #[must_use] + pub fn v8_tag(&self) -> u32 { + self.inner.resolve_tag() + } + + /// Storage root - useful for tests. + #[must_use] + pub fn root(&self) -> &Path { + &self.inner.root + } + + /// Returns the absolute path used to store the cache entry for + /// `source`. Public for tests; not stable. + #[must_use] + #[doc(hidden)] + pub fn entry_path(&self, source: &str) -> PathBuf { + let mut hasher = Sha256::new(); + hasher.update(source.as_bytes()); + let digest = hasher.finalize(); + let key = hex::encode(digest); + let tag = self.inner.resolve_tag(); + self.inner + .root + .join(SCHEMA_VERSION) + .join(format!("{tag:08x}")) + .join(format!("{key}.{FILE_EXT}")) + } + + /// Reads the cache entry for `source`. Returns `None` on miss or + /// I/O error. + pub fn lookup(&self, source: &str) -> Option> { + if !self.inner.enabled { + self.inner.metrics.record_miss(); + return None; + } + let path = self.entry_path(source); + match std::fs::read(&path) { + Ok(bytes) if !bytes.is_empty() => { + self.inner.metrics.record_hit(); + tracing::trace!( + target: LOG_TARGET, + path = %path.display(), + bytes = bytes.len(), + "code cache hit" + ); + Some(bytes) + } + Ok(_) => { + self.inner.metrics.record_miss(); + None + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + self.inner.metrics.record_miss(); + None + } + Err(err) => { + self.inner.metrics.record_miss(); + tracing::debug!( + target: LOG_TARGET, + path = %path.display(), + error = %err, + "code cache: lookup failed" + ); + None + } + } + } + + /// Persists `bytes` for `source`. Asynchronous when a Tokio + /// runtime is available, synchronous otherwise. Errors are logged + /// and counted but never propagated - the V8 hot path stays free. + pub fn store(&self, source: &str, bytes: Vec) { + if !self.inner.enabled || bytes.is_empty() { + return; + } + let path = self.entry_path(source); + let metrics = Arc::clone(&self.inner.metrics); + let bytes_len = bytes.len() as u64; + + let blocking = move || match write_atomic(&path, &bytes) { + Ok(()) => { + metrics.record_write(bytes_len); + tracing::trace!( + target: LOG_TARGET, + path = %path.display(), + bytes = bytes_len, + "code cache store" + ); + } + Err(err) => { + metrics.record_write_error(); + tracing::debug!( + target: LOG_TARGET, + path = %path.display(), + error = %err, + "code cache: store failed" + ); + } + }; + + match tokio::runtime::Handle::try_current() { + Ok(handle) => { + handle.spawn_blocking(blocking); + } + Err(_) => blocking(), + } + } + + /// Drops oldest entries (by mtime) until the cache fits the + /// configured quota. Returns the number of files removed. + /// Designed to be called from the idle-GC pump - never the hot + /// path. Cheap when below quota. + pub fn evict_to_quota(&self) -> usize { + if !self.inner.enabled { + return 0; + } + let tag = self.inner.resolve_tag(); + let dir = self + .inner + .root + .join(SCHEMA_VERSION) + .join(format!("{tag:08x}")); + evict_to_quota_in(&dir, self.inner.quota_bytes) + } +} + +fn default_cache_root() -> PathBuf { + let base = std::env::var("TMPDIR") + .ok() + .filter(|s| !s.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")); + base.join(".nexide-cache") +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let pid = std::process::id(); + let nonce: u64 = { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) + }; + let tmp = path.with_extension(format!("tmp.{pid}.{nonce}")); + std::fs::write(&tmp, bytes)?; + match std::fs::rename(&tmp, path) { + Ok(()) => Ok(()), + Err(err) => { + let _ = std::fs::remove_file(&tmp); + Err(err) + } + } +} + +fn evict_to_quota_in(dir: &Path, quota_bytes: u64) -> usize { + let entries = match std::fs::read_dir(dir) { + Ok(rd) => rd, + Err(_) => return 0, + }; + let mut files: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new(); + let mut total: u64 = 0; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let size = meta.len(); + let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH); + total = total.saturating_add(size); + files.push((path, size, mtime)); + } + if total <= quota_bytes { + return 0; + } + files.sort_by_key(|(_, _, m)| *m); + let mut removed = 0usize; + let mut current = total; + for (path, size, _) in files { + if current <= quota_bytes { + break; + } + if std::fs::remove_file(&path).is_ok() { + removed += 1; + current = current.saturating_sub(size); + } + } + removed +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn disabled_cache_is_noop() { + let cache = CodeCache::disabled(); + assert!(!cache.is_enabled()); + assert!(cache.lookup("anything").is_none()); + cache.store("anything", vec![1, 2, 3]); + let snap = cache.metrics().snapshot(); + assert_eq!(snap.hits, 0); + assert_eq!(snap.writes, 0); + } + + #[test] + fn store_then_lookup_roundtrips_identical_bytes() { + let dir = TempDir::new().unwrap(); + let cache = CodeCache::with_root(dir.path().to_path_buf(), 0xdead_beef, 64 * 1024 * 1024); + let src = "module.exports = 1"; + let blob = vec![9u8; 128]; + cache.store(src, blob.clone()); + let got = cache.lookup(src).expect("hit"); + assert_eq!(got, blob); + let snap = cache.metrics().snapshot(); + assert_eq!(snap.hits, 1); + assert_eq!(snap.writes, 1); + assert_eq!(snap.bytes_cached, 128); + } + + #[test] + fn lookup_miss_returns_none_and_increments_misses() { + let dir = TempDir::new().unwrap(); + let cache = CodeCache::with_root(dir.path().to_path_buf(), 1, 1 << 20); + assert!(cache.lookup("nope").is_none()); + let snap = cache.metrics().snapshot(); + assert_eq!(snap.misses, 1); + assert_eq!(snap.hits, 0); + } + + #[test] + fn entry_paths_are_partitioned_by_v8_tag() { + let dir = TempDir::new().unwrap(); + let a = CodeCache::with_root(dir.path().to_path_buf(), 1, 1 << 20); + let b = CodeCache::with_root(dir.path().to_path_buf(), 2, 1 << 20); + a.store("same source", vec![1; 8]); + assert!(a.lookup("same source").is_some()); + assert!(b.lookup("same source").is_none()); + } + + #[test] + fn entry_paths_differ_per_source_via_sha256() { + let dir = TempDir::new().unwrap(); + let cache = CodeCache::with_root(dir.path().to_path_buf(), 7, 1 << 20); + let p1 = cache.entry_path("foo"); + let p2 = cache.entry_path("bar"); + assert_ne!(p1, p2); + } + + #[test] + fn evict_to_quota_drops_oldest_first() { + let dir = TempDir::new().unwrap(); + let cache = CodeCache::with_root(dir.path().to_path_buf(), 0, 200); + cache.store("a", vec![1; 90]); + std::thread::sleep(std::time::Duration::from_millis(20)); + cache.store("b", vec![2; 90]); + std::thread::sleep(std::time::Duration::from_millis(20)); + cache.store("c", vec![3; 90]); + let removed = cache.evict_to_quota(); + assert!(removed >= 1, "expected eviction (removed = {removed})"); + assert!( + cache.lookup("a").is_none() || cache.lookup("b").is_none(), + "oldest entry must be gone" + ); + assert!(cache.lookup("c").is_some(), "newest entry must survive"); + } + + #[test] + fn corrupt_zero_byte_file_is_treated_as_miss() { + let dir = TempDir::new().unwrap(); + let cache = CodeCache::with_root(dir.path().to_path_buf(), 42, 1 << 20); + let p = cache.entry_path("empty"); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, b"").unwrap(); + assert!(cache.lookup("empty").is_none()); + let snap = cache.metrics().snapshot(); + assert_eq!(snap.hits, 0); + assert_eq!(snap.misses, 1); + } + + #[test] + fn metrics_hit_ratio_zero_when_idle() { + let snap = CacheMetricsSnapshot::default(); + assert!((snap.hit_ratio() - 0.0).abs() < f64::EPSILON); + } + + #[test] + fn metrics_hit_ratio_computes_against_total_lookups() { + let snap = CacheMetricsSnapshot { + hits: 3, + misses: 1, + ..Default::default() + }; + assert!((snap.hit_ratio() - 0.75).abs() < f64::EPSILON); + } + + #[test] + fn from_env_kill_switch_disables_cache() { + let restore = EnvGuard::set(ENV_KILL_SWITCH, "0"); + let cache = CodeCache::from_env(); + assert!(!cache.is_enabled()); + drop(restore); + } + + struct EnvGuard { + key: &'static str, + prev: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let prev = std::env::var(key).ok(); + // SAFETY: tests in this file run on a single thread per + // `cargo test` invocation - rust insta-mut env access is + // sound under that assumption. + unsafe { + std::env::set_var(key, value); + } + Self { key, prev } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: see `EnvGuard::set`. + unsafe { + if let Some(ref v) = self.prev { + std::env::set_var(self.key, v); + } else { + std::env::remove_var(self.key); + } + } + } + } +} diff --git a/crates/nexide/src/engine/mod.rs b/crates/nexide/src/engine/mod.rs index 174a714..07f95a8 100644 --- a/crates/nexide/src/engine/mod.rs +++ b/crates/nexide/src/engine/mod.rs @@ -11,7 +11,9 @@ mod isolate; pub(crate) mod v8_engine; pub mod cjs; +pub mod code_cache; +pub use code_cache::{CacheMetrics, CacheMetricsSnapshot, CodeCache}; pub use errors::EngineError; pub use heap_config::{HeapLimitConfig, heap_limit_from_env}; pub use isolate::{HeapStats, IsolateHandle}; diff --git a/crates/nexide/src/engine/v8_engine/bridge.rs b/crates/nexide/src/engine/v8_engine/bridge.rs index 017f479..10164f4 100644 --- a/crates/nexide/src/engine/v8_engine/bridge.rs +++ b/crates/nexide/src/engine/v8_engine/bridge.rs @@ -21,11 +21,13 @@ use crate::ops::{DispatchTable, EnvOverlay, FsHandle, ProcessConfig, RequestQueu pub(crate) type NapiWorkItem = Box) + Send + 'static>; -/// TCP socket entry: shared, async-locked stream so reads and writes -/// from JS land on the same FD without racing. `Rc` because each -/// isolate is single-threaded; `tokio::sync::Mutex` because read / -/// write futures must be awaited. -pub(super) type TcpStreamSlot = std::rc::Rc>; +/// TCP socket entry: shared TcpStream — `Rc` because each isolate is +/// single-threaded. No `Mutex` needed: `tokio::net::TcpStream`'s I/O +/// methods (`readable`/`try_read`/`writable`/`try_write`) take `&self` +/// and tolerate concurrent read+write from the same task, which is +/// required so a pending read does not block an outgoing write on the +/// same FD (Node's net.Socket semantics). +pub(super) type TcpStreamSlot = std::rc::Rc; /// TCP listener entry: shared so multiple `accept` ops can target /// the same listener concurrently if the JS code chooses to. diff --git a/crates/nexide/src/engine/v8_engine/engine.rs b/crates/nexide/src/engine/v8_engine/engine.rs index 41a6971..ec02ab5 100644 --- a/crates/nexide/src/engine/v8_engine/engine.rs +++ b/crates/nexide/src/engine/v8_engine/engine.rs @@ -24,6 +24,8 @@ use crate::ops::{ ResponsePayload, WorkerId, }; +const LOG_TARGET: &str = "nexide::engine::v8"; + static V8_INIT: Once = Once::new(); #[repr(C, align(16))] @@ -70,6 +72,7 @@ pub struct BootContext { fs: Option, cjs: Option>, cjs_root: Option, + code_cache: Option, } impl BootContext { @@ -123,6 +126,15 @@ impl BootContext { self.cjs_root = Some(root.into()); self } + + /// Installs a persistent V8 bytecode cache shared across every + /// compile site that opts in (see + /// [`crate::engine::code_cache::CodeCache`]). + #[must_use] + pub fn with_code_cache(mut self, cache: crate::engine::code_cache::CodeCache) -> Self { + self.code_cache = Some(cache); + self + } } // ────────────────────────────────────────────────────────────────────── @@ -168,6 +180,15 @@ impl V8Engine { path: entrypoint.to_path_buf(), })?; + let worker_id = ctx.worker_id.unwrap_or_else(|| WorkerId::new(0, true)); + tracing::debug!( + target: LOG_TARGET, + worker = worker_id.id, + primary = worker_id.is_primary, + entry = %entry_path.display(), + "v8 isolate boot starting", + ); + let heap_limit = ctx.heap_limit.unwrap_or_default(); let create_params = heap_limit.to_create_params(); let mut isolate = v8::Isolate::new(create_params); @@ -178,7 +199,7 @@ impl V8Engine { let bridge = BridgeState { queue: Rc::new(RequestQueue::new()), dispatch_table: DispatchTable::default(), - worker_id: ctx.worker_id.unwrap_or_else(|| WorkerId::new(0, true)), + worker_id, process: ctx.process, env_overlay: crate::ops::EnvOverlay::default(), fs: ctx.fs, @@ -204,6 +225,11 @@ impl V8Engine { }; isolate.set_slot(BridgeStateHandle::new(bridge)); isolate.set_slot(ModuleMap::new()); + isolate.set_slot( + ctx.code_cache + .unwrap_or_else(crate::engine::code_cache::CodeCache::disabled), + ); + isolate.set_host_import_module_dynamically_callback(host_import_module_dynamically); let context_global = { v8::scope!(let scope, &mut isolate); @@ -233,11 +259,22 @@ impl V8Engine { load_and_run_entrypoint(scope_cs, &entry_path)?; } + run_eden_warmup(scope_cs)?; + v8::Global::new(scope_cs, context) }; let stats = capture_heap_stats(&mut isolate); + tracing::debug!( + target: LOG_TARGET, + worker = worker_id.id, + entry = %entry_path.display(), + heap_used = stats.used_heap_size, + heap_total = stats.total_heap_size, + "v8 isolate boot complete", + ); + Ok(Self { isolate, context: context_global, @@ -291,6 +328,30 @@ impl V8Engine { rx } + /// Streaming variant of [`Self::enqueue_with`]: pushes the request + /// and immediately attaches the supplied [`crate::ops::StreamTaps`] + /// so that subsequent `send_head`/`send_chunk` ops fire the head + /// oneshot and forward chunks to the body channel as they arrive. + pub fn enqueue_streaming( + &self, + slot: RequestSlot, + completion: oneshot::Sender>, + taps: crate::ops::StreamTaps, + ) -> RequestId { + let handle = self + .isolate + .get_slot::() + .cloned() + .expect("bridge state must be installed"); + let mut state = handle.0.borrow_mut(); + let id = state.dispatch_table.insert(slot, completion); + if let Ok(inflight) = state.dispatch_table.get_mut(id) { + inflight.attach_taps(taps); + } + state.queue.push(id); + id + } + /// Drains the dispatch table, failing every in-flight request /// with `RequestFailure::PumpDied(reason)`. pub fn fail_inflight(&self, reason: &str) { @@ -320,11 +381,25 @@ impl V8Engine { /// Evaluates a classic script in the engine's main realm. pub fn execute(&mut self, name: &str, source: &str) -> Result<(), EngineError> { + tracing::trace!( + target: LOG_TARGET, + script = name, + bytes = source.len(), + "execute classic script", + ); let context = self.context.clone(); v8::scope!(let scope, &mut self.isolate); let context = v8::Local::new(scope, context); let scope_cs = &mut v8::ContextScope::new(scope, context); - eval_script(scope_cs, name, source) + eval_script(scope_cs, name, source).map_err(|e| { + tracing::warn!( + target: LOG_TARGET, + script = name, + error = %e, + "classic script failed", + ); + e + }) } /// Returns `true` when no requests are queued and there are no @@ -380,6 +455,31 @@ impl V8Engine { self.isolate.perform_microtask_checkpoint(); self.last_stats = capture_heap_stats(&mut self.isolate); } + + /// Hints V8 that the host is under memory pressure so the isolate + /// should give back as much heap as it can right now. + /// + /// Calls + /// `v8::Isolate::MemoryPressureNotification(MemoryPressureLevel::Critical)` + /// which triggers a major GC and asks V8 to release reclaimed + /// pages back to the OS - the only way for an idle Node-style + /// isolate to drop its working-set RSS below the high-water mark + /// reached during peak traffic. Combined with jemalloc decay (or + /// `malloc_trim` on glibc), this lets a long-idle worker shrink + /// from `100-150` MiB back down to the cold-boot baseline + /// (~`30-40` MiB) without restarting the isolate. + /// + /// Cheap to call (one virtual call into V8 + one major GC pause + /// of `~5-30` ms depending on heap size). The caller is + /// responsible for rate-limiting; the typical pattern is to + /// invoke this once after the dispatch queue has been empty for + /// `NEXIDE_IDLE_GC_MS` (default `30_000` ms - see + /// [`run_pump`](crate::pool::engine_pump) for the exact wiring). + pub fn notify_low_memory(&mut self) { + self.isolate + .memory_pressure_notification(v8::MemoryPressureLevel::Critical); + self.last_stats = capture_heap_stats(&mut self.isolate); + } } fn drain_napi_work<'s>( @@ -431,6 +531,23 @@ fn run_polyfill_bootstrap<'s>(scope: &mut v8::PinScope<'s, '_>) -> Result<(), En Ok(()) } +const EDEN_WARMUP_SCRIPT: &str = r#"(function nexideEdenWarmup() { + let buf = []; + const target = 4096; + for (let i = 0; i < target; i++) { + buf.push({ k: 'wm-' + i, v: new Array(64).fill(i) }); + } + buf = null; +})();"#; + +fn run_eden_warmup<'s>(scope: &mut v8::PinScope<'s, '_>) -> Result<(), EngineError> { + if std::env::var("NEXIDE_DISABLE_EDEN_WARMUP").is_ok() { + return Ok(()); + } + eval_script(scope, "[nexide:eden-warmup]", EDEN_WARMUP_SCRIPT)?; + Ok(()) +} + fn eval_script<'s>( scope: &mut v8::PinScope<'s, '_>, name: &str, @@ -472,7 +589,7 @@ fn eval_script<'s>( Ok(()) } -fn load_and_run_entrypoint<'s>( +pub(super) fn load_and_run_entrypoint<'s>( scope: &mut v8::PinScope<'s, '_>, entry: &Path, ) -> Result<(), EngineError> { @@ -506,7 +623,7 @@ fn load_and_run_entrypoint<'s>( Ok(()) } -fn compile_module<'s>( +pub(super) fn compile_module<'s>( scope: &mut v8::PinScope<'s, '_>, path: &Path, ) -> Result, EngineError> { @@ -531,13 +648,44 @@ fn compile_module<'s>( true, None, ); - let mut source = v8::script_compiler::Source::new(code, Some(&origin)); - let module = v8::script_compiler::compile_module(scope, &mut source).ok_or_else(|| { - EngineError::JsRuntime { - message: format!("compile failed for {}", path.display()), + + let cache = code_cache_from_isolate(scope); + let cached_bytes = cache + .as_ref() + .filter(|c| c.is_enabled()) + .and_then(|c| c.lookup(&source_text)); + + let (mut source, options) = match cached_bytes { + Some(bytes) => { + let cached = v8::script_compiler::CachedData::new(&bytes); + ( + v8::script_compiler::Source::new_with_cached_data(code, Some(&origin), cached), + v8::script_compiler::CompileOptions::ConsumeCodeCache, + ) } + None => ( + v8::script_compiler::Source::new(code, Some(&origin)), + v8::script_compiler::CompileOptions::NoCompileOptions, + ), + }; + + let module = v8::script_compiler::compile_module2( + scope, + &mut source, + options, + v8::script_compiler::NoCacheReason::NoReason, + ) + .ok_or_else(|| EngineError::JsRuntime { + message: format!("compile failed for {}", path.display()), })?; + let fresh_blob = module + .get_unbound_module_script(scope) + .create_code_cache() + .map(|cd| cd.to_vec()); + + finalise_cache_after_compile(cache.as_ref(), &source, &source_text, options, fresh_blob); + let hash = module.get_identity_hash().get(); let module_global = v8::Global::new(scope, module); if let Some(map) = get_module_map_mut(scope) { @@ -546,13 +694,57 @@ fn compile_module<'s>( Ok(module) } +/// Returns the [`code_cache::CodeCache`] attached to `isolate`, or +/// `None` when no cache slot has been installed (e.g. in unit tests +/// that bypass [`V8Engine::boot_internal`]). +pub(super) fn code_cache_from_isolate<'s>( + scope: &v8::PinScope<'s, '_>, +) -> Option { + scope + .get_slot::() + .cloned() +} + +fn finalise_cache_after_compile( + cache: Option<&crate::engine::code_cache::CodeCache>, + source_obj: &v8::script_compiler::Source, + source_text: &str, + options: v8::script_compiler::CompileOptions, + fresh_blob: Option>, +) { + let Some(cache) = cache else { return }; + if !cache.is_enabled() { + return; + } + + let consumed = options.contains(v8::script_compiler::CompileOptions::ConsumeCodeCache); + let rejected = source_obj + .get_cached_data() + .map(v8::CachedData::rejected) + .unwrap_or(false); + + if consumed && !rejected { + return; + } + + if consumed && rejected { + cache.metrics().record_reject(); + } + + if let Some(blob) = fresh_blob.filter(|b| !b.is_empty()) { + cache.store(source_text, blob); + } +} + /// Returns `Some(&mut ModuleMap)` from the isolate's slot store. -fn get_module_map_mut<'s, 'a>(scope: &'a mut v8::PinScope<'s, '_>) -> Option<&'a mut ModuleMap> { +pub(super) fn get_module_map_mut<'s, 'a>( + scope: &'a mut v8::PinScope<'s, '_>, +) -> Option<&'a mut ModuleMap> { scope.get_slot_mut::() } #[allow(clippy::unnecessary_wraps)] -fn resolve_module_callback<'s>( +pub(super) fn resolve_module_callback<'s>( context: v8::Local<'s, v8::Context>, specifier: v8::Local<'s, v8::String>, _import_attributes: v8::Local<'s, v8::FixedArray>, @@ -563,6 +755,21 @@ fn resolve_module_callback<'s>( let specifier_str = specifier.to_rust_string_lossy(scope); let referrer_hash = referrer.get_identity_hash().get(); + // Fast path: ESM loader pre-resolved this dependency and stored + // the absolute key path in the module map. + let resolved_key: Option = scope.get_slot::().and_then(|m| { + m.lookup_resolution(referrer_hash, &specifier_str) + .map(Path::to_path_buf) + }); + if let Some(key) = resolved_key { + let cached: Option> = scope + .get_slot::() + .and_then(|m: &ModuleMap| m.get(&key).cloned()); + if let Some(g) = cached { + return Some(v8::Local::new(scope, &g)); + } + } + let parent_path: Option = scope .get_slot::() .and_then(|m: &ModuleMap| m.path_of_hash(referrer_hash).map(Path::to_path_buf)); @@ -590,12 +797,47 @@ fn resolve_module_callback<'s>( } } -fn throw_error<'s>(scope: &mut v8::PinScope<'s, '_>, message: &str) { +pub(super) fn throw_error<'s>(scope: &mut v8::PinScope<'s, '_>, message: &str) { let msg = v8::String::new(scope, message).unwrap(); let exc = v8::Exception::error(scope, msg); scope.throw_exception(exc); } +/// V8 host hook for `import(specifier)` expressions. Bridges to the +/// real ESM loader in [`super::esm`] which: +/// +/// * resolves the specifier with ESM conditions, +/// * compiles + instantiates real `.mjs` graphs, +/// * wraps any CJS dependency as a synthetic V8 module (default +/// export = `module.exports`), +/// * settles the returned promise with the module namespace once the +/// evaluate promise (top-level await aware) fulfils. +/// +/// CJS-only callers fall through to `__nexideCjs.dynamicImport` via +/// the `op_esm_dynamic_import` op the JS shim invokes. +fn host_import_module_dynamically<'s>( + scope: &mut v8::PinScope<'s, '_>, + _host_defined_options: v8::Local<'s, v8::Data>, + resource_name: v8::Local<'s, v8::Value>, + specifier: v8::Local<'s, v8::String>, + _import_attributes: v8::Local<'s, v8::FixedArray>, +) -> Option> { + let specifier_str = specifier.to_rust_string_lossy(scope); + let referrer_str = if resource_name.is_string() { + Some(resource_name.to_rust_string_lossy(scope)) + } else { + None + }; + tracing::trace!( + target: LOG_TARGET, + specifier = %specifier_str, + referrer = ?referrer_str, + "host_import_module_dynamically", + ); + let promise = super::esm::do_esm_dynamic_import(scope, &specifier_str, referrer_str.as_deref()); + Some(promise) +} + #[cfg(test)] mod tests { use super::*; @@ -644,4 +886,32 @@ mod tests { }) .await; } + + #[tokio::test(flavor = "current_thread")] + async fn text_decoder_streams_split_utf8() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let src = r#" + const json = JSON.stringify({"hello":"świecie","poly":"łódź żółć","amount":"1 234 567,89"}); + const bytes = new TextEncoder().encode(json); + for (const chunkSize of [1, 2, 3, 5, 7, 11, 13, 17]) { + const td = new TextDecoder("utf-8"); + let out = ""; + for (let i = 0; i < bytes.length; i += chunkSize) { + out += td.decode(bytes.subarray(i, Math.min(i + chunkSize, bytes.length)), { stream: true }); + } + out += td.decode(); + if (out !== json) { + throw new Error("mismatch at chunkSize=" + chunkSize + ":\nexpected: " + json + "\ngot: " + out); + } + JSON.parse(out); + } + "#; + let file = write_temp(src); + let result = V8Engine::boot(file.path()).await; + assert!(result.is_ok(), "TextDecoder streaming failed: {:?}", result.err()); + }) + .await; + } } diff --git a/crates/nexide/src/engine/v8_engine/esm.rs b/crates/nexide/src/engine/v8_engine/esm.rs new file mode 100644 index 0000000..73b73dd --- /dev/null +++ b/crates/nexide/src/engine/v8_engine/esm.rs @@ -0,0 +1,655 @@ +//! Real ESM dynamic-import support. +//! +//! The flow mirrors Node.js / Deno semantics: +//! +//! 1. The V8 host hook (in [`super::engine`]) and the JS-level +//! `__nexideCjs.dynamicImport` shim both forward to +//! [`do_esm_dynamic_import`] / [`op_esm_dynamic_import`]. +//! 2. Specifiers are resolved through the CJS resolver using ESM +//! conditions (`["node", "import", "default"]`). +//! 3. ESM files (`.mjs` or `.js` with `package.json#type == "module"`) +//! are compiled via `v8::script_compiler::compile_module`. Their +//! static `import` graph is walked depth-first and pre-compiled +//! with cycle protection (the [`super::modules::ModuleMap`] holds a +//! handle to a partially-initialised module; the resolve callback +//! accepts uninstantiated modules during linking). +//! 4. CJS dependencies referenced from ESM are loaded through the +//! existing CJS loader (`__nexideCjs.load`) and wrapped in a +//! synthetic V8 module whose `default` export is the CJS +//! `module.exports` and whose named exports mirror its enumerable +//! own properties. +//! 5. After instantiate + evaluate, the outer dynamic-import promise +//! is settled with the module namespace once the evaluate promise +//! (which may be pending under top-level await) fulfils. +//! +//! Known gaps: +//! * `import.meta.url` is not yet wired through `compile_module`. +//! * Synthetic modules import-from-ESM-back-to-CJS cycles are +//! resolved through whatever the CJS cache already contains. +//! * Worker-thread isolates re-resolve every dependency; no shared +//! compilation cache across isolates. + +use std::path::{Path, PathBuf}; + +use super::bridge::from_isolate; +use super::engine::{compile_module, get_module_map_mut, resolve_module_callback}; +use super::modules::{ModuleMap, resolve_relative}; +use crate::engine::EngineError; +use crate::engine::cjs::{self, Resolved, is_esm_path}; + +const LOG_TARGET: &str = "nexide::engine::esm"; + +/// Public entry point used from the V8 host hook and from +/// `op_esm_dynamic_import`. Always returns a promise; failures are +/// rejected, never thrown. +pub(super) fn do_esm_dynamic_import<'s>( + scope: &mut v8::PinScope<'s, '_>, + specifier: &str, + referrer: Option<&str>, +) -> v8::Local<'s, v8::Promise> { + let outer = v8::PromiseResolver::new(scope).expect("PromiseResolver::new"); + let outer_promise = outer.get_promise(scope); + tracing::trace!( + target: LOG_TARGET, + specifier, + referrer = referrer.unwrap_or(""), + "dynamic import requested", + ); + match try_dynamic_import(scope, specifier, referrer) { + Ok(value) => { + tracing::debug!( + target: LOG_TARGET, + specifier, + referrer = referrer.unwrap_or(""), + "dynamic import resolved", + ); + outer.resolve(scope, value); + } + Err(err) => { + tracing::warn!( + target: LOG_TARGET, + specifier, + referrer = referrer.unwrap_or(""), + error = %err, + "dynamic import failed", + ); + let msg = format!("dynamic import: {err} (specifier '{specifier}')"); + let s = v8::String::new(scope, &msg).unwrap(); + let exc = v8::Exception::error(scope, s); + outer.reject(scope, exc); + } + } + outer_promise +} + +fn try_dynamic_import<'s>( + scope: &mut v8::PinScope<'s, '_>, + specifier: &str, + referrer: Option<&str>, +) -> Result, String> { + let parent = referrer + .map(str::to_owned) + .unwrap_or_else(|| cjs_root_parent(scope)); + + let resolved = resolve_dependency(scope, &parent, specifier)?; + match resolved { + DepResolved::Esm(abs) => load_and_evaluate_esm(scope, &abs), + DepResolved::Cjs { + key: _, + parent_arg, + request_arg, + } => { + let exports = call_cjs_load(scope, &parent_arg, &request_arg)?; + Ok(build_namespace_object(scope, exports)) + } + } +} + +fn cjs_root_parent<'s>(scope: &mut v8::PinScope<'s, '_>) -> String { + let handle = from_isolate(scope); + handle.0.borrow().cjs_root.clone() +} + +fn load_and_evaluate_esm<'s>( + scope: &mut v8::PinScope<'s, '_>, + abs_path: &Path, +) -> Result, String> { + let module = load_esm_graph(scope, abs_path).map_err(|e| { + tracing::warn!( + target: LOG_TARGET, + path = %abs_path.display(), + error = %e, + "esm graph load failed", + ); + e.to_string() + })?; + let namespace_after_eval = |scope: &mut v8::PinScope<'s, '_>, + module: v8::Local<'s, v8::Module>| { + if matches!(module.get_status(), v8::ModuleStatus::Errored) { + let exc = module.get_exception(); + return Err(value_to_string(scope, exc)); + } + Ok(module.get_module_namespace()) + }; + + if matches!( + module.get_status(), + v8::ModuleStatus::Evaluated | v8::ModuleStatus::Errored + ) { + tracing::trace!( + target: LOG_TARGET, + path = %abs_path.display(), + "esm module already evaluated, returning namespace", + ); + return namespace_after_eval(scope, module); + } + + if matches!(module.get_status(), v8::ModuleStatus::Uninstantiated) { + tracing::debug!( + target: LOG_TARGET, + path = %abs_path.display(), + "instantiating esm module", + ); + v8::tc_scope!(let tc, scope); + let ok = module + .instantiate_module(tc, resolve_module_callback) + .unwrap_or(false); + if !ok { + let exc = tc + .exception() + .map(|e| value_to_string(tc, e)) + .unwrap_or_else(|| format!("instantiate failed for {}", abs_path.display())); + tracing::warn!( + target: LOG_TARGET, + path = %abs_path.display(), + error = %exc, + "esm module instantiate failed", + ); + return Err(exc); + } + } + + tracing::debug!( + target: LOG_TARGET, + path = %abs_path.display(), + "evaluating esm module", + ); + let eval_value = { + v8::tc_scope!(let tc, scope); + match module.evaluate(tc) { + Some(v) => v, + None => { + let exc = tc + .exception() + .map(|e| value_to_string(tc, e)) + .unwrap_or_else(|| { + format!("evaluate returned none for {}", abs_path.display()) + }); + tracing::warn!( + target: LOG_TARGET, + path = %abs_path.display(), + error = %exc, + "esm module evaluate threw synchronously", + ); + return Err(exc); + } + } + }; + + if matches!(module.get_status(), v8::ModuleStatus::Errored) { + let exc = module.get_exception(); + let msg = value_to_string(scope, exc); + tracing::warn!( + target: LOG_TARGET, + path = %abs_path.display(), + error = %msg, + "esm module entered errored state after evaluate", + ); + return Err(msg); + } + + let namespace = module.get_module_namespace(); + chain_namespace_after(scope, eval_value, namespace).map_err(|e| { + tracing::warn!( + target: LOG_TARGET, + path = %abs_path.display(), + error = %e, + "esm namespace chaining failed", + ); + e.to_string() + }) +} + +/// Calls `globalThis.__nexideEsm.chain(evalPromise, namespace)` to +/// produce a Promise that fulfils with `namespace` once `evalPromise` +/// settles. When `eval_value` is not a Promise (e.g. classic eager +/// evaluation), the helper falls back to `Promise.resolve(namespace)`. +fn chain_namespace_after<'s>( + scope: &mut v8::PinScope<'s, '_>, + eval_value: v8::Local<'s, v8::Value>, + namespace: v8::Local<'s, v8::Value>, +) -> Result, String> { + let context = scope.get_current_context(); + let global = context.global(scope); + let key = v8::String::new(scope, "__nexideEsm").unwrap(); + let helper_obj = global + .get(scope, key.into()) + .ok_or_else(|| "missing __nexideEsm".to_owned())?; + let helper_obj: v8::Local = helper_obj + .try_into() + .map_err(|_| "__nexideEsm is not an object".to_owned())?; + let chain_key = v8::String::new(scope, "chain").unwrap(); + let chain_val = helper_obj + .get(scope, chain_key.into()) + .ok_or_else(|| "missing __nexideEsm.chain".to_owned())?; + let chain_fn: v8::Local = chain_val + .try_into() + .map_err(|_| "__nexideEsm.chain is not a function".to_owned())?; + v8::tc_scope!(let tc, scope); + let recv: v8::Local = helper_obj.into(); + let args = [eval_value, namespace]; + chain_fn.call(tc, recv, &args).ok_or_else(|| { + tc.exception() + .map(|e| value_to_string(tc, e)) + .unwrap_or_else(|| "chain helper threw".to_owned()) + }) +} + +/// Recursively compiles `abs_path` and all of its static imports. +/// Caches everything in [`ModuleMap`] keyed by absolute path; cycles +/// are handled by the cache check at the top. +pub(super) fn load_esm_graph<'s>( + scope: &mut v8::PinScope<'s, '_>, + abs_path: &Path, +) -> Result, EngineError> { + if let Some(cached) = get_module_map_mut(scope).and_then(|m| m.get(abs_path).cloned()) { + tracing::trace!( + target: LOG_TARGET, + path = %abs_path.display(), + "esm graph cache hit", + ); + return Ok(v8::Local::new(scope, &cached)); + } + tracing::debug!( + target: LOG_TARGET, + path = %abs_path.display(), + "compiling esm module", + ); + let module = compile_module(scope, abs_path).map_err(|e| { + tracing::warn!( + target: LOG_TARGET, + path = %abs_path.display(), + error = %e, + "esm module compile failed", + ); + e + })?; + process_module_requests(scope, module, abs_path)?; + Ok(module) +} + +fn process_module_requests<'s>( + scope: &mut v8::PinScope<'s, '_>, + module: v8::Local<'s, v8::Module>, + parent_path: &Path, +) -> Result<(), EngineError> { + let parent_hash = module.get_identity_hash().get(); + let requests = module.get_module_requests(); + let len = requests.length(); + + let mut specifiers: Vec = Vec::with_capacity(len); + for i in 0..len { + let item = requests + .get(scope, i) + .ok_or_else(|| EngineError::JsRuntime { + message: format!("missing module request #{i}"), + })?; + // V8 promises that requests entries are always ModuleRequest. + let req: v8::Local = + item.try_into().map_err(|_| EngineError::JsRuntime { + message: format!("module request #{i} is not a ModuleRequest"), + })?; + let spec = req.get_specifier().to_rust_string_lossy(scope); + specifiers.push(spec); + } + + let parent_str = parent_path.to_string_lossy().into_owned(); + for spec in specifiers { + let resolved = + resolve_dependency(scope, &parent_str, &spec).map_err(|e| EngineError::JsRuntime { + message: format!("ESM resolve '{spec}' from '{}': {e}", parent_path.display()), + })?; + match resolved { + DepResolved::Esm(abs) => { + if let Some(map) = get_module_map_mut(scope) { + map.set_resolution(parent_hash, &spec, abs.clone()); + } + load_esm_graph(scope, &abs)?; + } + DepResolved::Cjs { + key, + parent_arg, + request_arg, + } => { + ensure_synthetic_for_cjs(scope, &key, &parent_arg, &request_arg)?; + if let Some(map) = get_module_map_mut(scope) { + map.set_resolution(parent_hash, &spec, key); + } + } + } + } + Ok(()) +} + +#[derive(Debug)] +enum DepResolved { + Esm(PathBuf), + Cjs { + /// Key under which the synthetic module is cached. + key: PathBuf, + /// `parent` argument to forward to `__nexideCjs.load`. + parent_arg: String, + /// `request` argument to forward to `__nexideCjs.load`. + request_arg: String, + }, +} + +fn resolve_dependency<'s>( + scope: &mut v8::PinScope<'s, '_>, + parent: &str, + request: &str, +) -> Result { + if let Some(name) = request.strip_prefix("node:") { + return Ok(DepResolved::Cjs { + key: PathBuf::from(format!("node:{name}")), + parent_arg: parent.to_owned(), + request_arg: request.to_owned(), + }); + } + + let is_relative = request.starts_with("./") + || request.starts_with("../") + || request.starts_with('/') + || request == "." + || request == ".."; + + if is_relative { + let parent_path = Path::new(parent); + let candidate = resolve_relative(parent_path, request); + let resolver = cjs_resolver(scope)?; + match resolver.resolve(parent, request) { + Ok(Resolved::File(p)) | Ok(Resolved::Json(p)) | Ok(Resolved::Native(p)) => { + if is_esm_path(&p) { + Ok(DepResolved::Esm(p)) + } else { + Ok(DepResolved::Cjs { + key: p, + parent_arg: parent.to_owned(), + request_arg: request.to_owned(), + }) + } + } + Ok(Resolved::Builtin(name)) => Ok(DepResolved::Cjs { + key: PathBuf::from(format!("node:{name}")), + parent_arg: parent.to_owned(), + request_arg: request.to_owned(), + }), + Err(_) => { + // Fall back to plain relative path; ESM if extension says so. + if is_esm_path(&candidate) { + Ok(DepResolved::Esm(candidate)) + } else { + Err(format!( + "could not resolve relative '{request}' from '{parent}'" + )) + } + } + } + } else { + let resolver = cjs_resolver(scope)?; + let resolved = resolver + .resolve_esm(parent, request) + .map_err(|e| e.to_string())?; + Ok(match resolved { + Resolved::File(p) | Resolved::Json(p) | Resolved::Native(p) => { + if is_esm_path(&p) { + DepResolved::Esm(p) + } else { + DepResolved::Cjs { + key: p, + parent_arg: parent.to_owned(), + request_arg: request.to_owned(), + } + } + } + Resolved::Builtin(name) => DepResolved::Cjs { + key: PathBuf::from(format!("node:{name}")), + parent_arg: parent.to_owned(), + request_arg: request.to_owned(), + }, + }) + } +} + +fn cjs_resolver<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Result, String> { + let handle = from_isolate(scope); + handle + .0 + .borrow() + .cjs + .clone() + .ok_or_else(|| "cjs resolver not configured".to_owned()) +} + +/// Pre-loads the CJS exports for `request` and wraps them as a V8 +/// SyntheticModule. Cached in [`ModuleMap`] under `key`. +fn ensure_synthetic_for_cjs<'s>( + scope: &mut v8::PinScope<'s, '_>, + key: &Path, + parent_arg: &str, + request_arg: &str, +) -> Result, EngineError> { + if let Some(cached) = get_module_map_mut(scope).and_then(|m| m.get(key).cloned()) { + return Ok(v8::Local::new(scope, &cached)); + } + let exports = + call_cjs_load(scope, parent_arg, request_arg).map_err(|e| EngineError::JsRuntime { + message: format!("CJS load failed for '{request_arg}': {e}"), + })?; + + let mut export_names: Vec> = Vec::new(); + let default_name = v8::String::new(scope, "default").unwrap(); + export_names.push(default_name); + + let mut export_name_strings: Vec = vec!["default".to_owned()]; + + if let Ok(obj) = TryInto::>::try_into(exports) + && let Some(names) = obj.get_own_property_names(scope, v8::GetPropertyNamesArgs::default()) + { + let len = names.length(); + for i in 0..len { + if let Some(k) = names.get_index(scope, i) + && let Some(s) = k.to_string(scope) + { + let rust = s.to_rust_string_lossy(scope); + if !is_valid_export_identifier(&rust) || rust == "default" { + continue; + } + if export_name_strings.iter().any(|e| e == &rust) { + continue; + } + export_name_strings.push(rust); + } + } + } + + let mut name_locals: Vec> = Vec::with_capacity(export_name_strings.len()); + for name in &export_name_strings { + name_locals.push(v8::String::new(scope, name).unwrap()); + } + + let module_name = v8::String::new(scope, &key.to_string_lossy()).unwrap(); + let module = + v8::Module::create_synthetic_module(scope, module_name, &name_locals, synthetic_eval_steps); + + let module_hash = module.get_identity_hash().get(); + let exports_global = v8::Global::new(scope, exports); + if let Some(map) = get_module_map_mut(scope) { + map.stash_synthetic_exports(module_hash, exports_global); + } + + let module_global = v8::Global::new(scope, module); + if let Some(map) = get_module_map_mut(scope) { + map.insert(key.to_path_buf(), module_hash, module_global); + } + Ok(module) +} + +fn is_valid_export_identifier(s: &str) -> bool { + let mut chars = s.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == '_' || first == '$') { + return false; + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +#[allow(clippy::unnecessary_wraps)] +fn synthetic_eval_steps<'s>( + context: v8::Local<'s, v8::Context>, + module: v8::Local<'s, v8::Module>, +) -> Option> { + v8::callback_scope!(unsafe scope, context); + let id = module.get_identity_hash().get(); + let exports_global = scope + .get_slot_mut::() + .and_then(|m| m.take_synthetic_exports(id))?; + let exports = v8::Local::new(scope, &exports_global); + + let default_name = v8::String::new(scope, "default")?; + module.set_synthetic_module_export(scope, default_name, exports)?; + + if let Ok(obj) = TryInto::>::try_into(exports) + && let Some(names) = obj.get_own_property_names(scope, v8::GetPropertyNamesArgs::default()) + { + let len = names.length(); + for i in 0..len { + let Some(key_val) = names.get_index(scope, i) else { + continue; + }; + let Some(key_str) = key_val.to_string(scope) else { + continue; + }; + let rust = key_str.to_rust_string_lossy(scope); + if !is_valid_export_identifier(&rust) || rust == "default" { + continue; + } + let Some(value) = obj.get(scope, key_str.into()) else { + continue; + }; + // Ignore failures: the export name was filtered out at + // creation time (e.g. duplicate, invalid identifier). + let _ = module.set_synthetic_module_export(scope, key_str, value); + } + } + let undef = v8::undefined(scope); + Some(undef.into()) +} + +fn call_cjs_load<'s>( + scope: &mut v8::PinScope<'s, '_>, + parent: &str, + request: &str, +) -> Result, String> { + let context = scope.get_current_context(); + let global = context.global(scope); + let key = v8::String::new(scope, "__nexideCjs").unwrap(); + let cjs_val = global + .get(scope, key.into()) + .ok_or_else(|| "missing __nexideCjs".to_owned())?; + let cjs_obj: v8::Local = cjs_val + .try_into() + .map_err(|_| "__nexideCjs is not an object".to_owned())?; + let load_key = v8::String::new(scope, "load").unwrap(); + let load_val = cjs_obj + .get(scope, load_key.into()) + .ok_or_else(|| "missing __nexideCjs.load".to_owned())?; + let load_fn: v8::Local = load_val + .try_into() + .map_err(|_| "__nexideCjs.load is not a function".to_owned())?; + let parent_str = v8::String::new(scope, parent).unwrap(); + let request_str = v8::String::new(scope, request).unwrap(); + v8::tc_scope!(let tc, scope); + let recv: v8::Local = cjs_obj.into(); + let args = [parent_str.into(), request_str.into()]; + load_fn.call(tc, recv, &args).ok_or_else(|| { + tc.exception() + .map(|e| value_to_string(tc, e)) + .unwrap_or_else(|| "CJS load threw".to_owned()) + }) +} + +/// Builds a CJS-style namespace object: own enumerable properties of +/// `exports` plus `default = exports`. Mirrors the legacy +/// `__nexideCjs.dynamicImport` behaviour for callers expecting an +/// object instead of a real ES module namespace. +fn build_namespace_object<'s>( + scope: &mut v8::PinScope<'s, '_>, + exports: v8::Local<'s, v8::Value>, +) -> v8::Local<'s, v8::Value> { + let null_proto = v8::null(scope).into(); + let ns = v8::Object::with_prototype_and_properties(scope, null_proto, &[], &[]); + if let Ok(obj) = TryInto::>::try_into(exports) + && let Some(names) = obj.get_own_property_names(scope, v8::GetPropertyNamesArgs::default()) + { + let len = names.length(); + for i in 0..len { + if let Some(k) = names.get_index(scope, i) + && let Some(s) = k.to_string(scope) + && let Some(v) = obj.get(scope, s.into()) + { + ns.set(scope, s.into(), v); + } + } + } + let default_key = v8::String::new(scope, "default").unwrap(); + ns.set(scope, default_key.into(), exports); + ns.into() +} + +fn value_to_string<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, +) -> String { + value + .to_string(scope) + .map(|s| s.to_rust_string_lossy(scope)) + .unwrap_or_else(|| "".to_owned()) +} + +#[allow(unused)] +pub(super) fn placeholder() {} + +// ────────────────────────────────────────────────────────────────────── +// Op +// ────────────────────────────────────────────────────────────────────── + +pub(super) fn op_esm_dynamic_import<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let specifier = args.get(0).to_rust_string_lossy(scope); + let referrer_arg = args.get(1); + let referrer = if referrer_arg.is_string() { + Some(referrer_arg.to_rust_string_lossy(scope)) + } else { + None + }; + let promise = do_esm_dynamic_import(scope, &specifier, referrer.as_deref()); + rv.set(promise.into()); +} diff --git a/crates/nexide/src/engine/v8_engine/mod.rs b/crates/nexide/src/engine/v8_engine/mod.rs index f2f0e2e..56e1d57 100644 --- a/crates/nexide/src/engine/v8_engine/mod.rs +++ b/crates/nexide/src/engine/v8_engine/mod.rs @@ -23,6 +23,7 @@ mod async_ops; mod bootstrap; mod bridge; mod engine; +mod esm; mod handle_table; mod modules; mod ops_bridge; diff --git a/crates/nexide/src/engine/v8_engine/modules.rs b/crates/nexide/src/engine/v8_engine/modules.rs index 75859f6..d168bf1 100644 --- a/crates/nexide/src/engine/v8_engine/modules.rs +++ b/crates/nexide/src/engine/v8_engine/modules.rs @@ -21,6 +21,8 @@ use crate::engine::EngineError; pub(super) struct ModuleMap { by_path: HashMap>, by_hash: HashMap, + resolution: HashMap<(i32, String), PathBuf>, + synthetic_exports: HashMap>, } impl ModuleMap { @@ -56,6 +58,43 @@ impl ModuleMap { pub(super) fn path_of_hash(&self, hash: i32) -> Option<&Path> { self.by_hash.get(&hash).map(PathBuf::as_path) } + + /// Records the resolution `(referrer, specifier) -> abs_key` so + /// that V8's resolve callback can map module requests back to + /// modules pre-compiled by the ESM loader. + pub(super) fn set_resolution(&mut self, referrer_hash: i32, specifier: &str, key: PathBuf) { + self.resolution + .insert((referrer_hash, specifier.to_owned()), key); + } + + /// Looks up a previously recorded resolution. + #[must_use] + pub(super) fn lookup_resolution(&self, referrer_hash: i32, specifier: &str) -> Option<&Path> { + self.resolution + .get(&(referrer_hash, specifier.to_owned())) + .map(PathBuf::as_path) + } + + /// Stashes the CJS exports object for later consumption by the + /// synthetic-module evaluation steps callback. + pub(super) fn stash_synthetic_exports( + &mut self, + module_hash: i32, + exports: v8::Global, + ) { + self.synthetic_exports.insert(module_hash, exports); + } + + /// Pops the stashed exports object for `module_hash`. The callback + /// only needs them once - V8 invokes evaluation steps a single + /// time per synthetic module. + #[must_use] + pub(super) fn take_synthetic_exports( + &mut self, + module_hash: i32, + ) -> Option> { + self.synthetic_exports.remove(&module_hash) + } } /// Joins `parent.parent()` with the relative `specifier` and diff --git a/crates/nexide/src/engine/v8_engine/ops_bridge.rs b/crates/nexide/src/engine/v8_engine/ops_bridge.rs index 5c03dbf..b60e61a 100644 --- a/crates/nexide/src/engine/v8_engine/ops_bridge.rs +++ b/crates/nexide/src/engine/v8_engine/ops_bridge.rs @@ -77,6 +77,12 @@ fn install_ops<'s>(scope: &mut v8::PinScope<'s, '_>, ops: v8::Local<'s, v8::Obje install_fn(scope, ops, "op_process_hrtime_ns", op_process_hrtime_ns); install_fn(scope, ops, "op_process_exit", op_process_exit); install_fn(scope, ops, "op_process_kill", op_process_kill); + install_fn( + scope, + ops, + "op_process_drain_signals", + op_process_drain_signals, + ); install_fn(scope, ops, "op_process_cpu_usage", op_process_cpu_usage); install_fn( scope, @@ -87,7 +93,19 @@ fn install_ops<'s>(scope: &mut v8::PinScope<'s, '_>, ops: v8::Local<'s, v8::Obje install_fn(scope, ops, "op_cjs_root_parent", op_cjs_root_parent); install_fn(scope, ops, "op_cjs_resolve", op_cjs_resolve); install_fn(scope, ops, "op_cjs_read_source", op_cjs_read_source); + install_fn( + scope, + ops, + "op_cjs_compile_function", + op_cjs_compile_function, + ); install_fn(scope, ops, "op_napi_load", op_napi_load); + install_fn( + scope, + ops, + "op_esm_dynamic_import", + super::esm::op_esm_dynamic_import, + ); install_fn(scope, ops, "op_os_arch", op_os_arch); install_fn(scope, ops, "op_os_platform", op_os_platform); @@ -112,6 +130,32 @@ fn install_ops<'s>(scope: &mut v8::PinScope<'s, '_>, ops: v8::Local<'s, v8::Obje install_fn(scope, ops, "op_fs_rm", op_fs_rm); install_fn(scope, ops, "op_fs_copy", op_fs_copy); install_fn(scope, ops, "op_fs_readlink", op_fs_readlink); + install_fn(scope, ops, "op_fs_rename", op_fs_rename); + install_fn(scope, ops, "op_fs_append", op_fs_append); + install_fn(scope, ops, "op_fs_read_async", op_fs_read_async); + install_fn(scope, ops, "op_fs_write_async", op_fs_write_async); + install_fn(scope, ops, "op_fs_append_async", op_fs_append_async); + install_fn(scope, ops, "op_fs_stat_async", op_fs_stat_async); + install_fn(scope, ops, "op_fs_readdir_async", op_fs_readdir_async); + install_fn(scope, ops, "op_fs_mkdir_async", op_fs_mkdir_async); + install_fn(scope, ops, "op_fs_rm_async", op_fs_rm_async); + install_fn(scope, ops, "op_fs_copy_async", op_fs_copy_async); + install_fn(scope, ops, "op_fs_rename_async", op_fs_rename_async); + install_fn(scope, ops, "op_fs_realpath_async", op_fs_realpath_async); + install_fn(scope, ops, "op_url_parse", op_url_parse); + install_fn(scope, ops, "op_url_can_parse", op_url_can_parse); + install_fn( + scope, + ops, + "op_os_network_interfaces", + op_os_network_interfaces, + ); + install_fn(scope, ops, "op_fs_chmod", op_fs_chmod); + install_fn(scope, ops, "op_fs_chmod_async", op_fs_chmod_async); + install_fn(scope, ops, "op_fs_symlink", op_fs_symlink); + install_fn(scope, ops, "op_fs_link", op_fs_link); + install_fn(scope, ops, "op_fs_truncate", op_fs_truncate); + install_fn(scope, ops, "op_fs_utimes", op_fs_utimes); install_fn(scope, ops, "op_crypto_hash", op_crypto_hash); install_fn(scope, ops, "op_crypto_hmac", op_crypto_hmac); @@ -143,6 +187,48 @@ fn install_ops<'s>(scope: &mut v8::PinScope<'s, '_>, ops: v8::Local<'s, v8::Obje ); install_fn(scope, ops, "op_crypto_sign", op_crypto_sign); install_fn(scope, ops, "op_crypto_verify", op_crypto_verify); + install_fn(scope, ops, "op_crypto_pem_decode", op_crypto_pem_decode); + install_fn(scope, ops, "op_crypto_pem_encode", op_crypto_pem_encode); + install_fn( + scope, + ops, + "op_crypto_generate_key_pair", + op_crypto_generate_key_pair, + ); + install_fn(scope, ops, "op_crypto_key_inspect", op_crypto_key_inspect); + install_fn(scope, ops, "op_crypto_key_convert", op_crypto_key_convert); + install_fn(scope, ops, "op_crypto_jwk_to_der", op_crypto_jwk_to_der); + install_fn(scope, ops, "op_crypto_der_to_jwk", op_crypto_der_to_jwk); + install_fn(scope, ops, "op_crypto_rsa_encrypt", op_crypto_rsa_encrypt); + install_fn(scope, ops, "op_crypto_rsa_decrypt", op_crypto_rsa_decrypt); + install_fn(scope, ops, "op_crypto_sign_der", op_crypto_sign_der); + install_fn(scope, ops, "op_crypto_verify_der", op_crypto_verify_der); + install_fn(scope, ops, "op_crypto_ecdh_derive", op_crypto_ecdh_derive); + install_fn( + scope, + ops, + "op_crypto_x25519_derive", + op_crypto_x25519_derive, + ); + install_fn( + scope, + ops, + "op_crypto_ecdh_generate", + op_crypto_ecdh_generate, + ); + install_fn( + scope, + ops, + "op_crypto_ecdh_from_raw", + op_crypto_ecdh_from_raw, + ); + install_fn( + scope, + ops, + "op_crypto_ecdh_compute_raw", + op_crypto_ecdh_compute_raw, + ); + install_fn(scope, ops, "op_crypto_hkdf", op_crypto_hkdf); install_fn(scope, ops, "op_zlib_encode", op_zlib_encode); install_fn(scope, ops, "op_zlib_decode", op_zlib_decode); @@ -168,6 +254,7 @@ fn install_ops<'s>(scope: &mut v8::PinScope<'s, '_>, ops: v8::Local<'s, v8::Obje install_fn(scope, ops, "op_net_set_keepalive", op_net_set_keepalive); install_fn(scope, ops, "op_tls_connect", op_tls_connect); + install_fn(scope, ops, "op_tls_upgrade", op_tls_upgrade); install_fn(scope, ops, "op_tls_read", op_tls_read); install_fn(scope, ops, "op_tls_write", op_tls_write); install_fn(scope, ops, "op_tls_close", op_tls_close); @@ -176,6 +263,25 @@ fn install_ops<'s>(scope: &mut v8::PinScope<'s, '_>, ops: v8::Local<'s, v8::Obje install_fn(scope, ops, "op_http_response_read", op_http_response_read); install_fn(scope, ops, "op_http_response_close", op_http_response_close); + install_fn( + scope, + ops, + "op_upgrade_socket_read_async", + op_upgrade_socket_read_async, + ); + install_fn( + scope, + ops, + "op_upgrade_socket_write_async", + op_upgrade_socket_write_async, + ); + install_fn( + scope, + ops, + "op_upgrade_socket_close", + op_upgrade_socket_close, + ); + install_fn(scope, ops, "op_proc_spawn", op_proc_spawn); install_fn(scope, ops, "op_proc_wait", op_proc_wait); install_fn(scope, ops, "op_proc_kill", op_proc_kill); @@ -282,19 +388,28 @@ fn read_bytes_arg<'s>( ) -> Option { if let Ok(view) = TryInto::>::try_into(value) { let len = view.byte_length(); - let mut buf = vec![0u8; len]; - view.copy_contents(&mut buf); + if len == 0 { + return Some(Bytes::new()); + } + let mut buf: Vec = Vec::with_capacity(len); + unsafe { + let slice = std::slice::from_raw_parts_mut(buf.as_mut_ptr(), len); + let copied = view.copy_contents(slice); + buf.set_len(copied); + } return Some(Bytes::from(buf)); } if let Ok(buf) = TryInto::>::try_into(value) { let store = buf.get_backing_store(); let len = store.byte_length(); - let mut out = vec![0u8; len]; + if len == 0 { + return Some(Bytes::new()); + } if let Some(data) = store.data() { let raw = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, len) }; - out.copy_from_slice(raw); + return Some(Bytes::copy_from_slice(raw)); } - return Some(Bytes::from(out)); + return Some(Bytes::new()); } None } @@ -315,6 +430,9 @@ fn throw_type_error<'s>(scope: &mut v8::PinScope<'s, '_>, message: &str) { /// completion oneshot with `Ok(payload)`, and removes the slot. fn settle_ok(table: &mut DispatchTable, id: RequestId) -> Result<(), String> { let inflight = table.get_mut(id).map_err(|e| e.to_string())?; + if let Some(taps) = inflight.stream_taps_mut() { + taps.finish(); + } let response = std::mem::take(inflight.response_mut()); let payload = response.finish().map_err(|e| e.to_string())?; if let Some(tx) = inflight.take_completion() { @@ -327,6 +445,9 @@ fn settle_ok(table: &mut DispatchTable, id: RequestId) -> Result<(), String> { /// Settles a request with a handler error. fn settle_err(table: &mut DispatchTable, id: RequestId, msg: &str) -> Result<(), String> { let inflight = table.get_mut(id).map_err(|e| e.to_string())?; + if let Some(taps) = inflight.stream_taps_mut() { + taps.finish_error(crate::ops::RequestFailure::Handler(msg.to_owned())); + } if let Some(tx) = inflight.take_completion() { let _ = tx.send(Err(crate::ops::RequestFailure::Handler(msg.to_owned()))); } @@ -465,14 +586,19 @@ fn op_nexide_get_meta<'s>( } } }; - let obj = v8::Object::new(scope); - let m_key = v8::String::new(scope, "method").unwrap(); - let m_val = v8::String::new(scope, &method).unwrap(); - obj.set(scope, m_key.into(), m_val.into()); - let u_key = v8::String::new(scope, "uri").unwrap(); - let u_val = v8::String::new(scope, &uri).unwrap(); - obj.set(scope, u_key.into(), u_val.into()); - rv.set(obj.into()); + // Hot-path optimisation: HTTP method (RFC 7230 token) and URI + // (RFC 3986 ASCII) are always one-byte; `new_from_one_byte` + // bypasses V8's UTF-8 → UTF-16 transcoding path (typical 2-3× + // faster for short strings). Layout switched from + // `{ method, uri }` to `[method, uri]`: saves one `v8::Object` + // allocation, two property `Set` calls, and the hidden-class + // transition per request. JS side reads `meta[0]`/`meta[1]`. + let m_val = ascii_v8_string(scope, method.as_bytes()); + let u_val = ascii_v8_string(scope, uri.as_bytes()); + let array = v8::Array::new(scope, 2); + array.set_index(scope, 0, m_val.into()); + array.set_index(scope, 1, u_val.into()); + rv.set(array.into()); } fn op_nexide_get_headers<'s>( @@ -502,20 +628,49 @@ fn op_nexide_get_headers<'s>( } } }; - let array = v8::Array::new(scope, headers.len() as i32); - let name_key = v8::String::new(scope, "name").unwrap(); - let value_key = v8::String::new(scope, "value").unwrap(); + // Hot-path optimisation: returns a *flat* `[name, value, name, + // value, ...]` array instead of an array of `{ name, value }` + // objects. This eliminates one `v8::Object` allocation and two + // property `Set` calls per header (typical request: ~15 headers + // → 15 fewer object allocations + 30 fewer hidden-class + // transitions). Combined with the ASCII fast-path + // (`new_from_one_byte`, bypasses UTF-8 → UTF-16 transcoding for + // header names+values which `HeaderValue::to_str` already + // guarantees are visible ASCII), this is one of the heaviest + // per-request bridge calls. JS side iterates by stride-2. + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + let array = v8::Array::new(scope, (headers.len() * 2) as i32); for (i, (name, value)) in headers.into_iter().enumerate() { - let obj = v8::Object::new(scope); - let n = v8::String::new(scope, &name).unwrap(); - let v = v8::String::new(scope, &value).unwrap(); - obj.set(scope, name_key.into(), n.into()); - obj.set(scope, value_key.into(), v.into()); - array.set_index(scope, i as u32, obj.into()); + let n = ascii_v8_string(scope, name.as_bytes()); + let v = ascii_v8_string(scope, value.as_bytes()); + #[allow(clippy::cast_possible_truncation)] + let base = (i * 2) as u32; + array.set_index(scope, base, n.into()); + array.set_index(scope, base + 1, v.into()); } rv.set(array.into()); } +/// Allocates a V8 string from an ASCII byte slice using the one-byte +/// fast path. +/// +/// `v8::String::new_from_one_byte` skips the UTF-8 → UTF-16 +/// transcoding step that `v8::String::new` (UTF-8) always pays. For +/// HTTP traffic - method/URI/header names+values - the bytes are +/// guaranteed visible ASCII (method is a token per RFC 7230, URI is +/// ASCII per RFC 3986, header values that survived +/// `HeaderValue::to_str` are visible ASCII), so this is always safe +/// and measurably faster on hot paths. Falls back to an empty string +/// only on the V8-internal length overflow case (effectively +/// unreachable for sane HTTP). +fn ascii_v8_string<'s>( + scope: &mut v8::PinScope<'s, '_>, + bytes: &[u8], +) -> v8::Local<'s, v8::String> { + v8::String::new_from_one_byte(scope, bytes, v8::NewStringType::Normal) + .unwrap_or_else(|| v8::String::empty(scope)) +} + fn op_nexide_read_body<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -578,10 +733,14 @@ fn op_nexide_send_head<'s>( let result = { let mut state = handle.0.borrow_mut(); match state.dispatch_table.get_mut(id) { - Ok(slot) => slot - .response_mut() - .send_head(head) - .map_err(|e| e.to_string()), + Ok(slot) => { + if let Some(taps) = slot.stream_taps_mut() { + taps.fire_head(head.clone()); + } + slot.response_mut() + .send_head(head) + .map_err(|e| e.to_string()) + } Err(err) => Err(err.to_string()), } }; @@ -609,10 +768,25 @@ fn op_nexide_send_chunk<'s>( let result = { let mut state = handle.0.borrow_mut(); match state.dispatch_table.get_mut(id) { - Ok(slot) => slot - .response_mut() - .send_chunk(bytes) - .map_err(|e| e.to_string()), + Ok(slot) => { + if let Some(taps) = slot.stream_taps_mut() { + if taps.body_open() { + if taps.push_chunk(bytes.clone()).is_err() { + Err("response stream cancelled by client".to_owned()) + } else { + Ok(()) + } + } else { + slot.response_mut() + .send_chunk(bytes) + .map_err(|e| e.to_string()) + } + } else { + slot.response_mut() + .send_chunk(bytes) + .map_err(|e| e.to_string()) + } + } Err(err) => Err(err.to_string()), } }; @@ -663,10 +837,30 @@ fn op_nexide_send_response<'s>( let mut state = handle.0.borrow_mut(); let table = &mut state.dispatch_table; let inflight = table.get_mut(id).map_err(|e| e.to_string())?; - let response = inflight.response_mut(); - response.send_head(head).map_err(|e| e.to_string())?; + let streaming = inflight + .stream_taps_mut() + .map(|t| t.body_open()) + .unwrap_or(false); + if let Some(taps) = inflight.stream_taps_mut() { + taps.fire_head(head.clone()); + } + inflight + .response_mut() + .send_head(head) + .map_err(|e| e.to_string())?; if !body.is_empty() { - response.send_chunk(body).map_err(|e| e.to_string())?; + if streaming { + if let Some(taps) = inflight.stream_taps_mut() + && taps.push_chunk(body).is_err() + { + return Err("response stream cancelled by client".to_owned()); + } + } else { + inflight + .response_mut() + .send_chunk(body) + .map_err(|e| e.to_string())?; + } } drop(state); settle_ok(&mut handle.0.borrow_mut().dispatch_table, id) @@ -1126,6 +1320,29 @@ fn op_process_kill<'s>( } } +/// `op_process_drain_signals()` - returns the OS signals delivered +/// to the host process since the previous call (in arrival order). +/// +/// The JS `process` polyfill polls this op from a 100 ms interval and +/// emits the corresponding `'SIGTERM'` / `'SIGINT'` / `'SIGHUP'` +/// events on the in-isolate `EventEmitter`, giving Next.js graceful +/// shutdown hooks (DB pool drain, queue worker stop, log flushers) +/// the same surface they rely on under upstream Node. +fn op_process_drain_signals<'s>( + scope: &mut v8::PinScope<'s, '_>, + _args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let names = crate::ops::drain_signals(); + let arr = v8::Array::new(scope, names.len() as i32); + for (idx, name) in names.iter().enumerate() { + let s = v8::String::new(scope, name).unwrap(); + let i = v8::Integer::new(scope, idx as i32); + arr.set(scope, i.into(), s.into()); + } + rv.set(arr.into()); +} + /// `process.cpuUsage()` - returns `{ user, system }` in microseconds. /// Unix uses `getrusage(RUSAGE_SELF)`; other platforms return zeroes. fn op_process_cpu_usage<'s>( @@ -1331,6 +1548,148 @@ fn op_cjs_read_source<'s>( rv.set(arr.into()); } +fn op_cjs_compile_function<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let source = args.get(0).to_rust_string_lossy(scope); + let specifier = args.get(1).to_rust_string_lossy(scope); + if specifier.is_empty() + || specifier + .chars() + .any(|c| c == '\n' || c == '\r' || c == '\0') + { + throw_error(scope, "EINVAL: invalid module specifier"); + return; + } + let Some(code_str) = v8::String::new(scope, &source) else { + throw_error(scope, "op_cjs_compile_function: failed to allocate source"); + return; + }; + let Some(resource) = v8::String::new(scope, &specifier) else { + throw_error( + scope, + "op_cjs_compile_function: failed to allocate specifier", + ); + return; + }; + let undefined = v8::undefined(scope).into(); + let origin = v8::ScriptOrigin::new( + scope, + resource.into(), + 0, + 0, + false, + 0, + Some(undefined), + false, + false, + false, + None, + ); + + let cache = super::engine::code_cache_from_isolate(scope); + let cached_bytes = cache + .as_ref() + .filter(|c| c.is_enabled()) + .and_then(|c| c.lookup(&source)); + + let (mut src_obj, options) = match cached_bytes { + Some(bytes) => { + let cached = v8::script_compiler::CachedData::new(&bytes); + ( + v8::script_compiler::Source::new_with_cached_data(code_str, Some(&origin), cached), + v8::script_compiler::CompileOptions::ConsumeCodeCache, + ) + } + None => ( + v8::script_compiler::Source::new(code_str, Some(&origin)), + v8::script_compiler::CompileOptions::NoCompileOptions, + ), + }; + + let arg_names = [ + v8::String::new(scope, "exports").unwrap(), + v8::String::new(scope, "require").unwrap(), + v8::String::new(scope, "module").unwrap(), + v8::String::new(scope, "__filename").unwrap(), + v8::String::new(scope, "__dirname").unwrap(), + ]; + let func = match v8::script_compiler::compile_function( + scope, + &mut src_obj, + &arg_names, + &[], + options, + v8::script_compiler::NoCacheReason::NoReason, + ) { + Some(f) => f, + None => return, + }; + + if let Some(cache) = cache.as_ref().filter(|c| c.is_enabled()) { + let consumed = options.contains(v8::script_compiler::CompileOptions::ConsumeCodeCache); + let rejected = src_obj + .get_cached_data() + .map(v8::CachedData::rejected) + .unwrap_or(false); + + if !consumed || rejected { + if consumed { + cache.metrics().record_reject(); + } + let cache_func = if consumed && rejected { + let resource_fresh = v8::String::new(scope, &specifier).unwrap_or(resource); + let undefined_fresh = v8::undefined(scope).into(); + let origin_fresh = v8::ScriptOrigin::new( + scope, + resource_fresh.into(), + 0, + 0, + false, + 0, + Some(undefined_fresh), + false, + false, + false, + None, + ); + v8::String::new(scope, &source).and_then(|code_fresh| { + let mut src_fresh = + v8::script_compiler::Source::new(code_fresh, Some(&origin_fresh)); + let arg_names_fresh = [ + v8::String::new(scope, "exports").unwrap(), + v8::String::new(scope, "require").unwrap(), + v8::String::new(scope, "module").unwrap(), + v8::String::new(scope, "__filename").unwrap(), + v8::String::new(scope, "__dirname").unwrap(), + ]; + v8::script_compiler::compile_function( + scope, + &mut src_fresh, + &arg_names_fresh, + &[], + v8::script_compiler::CompileOptions::EagerCompile, + v8::script_compiler::NoCacheReason::NoReason, + ) + }) + } else { + None + }; + let func_for_cache = cache_func.as_ref().unwrap_or(&func); + if let Some(blob) = func_for_cache.create_code_cache() { + let bytes = blob.to_vec(); + if !bytes.is_empty() { + cache.store(&source, bytes); + } + } + } + } + + rv.set(func.into()); +} + fn op_napi_load<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -1839,1473 +2198,1488 @@ fn op_fs_readlink<'s>( } } -// ────────────────────────────────────────────────────────────────────── -// node:crypto -// ────────────────────────────────────────────────────────────────────── - -fn op_crypto_hash<'s>( +fn op_fs_rename<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, + _rv: v8::ReturnValue<'s, v8::Value>, ) { - use digest::Digest; - let algo = string_arg(scope, &args, 0); - let Some(input) = bytes_arg(scope, &args, 1) else { - throw_error(scope, "crypto.hash: data must be Uint8Array"); - return; - }; - let digest: Vec = match algo.as_str() { - "sha1" => { - let mut h = sha1::Sha1::new(); - h.update(&input); - h.finalize().to_vec() - } - "sha256" => { - let mut h = sha2::Sha256::new(); - h.update(&input); - h.finalize().to_vec() - } - "sha384" => { - let mut h = sha2::Sha384::new(); - h.update(&input); - h.finalize().to_vec() - } - "sha512" => { - let mut h = sha2::Sha512::new(); - h.update(&input); - h.finalize().to_vec() - } - "md5" => { - let mut h = md5::Md5::new(); - h.update(&input); - h.finalize().to_vec() - } - other => { - throw_error(scope, &format!("ENOSYS: hash {other}")); - return; - } + let src = string_arg(scope, &args, 0); + let dst = string_arg(scope, &args, 1); + let result = if let Some(fs) = fs_handle_for(scope) { + fs.rename(&src, &dst).map_err(|e| (e.code, e.message)) + } else { + std::fs::rename(&src, &dst).map_err(map_io_err) }; - let arr = bytes_to_uint8array(scope, &digest); - rv.set(arr.into()); + if let Err((code, msg)) = result { + throw_error(scope, &format!("{code}: {msg}")); + } } -fn op_crypto_hmac<'s>( +fn op_fs_append<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, + _rv: v8::ReturnValue<'s, v8::Value>, ) { - use hmac::Mac; - let algo = string_arg(scope, &args, 0); - let Some(key) = bytes_arg(scope, &args, 1) else { - throw_error(scope, "hmac: key must be Uint8Array"); - return; - }; - let Some(input) = bytes_arg(scope, &args, 2) else { - throw_error(scope, "hmac: data must be Uint8Array"); + let path = string_arg(scope, &args, 0); + let Some(data) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "fs.append: data must be Uint8Array"); return; }; - let digest: Vec = match algo.as_str() { - "sha1" => { - let mut m = hmac::Hmac::::new_from_slice(&key).expect("hmac key"); - m.update(&input); - m.finalize().into_bytes().to_vec() - } - "sha256" => { - let mut m = hmac::Hmac::::new_from_slice(&key).expect("hmac key"); - m.update(&input); - m.finalize().into_bytes().to_vec() - } - "sha384" => { - let mut m = hmac::Hmac::::new_from_slice(&key).expect("hmac key"); - m.update(&input); - m.finalize().into_bytes().to_vec() - } - "sha512" => { - let mut m = hmac::Hmac::::new_from_slice(&key).expect("hmac key"); - m.update(&input); - m.finalize().into_bytes().to_vec() - } - other => { - throw_error(scope, &format!("ENOSYS: hmac {other}")); - return; - } + let result = if let Some(fs) = fs_handle_for(scope) { + fs.append(&path, &data).map_err(|e| (e.code, e.message)) + } else { + use std::io::Write as _; + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut f| f.write_all(&data)) + .map_err(map_io_err) }; - let arr = bytes_to_uint8array(scope, &digest); - rv.set(arr.into()); + if let Err((code, msg)) = result { + throw_error(scope, &format!("{code}: {msg}")); + } } -fn op_crypto_random_bytes<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, -) { - use rand::RngCore; - let len = args.get(0).uint32_value(scope).unwrap_or(0) as usize; - let mut buf = vec![0u8; len]; - rand::rng().fill_bytes(&mut buf); - let arr = bytes_to_uint8array(scope, &buf); - rv.set(arr.into()); -} +// ────────────────────────────────────────────────────────────────────── +// node:fs async ops +// ────────────────────────────────────────────────────────────────────── +// +// Mirror image of the sync ops above, but each entry point allocates +// a `PromiseResolver` and spawns the actual I/O on `tokio::fs`. The +// hot Tokio thread (HTTP accept + JS pump + recv loop) keeps making +// progress while the FS request is in flight on the blocking pool - +// closing the gap with Node's libuv thread pool. Sandbox admission +// runs on the isolate thread (cheap, in-memory path comparison) so +// the async surface inherits the same `EACCES` policy as the sync +// surface. -fn op_crypto_random_uuid<'s>( +fn schedule_fs<'s, Fut, T, Mk>( scope: &mut v8::PinScope<'s, '_>, - _args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, -) { - use rand::RngCore; - let mut bytes = [0u8; 16]; - rand::rng().fill_bytes(&mut bytes); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - let s = format!( - "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", - bytes[0], - bytes[1], - bytes[2], - bytes[3], - bytes[4], - bytes[5], - bytes[6], - bytes[7], - bytes[8], - bytes[9], - bytes[10], - bytes[11], - bytes[12], - bytes[13], - bytes[14], - bytes[15], - ); - let v = v8::String::new(scope, &s).unwrap(); - rv.set(v.into()); + work: Fut, + on_ok: Mk, +) -> Option> +where + Fut: std::future::Future> + 'static, + T: 'static, + Mk: for<'a, 'b> FnOnce(&mut v8::PinScope<'a, 'b>, T) -> v8::Local<'a, v8::Value> + 'static, +{ + let resolver = v8::PromiseResolver::new(scope)?; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + tokio::task::spawn_local(async move { + let result = work.await; + let settler: super::async_ops::Settler = match result { + Ok(value) => Box::new(move |scope, resolver| { + let v = on_ok(scope, value); + resolver.resolve(scope, v); + }), + Err((code, message)) => super::async_ops::reject_with_code(message, code), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + Some(promise) } -fn op_crypto_timing_safe_equal<'s>( +fn schedule_fs_void<'s, Fut>( + scope: &mut v8::PinScope<'s, '_>, + work: Fut, +) -> Option> +where + Fut: std::future::Future> + 'static, +{ + schedule_fs(scope, work, |scope, ()| v8::undefined(scope).into()) +} + +fn admit_for_async( + scope: &mut v8::PinScope<'_, '_>, + path: &str, +) -> Result { + if let Some(fs) = fs_handle_for(scope) { + fs.admit(path).map_err(|e| (e.code, e.message)) + } else { + Ok(std::path::PathBuf::from(path)) + } +} + +fn map_tokio_io_err>( + err: std::io::Error, + _path: P, +) -> (&'static str, String) { + (io_error_code(&err), err.to_string()) +} + +fn op_fs_read_async<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let Some(a) = bytes_arg(scope, &args, 0) else { - rv.set(v8::Boolean::new(scope, false).into()); - return; + let path = string_arg(scope, &args, 0); + let admitted = match admit_for_async(scope, &path) { + Ok(p) => p, + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); + return; + } }; - let Some(b) = bytes_arg(scope, &args, 1) else { - rv.set(v8::Boolean::new(scope, false).into()); - return; + let work = async move { + tokio::fs::read(&admitted) + .await + .map_err(|e| map_tokio_io_err(e, &admitted)) }; - if a.len() != b.len() { - rv.set(v8::Boolean::new(scope, false).into()); + let Some(promise) = schedule_fs(scope, work, |scope, bytes| { + bytes_to_uint8array(scope, &bytes).into() + }) else { + throw_error(scope, "fs.readAsync: failed to allocate promise"); return; - } - let mut diff = 0u8; - for (x, y) in a.iter().zip(b.iter()) { - diff |= x ^ y; - } - rv.set(v8::Boolean::new(scope, diff == 0).into()); + }; + rv.set(promise.into()); } -fn op_crypto_aes_gcm_seal<'s>( +fn op_fs_write_async<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - use aes_gcm::aead::{Aead, KeyInit, Payload}; - use aes_gcm::{Aes256Gcm, Key, Nonce}; - let Some(key_bytes) = bytes_arg(scope, &args, 0) else { - throw_error(scope, "aes_gcm_seal: key"); + let path = string_arg(scope, &args, 0); + let Some(data) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "fs.writeAsync: data must be Uint8Array"); return; }; - let Some(iv_bytes) = bytes_arg(scope, &args, 1) else { - throw_error(scope, "aes_gcm_seal: iv"); - return; + let admitted = match admit_for_async(scope, &path) { + Ok(p) => p, + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); + return; + } }; - let Some(plaintext) = bytes_arg(scope, &args, 2) else { - throw_error(scope, "aes_gcm_seal: pt"); - return; + let bytes = data.to_vec(); + let work = async move { + tokio::fs::write(&admitted, &bytes) + .await + .map_err(|e| map_tokio_io_err(e, &admitted)) }; - let aad = bytes_arg(scope, &args, 3).unwrap_or_default(); - if key_bytes.len() != 32 || iv_bytes.len() != 12 { - throw_error(scope, "aes_gcm_seal: key=32 iv=12"); + let Some(promise) = schedule_fs_void(scope, work) else { + throw_error(scope, "fs.writeAsync: failed to allocate promise"); return; - } - let key = Key::::from_slice(&key_bytes); - let cipher = Aes256Gcm::new(key); - let nonce = Nonce::from_slice(&iv_bytes); - let payload = Payload { - msg: &plaintext, - aad: &aad, }; - match cipher.encrypt(nonce, payload) { - Ok(ct) => { - let arr = bytes_to_uint8array(scope, &ct); - rv.set(arr.into()); - } - Err(_) => throw_error(scope, "aes_gcm_seal: encrypt failed"), - } + rv.set(promise.into()); } -fn op_crypto_aes_gcm_open<'s>( +fn op_fs_append_async<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - use aes_gcm::aead::{Aead, KeyInit, Payload}; - use aes_gcm::{Aes256Gcm, Key, Nonce}; - let Some(key_bytes) = bytes_arg(scope, &args, 0) else { - throw_error(scope, "aes_gcm_open: key"); - return; - }; - let Some(iv_bytes) = bytes_arg(scope, &args, 1) else { - throw_error(scope, "aes_gcm_open: iv"); + let path = string_arg(scope, &args, 0); + let Some(data) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "fs.appendAsync: data must be Uint8Array"); return; }; - let Some(ct) = bytes_arg(scope, &args, 2) else { - throw_error(scope, "aes_gcm_open: ct"); - return; + let admitted = match admit_for_async(scope, &path) { + Ok(p) => p, + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); + return; + } }; - let aad = bytes_arg(scope, &args, 3).unwrap_or_default(); - if key_bytes.len() != 32 || iv_bytes.len() != 12 { - throw_error(scope, "aes_gcm_open: key=32 iv=12"); + let bytes = data.to_vec(); + let work = async move { + use tokio::io::AsyncWriteExt as _; + let mut f = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&admitted) + .await + .map_err(|e| map_tokio_io_err(e, &admitted))?; + f.write_all(&bytes) + .await + .map_err(|e| map_tokio_io_err(e, &admitted))?; + Ok(()) + }; + let Some(promise) = schedule_fs_void(scope, work) else { + throw_error(scope, "fs.appendAsync: failed to allocate promise"); return; - } - let key = Key::::from_slice(&key_bytes); - let cipher = Aes256Gcm::new(key); - let nonce = Nonce::from_slice(&iv_bytes); - let payload = Payload { - msg: &ct, - aad: &aad, }; - match cipher.decrypt(nonce, payload) { - Ok(pt) => { - let arr = bytes_to_uint8array(scope, &pt); - rv.set(arr.into()); - } - Err(_) => throw_error(scope, "aes_gcm_open: auth failed"), - } + rv.set(promise.into()); } -// ────────────────────────────────────────────────────────────────────── -// node:zlib -// ────────────────────────────────────────────────────────────────────── - -fn op_zlib_encode<'s>( +fn op_fs_stat_async<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - use std::io::Write; - let algo = string_arg(scope, &args, 0); - let Some(input) = bytes_arg(scope, &args, 1) else { - throw_error(scope, "zlib.encode: data must be Uint8Array"); - return; - }; - let out: std::io::Result> = match algo.as_str() { - "gzip" => { - let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); - e.write_all(&input).and_then(|()| e.finish()) + let path = string_arg(scope, &args, 0); + let follow = args.get(1).boolean_value(scope); + let admitted = match admit_for_async(scope, &path) { + Ok(p) => p, + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); + return; } - "deflate" => { - let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - e.write_all(&input).and_then(|()| e.finish()) + }; + let work = async move { + let meta_res = if follow { + tokio::fs::metadata(&admitted).await + } else { + tokio::fs::symlink_metadata(&admitted).await + }; + let meta = meta_res.map_err(|e| map_tokio_io_err(e, &admitted))?; + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or(0.0); + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt as _; + meta.permissions().mode() + }; + #[cfg(not(unix))] + let mode = 0u32; + Ok(( + meta.len(), + meta.is_file(), + meta.is_dir(), + meta.file_type().is_symlink(), + mtime, + mode, + )) + }; + let Some(promise) = schedule_fs(scope, work, |scope, t| { + let (size, is_file, is_dir, is_symlink, mtime_ms, mode) = t; + let obj = v8::Object::new(scope); + let names = [ + "size", + "is_file", + "is_dir", + "is_symlink", + "mtime_ms", + "mode", + ]; + let values: [v8::Local<'_, v8::Value>; 6] = [ + v8::Number::new(scope, size as f64).into(), + v8::Boolean::new(scope, is_file).into(), + v8::Boolean::new(scope, is_dir).into(), + v8::Boolean::new(scope, is_symlink).into(), + v8::Number::new(scope, mtime_ms).into(), + v8::Integer::new_from_unsigned(scope, mode).into(), + ]; + for (n, v) in names.iter().zip(values.iter()) { + let key = v8::String::new(scope, n).unwrap(); + obj.set(scope, key.into(), *v); } - "deflate-raw" => { - let mut e = - flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); - e.write_all(&input).and_then(|()| e.finish()) + obj.into() + }) else { + throw_error(scope, "fs.statAsync: failed to allocate promise"); + return; + }; + rv.set(promise.into()); +} + +fn op_fs_readdir_async<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let path = string_arg(scope, &args, 0); + let admitted = match admit_for_async(scope, &path) { + Ok(p) => p, + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); + return; } - "brotli" => { - let mut buf = Vec::new(); - { - let mut w = brotli::CompressorWriter::new(&mut buf, 4096, 4, 22); - if let Err(err) = w.write_all(&input) { - throw_error(scope, &format!("EIO: {err}")); - return; + }; + let work = async move { + let mut iter = tokio::fs::read_dir(&admitted) + .await + .map_err(|e| map_tokio_io_err(e, &admitted))?; + let mut out: Vec<(String, bool, bool)> = Vec::new(); + loop { + match iter.next_entry().await { + Ok(Some(entry)) => { + let ft = entry + .file_type() + .await + .map_err(|e| map_tokio_io_err(e, &admitted))?; + out.push(( + entry.file_name().to_string_lossy().into_owned(), + ft.is_dir(), + ft.is_symlink(), + )); } + Ok(None) => break, + Err(e) => return Err(map_tokio_io_err(e, &admitted)), } - Ok(buf) - } - other => { - throw_error(scope, &format!("ENOSYS: zlib {other}")); - return; } + Ok(out) }; - match out { - Ok(bytes) => { - let arr = bytes_to_uint8array(scope, &bytes); - rv.set(arr.into()); + let Some(promise) = schedule_fs(scope, work, |scope, entries| { + let arr = v8::Array::new(scope, entries.len() as i32); + for (i, (name, is_dir, is_symlink)) in entries.iter().enumerate() { + let obj = v8::Object::new(scope); + let n_key = v8::String::new(scope, "name").unwrap(); + let n_val = v8::String::new(scope, name).unwrap(); + obj.set(scope, n_key.into(), n_val.into()); + let d_key = v8::String::new(scope, "is_dir").unwrap(); + let d_val = v8::Boolean::new(scope, *is_dir); + obj.set(scope, d_key.into(), d_val.into()); + let s_key = v8::String::new(scope, "is_symlink").unwrap(); + let s_val = v8::Boolean::new(scope, *is_symlink); + obj.set(scope, s_key.into(), s_val.into()); + let idx = v8::Integer::new(scope, i as i32); + arr.set(scope, idx.into(), obj.into()); } - Err(err) => throw_error(scope, &format!("EIO: {err}")), - } + arr.into() + }) else { + throw_error(scope, "fs.readdirAsync: failed to allocate promise"); + return; + }; + rv.set(promise.into()); } -fn op_zlib_decode<'s>( +fn op_fs_mkdir_async<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - use std::io::Read; - let algo = string_arg(scope, &args, 0); - let Some(input) = bytes_arg(scope, &args, 1) else { - throw_error(scope, "zlib.decode: data must be Uint8Array"); - return; - }; - let mut out = Vec::new(); - let res: std::io::Result<()> = match algo.as_str() { - "gzip" => flate2::read::GzDecoder::new(&input[..]) - .read_to_end(&mut out) - .map(|_| ()), - "deflate" => flate2::read::ZlibDecoder::new(&input[..]) - .read_to_end(&mut out) - .map(|_| ()), - "deflate-raw" => flate2::read::DeflateDecoder::new(&input[..]) - .read_to_end(&mut out) - .map(|_| ()), - "brotli" => brotli::Decompressor::new(&input[..], 4096) - .read_to_end(&mut out) - .map(|_| ()), - other => { - throw_error(scope, &format!("ENOSYS: zlib {other}")); + let path = string_arg(scope, &args, 0); + let recursive = args.get(1).boolean_value(scope); + let admitted = match admit_for_async(scope, &path) { + Ok(p) => p, + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); return; } }; - match res { - Ok(()) => { - let arr = bytes_to_uint8array(scope, &out); - rv.set(arr.into()); - } - Err(err) => throw_error(scope, &format!("EIO: {err}")), - } -} - -// ────────────────────────────────────────────────────────────────────── -// node:dns ops -// ────────────────────────────────────────────────────────────────────── -// -// Each op follows the same shape: -// 1. Pull the call arguments off `args`, allocate a fresh -// `PromiseResolver`, and clone the bridge's async-completion -// sender. -// 2. `tokio::task::spawn_local` an async block that runs the -// lookup off the JS critical path. On completion it builds a -// `Settler` closure that marshals the result into `v8::Value`s -// and forwards it through the channel. -// 3. The engine pump drains the channel on every tick and resolves -// / rejects each promise on the isolate thread (where -// `v8::Local` values are valid). - -use std::future::Future; - -use crate::ops::DnsError; - -/// Schedules `work` off-isolate and resolves the returned promise -/// with the value built by `on_ok` (run on the isolate thread). -/// -/// On `Err(DnsError)` the promise rejects with a Node-style `Error` -/// whose `.code` carries the mapped error string. -fn schedule_dns<'s, Fut, T, Mk>( - scope: &mut v8::PinScope<'s, '_>, - work: Fut, - on_ok: Mk, -) -> Option> -where - Fut: Future> + 'static, - T: 'static, - Mk: for<'a, 'b> FnOnce(&mut v8::PinScope<'a, 'b>, T) -> v8::Local<'a, v8::Value> + 'static, -{ - let resolver = v8::PromiseResolver::new(scope)?; - let promise = resolver.get_promise(scope); - let global = v8::Global::new(scope, resolver); - let handle = from_isolate(scope); - let tx = handle.0.borrow().async_completions_tx.clone(); - - tokio::task::spawn_local(async move { - let result = work.await; - let settler: super::async_ops::Settler = match result { - Ok(value) => Box::new(move |scope, resolver| { - let v = on_ok(scope, value); - resolver.resolve(scope, v); - }), - Err(err) => super::async_ops::reject_with_code(err.message, err.code), + let work = async move { + let res = if recursive { + tokio::fs::create_dir_all(&admitted).await + } else { + tokio::fs::create_dir(&admitted).await }; - let _ = tx.send(super::async_ops::Completion::new(global, settler)); - }); - - Some(promise) + res.map_err(|e| map_tokio_io_err(e, &admitted)) + }; + let Some(promise) = schedule_fs_void(scope, work) else { + throw_error(scope, "fs.mkdirAsync: failed to allocate promise"); + return; + }; + rv.set(promise.into()); } -fn op_dns_lookup<'s>( +fn op_fs_rm_async<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let host = args.get(0).to_rust_string_lossy(scope); - let family_node = args.get(1).uint32_value(scope).unwrap_or(0); - let all = args.get(2).boolean_value(scope); - let max = if all { usize::MAX } else { 1 }; - let family = crate::ops::LookupFamily::from_node(family_node); - - let work = async move { crate::ops::dns_lookup(&host, family, max).await }; - let promise = schedule_dns(scope, work, move |scope, results| { - if all { - let arr = v8::Array::new(scope, results.len() as i32); - for (i, r) in results.iter().enumerate() { - let obj = make_lookup_obj(scope, r); - arr.set_index(scope, i as u32, obj.into()); - } - arr.into() + let path = string_arg(scope, &args, 0); + let recursive = args.get(1).boolean_value(scope); + let admitted = match admit_for_async(scope, &path) { + Ok(p) => p, + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); + return; + } + }; + let work = async move { + let meta = tokio::fs::symlink_metadata(&admitted) + .await + .map_err(|e| map_tokio_io_err(e, &admitted))?; + if meta.is_dir() { + let res = if recursive { + tokio::fs::remove_dir_all(&admitted).await + } else { + tokio::fs::remove_dir(&admitted).await + }; + res.map_err(|e| map_tokio_io_err(e, &admitted)) } else { - let first = results.into_iter().next().expect("at least one result"); - make_lookup_obj(scope, &first).into() + tokio::fs::remove_file(&admitted) + .await + .map_err(|e| map_tokio_io_err(e, &admitted)) } - }); - if let Some(p) = promise { - rv.set(p.into()); - } + }; + let Some(promise) = schedule_fs_void(scope, work) else { + throw_error(scope, "fs.rmAsync: failed to allocate promise"); + return; + }; + rv.set(promise.into()); } -fn make_lookup_obj<'s>( +fn op_fs_copy_async<'s>( scope: &mut v8::PinScope<'s, '_>, - result: &crate::ops::LookupResult, -) -> v8::Local<'s, v8::Object> { - let obj = v8::Object::new(scope); - set_string_field(scope, obj, "address", &result.address.to_string()); - let fam_key = v8::String::new(scope, "family").unwrap(); - let fam_val = v8::Number::new(scope, f64::from(result.family)); - obj.set(scope, fam_key.into(), fam_val.into()); - obj + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let src = string_arg(scope, &args, 0); + let dst = string_arg(scope, &args, 1); + let admitted_src = match admit_for_async(scope, &src) { + Ok(p) => p, + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); + return; + } + }; + let admitted_dst = match admit_for_async(scope, &dst) { + Ok(p) => p, + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); + return; + } + }; + let work = async move { + tokio::fs::copy(&admitted_src, &admitted_dst) + .await + .map(|_| ()) + .map_err(|e| map_tokio_io_err(e, &admitted_src)) + }; + let Some(promise) = schedule_fs_void(scope, work) else { + throw_error(scope, "fs.copyAsync: failed to allocate promise"); + return; + }; + rv.set(promise.into()); } -fn op_dns_resolve4<'s>( +fn op_fs_rename_async<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let host = args.get(0).to_rust_string_lossy(scope); - let work = async move { crate::ops::dns_resolve4(&host).await }; - let promise = schedule_dns(scope, work, |scope, ips| ip_array(scope, &ips).into()); - if let Some(p) = promise { - rv.set(p.into()); - } + let src = string_arg(scope, &args, 0); + let dst = string_arg(scope, &args, 1); + let admitted_src = match admit_for_async(scope, &src) { + Ok(p) => p, + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); + return; + } + }; + let admitted_dst = match admit_for_async(scope, &dst) { + Ok(p) => p, + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); + return; + } + }; + let work = async move { + tokio::fs::rename(&admitted_src, &admitted_dst) + .await + .map_err(|e| map_tokio_io_err(e, &admitted_src)) + }; + let Some(promise) = schedule_fs_void(scope, work) else { + throw_error(scope, "fs.renameAsync: failed to allocate promise"); + return; + }; + rv.set(promise.into()); } -fn op_dns_resolve6<'s>( +fn op_fs_realpath_async<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let host = args.get(0).to_rust_string_lossy(scope); - let work = async move { crate::ops::dns_resolve6(&host).await }; - let promise = schedule_dns(scope, work, |scope, ips| ip_array(scope, &ips).into()); - if let Some(p) = promise { - rv.set(p.into()); - } + let path = string_arg(scope, &args, 0); + let admitted = match admit_for_async(scope, &path) { + Ok(p) => p, + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); + return; + } + }; + let work = async move { + tokio::fs::canonicalize(&admitted) + .await + .map_err(|e| map_tokio_io_err(e, &admitted)) + }; + let Some(promise) = schedule_fs(scope, work, |scope, p| { + let s = v8::String::new(scope, &p.to_string_lossy()).unwrap(); + s.into() + }) else { + throw_error(scope, "fs.realpathAsync: failed to allocate promise"); + return; + }; + rv.set(promise.into()); } -fn ip_array<'s>( - scope: &mut v8::PinScope<'s, '_>, - ips: &[std::net::IpAddr], -) -> v8::Local<'s, v8::Array> { - let arr = v8::Array::new(scope, ips.len() as i32); - for (i, ip) in ips.iter().enumerate() { - let s = v8::String::new(scope, &ip.to_string()).unwrap(); - arr.set_index(scope, i as u32, s.into()); - } - arr -} +// ────────────────────────────────────────────────────────────────────── +// node:url - WHATWG parsing +// ────────────────────────────────────────────────────────────────────── +// +// `op_url_parse` returns a 9-element array describing the parsed URL +// fields per the WHATWG URL spec. Strings are decoded by the `url` +// crate, which handles IPv6 brackets, IDN host punycode, percent +// normalisation and opaque vs special schemes correctly. The JS +// polyfill consumes this on construction and writes the fields into +// a NexideURL instance, falling back to the legacy regex parser if +// the op is missing (for ABI compatibility with older builds). -fn op_dns_resolve_mx<'s>( +fn op_url_parse<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let host = args.get(0).to_rust_string_lossy(scope); - let work = async move { crate::ops::dns_resolve_mx(&host).await }; - let promise = schedule_dns(scope, work, |scope, records| { - let arr = v8::Array::new(scope, records.len() as i32); - for (i, r) in records.iter().enumerate() { - let obj = v8::Object::new(scope); - let prio_key = v8::String::new(scope, "priority").unwrap(); - let prio_val = v8::Number::new(scope, f64::from(r.priority)); - obj.set(scope, prio_key.into(), prio_val.into()); - set_string_field(scope, obj, "exchange", &r.exchange); - arr.set_index(scope, i as u32, obj.into()); + let input = string_arg(scope, &args, 0); + let base_arg = args.get(1); + let parsed = if base_arg.is_string() { + let base = string_arg(scope, &args, 1); + match url::Url::parse(&base) { + Ok(b) => b.join(&input), + Err(e) => Err(e), } - arr.into() - }); - if let Some(p) = promise { - rv.set(p.into()); + } else { + url::Url::parse(&input) + }; + let url = match parsed { + Ok(u) => u, + Err(_) => { + rv.set_null(); + return; + } + }; + + let arr = v8::Array::new(scope, 10); + let put_str = + |scope: &mut v8::PinScope<'_, '_>, arr: v8::Local<'_, v8::Array>, idx: i32, s: &str| { + let v = v8::String::new(scope, s).unwrap(); + let i = v8::Integer::new(scope, idx); + arr.set(scope, i.into(), v.into()); + }; + let put_null = |scope: &mut v8::PinScope<'_, '_>, arr: v8::Local<'_, v8::Array>, idx: i32| { + let v = v8::null(scope); + let i = v8::Integer::new(scope, idx); + arr.set(scope, i.into(), v.into()); + }; + + put_str(scope, arr, 0, url.as_str()); + put_str(scope, arr, 1, &format!("{}:", url.scheme())); + put_str(scope, arr, 2, url.username()); + put_str(scope, arr, 3, url.password().unwrap_or("")); + match url.host_str() { + Some(h) => put_str(scope, arr, 4, h), + None => put_null(scope, arr, 4), } + match url.port() { + Some(p) => put_str(scope, arr, 5, &p.to_string()), + None => put_str(scope, arr, 5, ""), + } + put_str(scope, arr, 6, url.path()); + match url.query() { + Some(q) => put_str(scope, arr, 7, &format!("?{q}")), + None => put_str(scope, arr, 7, ""), + } + match url.fragment() { + Some(f) => put_str(scope, arr, 8, &format!("#{f}")), + None => put_str(scope, arr, 8, ""), + } + let origin = match url.origin() { + url::Origin::Tuple(scheme, host, port) => format!("{scheme}://{host}:{port}"), + url::Origin::Opaque(_) => "null".to_string(), + }; + put_str(scope, arr, 9, &origin); + rv.set(arr.into()); } -fn op_dns_resolve_txt<'s>( +fn op_url_can_parse<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let host = args.get(0).to_rust_string_lossy(scope); - let work = async move { crate::ops::dns_resolve_txt(&host).await }; - let promise = schedule_dns(scope, work, |scope, records| { - let arr = v8::Array::new(scope, records.len() as i32); - for (i, chunks) in records.iter().enumerate() { - let inner = v8::Array::new(scope, chunks.len() as i32); - for (j, chunk) in chunks.iter().enumerate() { - let s = v8::String::new(scope, chunk).unwrap(); - inner.set_index(scope, j as u32, s.into()); - } - arr.set_index(scope, i as u32, inner.into()); + let input = string_arg(scope, &args, 0); + let ok = if args.get(1).is_string() { + let base = string_arg(scope, &args, 1); + url::Url::parse(&base).and_then(|b| b.join(&input)).is_ok() + } else { + url::Url::parse(&input).is_ok() + }; + rv.set(v8::Boolean::new(scope, ok).into()); +} + +// ────────────────────────────────────────────────────────────────────── +// node:fs - extra metadata ops (chmod, symlink, link, truncate, utimes) +// ────────────────────────────────────────────────────────────────────── +// +// These bypass the `FsBackend` abstraction and call `std::fs` / +// `tokio::fs` directly, after running the same path-admission check +// the rest of the fs ops use. Memory-backed test backends do not see +// these ops because the tests do not exercise them; in production +// the sandbox check is what matters. + +fn fs_admit_or_throw(scope: &mut v8::PinScope<'_, '_>, path: &str) -> Option { + match admit_for_async(scope, path) { + Ok(p) => Some(p), + Err((code, msg)) => { + throw_error(scope, &format!("{code}: {msg}")); + None } - arr.into() - }); - if let Some(p) = promise { - rv.set(p.into()); } } -fn op_dns_resolve_cname<'s>( +fn op_fs_chmod<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, + _rv: v8::ReturnValue<'s, v8::Value>, ) { - let host = args.get(0).to_rust_string_lossy(scope); - let work = async move { crate::ops::dns_resolve_cname(&host).await }; - let promise = schedule_dns(scope, work, |scope, names| { - string_array(scope, &names).into() - }); - if let Some(p) = promise { - rv.set(p.into()); + let path = string_arg(scope, &args, 0); + let mode = args.get(1).uint32_value(scope).unwrap_or(0); + let Some(admitted) = fs_admit_or_throw(scope, &path) else { + return; + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if let Err(err) = std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(mode)) + { + throw_error(scope, &format!("{}: {}", io_error_code(&err), err)); + } + } + #[cfg(not(unix))] + { + let _ = (admitted, mode); + throw_error(scope, "ENOSYS: chmod is unix-only"); } } -fn op_dns_resolve_ns<'s>( +fn op_fs_chmod_async<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let host = args.get(0).to_rust_string_lossy(scope); - let work = async move { crate::ops::dns_resolve_ns(&host).await }; - let promise = schedule_dns(scope, work, |scope, names| { - string_array(scope, &names).into() - }); - if let Some(p) = promise { - rv.set(p.into()); - } -} - -fn string_array<'s>( - scope: &mut v8::PinScope<'s, '_>, - items: &[String], -) -> v8::Local<'s, v8::Array> { - let arr = v8::Array::new(scope, items.len() as i32); - for (i, item) in items.iter().enumerate() { - let s = v8::String::new(scope, item).unwrap(); - arr.set_index(scope, i as u32, s.into()); - } - arr + let path = string_arg(scope, &args, 0); + let mode = args.get(1).uint32_value(scope).unwrap_or(0); + let Some(admitted) = fs_admit_or_throw(scope, &path) else { + return; + }; + let work = async move { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + tokio::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(mode)) + .await + .map_err(|e| (io_error_code(&e), e.to_string())) + } + #[cfg(not(unix))] + { + let _ = (admitted, mode); + Err(("ENOSYS", "chmod is unix-only".to_string())) + } + }; + let Some(promise) = schedule_fs_void(scope, work) else { + throw_error(scope, "fs.chmodAsync: failed to allocate promise"); + return; + }; + rv.set(promise.into()); } -fn op_dns_resolve_srv<'s>( +fn op_fs_symlink<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, + _rv: v8::ReturnValue<'s, v8::Value>, ) { - let host = args.get(0).to_rust_string_lossy(scope); - let work = async move { crate::ops::dns_resolve_srv(&host).await }; - let promise = schedule_dns(scope, work, |scope, records| { - let arr = v8::Array::new(scope, records.len() as i32); - for (i, r) in records.iter().enumerate() { - let obj = v8::Object::new(scope); - let prio_key = v8::String::new(scope, "priority").unwrap(); - let prio_val = v8::Number::new(scope, f64::from(r.priority)); - obj.set(scope, prio_key.into(), prio_val.into()); - let weight_key = v8::String::new(scope, "weight").unwrap(); - let weight_val = v8::Number::new(scope, f64::from(r.weight)); - obj.set(scope, weight_key.into(), weight_val.into()); - let port_key = v8::String::new(scope, "port").unwrap(); - let port_val = v8::Number::new(scope, f64::from(r.port)); - obj.set(scope, port_key.into(), port_val.into()); - set_string_field(scope, obj, "name", &r.name); - arr.set_index(scope, i as u32, obj.into()); - } - arr.into() - }); - if let Some(p) = promise { - rv.set(p.into()); + let target = string_arg(scope, &args, 0); + let link = string_arg(scope, &args, 1); + let Some(link_admitted) = fs_admit_or_throw(scope, &link) else { + return; + }; + #[cfg(unix)] + let res = std::os::unix::fs::symlink(&target, &link_admitted); + #[cfg(windows)] + let res = std::os::windows::fs::symlink_file(&target, &link_admitted); + #[cfg(not(any(unix, windows)))] + let res: std::io::Result<()> = Err(std::io::Error::other("symlink unsupported")); + if let Err(err) = res { + throw_error(scope, &format!("{}: {}", io_error_code(&err), err)); } } -fn op_dns_reverse<'s>( +fn op_fs_link<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, + _rv: v8::ReturnValue<'s, v8::Value>, ) { - let ip_str = args.get(0).to_rust_string_lossy(scope); - let parsed: Result = ip_str.parse(); - match parsed { - Ok(ip) => { - let work = async move { crate::ops::dns_reverse(ip).await }; - let promise = schedule_dns(scope, work, |scope, names| { - string_array(scope, &names).into() - }); - if let Some(p) = promise { - rv.set(p.into()); - } - } - Err(_) => { - throw_type_error(scope, "dns.reverse: invalid IP address"); - } + let existing = string_arg(scope, &args, 0); + let link = string_arg(scope, &args, 1); + let Some(existing_admitted) = fs_admit_or_throw(scope, &existing) else { + return; + }; + let Some(link_admitted) = fs_admit_or_throw(scope, &link) else { + return; + }; + if let Err(err) = std::fs::hard_link(&existing_admitted, &link_admitted) { + throw_error(scope, &format!("{}: {}", io_error_code(&err), err)); } } -/// Sleeps for `ms` milliseconds and resolves the returned promise. -/// -/// Backed by `tokio::time::sleep` and the shared async-completion -/// channel, so the JS pump drives it on the isolate thread once the -/// timer fires. `ms` is clamped to `[0, i32::MAX]` so a misbehaving -/// caller cannot register a future that never fires. -fn op_timer_sleep<'s>( +fn op_fs_truncate<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, + _rv: v8::ReturnValue<'s, v8::Value>, ) { - let ms_raw = args.get(0).number_value(scope).unwrap_or(0.0); - let ms = if ms_raw.is_finite() && ms_raw > 0.0 { - ms_raw.min(f64::from(i32::MAX)) as u64 - } else { - 0 + let path = string_arg(scope, &args, 0); + let len = args.get(1).number_value(scope).unwrap_or(0.0) as u64; + let Some(admitted) = fs_admit_or_throw(scope, &path) else { + return; }; + let res = std::fs::OpenOptions::new() + .write(true) + .open(&admitted) + .and_then(|f| f.set_len(len)); + if let Err(err) = res { + throw_error(scope, &format!("{}: {}", io_error_code(&err), err)); + } +} - let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set_undefined(); +fn op_fs_utimes<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s, v8::Value>, +) { + let path = string_arg(scope, &args, 0); + let atime = args.get(1).number_value(scope).unwrap_or(0.0); + let mtime = args.get(2).number_value(scope).unwrap_or(0.0); + let Some(admitted) = fs_admit_or_throw(scope, &path) else { return; }; - let promise = resolver.get_promise(scope); - let global = v8::Global::new(scope, resolver); - let handle = from_isolate(scope); - let tx = handle.0.borrow().async_completions_tx.clone(); - - tokio::task::spawn_local(async move { - tokio::time::sleep(std::time::Duration::from_millis(ms)).await; - let settler: super::async_ops::Settler = Box::new(|scope, resolver| { - let undef = v8::undefined(scope); - resolver.resolve(scope, undef.into()); + let to_systime = |ms: f64| -> std::time::SystemTime { + let d = std::time::Duration::from_secs_f64((ms / 1000.0).max(0.0)); + std::time::SystemTime::UNIX_EPOCH + d + }; + let res = std::fs::File::options() + .write(true) + .open(&admitted) + .and_then(|f| { + f.set_modified(to_systime(mtime))?; + // set_accessed isn't stable; use filetime via libc on unix. + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd as _; + let raw = f.as_raw_fd(); + let times = [ + libc::timespec { + tv_sec: (atime / 1000.0) as libc::time_t, + tv_nsec: 0, + }, + libc::timespec { + tv_sec: (mtime / 1000.0) as libc::time_t, + tv_nsec: 0, + }, + ]; + let r = unsafe { libc::futimens(raw, times.as_ptr()) }; + if r != 0 { + return Err(std::io::Error::last_os_error()); + } + } + #[cfg(not(unix))] + { + let _ = atime; + } + Ok(()) }); - let _ = tx.send(super::async_ops::Completion::new(global, settler)); - }); - - rv.set(promise.into()); + if let Err(err) = res { + throw_error(scope, &format!("{}: {}", io_error_code(&err), err)); + } } // ────────────────────────────────────────────────────────────────────── -// node:net ops +// node:os - networkInterfaces() // ────────────────────────────────────────────────────────────────────── -use crate::ops::{AddressInfo, NetError}; - -fn make_address_obj<'s>( - scope: &mut v8::PinScope<'s, '_>, - info: &AddressInfo, -) -> v8::Local<'s, v8::Object> { - let obj = v8::Object::new(scope); - set_string_field(scope, obj, "address", &info.address); - let port_key = v8::String::new(scope, "port").unwrap(); - let port_val = v8::Number::new(scope, f64::from(info.port)); - obj.set(scope, port_key.into(), port_val.into()); - let fam_key = v8::String::new(scope, "family").unwrap(); - let fam_val = v8::Number::new(scope, f64::from(info.family)); - obj.set(scope, fam_key.into(), fam_val.into()); - obj -} - -fn reject_net<'s>( +fn op_os_network_interfaces<'s>( scope: &mut v8::PinScope<'s, '_>, - resolver: v8::Local<'s, v8::PromiseResolver>, - err: &NetError, + _args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let msg = v8::String::new(scope, &err.message).unwrap_or_else(|| v8::String::empty(scope)); - let exc = v8::Exception::error(scope, msg); - if let Ok(obj) = TryInto::>::try_into(exc) { - set_string_field(scope, obj, "code", err.code); + let obj = v8::Object::new(scope); + let Ok(addrs) = if_addrs::get_if_addrs() else { + rv.set(obj.into()); + return; + }; + use std::collections::BTreeMap; + let mut grouped: BTreeMap> = BTreeMap::new(); + for a in &addrs { + grouped.entry(a.name.clone()).or_default().push(a); } - resolver.reject(scope, exc); + for (name, list) in grouped { + let arr = v8::Array::new(scope, list.len() as i32); + for (i, iface) in list.iter().enumerate() { + let entry = v8::Object::new(scope); + let (address, netmask, family) = match &iface.addr { + if_addrs::IfAddr::V4(v4) => (v4.ip.to_string(), v4.netmask.to_string(), "IPv4"), + if_addrs::IfAddr::V6(v6) => (v6.ip.to_string(), v6.netmask.to_string(), "IPv6"), + }; + let internal = iface.is_loopback(); + let mac = String::new(); + let cidr = match &iface.addr { + if_addrs::IfAddr::V4(v4) => { + format!("{}/{}", v4.ip, u32::from(v4.netmask).count_ones()) + } + if_addrs::IfAddr::V6(v6) => format!( + "{}/{}", + v6.ip, + v6.netmask + .octets() + .iter() + .map(|b| b.count_ones()) + .sum::() + ), + }; + let pairs: [(&str, v8::Local<'_, v8::Value>); 6] = [ + ("address", v8::String::new(scope, &address).unwrap().into()), + ("netmask", v8::String::new(scope, &netmask).unwrap().into()), + ("family", v8::String::new(scope, family).unwrap().into()), + ("mac", v8::String::new(scope, &mac).unwrap().into()), + ("internal", v8::Boolean::new(scope, internal).into()), + ("cidr", v8::String::new(scope, &cidr).unwrap().into()), + ]; + for (k, v) in pairs { + let key = v8::String::new(scope, k).unwrap(); + entry.set(scope, key.into(), v); + } + let idx = v8::Integer::new(scope, i as i32); + arr.set(scope, idx.into(), entry.into()); + } + let key = v8::String::new(scope, &name).unwrap(); + obj.set(scope, key.into(), arr.into()); + } + rv.set(obj.into()); } -fn net_settler_err(err: NetError) -> super::async_ops::Settler { - Box::new(move |scope, resolver| reject_net(scope, resolver, &err)) -} +// ────────────────────────────────────────────────────────────────────── +// node:crypto +// ────────────────────────────────────────────────────────────────────── -fn op_net_connect<'s>( +fn op_crypto_hash<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let host = args.get(0).to_rust_string_lossy(scope); - let port = args.get(1).uint32_value(scope).unwrap_or(0) as u16; - - let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set_undefined(); + use digest::Digest; + let algo = string_arg(scope, &args, 0); + let Some(input) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "crypto.hash: data must be Uint8Array"); return; }; - let promise = resolver.get_promise(scope); - let global = v8::Global::new(scope, resolver); - let handle = from_isolate(scope); - let tx = handle.0.borrow().async_completions_tx.clone(); - let table = handle.0.borrow().net_streams.clone(); - - tokio::task::spawn_local(async move { - let result = crate::ops::net_connect(&host, port).await; - let settler: super::async_ops::Settler = match result { - Ok((stream, local, remote)) => { - let slot = std::rc::Rc::new(tokio::sync::Mutex::new(stream)); - let id = table.insert(slot); - Box::new(move |scope, resolver| { - let obj = v8::Object::new(scope); - let id_key = v8::String::new(scope, "id").unwrap(); - let id_val = v8::Number::new(scope, f64::from(id)); - obj.set(scope, id_key.into(), id_val.into()); - let local_obj = make_address_obj(scope, &local); - let local_key = v8::String::new(scope, "local").unwrap(); - obj.set(scope, local_key.into(), local_obj.into()); - let remote_obj = make_address_obj(scope, &remote); - let remote_key = v8::String::new(scope, "remote").unwrap(); - obj.set(scope, remote_key.into(), remote_obj.into()); - resolver.resolve(scope, obj.into()); - }) - } - Err(err) => net_settler_err(err), - }; - let _ = tx.send(super::async_ops::Completion::new(global, settler)); - }); - rv.set(promise.into()); + let digest: Vec = match algo.as_str() { + "sha1" => { + let mut h = sha1::Sha1::new(); + h.update(&input); + h.finalize().to_vec() + } + "sha256" => { + let mut h = sha2::Sha256::new(); + h.update(&input); + h.finalize().to_vec() + } + "sha384" => { + let mut h = sha2::Sha384::new(); + h.update(&input); + h.finalize().to_vec() + } + "sha512" => { + let mut h = sha2::Sha512::new(); + h.update(&input); + h.finalize().to_vec() + } + "md5" => { + let mut h = md5::Md5::new(); + h.update(&input); + h.finalize().to_vec() + } + other => { + throw_error(scope, &format!("ENOSYS: hash {other}")); + return; + } + }; + let arr = bytes_to_uint8array(scope, &digest); + rv.set(arr.into()); } -fn op_net_listen<'s>( +fn op_crypto_hmac<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let host = args.get(0).to_rust_string_lossy(scope); - let port = args.get(1).uint32_value(scope).unwrap_or(0) as u16; - let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set_undefined(); + use hmac::Mac; + let algo = string_arg(scope, &args, 0); + let Some(key) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "hmac: key must be Uint8Array"); return; }; - let promise = resolver.get_promise(scope); - let global = v8::Global::new(scope, resolver); - let handle = from_isolate(scope); - let tx = handle.0.borrow().async_completions_tx.clone(); - let table = handle.0.borrow().net_listeners.clone(); - - tokio::task::spawn_local(async move { - let settler: super::async_ops::Settler = match crate::ops::net_listen(&host, port).await { - Ok((listener, addr)) => { - let id = table.insert(std::rc::Rc::new(listener)); - Box::new(move |scope, resolver| { - let obj = v8::Object::new(scope); - let id_key = v8::String::new(scope, "id").unwrap(); - let id_val = v8::Number::new(scope, f64::from(id)); - obj.set(scope, id_key.into(), id_val.into()); - let addr_obj = make_address_obj(scope, &addr); - let addr_key = v8::String::new(scope, "address").unwrap(); - obj.set(scope, addr_key.into(), addr_obj.into()); - resolver.resolve(scope, obj.into()); - }) - } - Err(err) => net_settler_err(err), - }; - let _ = tx.send(super::async_ops::Completion::new(global, settler)); - }); - rv.set(promise.into()); + let Some(input) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "hmac: data must be Uint8Array"); + return; + }; + let digest: Vec = match algo.as_str() { + "sha1" => { + let mut m = hmac::Hmac::::new_from_slice(&key).expect("hmac key"); + m.update(&input); + m.finalize().into_bytes().to_vec() + } + "sha256" => { + let mut m = hmac::Hmac::::new_from_slice(&key).expect("hmac key"); + m.update(&input); + m.finalize().into_bytes().to_vec() + } + "sha384" => { + let mut m = hmac::Hmac::::new_from_slice(&key).expect("hmac key"); + m.update(&input); + m.finalize().into_bytes().to_vec() + } + "sha512" => { + let mut m = hmac::Hmac::::new_from_slice(&key).expect("hmac key"); + m.update(&input); + m.finalize().into_bytes().to_vec() + } + other => { + throw_error(scope, &format!("ENOSYS: hmac {other}")); + return; + } + }; + let arr = bytes_to_uint8array(scope, &digest); + rv.set(arr.into()); } -fn op_net_accept<'s>( +fn op_crypto_random_bytes<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let listener_id = args.get(0).uint32_value(scope).unwrap_or(0); - let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set_undefined(); - return; - }; - let promise = resolver.get_promise(scope); - let global = v8::Global::new(scope, resolver); - let handle = from_isolate(scope); - let tx = handle.0.borrow().async_completions_tx.clone(); - let listeners = handle.0.borrow().net_listeners.clone(); - let streams = handle.0.borrow().net_streams.clone(); - - let Some(listener) = listeners.with(listener_id, std::rc::Rc::clone) else { - let err = NetError::new("EBADF", "listener has been closed"); - reject_net(scope, v8::Local::new(scope, &global), &err); - rv.set(promise.into()); - return; - }; + use rand::RngCore; + let len = args.get(0).uint32_value(scope).unwrap_or(0) as usize; + let mut buf = vec![0u8; len]; + rand::rng().fill_bytes(&mut buf); + let arr = bytes_to_uint8array(scope, &buf); + rv.set(arr.into()); +} - tokio::task::spawn_local(async move { - let settler: super::async_ops::Settler = match crate::ops::net_accept(&listener).await { - Ok((stream, local, remote)) => { - let slot = std::rc::Rc::new(tokio::sync::Mutex::new(stream)); - let id = streams.insert(slot); - Box::new(move |scope, resolver| { - let obj = v8::Object::new(scope); - let id_key = v8::String::new(scope, "id").unwrap(); - let id_val = v8::Number::new(scope, f64::from(id)); - obj.set(scope, id_key.into(), id_val.into()); - let local_obj = make_address_obj(scope, &local); - let local_key = v8::String::new(scope, "local").unwrap(); - obj.set(scope, local_key.into(), local_obj.into()); - let remote_obj = make_address_obj(scope, &remote); - let remote_key = v8::String::new(scope, "remote").unwrap(); - obj.set(scope, remote_key.into(), remote_obj.into()); - resolver.resolve(scope, obj.into()); - }) - } - Err(err) => net_settler_err(err), - }; - let _ = tx.send(super::async_ops::Completion::new(global, settler)); - }); - rv.set(promise.into()); +fn op_crypto_random_uuid<'s>( + scope: &mut v8::PinScope<'s, '_>, + _args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + use rand::RngCore; + let mut bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let s = format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + bytes[6], + bytes[7], + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15], + ); + let v = v8::String::new(scope, &s).unwrap(); + rv.set(v.into()); } -fn op_net_read<'s>( +fn op_crypto_timing_safe_equal<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); - let max = args.get(1).uint32_value(scope).unwrap_or(64 * 1024) as usize; - let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set_undefined(); + let Some(a) = bytes_arg(scope, &args, 0) else { + rv.set(v8::Boolean::new(scope, false).into()); return; }; - let promise = resolver.get_promise(scope); - let global = v8::Global::new(scope, resolver); - let handle = from_isolate(scope); - let tx = handle.0.borrow().async_completions_tx.clone(); - let streams = handle.0.borrow().net_streams.clone(); - - let Some(slot) = streams.with(stream_id, std::rc::Rc::clone) else { - let err = NetError::new("EBADF", "socket has been closed"); - reject_net(scope, v8::Local::new(scope, &global), &err); - rv.set(promise.into()); + let Some(b) = bytes_arg(scope, &args, 1) else { + rv.set(v8::Boolean::new(scope, false).into()); return; }; - - tokio::task::spawn_local(async move { - let result = { - let mut guard = slot.lock().await; - crate::ops::net_read_chunk(&mut guard, max).await - }; - let settler: super::async_ops::Settler = match result { - Ok(bytes) => Box::new(move |scope, resolver| { - let store = v8::ArrayBuffer::new_backing_store_from_vec(bytes).make_shared(); - let buf = v8::ArrayBuffer::with_backing_store(scope, &store); - let view = v8::Uint8Array::new(scope, buf, 0, buf.byte_length()).unwrap(); - resolver.resolve(scope, view.into()); - }), - Err(err) => net_settler_err(err), - }; - let _ = tx.send(super::async_ops::Completion::new(global, settler)); - }); - rv.set(promise.into()); + if a.len() != b.len() { + rv.set(v8::Boolean::new(scope, false).into()); + return; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + rv.set(v8::Boolean::new(scope, diff == 0).into()); } -fn op_net_write<'s>( +fn op_crypto_aes_gcm_seal<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); - let data = match read_uint8_array(scope, args.get(1)) { - Some(bytes) => bytes, - None => { - throw_type_error(scope, "net.write: expected Uint8Array"); - return; - } + use aes_gcm::aead::{Aead, KeyInit, Payload}; + use aes_gcm::{Aes256Gcm, Key, Nonce}; + let Some(key_bytes) = bytes_arg(scope, &args, 0) else { + throw_error(scope, "aes_gcm_seal: key"); + return; }; - let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set_undefined(); + let Some(iv_bytes) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "aes_gcm_seal: iv"); return; }; - let promise = resolver.get_promise(scope); - let global = v8::Global::new(scope, resolver); - let handle = from_isolate(scope); - let tx = handle.0.borrow().async_completions_tx.clone(); - let streams = handle.0.borrow().net_streams.clone(); - - let Some(slot) = streams.with(stream_id, std::rc::Rc::clone) else { - let err = NetError::new("EBADF", "socket has been closed"); - reject_net(scope, v8::Local::new(scope, &global), &err); - rv.set(promise.into()); + let Some(plaintext) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "aes_gcm_seal: pt"); return; }; - - tokio::task::spawn_local(async move { - let result = { - let mut guard = slot.lock().await; - crate::ops::net_write_all(&mut guard, &data).await - }; - let settler: super::async_ops::Settler = match result { - Ok(()) => Box::new(move |scope, resolver| { - let undef = v8::undefined(scope); - resolver.resolve(scope, undef.into()); - }), - Err(err) => net_settler_err(err), - }; - let _ = tx.send(super::async_ops::Completion::new(global, settler)); - }); - rv.set(promise.into()); -} - -fn op_net_close_stream<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, -) { - let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); - let handle = from_isolate(scope); - let removed = handle.0.borrow().net_streams.remove(stream_id); - let result = v8::Boolean::new(scope, removed); - rv.set(result.into()); + let aad = bytes_arg(scope, &args, 3).unwrap_or_default(); + if key_bytes.len() != 32 || iv_bytes.len() != 12 { + throw_error(scope, "aes_gcm_seal: key=32 iv=12"); + return; + } + let key = Key::::from_slice(&key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&iv_bytes); + let payload = Payload { + msg: &plaintext, + aad: &aad, + }; + match cipher.encrypt(nonce, payload) { + Ok(ct) => { + let arr = bytes_to_uint8array(scope, &ct); + rv.set(arr.into()); + } + Err(_) => throw_error(scope, "aes_gcm_seal: encrypt failed"), + } } -fn op_net_close_listener<'s>( +fn op_crypto_aes_gcm_open<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let listener_id = args.get(0).uint32_value(scope).unwrap_or(0); - let handle = from_isolate(scope); - let removed = handle.0.borrow().net_listeners.remove(listener_id); - let result = v8::Boolean::new(scope, removed); - rv.set(result.into()); -} - -fn op_net_set_nodelay<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, -) { - let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); - let enable = args.get(1).boolean_value(scope); - let handle = from_isolate(scope); - let streams = handle.0.borrow().net_streams.clone(); - let applied = streams - .with(stream_id, |slot| { - if let Ok(guard) = slot.try_lock() { - guard.set_nodelay(enable).is_ok() - } else { - false - } - }) - .unwrap_or(false); - let result = v8::Boolean::new(scope, applied); - rv.set(result.into()); -} - -fn op_net_set_keepalive<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - _rv: v8::ReturnValue<'s, v8::Value>, -) { - let _stream_id = args.get(0).uint32_value(scope).unwrap_or(0); - let _enable = args.get(1).boolean_value(scope); - let _ = scope; -} - -fn read_uint8_array<'s>( - _scope: &mut v8::PinScope<'s, '_>, - value: v8::Local<'s, v8::Value>, -) -> Option> { - let view = v8::Local::::try_from(value).ok()?; - let len = view.byte_length(); - let mut out = vec![0u8; len]; - let copied = view.copy_contents(&mut out); - if copied != len { - return None; + use aes_gcm::aead::{Aead, KeyInit, Payload}; + use aes_gcm::{Aes256Gcm, Key, Nonce}; + let Some(key_bytes) = bytes_arg(scope, &args, 0) else { + throw_error(scope, "aes_gcm_open: key"); + return; + }; + let Some(iv_bytes) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "aes_gcm_open: iv"); + return; + }; + let Some(ct) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "aes_gcm_open: ct"); + return; + }; + let aad = bytes_arg(scope, &args, 3).unwrap_or_default(); + if key_bytes.len() != 32 || iv_bytes.len() != 12 { + throw_error(scope, "aes_gcm_open: key=32 iv=12"); + return; + } + let key = Key::::from_slice(&key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&iv_bytes); + let payload = Payload { + msg: &ct, + aad: &aad, + }; + match cipher.decrypt(nonce, payload) { + Ok(pt) => { + let arr = bytes_to_uint8array(scope, &pt); + rv.set(arr.into()); + } + Err(_) => throw_error(scope, "aes_gcm_open: auth failed"), } - Some(out) } // ────────────────────────────────────────────────────────────────────── -// node:tls ops +// node:zlib // ────────────────────────────────────────────────────────────────────── -fn op_tls_connect<'s>( +fn op_zlib_encode<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let host = args.get(0).to_rust_string_lossy(scope); - let port = args.get(1).uint32_value(scope).unwrap_or(0) as u16; - let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set_undefined(); + use std::io::Write; + let algo = string_arg(scope, &args, 0); + let Some(input) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "zlib.encode: data must be Uint8Array"); return; }; - let promise = resolver.get_promise(scope); - let global = v8::Global::new(scope, resolver); - let handle = from_isolate(scope); - let tx = handle.0.borrow().async_completions_tx.clone(); - let table = handle.0.borrow().tls_streams.clone(); - - tokio::task::spawn_local(async move { - let settler: super::async_ops::Settler = match crate::ops::tls_connect(&host, port).await { - Ok((stream, local, remote)) => { - let id = table.insert(std::rc::Rc::new(tokio::sync::Mutex::new(stream))); - Box::new(move |scope, resolver| { - let obj = v8::Object::new(scope); - let id_key = v8::String::new(scope, "id").unwrap(); - let id_val = v8::Number::new(scope, f64::from(id)); - obj.set(scope, id_key.into(), id_val.into()); - let local_obj = make_address_obj(scope, &local); - let local_key = v8::String::new(scope, "local").unwrap(); - obj.set(scope, local_key.into(), local_obj.into()); - let remote_obj = make_address_obj(scope, &remote); - let remote_key = v8::String::new(scope, "remote").unwrap(); - obj.set(scope, remote_key.into(), remote_obj.into()); - resolver.resolve(scope, obj.into()); - }) + let out: std::io::Result> = match algo.as_str() { + "gzip" => { + let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(&input).and_then(|()| e.finish()) + } + "deflate" => { + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(&input).and_then(|()| e.finish()) + } + "deflate-raw" => { + let mut e = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(&input).and_then(|()| e.finish()) + } + "brotli" => { + let mut buf = Vec::new(); + { + let mut w = brotli::CompressorWriter::new(&mut buf, 4096, 4, 22); + if let Err(err) = w.write_all(&input) { + throw_error(scope, &format!("EIO: {err}")); + return; + } } - Err(err) => net_settler_err(err), - }; - let _ = tx.send(super::async_ops::Completion::new(global, settler)); - }); - rv.set(promise.into()); + Ok(buf) + } + other => { + throw_error(scope, &format!("ENOSYS: zlib {other}")); + return; + } + }; + match out { + Ok(bytes) => { + let arr = bytes_to_uint8array(scope, &bytes); + rv.set(arr.into()); + } + Err(err) => throw_error(scope, &format!("EIO: {err}")), + } } -fn op_tls_read<'s>( +fn op_zlib_decode<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); - let max = args.get(1).uint32_value(scope).unwrap_or(64 * 1024) as usize; - let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set_undefined(); + use std::io::Read; + let algo = string_arg(scope, &args, 0); + let Some(input) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "zlib.decode: data must be Uint8Array"); return; }; + let mut out = Vec::new(); + let res: std::io::Result<()> = match algo.as_str() { + "gzip" => flate2::read::GzDecoder::new(&input[..]) + .read_to_end(&mut out) + .map(|_| ()), + "deflate" => flate2::read::ZlibDecoder::new(&input[..]) + .read_to_end(&mut out) + .map(|_| ()), + "deflate-raw" => flate2::read::DeflateDecoder::new(&input[..]) + .read_to_end(&mut out) + .map(|_| ()), + "brotli" => brotli::Decompressor::new(&input[..], 4096) + .read_to_end(&mut out) + .map(|_| ()), + other => { + throw_error(scope, &format!("ENOSYS: zlib {other}")); + return; + } + }; + match res { + Ok(()) => { + let arr = bytes_to_uint8array(scope, &out); + rv.set(arr.into()); + } + Err(err) => throw_error(scope, &format!("EIO: {err}")), + } +} + +// ────────────────────────────────────────────────────────────────────── +// node:dns ops +// ────────────────────────────────────────────────────────────────────── +// +// Each op follows the same shape: +// 1. Pull the call arguments off `args`, allocate a fresh +// `PromiseResolver`, and clone the bridge's async-completion +// sender. +// 2. `tokio::task::spawn_local` an async block that runs the +// lookup off the JS critical path. On completion it builds a +// `Settler` closure that marshals the result into `v8::Value`s +// and forwards it through the channel. +// 3. The engine pump drains the channel on every tick and resolves +// / rejects each promise on the isolate thread (where +// `v8::Local` values are valid). + +use std::future::Future; + +use crate::ops::DnsError; + +/// Schedules `work` off-isolate and resolves the returned promise +/// with the value built by `on_ok` (run on the isolate thread). +/// +/// On `Err(DnsError)` the promise rejects with a Node-style `Error` +/// whose `.code` carries the mapped error string. +fn schedule_dns<'s, Fut, T, Mk>( + scope: &mut v8::PinScope<'s, '_>, + work: Fut, + on_ok: Mk, +) -> Option> +where + Fut: Future> + 'static, + T: 'static, + Mk: for<'a, 'b> FnOnce(&mut v8::PinScope<'a, 'b>, T) -> v8::Local<'a, v8::Value> + 'static, +{ + let resolver = v8::PromiseResolver::new(scope)?; let promise = resolver.get_promise(scope); let global = v8::Global::new(scope, resolver); let handle = from_isolate(scope); let tx = handle.0.borrow().async_completions_tx.clone(); - let streams = handle.0.borrow().tls_streams.clone(); - let Some(slot) = streams.with(stream_id, std::rc::Rc::clone) else { - let err = NetError::new("EBADF", "tls stream has been closed"); - reject_net(scope, v8::Local::new(scope, &global), &err); - rv.set(promise.into()); - return; - }; + tokio::task::spawn_local(async move { - let result = { - let mut guard = slot.lock().await; - crate::ops::tls_read_chunk(&mut guard, max).await - }; + let result = work.await; let settler: super::async_ops::Settler = match result { - Ok(bytes) => Box::new(move |scope, resolver| { - let store = v8::ArrayBuffer::new_backing_store_from_vec(bytes).make_shared(); - let buf = v8::ArrayBuffer::with_backing_store(scope, &store); - let view = v8::Uint8Array::new(scope, buf, 0, buf.byte_length()).unwrap(); - resolver.resolve(scope, view.into()); + Ok(value) => Box::new(move |scope, resolver| { + let v = on_ok(scope, value); + resolver.resolve(scope, v); }), - Err(err) => net_settler_err(err), + Err(err) => super::async_ops::reject_with_code(err.message, err.code), }; let _ = tx.send(super::async_ops::Completion::new(global, settler)); }); - rv.set(promise.into()); + + Some(promise) } -fn op_tls_write<'s>( +fn op_dns_lookup<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); - let data = match read_uint8_array(scope, args.get(1)) { - Some(b) => b, - None => { - throw_type_error(scope, "tls.write: expected Uint8Array"); - return; - } - }; - let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set_undefined(); - return; - }; - let promise = resolver.get_promise(scope); - let global = v8::Global::new(scope, resolver); - let handle = from_isolate(scope); - let tx = handle.0.borrow().async_completions_tx.clone(); - let streams = handle.0.borrow().tls_streams.clone(); - let Some(slot) = streams.with(stream_id, std::rc::Rc::clone) else { - let err = NetError::new("EBADF", "tls stream has been closed"); - reject_net(scope, v8::Local::new(scope, &global), &err); - rv.set(promise.into()); - return; - }; - tokio::task::spawn_local(async move { - let result = { - let mut guard = slot.lock().await; - crate::ops::tls_write_all(&mut guard, &data).await - }; - let settler: super::async_ops::Settler = match result { - Ok(()) => Box::new(move |scope, resolver| { - let undef = v8::undefined(scope); - resolver.resolve(scope, undef.into()); - }), - Err(err) => net_settler_err(err), - }; - let _ = tx.send(super::async_ops::Completion::new(global, settler)); + let host = args.get(0).to_rust_string_lossy(scope); + let family_node = args.get(1).uint32_value(scope).unwrap_or(0); + let all = args.get(2).boolean_value(scope); + let max = if all { usize::MAX } else { 1 }; + let family = crate::ops::LookupFamily::from_node(family_node); + + let work = async move { crate::ops::dns_lookup(&host, family, max).await }; + let promise = schedule_dns(scope, work, move |scope, results| { + if all { + let arr = v8::Array::new(scope, results.len() as i32); + for (i, r) in results.iter().enumerate() { + let obj = make_lookup_obj(scope, r); + arr.set_index(scope, i as u32, obj.into()); + } + arr.into() + } else { + let first = results.into_iter().next().expect("at least one result"); + make_lookup_obj(scope, &first).into() + } }); - rv.set(promise.into()); + if let Some(p) = promise { + rv.set(p.into()); + } } -fn op_tls_close<'s>( +fn make_lookup_obj<'s>( + scope: &mut v8::PinScope<'s, '_>, + result: &crate::ops::LookupResult, +) -> v8::Local<'s, v8::Object> { + let obj = v8::Object::new(scope); + set_string_field(scope, obj, "address", &result.address.to_string()); + let fam_key = v8::String::new(scope, "family").unwrap(); + let fam_val = v8::Number::new(scope, f64::from(result.family)); + obj.set(scope, fam_key.into(), fam_val.into()); + obj +} + +fn op_dns_resolve4<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); - let handle = from_isolate(scope); - let streams = handle.0.borrow().tls_streams.clone(); - let removed = streams.take(stream_id); - let was_present = removed.is_some(); - if let Some(slot) = removed { - tokio::task::spawn_local(async move { - if let Ok(mut guard) = std::rc::Rc::try_unwrap(slot) - .map_err(|_| ()) - .map(tokio::sync::Mutex::into_inner) - { - let _ = crate::ops::tls_shutdown(&mut guard).await; - } - }); + let host = args.get(0).to_rust_string_lossy(scope); + let work = async move { crate::ops::dns_resolve4(&host).await }; + let promise = schedule_dns(scope, work, |scope, ips| ip_array(scope, &ips).into()); + if let Some(p) = promise { + rv.set(p.into()); } - let result = v8::Boolean::new(scope, was_present); - rv.set(result.into()); } -// ────────────────────────────────────────────────────────────────────── -// node:http / node:https client ops -// ────────────────────────────────────────────────────────────────────── +fn op_dns_resolve6<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let host = args.get(0).to_rust_string_lossy(scope); + let work = async move { crate::ops::dns_resolve6(&host).await }; + let promise = schedule_dns(scope, work, |scope, ips| ip_array(scope, &ips).into()); + if let Some(p) = promise { + rv.set(p.into()); + } +} -use crate::ops::{HttpHeader, HttpRequest, http_request}; +fn ip_array<'s>( + scope: &mut v8::PinScope<'s, '_>, + ips: &[std::net::IpAddr], +) -> v8::Local<'s, v8::Array> { + let arr = v8::Array::new(scope, ips.len() as i32); + for (i, ip) in ips.iter().enumerate() { + let s = v8::String::new(scope, &ip.to_string()).unwrap(); + arr.set_index(scope, i as u32, s.into()); + } + arr +} -/// Reads `{ method, url, headers: [[name, value], ...], body: Uint8Array? }` -/// from the JS argument, fires the request asynchronously, and resolves -/// with `{ status, statusText, headers: [[name, value], ...], bodyId }`. -/// `bodyId` is the [`super::bridge::HttpResponseSlot`] handle the JS side -/// uses with `op_http_response_read` until the channel is drained. -fn op_http_request<'s>( +fn op_dns_resolve_mx<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set_undefined(); - return; - }; - let promise = resolver.get_promise(scope); - let global = v8::Global::new(scope, resolver); - - let req = match decode_http_request(scope, args.get(0)) { - Ok(req) => req, - Err(err) => { - reject_net(scope, v8::Local::new(scope, &global), &err); - rv.set(promise.into()); - return; + let host = args.get(0).to_rust_string_lossy(scope); + let work = async move { crate::ops::dns_resolve_mx(&host).await }; + let promise = schedule_dns(scope, work, |scope, records| { + let arr = v8::Array::new(scope, records.len() as i32); + for (i, r) in records.iter().enumerate() { + let obj = v8::Object::new(scope); + let prio_key = v8::String::new(scope, "priority").unwrap(); + let prio_val = v8::Number::new(scope, f64::from(r.priority)); + obj.set(scope, prio_key.into(), prio_val.into()); + set_string_field(scope, obj, "exchange", &r.exchange); + arr.set_index(scope, i as u32, obj.into()); } - }; - - let handle = from_isolate(scope); - let tx = handle.0.borrow().async_completions_tx.clone(); - let table = handle.0.borrow().http_responses.clone(); - - tokio::task::spawn_local(async move { - let settler: super::async_ops::Settler = match http_request(req).await { - Ok(response) => { - let id = table.insert(std::rc::Rc::new(tokio::sync::Mutex::new(response.body))); - let status = response.status; - let status_text = response.status_text; - let headers = response.headers; - Box::new(move |scope, resolver| { - let obj = v8::Object::new(scope); - let status_key = v8::String::new(scope, "status").unwrap(); - let status_val = v8::Number::new(scope, f64::from(status)); - obj.set(scope, status_key.into(), status_val.into()); - set_string_field(scope, obj, "statusText", &status_text); - let headers_arr = build_header_array(scope, &headers); - let headers_key = v8::String::new(scope, "headers").unwrap(); - obj.set(scope, headers_key.into(), headers_arr.into()); - let body_key = v8::String::new(scope, "bodyId").unwrap(); - let body_val = v8::Number::new(scope, f64::from(id)); - obj.set(scope, body_key.into(), body_val.into()); - resolver.resolve(scope, obj.into()); - }) - } - Err(err) => net_settler_err(err), - }; - let _ = tx.send(super::async_ops::Completion::new(global, settler)); + arr.into() }); - rv.set(promise.into()); + if let Some(p) = promise { + rv.set(p.into()); + } } -/// Pulls one chunk from the response body channel identified by `bodyId`. -/// Resolves with a `Uint8Array` carrying the chunk or `null` once the -/// channel signals end-of-stream. -fn op_http_response_read<'s>( +fn op_dns_resolve_txt<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let body_id = args.get(0).uint32_value(scope).unwrap_or(0); - let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set_undefined(); - return; - }; - let promise = resolver.get_promise(scope); - let global = v8::Global::new(scope, resolver); - let handle = from_isolate(scope); - let tx = handle.0.borrow().async_completions_tx.clone(); - let bodies = handle.0.borrow().http_responses.clone(); - let Some(slot) = bodies.with(body_id, std::rc::Rc::clone) else { - let err = NetError::new("EBADF", "http response has been closed"); - reject_net(scope, v8::Local::new(scope, &global), &err); - rv.set(promise.into()); - return; - }; - - tokio::task::spawn_local(async move { - let next = { - let mut guard = slot.lock().await; - guard.recv().await - }; - let settler: super::async_ops::Settler = match next { - Some(Ok(chunk)) => Box::new(move |scope, resolver| { - let view = bytes_to_uint8_array(scope, &chunk); - resolver.resolve(scope, view.into()); - }), - Some(Err(err)) => net_settler_err(err), - None => Box::new(|scope, resolver| { - resolver.resolve(scope, v8::null(scope).into()); - }), - }; - let _ = tx.send(super::async_ops::Completion::new(global, settler)); + let host = args.get(0).to_rust_string_lossy(scope); + let work = async move { crate::ops::dns_resolve_txt(&host).await }; + let promise = schedule_dns(scope, work, |scope, records| { + let arr = v8::Array::new(scope, records.len() as i32); + for (i, chunks) in records.iter().enumerate() { + let inner = v8::Array::new(scope, chunks.len() as i32); + for (j, chunk) in chunks.iter().enumerate() { + let s = v8::String::new(scope, chunk).unwrap(); + inner.set_index(scope, j as u32, s.into()); + } + arr.set_index(scope, i as u32, inner.into()); + } + arr.into() }); - rv.set(promise.into()); + if let Some(p) = promise { + rv.set(p.into()); + } } -/// Drops the response slot, aborting any in-flight body streaming. -fn op_http_response_close<'s>( +fn op_dns_resolve_cname<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let body_id = args.get(0).uint32_value(scope).unwrap_or(0); - let handle = from_isolate(scope); - let removed = handle.0.borrow().http_responses.remove(body_id); - let result = v8::Boolean::new(scope, removed); - rv.set(result.into()); + let host = args.get(0).to_rust_string_lossy(scope); + let work = async move { crate::ops::dns_resolve_cname(&host).await }; + let promise = schedule_dns(scope, work, |scope, names| { + string_array(scope, &names).into() + }); + if let Some(p) = promise { + rv.set(p.into()); + } } -fn decode_http_request<'s>( +fn op_dns_resolve_ns<'s>( scope: &mut v8::PinScope<'s, '_>, - value: v8::Local<'s, v8::Value>, -) -> Result { - let obj = v8::Local::::try_from(value) - .map_err(|_| NetError::new("ERR_INVALID_ARG_TYPE", "request must be an object"))?; - let method = read_string_field(scope, obj, "method") - .unwrap_or_else(|| "GET".to_owned()) - .to_uppercase(); - let url = read_string_field(scope, obj, "url") - .ok_or_else(|| NetError::new("ERR_INVALID_URL", "request.url is required"))?; - let headers = read_header_array(scope, obj, "headers"); - let body = read_optional_body(scope, obj, "body"); - Ok(HttpRequest { - method, - url, - headers, - body, - }) -} - -fn read_string_field<'s>( - scope: &mut v8::PinScope<'s, '_>, - obj: v8::Local<'s, v8::Object>, - name: &str, -) -> Option { - let key = v8::String::new(scope, name)?; - let v = obj.get(scope, key.into())?; - if v.is_null_or_undefined() { - return None; - } - Some(v.to_rust_string_lossy(scope)) -} - -fn read_header_array<'s>( - scope: &mut v8::PinScope<'s, '_>, - obj: v8::Local<'s, v8::Object>, - name: &str, -) -> Vec { - let Some(key) = v8::String::new(scope, name) else { - return Vec::new(); - }; - let Some(value) = obj.get(scope, key.into()) else { - return Vec::new(); - }; - let Ok(arr) = v8::Local::::try_from(value) else { - return Vec::new(); - }; - let len = arr.length(); - let mut out = Vec::with_capacity(len as usize); - for i in 0..len { - let Some(entry) = arr.get_index(scope, i) else { - continue; - }; - let Ok(pair) = v8::Local::::try_from(entry) else { - continue; - }; - if pair.length() < 2 { - continue; - } - let Some(name_v) = pair.get_index(scope, 0) else { - continue; - }; - let Some(value_v) = pair.get_index(scope, 1) else { - continue; - }; - out.push(HttpHeader { - name: name_v.to_rust_string_lossy(scope), - value: value_v.to_rust_string_lossy(scope), - }); - } - out -} - -fn read_optional_body<'s>( - scope: &mut v8::PinScope<'s, '_>, - obj: v8::Local<'s, v8::Object>, - name: &str, -) -> Vec { - let Some(key) = v8::String::new(scope, name) else { - return Vec::new(); - }; - let Some(value) = obj.get(scope, key.into()) else { - return Vec::new(); - }; - if value.is_null_or_undefined() { - return Vec::new(); + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let host = args.get(0).to_rust_string_lossy(scope); + let work = async move { crate::ops::dns_resolve_ns(&host).await }; + let promise = schedule_dns(scope, work, |scope, names| { + string_array(scope, &names).into() + }); + if let Some(p) = promise { + rv.set(p.into()); } - read_uint8_array(scope, value).unwrap_or_default() } -fn build_header_array<'s>( +fn string_array<'s>( scope: &mut v8::PinScope<'s, '_>, - headers: &[HttpHeader], + items: &[String], ) -> v8::Local<'s, v8::Array> { - let arr = v8::Array::new(scope, headers.len() as i32); - for (i, h) in headers.iter().enumerate() { - let pair = v8::Array::new(scope, 2); - let name = v8::String::new(scope, &h.name).unwrap_or_else(|| v8::String::empty(scope)); - let value = v8::String::new(scope, &h.value).unwrap_or_else(|| v8::String::empty(scope)); - pair.set_index(scope, 0, name.into()); - pair.set_index(scope, 1, value.into()); - arr.set_index(scope, i as u32, pair.into()); + let arr = v8::Array::new(scope, items.len() as i32); + for (i, item) in items.iter().enumerate() { + let s = v8::String::new(scope, item).unwrap(); + arr.set_index(scope, i as u32, s.into()); } arr } -fn bytes_to_uint8_array<'s>( +fn op_dns_resolve_srv<'s>( scope: &mut v8::PinScope<'s, '_>, - chunk: &[u8], -) -> v8::Local<'s, v8::Uint8Array> { - let backing = v8::ArrayBuffer::with_backing_store( - scope, - &v8::ArrayBuffer::new_backing_store_from_vec(chunk.to_vec()).make_shared(), - ); - v8::Uint8Array::new(scope, backing, 0, chunk.len()).expect("Uint8Array view") + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let host = args.get(0).to_rust_string_lossy(scope); + let work = async move { crate::ops::dns_resolve_srv(&host).await }; + let promise = schedule_dns(scope, work, |scope, records| { + let arr = v8::Array::new(scope, records.len() as i32); + for (i, r) in records.iter().enumerate() { + let obj = v8::Object::new(scope); + let prio_key = v8::String::new(scope, "priority").unwrap(); + let prio_val = v8::Number::new(scope, f64::from(r.priority)); + obj.set(scope, prio_key.into(), prio_val.into()); + let weight_key = v8::String::new(scope, "weight").unwrap(); + let weight_val = v8::Number::new(scope, f64::from(r.weight)); + obj.set(scope, weight_key.into(), weight_val.into()); + let port_key = v8::String::new(scope, "port").unwrap(); + let port_val = v8::Number::new(scope, f64::from(r.port)); + obj.set(scope, port_key.into(), port_val.into()); + set_string_field(scope, obj, "name", &r.name); + arr.set_index(scope, i as u32, obj.into()); + } + arr.into() + }); + if let Some(p) = promise { + rv.set(p.into()); + } } -// ────────────────────────────────────────────────────────────────────── -// node:child_process ops -// ────────────────────────────────────────────────────────────────────── - -use super::bridge::ChildSlot; -use crate::ops::{ - ExitInfo, SpawnRequest, StdioMode, proc_kill, proc_read_pipe, proc_spawn, proc_wait, - proc_write_pipe, -}; -use std::collections::HashMap; - -/// Spawns a child process from the JS descriptor -/// `{ command, args, cwd?, env?, clearEnv?, stdio: ["pipe"|"inherit"|"ignore", ...] }`. -/// Returns `{ pid, id, hasStdin, hasStdout, hasStderr }` on success; -/// rejects with a Node-style error code (e.g. `ENOENT`). -fn op_proc_spawn<'s>( +fn op_dns_reverse<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let descriptor = match decode_spawn_descriptor(scope, args.get(0)) { - Ok(req) => req, - Err(err) => { - let exc = make_node_error(scope, &err); - scope.throw_exception(exc); - return; - } - }; - let handle = from_isolate(scope); - { - let state = handle.0.borrow(); - let allowed = state - .process - .as_ref() - .is_none_or(crate::ops::ProcessConfig::subprocess_allowed); - if !allowed { - drop(state); - let err = NetError::new("EPERM", "subprocess spawning is disabled by ProcessConfig"); - let exc = make_node_error(scope, &err); - scope.throw_exception(exc); - return; - } - } - let table = handle.0.borrow().child_processes.clone(); - match proc_spawn(descriptor) { - Ok(child_handle) => { - let slot = std::rc::Rc::new(ChildSlot { - child: tokio::sync::Mutex::new(child_handle.child), - stdin: tokio::sync::Mutex::new(child_handle.stdin), - stdout: tokio::sync::Mutex::new(child_handle.stdout), - stderr: tokio::sync::Mutex::new(child_handle.stderr), + let ip_str = args.get(0).to_rust_string_lossy(scope); + let parsed: Result = ip_str.parse(); + match parsed { + Ok(ip) => { + let work = async move { crate::ops::dns_reverse(ip).await }; + let promise = schedule_dns(scope, work, |scope, names| { + string_array(scope, &names).into() }); - let has_stdin = slot.stdin.try_lock().is_ok_and(|g| g.is_some()); - let has_stdout = slot.stdout.try_lock().is_ok_and(|g| g.is_some()); - let has_stderr = slot.stderr.try_lock().is_ok_and(|g| g.is_some()); - let id = table.insert(slot); - let obj = v8::Object::new(scope); - let id_key = v8::String::new(scope, "id").unwrap(); - let id_val = v8::Number::new(scope, f64::from(id)); - obj.set(scope, id_key.into(), id_val.into()); - let pid_key = v8::String::new(scope, "pid").unwrap(); - let pid_val = v8::Number::new(scope, f64::from(child_handle.pid)); - obj.set(scope, pid_key.into(), pid_val.into()); - set_bool_field(scope, obj, "hasStdin", has_stdin); - set_bool_field(scope, obj, "hasStdout", has_stdout); - set_bool_field(scope, obj, "hasStderr", has_stderr); - rv.set(obj.into()); + if let Some(p) = promise { + rv.set(p.into()); + } } - Err(err) => { - let exc = make_node_error(scope, &err); - scope.throw_exception(exc); + Err(_) => { + throw_type_error(scope, "dns.reverse: invalid IP address"); } } } -/// Awaits the child's exit. Resolves with `{ code, signal }`. -fn op_proc_wait<'s>( +/// Sleeps for `ms` milliseconds and resolves the returned promise. +/// +/// Backed by `tokio::time::sleep` and the shared async-completion +/// channel, so the JS pump drives it on the isolate thread once the +/// timer fires. `ms` is clamped to `[0, i32::MAX]` so a misbehaving +/// caller cannot register a future that never fires. +fn op_timer_sleep<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let id = args.get(0).uint32_value(scope).unwrap_or(0); + let ms_raw = args.get(0).number_value(scope).unwrap_or(0.0); + let ms = if ms_raw.is_finite() && ms_raw > 0.0 { + ms_raw.min(f64::from(i32::MAX)) as u64 + } else { + 0 + }; + let Some(resolver) = v8::PromiseResolver::new(scope) else { rv.set_undefined(); return; @@ -3314,75 +3688,67 @@ fn op_proc_wait<'s>( let global = v8::Global::new(scope, resolver); let handle = from_isolate(scope); let tx = handle.0.borrow().async_completions_tx.clone(); - let table = handle.0.borrow().child_processes.clone(); - let Some(slot) = table.with(id, std::rc::Rc::clone) else { - let err = NetError::new("EBADF", "child process has been closed"); - reject_net(scope, v8::Local::new(scope, &global), &err); - rv.set(promise.into()); - return; - }; + tokio::task::spawn_local(async move { - let result = { - let mut child = slot.child.lock().await; - proc_wait(&mut child).await - }; - let settler: super::async_ops::Settler = match result { - Ok(info) => Box::new(move |scope, resolver| { - let obj = exit_info_to_object(scope, info); - resolver.resolve(scope, obj.into()); - }), - Err(err) => net_settler_err(err), - }; + tokio::time::sleep(std::time::Duration::from_millis(ms)).await; + let settler: super::async_ops::Settler = Box::new(|scope, resolver| { + let undef = v8::undefined(scope); + resolver.resolve(scope, undef.into()); + }); let _ = tx.send(super::async_ops::Completion::new(global, settler)); }); + rv.set(promise.into()); } -/// Sends a signal (or terminates) the child. -fn op_proc_kill<'s>( +// ────────────────────────────────────────────────────────────────────── +// node:net ops +// ────────────────────────────────────────────────────────────────────── + +use crate::ops::{AddressInfo, NetError}; + +const NET_BRIDGE_TARGET: &str = "nexide::engine::bridge::net"; + +fn make_address_obj<'s>( scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, + info: &AddressInfo, +) -> v8::Local<'s, v8::Object> { + let obj = v8::Object::new(scope); + set_string_field(scope, obj, "address", &info.address); + let port_key = v8::String::new(scope, "port").unwrap(); + let port_val = v8::Number::new(scope, f64::from(info.port)); + obj.set(scope, port_key.into(), port_val.into()); + let fam_key = v8::String::new(scope, "family").unwrap(); + let fam_val = v8::Number::new(scope, f64::from(info.family)); + obj.set(scope, fam_key.into(), fam_val.into()); + obj +} + +fn reject_net<'s>( + scope: &mut v8::PinScope<'s, '_>, + resolver: v8::Local<'s, v8::PromiseResolver>, + err: &NetError, ) { - let id = args.get(0).uint32_value(scope).unwrap_or(0); - let signal = args.get(1).int32_value(scope).unwrap_or(15); - let handle = from_isolate(scope); - let table = handle.0.borrow().child_processes.clone(); - let Some(slot) = table.with(id, std::rc::Rc::clone) else { - rv.set(v8::Boolean::new(scope, false).into()); - return; - }; - let result = { - let mut child_guard = slot.child.try_lock(); - match child_guard.as_mut() { - Ok(child) => proc_kill(child, signal), - Err(_) => Err(NetError::new("EBUSY", "child is being awaited")), - } - }; - match result { - Ok(()) => rv.set(v8::Boolean::new(scope, true).into()), - Err(err) => { - let exc = make_node_error(scope, &err); - scope.throw_exception(exc); - } + let msg = v8::String::new(scope, &err.message).unwrap_or_else(|| v8::String::empty(scope)); + let exc = v8::Exception::error(scope, msg); + if let Ok(obj) = TryInto::>::try_into(exc) { + set_string_field(scope, obj, "code", err.code); } + resolver.reject(scope, exc); } -fn op_proc_stdin_write<'s>( +fn net_settler_err(err: NetError) -> super::async_ops::Settler { + Box::new(move |scope, resolver| reject_net(scope, resolver, &err)) +} + +fn op_net_connect<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let id = args.get(0).uint32_value(scope).unwrap_or(0); - let data = match read_uint8_array(scope, args.get(1)) { - Some(d) => d, - None => { - let err = NetError::new("ERR_INVALID_ARG_TYPE", "expected Uint8Array"); - let exc = make_node_error(scope, &err); - scope.throw_exception(exc); - return; - } - }; + let host = args.get(0).to_rust_string_lossy(scope); + let port = args.get(1).uint32_value(scope).unwrap_or(0) as u16; + let Some(resolver) = v8::PromiseResolver::new(scope) else { rv.set_undefined(); return; @@ -3391,25 +3757,35 @@ fn op_proc_stdin_write<'s>( let global = v8::Global::new(scope, resolver); let handle = from_isolate(scope); let tx = handle.0.borrow().async_completions_tx.clone(); - let table = handle.0.borrow().child_processes.clone(); - let Some(slot) = table.with(id, std::rc::Rc::clone) else { - let err = NetError::new("EBADF", "child process has been closed"); - reject_net(scope, v8::Local::new(scope, &global), &err); - rv.set(promise.into()); - return; - }; + let table = handle.0.borrow().net_streams.clone(); + tokio::task::spawn_local(async move { - let result = { - let mut guard = slot.stdin.lock().await; - match guard.as_mut() { - Some(pipe) => proc_write_pipe(pipe, &data).await, - None => Err(NetError::new("EPIPE", "child stdin is not piped")), - } - }; + let result = crate::ops::net_connect(&host, port).await; let settler: super::async_ops::Settler = match result { - Ok(()) => Box::new(|scope, resolver| { - resolver.resolve(scope, v8::undefined(scope).into()); - }), + Ok((stream, local, remote)) => { + let slot = std::rc::Rc::new(stream); + let id = table.insert(slot); + tracing::debug!( + target: NET_BRIDGE_TARGET, + stream_id = id, + local = %local, + remote = %remote, + "net stream slot allocated", + ); + Box::new(move |scope, resolver| { + let obj = v8::Object::new(scope); + let id_key = v8::String::new(scope, "id").unwrap(); + let id_val = v8::Number::new(scope, f64::from(id)); + obj.set(scope, id_key.into(), id_val.into()); + let local_obj = make_address_obj(scope, &local); + let local_key = v8::String::new(scope, "local").unwrap(); + obj.set(scope, local_key.into(), local_obj.into()); + let remote_obj = make_address_obj(scope, &remote); + let remote_key = v8::String::new(scope, "remote").unwrap(); + obj.set(scope, remote_key.into(), remote_obj.into()); + resolver.resolve(scope, obj.into()); + }) + } Err(err) => net_settler_err(err), }; let _ = tx.send(super::async_ops::Completion::new(global, settler)); @@ -3417,48 +3793,101 @@ fn op_proc_stdin_write<'s>( rv.set(promise.into()); } -fn op_proc_stdin_close<'s>( +fn op_net_listen<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let id = args.get(0).uint32_value(scope).unwrap_or(0); - let handle = from_isolate(scope); - let table = handle.0.borrow().child_processes.clone(); - let Some(slot) = table.with(id, std::rc::Rc::clone) else { - rv.set(v8::Boolean::new(scope, false).into()); + let host = args.get(0).to_rust_string_lossy(scope); + let port = args.get(1).uint32_value(scope).unwrap_or(0) as u16; + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); return; }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + let table = handle.0.borrow().net_listeners.clone(); + tokio::task::spawn_local(async move { - let mut guard = slot.stdin.lock().await; - let _ = guard.take(); + let settler: super::async_ops::Settler = match crate::ops::net_listen(&host, port).await { + Ok((listener, addr)) => { + let id = table.insert(std::rc::Rc::new(listener)); + Box::new(move |scope, resolver| { + let obj = v8::Object::new(scope); + let id_key = v8::String::new(scope, "id").unwrap(); + let id_val = v8::Number::new(scope, f64::from(id)); + obj.set(scope, id_key.into(), id_val.into()); + let addr_obj = make_address_obj(scope, &addr); + let addr_key = v8::String::new(scope, "address").unwrap(); + obj.set(scope, addr_key.into(), addr_obj.into()); + resolver.resolve(scope, obj.into()); + }) + } + Err(err) => net_settler_err(err), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); }); - rv.set(v8::Boolean::new(scope, true).into()); + rv.set(promise.into()); } -fn op_proc_stdout_read<'s>( +fn op_net_accept<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, - rv: v8::ReturnValue<'s, v8::Value>, + mut rv: v8::ReturnValue<'s, v8::Value>, ) { - proc_pipe_read(scope, args, rv, /*is_stderr=*/ false); -} + let listener_id = args.get(0).uint32_value(scope).unwrap_or(0); + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + let listeners = handle.0.borrow().net_listeners.clone(); + let streams = handle.0.borrow().net_streams.clone(); -fn op_proc_stderr_read<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - rv: v8::ReturnValue<'s, v8::Value>, -) { - proc_pipe_read(scope, args, rv, /*is_stderr=*/ true); + let Some(listener) = listeners.with(listener_id, std::rc::Rc::clone) else { + let err = NetError::new("EBADF", "listener has been closed"); + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + }; + + tokio::task::spawn_local(async move { + let settler: super::async_ops::Settler = match crate::ops::net_accept(&listener).await { + Ok((stream, local, remote)) => { + let slot = std::rc::Rc::new(stream); + let id = streams.insert(slot); + Box::new(move |scope, resolver| { + let obj = v8::Object::new(scope); + let id_key = v8::String::new(scope, "id").unwrap(); + let id_val = v8::Number::new(scope, f64::from(id)); + obj.set(scope, id_key.into(), id_val.into()); + let local_obj = make_address_obj(scope, &local); + let local_key = v8::String::new(scope, "local").unwrap(); + obj.set(scope, local_key.into(), local_obj.into()); + let remote_obj = make_address_obj(scope, &remote); + let remote_key = v8::String::new(scope, "remote").unwrap(); + obj.set(scope, remote_key.into(), remote_obj.into()); + resolver.resolve(scope, obj.into()); + }) + } + Err(err) => net_settler_err(err), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); } -fn proc_pipe_read<'s>( +fn op_net_read<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, - is_stderr: bool, ) { - let id = args.get(0).uint32_value(scope).unwrap_or(0); + let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); let max = args.get(1).uint32_value(scope).unwrap_or(64 * 1024) as usize; let Some(resolver) = v8::PromiseResolver::new(scope) else { rv.set_undefined(); @@ -3468,35 +3897,30 @@ fn proc_pipe_read<'s>( let global = v8::Global::new(scope, resolver); let handle = from_isolate(scope); let tx = handle.0.borrow().async_completions_tx.clone(); - let table = handle.0.borrow().child_processes.clone(); - let Some(slot) = table.with(id, std::rc::Rc::clone) else { - let err = NetError::new("EBADF", "child process has been closed"); + let streams = handle.0.borrow().net_streams.clone(); + + let Some(slot) = streams.with(stream_id, std::rc::Rc::clone) else { + tracing::warn!( + target: NET_BRIDGE_TARGET, + stream_id, + op = "read", + "EBADF: read on closed slot", + ); + let err = NetError::new("EBADF", "socket has been closed"); reject_net(scope, v8::Local::new(scope, &global), &err); rv.set(promise.into()); return; }; + tokio::task::spawn_local(async move { - let result = if is_stderr { - let mut guard = slot.stderr.lock().await; - match guard.as_mut() { - Some(pipe) => proc_read_pipe(pipe, max).await, - None => Ok(None), - } - } else { - let mut guard = slot.stdout.lock().await; - match guard.as_mut() { - Some(pipe) => proc_read_pipe(pipe, max).await, - None => Ok(None), - } - }; + let result = crate::ops::net_read_chunk(&slot, max).await; let settler: super::async_ops::Settler = match result { - Ok(Some(chunk)) => Box::new(move |scope, resolver| { - let view = bytes_to_uint8_array(scope, &chunk); + Ok(bytes) => Box::new(move |scope, resolver| { + let store = v8::ArrayBuffer::new_backing_store_from_vec(bytes).make_shared(); + let buf = v8::ArrayBuffer::with_backing_store(scope, &store); + let view = v8::Uint8Array::new(scope, buf, 0, buf.byte_length()).unwrap(); resolver.resolve(scope, view.into()); }), - Ok(None) => Box::new(|scope, resolver| { - resolver.resolve(scope, v8::null(scope).into()); - }), Err(err) => net_settler_err(err), }; let _ = tx.send(super::async_ops::Completion::new(global, settler)); @@ -3504,774 +3928,3722 @@ fn proc_pipe_read<'s>( rv.set(promise.into()); } -fn op_proc_close<'s>( +fn op_net_write<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); + let data = match read_uint8_array(scope, args.get(1)) { + Some(bytes) => bytes, + None => { + throw_type_error(scope, "net.write: expected Uint8Array"); + return; + } + }; + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + let streams = handle.0.borrow().net_streams.clone(); + + let Some(slot) = streams.with(stream_id, std::rc::Rc::clone) else { + tracing::warn!( + target: NET_BRIDGE_TARGET, + stream_id, + op = "write", + len = data.len(), + "EBADF: write on closed slot", + ); + let err = NetError::new("EBADF", "socket has been closed"); + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + }; + + tokio::task::spawn_local(async move { + let result = crate::ops::net_write_all(&slot, &data).await; + let settler: super::async_ops::Settler = match result { + Ok(()) => Box::new(move |scope, resolver| { + let undef = v8::undefined(scope); + resolver.resolve(scope, undef.into()); + }), + Err(err) => net_settler_err(err), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); +} + +fn op_net_close_stream<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); + let handle = from_isolate(scope); + let removed = handle.0.borrow().net_streams.remove(stream_id); + if removed { + tracing::debug!(target: NET_BRIDGE_TARGET, stream_id, "net stream slot released"); + } else { + tracing::trace!( + target: NET_BRIDGE_TARGET, + stream_id, + "net stream close on already-closed slot", + ); + } + let result = v8::Boolean::new(scope, removed); + rv.set(result.into()); +} + +fn op_net_close_listener<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let listener_id = args.get(0).uint32_value(scope).unwrap_or(0); + let handle = from_isolate(scope); + let removed = handle.0.borrow().net_listeners.remove(listener_id); + if removed { + tracing::debug!( + target: NET_BRIDGE_TARGET, + listener_id, + "net listener slot released", + ); + } + let result = v8::Boolean::new(scope, removed); + rv.set(result.into()); +} + +fn op_net_set_nodelay<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); + let enable = args.get(1).boolean_value(scope); + let handle = from_isolate(scope); + let streams = handle.0.borrow().net_streams.clone(); + let applied = streams + .with(stream_id, |slot| slot.set_nodelay(enable).is_ok()) + .unwrap_or(false); + let result = v8::Boolean::new(scope, applied); + rv.set(result.into()); +} + +fn op_net_set_keepalive<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s, v8::Value>, +) { + let _stream_id = args.get(0).uint32_value(scope).unwrap_or(0); + let _enable = args.get(1).boolean_value(scope); + let _ = scope; +} + +fn read_uint8_array<'s>( + _scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, +) -> Option> { + let view = v8::Local::::try_from(value).ok()?; + let len = view.byte_length(); + let mut out = vec![0u8; len]; + let copied = view.copy_contents(&mut out); + if copied != len { + return None; + } + Some(out) +} + +// ────────────────────────────────────────────────────────────────────── +// node:tls ops +// ────────────────────────────────────────────────────────────────────── + +fn op_tls_connect<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let host = args.get(0).to_rust_string_lossy(scope); + let port = args.get(1).uint32_value(scope).unwrap_or(0) as u16; + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + let table = handle.0.borrow().tls_streams.clone(); + + tokio::task::spawn_local(async move { + let settler: super::async_ops::Settler = match crate::ops::tls_connect(&host, port).await { + Ok((stream, local, remote)) => { + let id = table.insert(std::rc::Rc::new(tokio::sync::Mutex::new(stream))); + Box::new(move |scope, resolver| { + let obj = v8::Object::new(scope); + let id_key = v8::String::new(scope, "id").unwrap(); + let id_val = v8::Number::new(scope, f64::from(id)); + obj.set(scope, id_key.into(), id_val.into()); + let local_obj = make_address_obj(scope, &local); + let local_key = v8::String::new(scope, "local").unwrap(); + obj.set(scope, local_key.into(), local_obj.into()); + let remote_obj = make_address_obj(scope, &remote); + let remote_key = v8::String::new(scope, "remote").unwrap(); + obj.set(scope, remote_key.into(), remote_obj.into()); + resolver.resolve(scope, obj.into()); + }) + } + Err(err) => net_settler_err(err), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); +} + +/// Upgrades an existing `op_net_connect` socket (identified by its +/// JS-side handle id) to TLS, performing a client handshake on top +/// of the live TCP stream. Mirrors `tls.connect({ socket })` semantics +/// from `node:tls`, which protocols like PostgreSQL `SSLRequest`, +/// SMTP `STARTTLS` and IMAP/POP3 `STARTTLS` rely on. Removes the +/// entry from `net_streams` on success; the JS Socket handle becomes +/// invalid and must not be used afterwards. +fn op_tls_upgrade<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let net_id = args.get(0).uint32_value(scope).unwrap_or(0); + let host = args.get(1).to_rust_string_lossy(scope); + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + let net_streams = handle.0.borrow().net_streams.clone(); + let tls_streams = handle.0.borrow().tls_streams.clone(); + + let Some(slot) = net_streams.take(net_id) else { + tracing::warn!( + target: NET_BRIDGE_TARGET, + stream_id = net_id, + op = "tls_upgrade", + "EBADF: tls_upgrade on closed net slot", + ); + let err = NetError::new("EBADF", "net stream has been closed"); + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + }; + + tokio::task::spawn_local(async move { + let stream = match std::rc::Rc::try_unwrap(slot) { + Ok(s) => s, + Err(_rc) => { + tracing::warn!( + target: NET_BRIDGE_TARGET, + stream_id = net_id, + op = "tls_upgrade", + "EBUSY: outstanding I/O on net slot; cannot upgrade", + ); + let err = NetError::new( + "EBUSY", + "net stream still has outstanding I/O; cannot upgrade to TLS", + ); + let settler = net_settler_err(err); + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + return; + } + }; + let result = crate::ops::tls_upgrade(stream, &host).await; + let settler: super::async_ops::Settler = match result { + Ok((tls, local, remote)) => { + let id = tls_streams.insert(std::rc::Rc::new(tokio::sync::Mutex::new(tls))); + tracing::debug!( + target: NET_BRIDGE_TARGET, + tls_id = id, + from_net_id = net_id, + local = %local, + remote = %remote, + "tls stream slot allocated from upgraded net stream", + ); + Box::new(move |scope, resolver| { + let obj = v8::Object::new(scope); + let id_key = v8::String::new(scope, "id").unwrap(); + let id_val = v8::Number::new(scope, f64::from(id)); + obj.set(scope, id_key.into(), id_val.into()); + let local_obj = make_address_obj(scope, &local); + let local_key = v8::String::new(scope, "local").unwrap(); + obj.set(scope, local_key.into(), local_obj.into()); + let remote_obj = make_address_obj(scope, &remote); + let remote_key = v8::String::new(scope, "remote").unwrap(); + obj.set(scope, remote_key.into(), remote_obj.into()); + resolver.resolve(scope, obj.into()); + }) + } + Err(err) => net_settler_err(err), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); +} + +fn op_tls_read<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); + let max = args.get(1).uint32_value(scope).unwrap_or(64 * 1024) as usize; + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + let streams = handle.0.borrow().tls_streams.clone(); + let Some(slot) = streams.with(stream_id, std::rc::Rc::clone) else { + let err = NetError::new("EBADF", "tls stream has been closed"); + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + }; + tokio::task::spawn_local(async move { + let result = { + let mut guard = slot.lock().await; + crate::ops::tls_read_chunk(&mut guard, max).await + }; + let settler: super::async_ops::Settler = match result { + Ok(bytes) => Box::new(move |scope, resolver| { + let store = v8::ArrayBuffer::new_backing_store_from_vec(bytes).make_shared(); + let buf = v8::ArrayBuffer::with_backing_store(scope, &store); + let view = v8::Uint8Array::new(scope, buf, 0, buf.byte_length()).unwrap(); + resolver.resolve(scope, view.into()); + }), + Err(err) => net_settler_err(err), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); +} + +fn op_tls_write<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); + let data = match read_uint8_array(scope, args.get(1)) { + Some(b) => b, + None => { + throw_type_error(scope, "tls.write: expected Uint8Array"); + return; + } + }; + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + let streams = handle.0.borrow().tls_streams.clone(); + let Some(slot) = streams.with(stream_id, std::rc::Rc::clone) else { + let err = NetError::new("EBADF", "tls stream has been closed"); + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + }; + tokio::task::spawn_local(async move { + let result = { + let mut guard = slot.lock().await; + crate::ops::tls_write_all(&mut guard, &data).await + }; + let settler: super::async_ops::Settler = match result { + Ok(()) => Box::new(move |scope, resolver| { + let undef = v8::undefined(scope); + resolver.resolve(scope, undef.into()); + }), + Err(err) => net_settler_err(err), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); +} + +fn op_tls_close<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let stream_id = args.get(0).uint32_value(scope).unwrap_or(0); + let handle = from_isolate(scope); + let streams = handle.0.borrow().tls_streams.clone(); + let removed = streams.take(stream_id); + let was_present = removed.is_some(); + if let Some(slot) = removed { + tokio::task::spawn_local(async move { + if let Ok(mut guard) = std::rc::Rc::try_unwrap(slot) + .map_err(|_| ()) + .map(tokio::sync::Mutex::into_inner) + { + let _ = crate::ops::tls_shutdown(&mut guard).await; + } + }); + } + let result = v8::Boolean::new(scope, was_present); + rv.set(result.into()); +} + +// ────────────────────────────────────────────────────────────────────── +// node:http / node:https client ops +// ────────────────────────────────────────────────────────────────────── + +use crate::ops::{HttpHeader, HttpRequest, http_request}; + +const HTTP_BRIDGE_TARGET: &str = "nexide::engine::bridge::http"; + +/// Reads `{ method, url, headers: [[name, value], ...], body: Uint8Array? }` +/// from the JS argument, fires the request asynchronously, and resolves +/// with `{ status, statusText, headers: [[name, value], ...], bodyId }`. +/// `bodyId` is the [`super::bridge::HttpResponseSlot`] handle the JS side +/// uses with `op_http_response_read` until the channel is drained. +fn op_http_request<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + + let req = match decode_http_request(scope, args.get(0)) { + Ok(req) => req, + Err(err) => { + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + } + }; + + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + let table = handle.0.borrow().http_responses.clone(); + + tokio::task::spawn_local(async move { + let settler: super::async_ops::Settler = match http_request(req).await { + Ok(response) => { + let id = table.insert(std::rc::Rc::new(tokio::sync::Mutex::new(response.body))); + let status = response.status; + let status_text = response.status_text; + let headers = response.headers; + tracing::debug!( + target: HTTP_BRIDGE_TARGET, + body_id = id, + status, + headers = headers.len(), + "http response slot allocated", + ); + Box::new(move |scope, resolver| { + let obj = v8::Object::new(scope); + let status_key = v8::String::new(scope, "status").unwrap(); + let status_val = v8::Number::new(scope, f64::from(status)); + obj.set(scope, status_key.into(), status_val.into()); + set_string_field(scope, obj, "statusText", &status_text); + let headers_arr = build_header_array(scope, &headers); + let headers_key = v8::String::new(scope, "headers").unwrap(); + obj.set(scope, headers_key.into(), headers_arr.into()); + let body_key = v8::String::new(scope, "bodyId").unwrap(); + let body_val = v8::Number::new(scope, f64::from(id)); + obj.set(scope, body_key.into(), body_val.into()); + resolver.resolve(scope, obj.into()); + }) + } + Err(err) => net_settler_err(err), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); +} + +/// Pulls one chunk from the response body channel identified by `bodyId`. +/// Resolves with a `Uint8Array` carrying the chunk or `null` once the +/// channel signals end-of-stream. +fn op_http_response_read<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let body_id = args.get(0).uint32_value(scope).unwrap_or(0); + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + let bodies = handle.0.borrow().http_responses.clone(); + let Some(slot) = bodies.with(body_id, std::rc::Rc::clone) else { + let err = NetError::new("EBADF", "http response has been closed"); + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + }; + + tokio::task::spawn_local(async move { + let next = { + let mut guard = slot.lock().await; + guard.recv().await + }; + let settler: super::async_ops::Settler = match next { + Some(Ok(chunk)) => Box::new(move |scope, resolver| { + let view = bytes_to_uint8_array(scope, &chunk); + resolver.resolve(scope, view.into()); + }), + Some(Err(err)) => net_settler_err(err), + None => Box::new(|scope, resolver| { + resolver.resolve(scope, v8::null(scope).into()); + }), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); +} + +/// Drops the response slot, aborting any in-flight body streaming. +fn op_http_response_close<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let body_id = args.get(0).uint32_value(scope).unwrap_or(0); + let handle = from_isolate(scope); + let removed = handle.0.borrow().http_responses.remove(body_id); + if removed { + tracing::debug!( + target: HTTP_BRIDGE_TARGET, + body_id, + "http response slot released", + ); + } + let result = v8::Boolean::new(scope, removed); + rv.set(result.into()); +} + +fn decode_http_request<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, +) -> Result { + let obj = v8::Local::::try_from(value) + .map_err(|_| NetError::new("ERR_INVALID_ARG_TYPE", "request must be an object"))?; + let method = read_string_field(scope, obj, "method") + .unwrap_or_else(|| "GET".to_owned()) + .to_uppercase(); + let url = read_string_field(scope, obj, "url") + .ok_or_else(|| NetError::new("ERR_INVALID_URL", "request.url is required"))?; + let headers = read_header_array(scope, obj, "headers"); + let body = read_optional_body(scope, obj, "body"); + Ok(HttpRequest { + method, + url, + headers, + body, + }) +} + +fn read_string_field<'s>( + scope: &mut v8::PinScope<'s, '_>, + obj: v8::Local<'s, v8::Object>, + name: &str, +) -> Option { + let key = v8::String::new(scope, name)?; + let v = obj.get(scope, key.into())?; + if v.is_null_or_undefined() { + return None; + } + Some(v.to_rust_string_lossy(scope)) +} + +fn read_header_array<'s>( + scope: &mut v8::PinScope<'s, '_>, + obj: v8::Local<'s, v8::Object>, + name: &str, +) -> Vec { + let Some(key) = v8::String::new(scope, name) else { + return Vec::new(); + }; + let Some(value) = obj.get(scope, key.into()) else { + return Vec::new(); + }; + let Ok(arr) = v8::Local::::try_from(value) else { + return Vec::new(); + }; + let len = arr.length(); + let mut out = Vec::with_capacity(len as usize); + for i in 0..len { + let Some(entry) = arr.get_index(scope, i) else { + continue; + }; + let Ok(pair) = v8::Local::::try_from(entry) else { + continue; + }; + if pair.length() < 2 { + continue; + } + let Some(name_v) = pair.get_index(scope, 0) else { + continue; + }; + let Some(value_v) = pair.get_index(scope, 1) else { + continue; + }; + out.push(HttpHeader { + name: name_v.to_rust_string_lossy(scope), + value: value_v.to_rust_string_lossy(scope), + }); + } + out +} + +fn read_optional_body<'s>( + scope: &mut v8::PinScope<'s, '_>, + obj: v8::Local<'s, v8::Object>, + name: &str, +) -> Vec { + let Some(key) = v8::String::new(scope, name) else { + return Vec::new(); + }; + let Some(value) = obj.get(scope, key.into()) else { + return Vec::new(); + }; + if value.is_null_or_undefined() { + return Vec::new(); + } + read_uint8_array(scope, value).unwrap_or_default() +} + +fn build_header_array<'s>( + scope: &mut v8::PinScope<'s, '_>, + headers: &[HttpHeader], +) -> v8::Local<'s, v8::Array> { + let arr = v8::Array::new(scope, headers.len() as i32); + for (i, h) in headers.iter().enumerate() { + let pair = v8::Array::new(scope, 2); + let name = v8::String::new(scope, &h.name).unwrap_or_else(|| v8::String::empty(scope)); + let value = v8::String::new(scope, &h.value).unwrap_or_else(|| v8::String::empty(scope)); + pair.set_index(scope, 0, name.into()); + pair.set_index(scope, 1, value.into()); + arr.set_index(scope, i as u32, pair.into()); + } + arr +} + +fn bytes_to_uint8_array<'s>( + scope: &mut v8::PinScope<'s, '_>, + chunk: &[u8], +) -> v8::Local<'s, v8::Uint8Array> { + let backing = v8::ArrayBuffer::with_backing_store( + scope, + &v8::ArrayBuffer::new_backing_store_from_vec(chunk.to_vec()).make_shared(), + ); + v8::Uint8Array::new(scope, backing, 0, chunk.len()).expect("Uint8Array view") +} + +// ────────────────────────────────────────────────────────────────────── +// node:child_process ops +// ────────────────────────────────────────────────────────────────────── + +use super::bridge::ChildSlot; + +const PROC_BRIDGE_TARGET: &str = "nexide::engine::bridge::process"; +use crate::ops::{ + ExitInfo, SpawnRequest, StdioMode, proc_kill, proc_read_pipe, proc_spawn, proc_wait, + proc_write_pipe, +}; +use std::collections::HashMap; + +/// Spawns a child process from the JS descriptor +/// `{ command, args, cwd?, env?, clearEnv?, stdio: ["pipe"|"inherit"|"ignore", ...] }`. +/// Returns `{ pid, id, hasStdin, hasStdout, hasStderr }` on success; +/// rejects with a Node-style error code (e.g. `ENOENT`). +fn op_proc_spawn<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let descriptor = match decode_spawn_descriptor(scope, args.get(0)) { + Ok(req) => req, + Err(err) => { + let exc = make_node_error(scope, &err); + scope.throw_exception(exc); + return; + } + }; + let handle = from_isolate(scope); + { + let state = handle.0.borrow(); + let allowed = state + .process + .as_ref() + .is_none_or(crate::ops::ProcessConfig::subprocess_allowed); + if !allowed { + drop(state); + let err = NetError::new("EPERM", "subprocess spawning is disabled by ProcessConfig"); + let exc = make_node_error(scope, &err); + scope.throw_exception(exc); + return; + } + } + let table = handle.0.borrow().child_processes.clone(); + match proc_spawn(descriptor) { + Ok(child_handle) => { + let slot = std::rc::Rc::new(ChildSlot { + child: tokio::sync::Mutex::new(child_handle.child), + stdin: tokio::sync::Mutex::new(child_handle.stdin), + stdout: tokio::sync::Mutex::new(child_handle.stdout), + stderr: tokio::sync::Mutex::new(child_handle.stderr), + }); + let has_stdin = slot.stdin.try_lock().is_ok_and(|g| g.is_some()); + let has_stdout = slot.stdout.try_lock().is_ok_and(|g| g.is_some()); + let has_stderr = slot.stderr.try_lock().is_ok_and(|g| g.is_some()); + let id = table.insert(slot); + tracing::debug!( + target: PROC_BRIDGE_TARGET, + child_id = id, + pid = child_handle.pid, + stdin = has_stdin, + stdout = has_stdout, + stderr = has_stderr, + "child process slot allocated", + ); + let obj = v8::Object::new(scope); + let id_key = v8::String::new(scope, "id").unwrap(); + let id_val = v8::Number::new(scope, f64::from(id)); + obj.set(scope, id_key.into(), id_val.into()); + let pid_key = v8::String::new(scope, "pid").unwrap(); + let pid_val = v8::Number::new(scope, f64::from(child_handle.pid)); + obj.set(scope, pid_key.into(), pid_val.into()); + set_bool_field(scope, obj, "hasStdin", has_stdin); + set_bool_field(scope, obj, "hasStdout", has_stdout); + set_bool_field(scope, obj, "hasStderr", has_stderr); + rv.set(obj.into()); + } + Err(err) => { + let exc = make_node_error(scope, &err); + scope.throw_exception(exc); + } + } +} + +/// Awaits the child's exit. Resolves with `{ code, signal }`. +fn op_proc_wait<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let id = args.get(0).uint32_value(scope).unwrap_or(0); + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + let table = handle.0.borrow().child_processes.clone(); + let Some(slot) = table.with(id, std::rc::Rc::clone) else { + let err = NetError::new("EBADF", "child process has been closed"); + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + }; + tokio::task::spawn_local(async move { + let result = { + let mut child = slot.child.lock().await; + proc_wait(&mut child).await + }; + let settler: super::async_ops::Settler = match result { + Ok(info) => Box::new(move |scope, resolver| { + let obj = exit_info_to_object(scope, info); + resolver.resolve(scope, obj.into()); + }), + Err(err) => net_settler_err(err), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); +} + +/// Sends a signal (or terminates) the child. +fn op_proc_kill<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let id = args.get(0).uint32_value(scope).unwrap_or(0); + let signal = args.get(1).int32_value(scope).unwrap_or(15); + let handle = from_isolate(scope); + let table = handle.0.borrow().child_processes.clone(); + let Some(slot) = table.with(id, std::rc::Rc::clone) else { + rv.set(v8::Boolean::new(scope, false).into()); + return; + }; + let result = { + let mut child_guard = slot.child.try_lock(); + match child_guard.as_mut() { + Ok(child) => proc_kill(child, signal), + Err(_) => Err(NetError::new("EBUSY", "child is being awaited")), + } + }; + match result { + Ok(()) => rv.set(v8::Boolean::new(scope, true).into()), + Err(err) => { + let exc = make_node_error(scope, &err); + scope.throw_exception(exc); + } + } +} + +fn op_proc_stdin_write<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let id = args.get(0).uint32_value(scope).unwrap_or(0); + let data = match read_uint8_array(scope, args.get(1)) { + Some(d) => d, + None => { + let err = NetError::new("ERR_INVALID_ARG_TYPE", "expected Uint8Array"); + let exc = make_node_error(scope, &err); + scope.throw_exception(exc); + return; + } + }; + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + let table = handle.0.borrow().child_processes.clone(); + let Some(slot) = table.with(id, std::rc::Rc::clone) else { + let err = NetError::new("EBADF", "child process has been closed"); + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + }; + tokio::task::spawn_local(async move { + let result = { + let mut guard = slot.stdin.lock().await; + match guard.as_mut() { + Some(pipe) => proc_write_pipe(pipe, &data).await, + None => Err(NetError::new("EPIPE", "child stdin is not piped")), + } + }; + let settler: super::async_ops::Settler = match result { + Ok(()) => Box::new(|scope, resolver| { + resolver.resolve(scope, v8::undefined(scope).into()); + }), + Err(err) => net_settler_err(err), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); +} + +fn op_proc_stdin_close<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let id = args.get(0).uint32_value(scope).unwrap_or(0); + let handle = from_isolate(scope); + let table = handle.0.borrow().child_processes.clone(); + let Some(slot) = table.with(id, std::rc::Rc::clone) else { + rv.set(v8::Boolean::new(scope, false).into()); + return; + }; + tokio::task::spawn_local(async move { + let mut guard = slot.stdin.lock().await; + let _ = guard.take(); + }); + rv.set(v8::Boolean::new(scope, true).into()); +} + +fn op_proc_stdout_read<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + rv: v8::ReturnValue<'s, v8::Value>, +) { + proc_pipe_read(scope, args, rv, /*is_stderr=*/ false); +} + +fn op_proc_stderr_read<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + rv: v8::ReturnValue<'s, v8::Value>, +) { + proc_pipe_read(scope, args, rv, /*is_stderr=*/ true); +} + +fn proc_pipe_read<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, + is_stderr: bool, +) { + let id = args.get(0).uint32_value(scope).unwrap_or(0); + let max = args.get(1).uint32_value(scope).unwrap_or(64 * 1024) as usize; + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + let table = handle.0.borrow().child_processes.clone(); + let Some(slot) = table.with(id, std::rc::Rc::clone) else { + let err = NetError::new("EBADF", "child process has been closed"); + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + }; + tokio::task::spawn_local(async move { + let result = if is_stderr { + let mut guard = slot.stderr.lock().await; + match guard.as_mut() { + Some(pipe) => proc_read_pipe(pipe, max).await, + None => Ok(None), + } + } else { + let mut guard = slot.stdout.lock().await; + match guard.as_mut() { + Some(pipe) => proc_read_pipe(pipe, max).await, + None => Ok(None), + } + }; + let settler: super::async_ops::Settler = match result { + Ok(Some(chunk)) => Box::new(move |scope, resolver| { + let view = bytes_to_uint8_array(scope, &chunk); + resolver.resolve(scope, view.into()); + }), + Ok(None) => Box::new(|scope, resolver| { + resolver.resolve(scope, v8::null(scope).into()); + }), + Err(err) => net_settler_err(err), + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); +} + +fn op_proc_close<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let id = args.get(0).uint32_value(scope).unwrap_or(0); + let handle = from_isolate(scope); + let removed = handle.0.borrow().child_processes.remove(id); + if removed { + tracing::debug!( + target: PROC_BRIDGE_TARGET, + child_id = id, + "child process slot released", + ); + } + rv.set(v8::Boolean::new(scope, removed).into()); +} + +fn decode_spawn_descriptor<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, +) -> Result { + let obj = v8::Local::::try_from(value) + .map_err(|_| NetError::new("ERR_INVALID_ARG_TYPE", "spawn descriptor must be an object"))?; + let command = read_string_field(scope, obj, "command") + .ok_or_else(|| NetError::new("ERR_INVALID_ARG_VALUE", "command is required"))?; + let args = read_string_array(scope, obj, "args"); + let cwd = read_string_field(scope, obj, "cwd"); + let env = read_string_string_record(scope, obj, "env"); + let clear_env = read_bool_field(scope, obj, "clearEnv"); + let stdio = read_stdio_modes(scope, obj, "stdio"); + Ok(SpawnRequest { + command, + args, + cwd, + env, + clear_env, + stdio, + }) +} + +fn read_string_array<'s>( + scope: &mut v8::PinScope<'s, '_>, + obj: v8::Local<'s, v8::Object>, + name: &str, +) -> Vec { + let Some(key) = v8::String::new(scope, name) else { + return Vec::new(); + }; + let Some(value) = obj.get(scope, key.into()) else { + return Vec::new(); + }; + let Ok(arr) = v8::Local::::try_from(value) else { + return Vec::new(); + }; + let len = arr.length(); + let mut out = Vec::with_capacity(len as usize); + for i in 0..len { + if let Some(entry) = arr.get_index(scope, i) { + out.push(entry.to_rust_string_lossy(scope)); + } + } + out +} + +fn read_string_string_record<'s>( + scope: &mut v8::PinScope<'s, '_>, + obj: v8::Local<'s, v8::Object>, + name: &str, +) -> HashMap { + let mut out = HashMap::new(); + let Some(key) = v8::String::new(scope, name) else { + return out; + }; + let Some(value) = obj.get(scope, key.into()) else { + return out; + }; + if value.is_null_or_undefined() { + return out; + } + let Ok(record) = v8::Local::::try_from(value) else { + return out; + }; + let Some(names) = + record.get_own_property_names(scope, v8::GetPropertyNamesArgsBuilder::new().build()) + else { + return out; + }; + for i in 0..names.length() { + let Some(k) = names.get_index(scope, i) else { + continue; + }; + let Some(v) = record.get(scope, k) else { + continue; + }; + out.insert(k.to_rust_string_lossy(scope), v.to_rust_string_lossy(scope)); + } + out +} + +fn read_bool_field<'s>( + scope: &mut v8::PinScope<'s, '_>, + obj: v8::Local<'s, v8::Object>, + name: &str, +) -> bool { + let Some(key) = v8::String::new(scope, name) else { + return false; + }; + let Some(value) = obj.get(scope, key.into()) else { + return false; + }; + value.boolean_value(scope) +} + +fn read_stdio_modes<'s>( + scope: &mut v8::PinScope<'s, '_>, + obj: v8::Local<'s, v8::Object>, + name: &str, +) -> [StdioMode; 3] { + let mut modes = [StdioMode::Pipe, StdioMode::Pipe, StdioMode::Pipe]; + let Some(key) = v8::String::new(scope, name) else { + return modes; + }; + let Some(value) = obj.get(scope, key.into()) else { + return modes; + }; + if let Ok(arr) = v8::Local::::try_from(value) { + let len = arr.length().min(3); + for i in 0..len { + if let Some(entry) = arr.get_index(scope, i) { + modes[i as usize] = parse_stdio_mode(&entry.to_rust_string_lossy(scope)); + } + } + } + modes +} + +fn parse_stdio_mode(s: &str) -> StdioMode { + match s { + "inherit" => StdioMode::Inherit, + "ignore" => StdioMode::Ignore, + _ => StdioMode::Pipe, + } +} + +fn exit_info_to_object<'s>( + scope: &mut v8::PinScope<'s, '_>, + info: ExitInfo, +) -> v8::Local<'s, v8::Object> { + let obj = v8::Object::new(scope); + let code_key = v8::String::new(scope, "code").unwrap(); + let code_val: v8::Local = match info.code { + Some(c) => v8::Number::new(scope, f64::from(c)).into(), + None => v8::null(scope).into(), + }; + obj.set(scope, code_key.into(), code_val); + let signal_key = v8::String::new(scope, "signal").unwrap(); + let signal_val: v8::Local = match info.signal { + Some(s) => v8::Number::new(scope, f64::from(s)).into(), + None => v8::null(scope).into(), + }; + obj.set(scope, signal_key.into(), signal_val); + obj +} + +fn make_node_error<'s>( + scope: &mut v8::PinScope<'s, '_>, + err: &NetError, +) -> v8::Local<'s, v8::Value> { + let msg = v8::String::new(scope, &err.message).unwrap_or_else(|| v8::String::empty(scope)); + let exc = v8::Exception::error(scope, msg); + if let Ok(obj) = TryInto::>::try_into(exc) { + set_string_field(scope, obj, "code", err.code); + } + exc +} + +fn set_bool_field<'s>( + scope: &mut v8::PinScope<'s, '_>, + obj: v8::Local<'s, v8::Object>, + name: &str, + value: bool, +) { + let key = v8::String::new(scope, name).unwrap(); + let val = v8::Boolean::new(scope, value); + obj.set(scope, key.into(), val.into()); +} + +// ────────────────────────────────────────────────────────────────────── +// node:zlib streaming ops +// ────────────────────────────────────────────────────────────────────── + +use crate::ops::{ZlibStream, parse_zlib_kind}; + +const ZLIB_BRIDGE_TARGET: &str = "nexide::engine::bridge::zlib"; + +/// Creates a streaming zlib state machine. `kind` is the kebab-case +/// identifier (`"deflate"`, `"gunzip"`, …) and `level` is the zlib +/// compression level (0..=9, ignored for decoders). +fn op_zlib_create<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let kind_str = args.get(0).to_rust_string_lossy(scope); + let level = args.get(1).uint32_value(scope).unwrap_or(6); + match parse_zlib_kind(&kind_str) { + Ok(kind) => { + let stream = ZlibStream::new(kind, level); + let handle = from_isolate(scope); + let table = handle.0.borrow().zlib_streams.clone(); + let id = table.insert(std::rc::Rc::new(std::cell::RefCell::new(Some(stream)))); + tracing::debug!( + target: ZLIB_BRIDGE_TARGET, + stream_id = id, + kind = %kind_str, + level, + "zlib stream slot allocated", + ); + rv.set(v8::Number::new(scope, f64::from(id)).into()); + } + Err(err) => { + tracing::warn!( + target: ZLIB_BRIDGE_TARGET, + kind = %kind_str, + code = err.code, + "zlib stream create rejected", + ); + let exc = make_node_error(scope, &err); + scope.throw_exception(exc); + } + } +} + +fn op_zlib_feed<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let id = args.get(0).uint32_value(scope).unwrap_or(0); + let data = match read_uint8_array(scope, args.get(1)) { + Some(d) => d, + None => { + let err = NetError::new("ERR_INVALID_ARG_TYPE", "expected Uint8Array"); + let exc = make_node_error(scope, &err); + scope.throw_exception(exc); + return; + } + }; + let handle = from_isolate(scope); + let table = handle.0.borrow().zlib_streams.clone(); + let Some(slot) = table.with(id, std::rc::Rc::clone) else { + let err = NetError::new("EBADF", "zlib stream is closed"); + let exc = make_node_error(scope, &err); + scope.throw_exception(exc); + return; + }; + let result = match slot.borrow_mut().as_mut() { + Some(stream) => stream.feed(&data), + None => Err(NetError::new("EBADF", "zlib stream is finalised")), + }; + match result { + Ok(out) => { + let view = bytes_to_uint8_array(scope, &out); + rv.set(view.into()); + } + Err(err) => { + let exc = make_node_error(scope, &err); + scope.throw_exception(exc); + } + } +} + +fn op_zlib_finish<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let id = args.get(0).uint32_value(scope).unwrap_or(0); + let handle = from_isolate(scope); + let table = handle.0.borrow().zlib_streams.clone(); + let Some(slot) = table.with(id, std::rc::Rc::clone) else { + let err = NetError::new("EBADF", "zlib stream is closed"); + let exc = make_node_error(scope, &err); + scope.throw_exception(exc); + return; + }; + let stream = slot.borrow_mut().take(); + let Some(stream) = stream else { + let err = NetError::new("EBADF", "zlib stream is already finalised"); + let exc = make_node_error(scope, &err); + scope.throw_exception(exc); + return; + }; + match stream.finish() { + Ok(out) => { + let view = bytes_to_uint8_array(scope, &out); + rv.set(view.into()); + } + Err(err) => { + let exc = make_node_error(scope, &err); + scope.throw_exception(exc); + } + } +} + +/// Drops the zlib stream slot. +fn op_zlib_close<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let id = args.get(0).uint32_value(scope).unwrap_or(0); + let handle = from_isolate(scope); + let removed = handle.0.borrow().zlib_streams.remove(id); + if removed { + tracing::debug!( + target: ZLIB_BRIDGE_TARGET, + stream_id = id, + "zlib stream slot released", + ); + } + rv.set(v8::Boolean::new(scope, removed).into()); +} + +// ────────────────────────────────────────────────────────────────────── +// crypto: KDFs, additional ciphers, sign/verify +// +// One-shot Rust ops backed by RustCrypto. The JS shells in +// `polyfills/node/crypto.js` accumulate `update()` chunks and call +// the matching op once during `final()` / `digest()`. +// ────────────────────────────────────────────────────────────────────── + +fn op_crypto_pbkdf2<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(password) = bytes_arg(scope, &args, 0) else { + throw_error(scope, "pbkdf2: password must be Uint8Array"); + return; + }; + let Some(salt) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "pbkdf2: salt must be Uint8Array"); + return; + }; + let iterations = args.get(2).uint32_value(scope).unwrap_or(0); + let keylen = args.get(3).uint32_value(scope).unwrap_or(0) as usize; + let digest_name = string_arg(scope, &args, 4); + if iterations == 0 || keylen == 0 { + throw_error(scope, "pbkdf2: iterations and keylen must be > 0"); + return; + } + let mut out = vec![0u8; keylen]; + let result = match digest_name.as_str() { + "sha1" => pbkdf2::pbkdf2::>(&password, &salt, iterations, &mut out), + "sha256" => { + pbkdf2::pbkdf2::>(&password, &salt, iterations, &mut out) + } + "sha384" => { + pbkdf2::pbkdf2::>(&password, &salt, iterations, &mut out) + } + "sha512" => { + pbkdf2::pbkdf2::>(&password, &salt, iterations, &mut out) + } + other => { + throw_error(scope, &format!("pbkdf2: unsupported digest {other}")); + return; + } + }; + if result.is_err() { + throw_error(scope, "pbkdf2: invalid key length"); + return; + } + let arr = bytes_to_uint8array(scope, &out); + rv.set(arr.into()); +} + +fn op_crypto_scrypt<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(password) = bytes_arg(scope, &args, 0) else { + throw_error(scope, "scrypt: password must be Uint8Array"); + return; + }; + let Some(salt) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "scrypt: salt must be Uint8Array"); + return; + }; + let keylen = args.get(2).uint32_value(scope).unwrap_or(0) as usize; + let n_raw = args.get(3).uint32_value(scope).unwrap_or(16384); + let r = args.get(4).uint32_value(scope).unwrap_or(8); + let p = args.get(5).uint32_value(scope).unwrap_or(1); + if keylen == 0 { + throw_error(scope, "scrypt: keylen must be > 0"); + return; + } + if !n_raw.is_power_of_two() || n_raw < 2 { + throw_error(scope, "scrypt: N must be a power of two >= 2"); + return; + } + let log_n = (31 - n_raw.leading_zeros()) as u8; + let params = match scrypt::Params::new(log_n, r, p, keylen) { + Ok(p) => p, + Err(err) => { + throw_error(scope, &format!("scrypt: invalid parameters: {err}")); + return; + } + }; + let mut out = vec![0u8; keylen]; + if let Err(err) = scrypt::scrypt(&password, &salt, ¶ms, &mut out) { + throw_error(scope, &format!("scrypt: derivation failed: {err}")); + return; + } + let arr = bytes_to_uint8array(scope, &out); + rv.set(arr.into()); +} + +/// Encrypts a buffer with a non-AEAD AES mode (CBC, CTR). +/// +/// `algo` is the Node.js style identifier (e.g. `aes-256-cbc`). +/// CBC requests apply PKCS#7 padding to match Node's default behaviour. +fn op_crypto_aes_encrypt<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + use aes::cipher::{BlockEncryptMut, KeyIvInit, StreamCipher}; + let algo = string_arg(scope, &args, 0); + let Some(key) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "aes encrypt: key must be Uint8Array"); + return; + }; + let Some(iv) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "aes encrypt: iv must be Uint8Array"); + return; + }; + let Some(data) = bytes_arg(scope, &args, 3) else { + throw_error(scope, "aes encrypt: data must be Uint8Array"); + return; + }; + let result: Result, &'static str> = match algo.as_str() { + "aes-128-cbc" => { + if key.len() != 16 || iv.len() != 16 { + Err("aes-128-cbc requires 16-byte key and iv") + } else { + let enc = cbc::Encryptor::::new_from_slices(&key, &iv).unwrap(); + Ok(enc.encrypt_padded_vec_mut::(&data)) + } + } + "aes-192-cbc" => { + if key.len() != 24 || iv.len() != 16 { + Err("aes-192-cbc requires 24-byte key and 16-byte iv") + } else { + let enc = cbc::Encryptor::::new_from_slices(&key, &iv).unwrap(); + Ok(enc.encrypt_padded_vec_mut::(&data)) + } + } + "aes-256-cbc" => { + if key.len() != 32 || iv.len() != 16 { + Err("aes-256-cbc requires 32-byte key and 16-byte iv") + } else { + let enc = cbc::Encryptor::::new_from_slices(&key, &iv).unwrap(); + Ok(enc.encrypt_padded_vec_mut::(&data)) + } + } + "aes-128-ctr" => { + if key.len() != 16 || iv.len() != 16 { + Err("aes-128-ctr requires 16-byte key and iv") + } else { + let mut buf = data.clone(); + let mut c = ctr::Ctr128BE::::new_from_slices(&key, &iv).unwrap(); + c.apply_keystream(&mut buf); + Ok(buf) + } + } + "aes-256-ctr" => { + if key.len() != 32 || iv.len() != 16 { + Err("aes-256-ctr requires 32-byte key and 16-byte iv") + } else { + let mut buf = data.clone(); + let mut c = ctr::Ctr128BE::::new_from_slices(&key, &iv).unwrap(); + c.apply_keystream(&mut buf); + Ok(buf) + } + } + other => Err(Box::leak( + format!("unsupported cipher {other}").into_boxed_str(), + )), + }; + match result { + Ok(out) => { + let arr = bytes_to_uint8array(scope, &out); + rv.set(arr.into()); + } + Err(msg) => throw_error(scope, msg), + } +} + +fn op_crypto_aes_decrypt<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + use aes::cipher::{BlockDecryptMut, KeyIvInit, StreamCipher}; + let algo = string_arg(scope, &args, 0); + let Some(key) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "aes decrypt: key must be Uint8Array"); + return; + }; + let Some(iv) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "aes decrypt: iv must be Uint8Array"); + return; + }; + let Some(data) = bytes_arg(scope, &args, 3) else { + throw_error(scope, "aes decrypt: data must be Uint8Array"); + return; + }; + let result: Result, String> = match algo.as_str() { + "aes-128-cbc" => cbc::Decryptor::::new_from_slices(&key, &iv) + .map_err(|e| e.to_string()) + .and_then(|dec| { + dec.decrypt_padded_vec_mut::(&data) + .map_err(|e| e.to_string()) + }), + "aes-192-cbc" => cbc::Decryptor::::new_from_slices(&key, &iv) + .map_err(|e| e.to_string()) + .and_then(|dec| { + dec.decrypt_padded_vec_mut::(&data) + .map_err(|e| e.to_string()) + }), + "aes-256-cbc" => cbc::Decryptor::::new_from_slices(&key, &iv) + .map_err(|e| e.to_string()) + .and_then(|dec| { + dec.decrypt_padded_vec_mut::(&data) + .map_err(|e| e.to_string()) + }), + "aes-128-ctr" => ctr::Ctr128BE::::new_from_slices(&key, &iv) + .map_err(|e| e.to_string()) + .map(|mut c| { + let mut buf = data.clone(); + c.apply_keystream(&mut buf); + buf + }), + "aes-256-ctr" => ctr::Ctr128BE::::new_from_slices(&key, &iv) + .map_err(|e| e.to_string()) + .map(|mut c| { + let mut buf = data.clone(); + c.apply_keystream(&mut buf); + buf + }), + other => Err(format!("unsupported cipher {other}")), + }; + match result { + Ok(out) => { + let arr = bytes_to_uint8array(scope, &out); + rv.set(arr.into()); + } + Err(err) => throw_error(scope, &err), + } +} + +/// AEAD seal with `chacha20-poly1305`. Output is `ciphertext || tag(16)`. +fn op_crypto_chacha20_seal<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + use chacha20poly1305::{ChaCha20Poly1305, KeyInit, aead::Aead, aead::Payload}; + let Some(key) = bytes_arg(scope, &args, 0) else { + throw_error(scope, "chacha20 seal: key must be Uint8Array"); + return; + }; + let Some(nonce) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "chacha20 seal: nonce must be Uint8Array"); + return; + }; + let Some(plaintext) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "chacha20 seal: plaintext must be Uint8Array"); + return; + }; + let aad = bytes_arg(scope, &args, 3).unwrap_or_default(); + if key.len() != 32 || nonce.len() != 12 { + throw_error( + scope, + "chacha20-poly1305 requires 32-byte key and 12-byte nonce", + ); + return; + } + let cipher = ChaCha20Poly1305::new_from_slice(&key).unwrap(); + let nonce_arr = chacha20poly1305::Nonce::from_slice(&nonce); + match cipher.encrypt( + nonce_arr, + Payload { + msg: &plaintext, + aad: &aad, + }, + ) { + Ok(out) => { + let arr = bytes_to_uint8array(scope, &out); + rv.set(arr.into()); + } + Err(err) => throw_error(scope, &format!("chacha20 seal: {err}")), + } +} + +fn op_crypto_chacha20_open<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + use chacha20poly1305::{ChaCha20Poly1305, KeyInit, aead::Aead, aead::Payload}; + let Some(key) = bytes_arg(scope, &args, 0) else { + throw_error(scope, "chacha20 open: key must be Uint8Array"); + return; + }; + let Some(nonce) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "chacha20 open: nonce must be Uint8Array"); + return; + }; + let Some(ciphertext) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "chacha20 open: ciphertext must be Uint8Array"); + return; + }; + let aad = bytes_arg(scope, &args, 3).unwrap_or_default(); + if key.len() != 32 || nonce.len() != 12 { + throw_error( + scope, + "chacha20-poly1305 requires 32-byte key and 12-byte nonce", + ); + return; + } + let cipher = ChaCha20Poly1305::new_from_slice(&key).unwrap(); + let nonce_arr = chacha20poly1305::Nonce::from_slice(&nonce); + match cipher.decrypt( + nonce_arr, + Payload { + msg: &ciphertext, + aad: &aad, + }, + ) { + Ok(out) => { + let arr = bytes_to_uint8array(scope, &out); + rv.set(arr.into()); + } + Err(err) => throw_error(scope, &format!("chacha20 open: {err}")), + } +} + +/// Signs a message with a PEM-encoded private key. +/// +/// Supported algorithms (Node-style identifiers): +/// * `rsa-sha256`, `rsa-sha384`, `rsa-sha512` - RSASSA-PKCS1-v1_5 +/// * `ecdsa-p256-sha256` - DER-encoded ECDSA on the P-256 curve +/// * `ed25519` - pure EdDSA, no prehash +fn op_crypto_sign<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let algo = string_arg(scope, &args, 0); + let key_pem = string_arg(scope, &args, 1); + let Some(data) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "sign: data must be Uint8Array"); + return; + }; + let result: Result, String> = match algo.as_str() { + "rsa-sha256" => rsa_sign::(&key_pem, &data), + "rsa-sha384" => rsa_sign::(&key_pem, &data), + "rsa-sha512" => rsa_sign::(&key_pem, &data), + "ecdsa-p256-sha256" => ecdsa_p256_sign(&key_pem, &data), + "ed25519" => ed25519_sign(&key_pem, &data), + other => Err(format!("unsupported sign algorithm: {other}")), + }; + match result { + Ok(sig) => { + let arr = bytes_to_uint8array(scope, &sig); + rv.set(arr.into()); + } + Err(err) => throw_error(scope, &err), + } +} + +fn op_crypto_verify<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let algo = string_arg(scope, &args, 0); + let key_pem = string_arg(scope, &args, 1); + let Some(data) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "verify: data must be Uint8Array"); + return; + }; + let Some(signature) = bytes_arg(scope, &args, 3) else { + throw_error(scope, "verify: signature must be Uint8Array"); + return; + }; + let result: Result = match algo.as_str() { + "rsa-sha256" => rsa_verify::(&key_pem, &data, &signature), + "rsa-sha384" => rsa_verify::(&key_pem, &data, &signature), + "rsa-sha512" => rsa_verify::(&key_pem, &data, &signature), + "ecdsa-p256-sha256" => ecdsa_p256_verify(&key_pem, &data, &signature), + "ed25519" => ed25519_verify(&key_pem, &data, &signature), + other => Err(format!("unsupported verify algorithm: {other}")), + }; + match result { + Ok(ok) => rv.set(v8::Boolean::new(scope, ok).into()), + Err(err) => throw_error(scope, &err), + } +} + +fn rsa_sign(key_pem: &str, data: &[u8]) -> Result, String> +where + D: digest::Digest + digest::const_oid::AssociatedOid, +{ + use rsa::pkcs1v15::SigningKey; + use rsa::pkcs8::DecodePrivateKey; + use rsa::signature::{SignatureEncoding, Signer}; + let key = rsa::RsaPrivateKey::from_pkcs8_pem(key_pem) + .or_else(|_| { + use rsa::pkcs1::DecodeRsaPrivateKey; + rsa::RsaPrivateKey::from_pkcs1_pem(key_pem) + }) + .map_err(|e| format!("rsa private key parse: {e}"))?; + let signing_key = SigningKey::::new(key); + let sig = signing_key.sign(data); + Ok(sig.to_bytes().into_vec()) +} + +fn rsa_verify(key_pem: &str, data: &[u8], sig: &[u8]) -> Result +where + D: digest::Digest + digest::const_oid::AssociatedOid, +{ + use rsa::pkcs1v15::{Signature, VerifyingKey}; + use rsa::pkcs8::DecodePublicKey; + use rsa::signature::Verifier; + let pub_key = rsa::RsaPublicKey::from_public_key_pem(key_pem) + .or_else(|_| { + use rsa::pkcs1::DecodeRsaPublicKey; + rsa::RsaPublicKey::from_pkcs1_pem(key_pem) + }) + .map_err(|e| format!("rsa public key parse: {e}"))?; + let verifying = VerifyingKey::::new(pub_key); + let signature = Signature::try_from(sig).map_err(|e| format!("rsa signature decode: {e}"))?; + Ok(verifying.verify(data, &signature).is_ok()) +} + +fn ecdsa_p256_sign(key_pem: &str, data: &[u8]) -> Result, String> { + use p256::ecdsa::signature::Signer; + use p256::ecdsa::{Signature, SigningKey}; + use p256::pkcs8::DecodePrivateKey; + let signing = + SigningKey::from_pkcs8_pem(key_pem).map_err(|e| format!("p256 private key parse: {e}"))?; + let sig: Signature = signing.sign(data); + Ok(sig.to_der().as_bytes().to_vec()) +} + +fn ecdsa_p256_verify(key_pem: &str, data: &[u8], sig: &[u8]) -> Result { + use p256::ecdsa::signature::Verifier; + use p256::ecdsa::{Signature, VerifyingKey}; + use p256::pkcs8::DecodePublicKey; + let verifying = VerifyingKey::from_public_key_pem(key_pem) + .map_err(|e| format!("p256 public key parse: {e}"))?; + let signature = Signature::from_der(sig) + .or_else(|_| Signature::try_from(sig)) + .map_err(|e| format!("p256 signature decode: {e}"))?; + Ok(verifying.verify(data, &signature).is_ok()) +} + +fn ed25519_sign(key_pem: &str, data: &[u8]) -> Result, String> { + use ed25519_dalek::Signer; + use ed25519_dalek::SigningKey; + use ed25519_dalek::pkcs8::DecodePrivateKey; + let signing = SigningKey::from_pkcs8_pem(key_pem) + .map_err(|e| format!("ed25519 private key parse: {e}"))?; + let sig = signing.sign(data); + Ok(sig.to_bytes().to_vec()) +} + +fn ed25519_verify(key_pem: &str, data: &[u8], sig: &[u8]) -> Result { + use ed25519_dalek::pkcs8::DecodePublicKey; + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + let verifying = VerifyingKey::from_public_key_pem(key_pem) + .map_err(|e| format!("ed25519 public key parse: {e}"))?; + if sig.len() != 64 { + return Err(format!( + "ed25519 signature must be 64 bytes, got {}", + sig.len() + )); + } + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(sig); + let signature = Signature::from_bytes(&sig_arr); + Ok(verifying.verify(data, &signature).is_ok()) +} + +fn op_crypto_pem_decode<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let id = args.get(0).uint32_value(scope).unwrap_or(0); - let handle = from_isolate(scope); - let removed = handle.0.borrow().child_processes.remove(id); - rv.set(v8::Boolean::new(scope, removed).into()); + let pem_str = string_arg(scope, &args, 0); + match pem::parse(&pem_str) { + Ok(parsed) => { + let obj = v8::Object::new(scope); + let label_key = v8::String::new(scope, "label").unwrap(); + let label_val = v8::String::new(scope, parsed.tag()).unwrap(); + obj.set(scope, label_key.into(), label_val.into()); + let der_key = v8::String::new(scope, "der").unwrap(); + let der_arr = bytes_to_uint8array(scope, parsed.contents()); + obj.set(scope, der_key.into(), der_arr.into()); + rv.set(obj.into()); + } + Err(e) => throw_error(scope, &format!("pem_decode: {e}")), + } } -fn decode_spawn_descriptor<'s>( +fn op_crypto_pem_encode<'s>( scope: &mut v8::PinScope<'s, '_>, - value: v8::Local<'s, v8::Value>, -) -> Result { - let obj = v8::Local::::try_from(value) - .map_err(|_| NetError::new("ERR_INVALID_ARG_TYPE", "spawn descriptor must be an object"))?; - let command = read_string_field(scope, obj, "command") - .ok_or_else(|| NetError::new("ERR_INVALID_ARG_VALUE", "command is required"))?; - let args = read_string_array(scope, obj, "args"); - let cwd = read_string_field(scope, obj, "cwd"); - let env = read_string_string_record(scope, obj, "env"); - let clear_env = read_bool_field(scope, obj, "clearEnv"); - let stdio = read_stdio_modes(scope, obj, "stdio"); - Ok(SpawnRequest { - command, - args, - cwd, - env, - clear_env, - stdio, - }) + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let label = string_arg(scope, &args, 0); + let Some(der) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "pem_encode: der must be Uint8Array"); + return; + }; + let pem_obj = pem::Pem::new(&label, der); + let config = pem::EncodeConfig::new().set_line_ending(pem::LineEnding::LF); + let encoded = pem::encode_config(&pem_obj, config); + let s = v8::String::new(scope, &encoded).unwrap(); + rv.set(s.into()); } -fn read_string_array<'s>( +fn op_crypto_generate_key_pair<'s>( scope: &mut v8::PinScope<'s, '_>, - obj: v8::Local<'s, v8::Object>, - name: &str, -) -> Vec { - let Some(key) = v8::String::new(scope, name) else { - return Vec::new(); - }; - let Some(value) = obj.get(scope, key.into()) else { - return Vec::new(); - }; - let Ok(arr) = v8::Local::::try_from(value) else { - return Vec::new(); - }; - let len = arr.length(); - let mut out = Vec::with_capacity(len as usize); - for i in 0..len { - if let Some(entry) = arr.get_index(scope, i) { - out.push(entry.to_rust_string_lossy(scope)); + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let key_type = string_arg(scope, &args, 0); + let options_json = string_arg(scope, &args, 1); + let options: serde_json::Value = serde_json::from_str(&options_json).unwrap_or_default(); + let result = generate_key_pair_impl(&key_type, &options); + match result { + Ok((pub_der, priv_der, info_json)) => { + let obj = v8::Object::new(scope); + let pub_key = v8::String::new(scope, "publicKey").unwrap(); + let pub_arr = bytes_to_uint8array(scope, &pub_der); + obj.set(scope, pub_key.into(), pub_arr.into()); + let priv_key = v8::String::new(scope, "privateKey").unwrap(); + let priv_arr = bytes_to_uint8array(scope, &priv_der); + obj.set(scope, priv_key.into(), priv_arr.into()); + let info_key = v8::String::new(scope, "info_json").unwrap(); + let info_val = v8::String::new(scope, &info_json).unwrap(); + obj.set(scope, info_key.into(), info_val.into()); + rv.set(obj.into()); } + Err(e) => throw_error(scope, &e), } - out } -fn read_string_string_record<'s>( - scope: &mut v8::PinScope<'s, '_>, - obj: v8::Local<'s, v8::Object>, - name: &str, -) -> HashMap { - let mut out = HashMap::new(); - let Some(key) = v8::String::new(scope, name) else { - return out; - }; - let Some(value) = obj.get(scope, key.into()) else { - return out; - }; - if value.is_null_or_undefined() { - return out; +fn generate_key_pair_impl( + key_type: &str, + options: &serde_json::Value, +) -> Result<(Vec, Vec, String), String> { + use pkcs8::{EncodePrivateKey, EncodePublicKey}; + use rand_core::{OsRng, RngCore}; + match key_type { + "rsa" | "rsa-pss" => { + let modulus_length = options["modulusLength"].as_u64().unwrap_or(2048) as usize; + let public_exponent = options["publicExponent"].as_u64().unwrap_or(65537); + let mut rng = OsRng; + let bits = modulus_length; + let priv_key = rsa::RsaPrivateKey::new_with_exp( + &mut rng, + bits, + &rsa::BigUint::from(public_exponent), + ) + .map_err(|e| format!("rsa keygen: {e}"))?; + let pub_key = priv_key.to_public_key(); + let priv_der = priv_key + .to_pkcs8_der() + .map_err(|e| format!("rsa priv pkcs8: {e}"))? + .as_bytes() + .to_vec(); + let pub_der = pub_key + .to_public_key_der() + .map_err(|e| format!("rsa pub spki: {e}"))? + .as_bytes() + .to_vec(); + let info = serde_json::json!({ + "asymmetricKeyType": key_type, + "modulusLength": modulus_length, + "publicExponent": public_exponent, + }); + Ok((pub_der, priv_der, info.to_string())) + } + "ec" => { + let curve_name = options["namedCurve"].as_str().unwrap_or("prime256v1"); + let (pub_der, priv_der) = match curve_name { + "prime256v1" | "P-256" => { + let signing = p256::ecdsa::SigningKey::random(&mut OsRng); + let priv_der = signing + .to_pkcs8_der() + .map_err(|e| format!("p256 priv: {e}"))? + .as_bytes() + .to_vec(); + let pub_der = signing + .verifying_key() + .to_public_key_der() + .map_err(|e| format!("p256 pub: {e}"))? + .as_bytes() + .to_vec(); + (pub_der, priv_der) + } + "secp384r1" | "P-384" => { + let signing = p384::ecdsa::SigningKey::random(&mut OsRng); + let priv_der = signing + .to_pkcs8_der() + .map_err(|e| format!("p384 priv: {e}"))? + .as_bytes() + .to_vec(); + let pub_der = signing + .verifying_key() + .to_public_key_der() + .map_err(|e| format!("p384 pub: {e}"))? + .as_bytes() + .to_vec(); + (pub_der, priv_der) + } + "secp521r1" | "P-521" => { + let secret = p521::SecretKey::random(&mut OsRng); + let public = secret.public_key(); + let priv_der = secret + .to_pkcs8_der() + .map_err(|e| format!("p521 priv: {e}"))? + .as_bytes() + .to_vec(); + let pub_der = public + .to_public_key_der() + .map_err(|e| format!("p521 pub: {e}"))? + .as_bytes() + .to_vec(); + (pub_der, priv_der) + } + other => return Err(format!("unsupported curve: {other}")), + }; + let info = serde_json::json!({ + "asymmetricKeyType": "ec", + "namedCurve": curve_name, + }); + Ok((pub_der, priv_der, info.to_string())) + } + "ed25519" => { + use ed25519_dalek::SigningKey; + let mut seed = [0u8; 32]; + OsRng.fill_bytes(&mut seed); + let signing = SigningKey::from_bytes(&seed); + let priv_der = signing + .to_pkcs8_der() + .map_err(|e| format!("ed25519 priv: {e}"))? + .as_bytes() + .to_vec(); + let pub_der = signing + .verifying_key() + .to_public_key_der() + .map_err(|e| format!("ed25519 pub: {e}"))? + .as_bytes() + .to_vec(); + let info = serde_json::json!({"asymmetricKeyType": "ed25519"}); + Ok((pub_der, priv_der, info.to_string())) + } + "x25519" => { + use x25519_dalek::StaticSecret; + let secret = StaticSecret::random_from_rng(OsRng); + let public = x25519_dalek::PublicKey::from(&secret); + let priv_der = + x25519_secret_to_pkcs8(&secret).map_err(|e| format!("x25519 priv: {e}"))?; + let pub_der = x25519_public_to_spki(&public).map_err(|e| format!("x25519 pub: {e}"))?; + let info = serde_json::json!({"asymmetricKeyType": "x25519"}); + Ok((pub_der, priv_der, info.to_string())) + } + other => Err(format!( + "unsupported key type: {other}. Supported: rsa, rsa-pss, ec, ed25519, x25519" + )), } - let Ok(record) = v8::Local::::try_from(value) else { - return out; - }; - let Some(names) = - record.get_own_property_names(scope, v8::GetPropertyNamesArgsBuilder::new().build()) - else { - return out; +} + +fn x25519_secret_to_pkcs8(secret: &x25519_dalek::StaticSecret) -> Result, String> { + use pkcs8::{PrivateKeyInfo, der::Encode}; + let secret_bytes = secret.to_bytes(); + let oid = pkcs8::ObjectIdentifier::new_unwrap("1.3.101.110"); + let alg = pkcs8::AlgorithmIdentifierRef { + oid, + parameters: None, }; - for i in 0..names.length() { - let Some(k) = names.get_index(scope, i) else { - continue; - }; - let Some(v) = record.get(scope, k) else { - continue; - }; - out.insert(k.to_rust_string_lossy(scope), v.to_rust_string_lossy(scope)); - } - out + let pki = PrivateKeyInfo::new(alg, &secret_bytes); + pki.to_der() + .map_err(|e| format!("x25519 pkcs8 encode: {e}")) } -fn read_bool_field<'s>( - scope: &mut v8::PinScope<'s, '_>, - obj: v8::Local<'s, v8::Object>, - name: &str, -) -> bool { - let Some(key) = v8::String::new(scope, name) else { - return false; +fn x25519_public_to_spki(public: &x25519_dalek::PublicKey) -> Result, String> { + use spki::{SubjectPublicKeyInfoOwned, der::Encode}; + let oid = spki::ObjectIdentifier::new_unwrap("1.3.101.110"); + let alg = spki::AlgorithmIdentifierOwned { + oid, + parameters: None, }; - let Some(value) = obj.get(scope, key.into()) else { - return false; + let spki = SubjectPublicKeyInfoOwned { + algorithm: alg, + subject_public_key: spki::der::asn1::BitString::from_bytes(public.as_bytes()) + .map_err(|e| format!("x25519 bitstring: {e}"))?, }; - value.boolean_value(scope) + spki.to_der() + .map_err(|e| format!("x25519 spki encode: {e}")) } -fn read_stdio_modes<'s>( +fn biguint_to_u64(n: &rsa::BigUint) -> u64 { + if n.bits() <= 64 { + let bytes = n.to_bytes_be(); + let mut arr = [0u8; 8]; + let start = 8usize.saturating_sub(bytes.len()); + arr[start..].copy_from_slice(&bytes[..bytes.len().min(8)]); + u64::from_be_bytes(arr) + } else { + 65537 + } +} + +fn op_crypto_key_inspect<'s>( scope: &mut v8::PinScope<'s, '_>, - obj: v8::Local<'s, v8::Object>, - name: &str, -) -> [StdioMode; 3] { - let mut modes = [StdioMode::Pipe, StdioMode::Pipe, StdioMode::Pipe]; - let Some(key) = v8::String::new(scope, name) else { - return modes; - }; - let Some(value) = obj.get(scope, key.into()) else { - return modes; + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(der) = bytes_arg(scope, &args, 0) else { + throw_error(scope, "key_inspect: der must be Uint8Array"); + return; }; - if let Ok(arr) = v8::Local::::try_from(value) { - let len = arr.length().min(3); - for i in 0..len { - if let Some(entry) = arr.get_index(scope, i) { - modes[i as usize] = parse_stdio_mode(&entry.to_rust_string_lossy(scope)); + let kind = string_arg(scope, &args, 1); + let result = key_inspect_impl(&der, &kind); + match result { + Ok(json_str) => { + let s = v8::String::new(scope, &json_str).unwrap(); + rv.set(s.into()); + } + Err(e) => throw_error(scope, &e), + } +} + +fn key_inspect_impl(der: &[u8], kind: &str) -> Result { + use pkcs8::{DecodePrivateKey, DecodePublicKey}; + use rsa::traits::PublicKeyParts; + match kind { + "private-pkcs8" => { + if let Ok(rsa_key) = rsa::RsaPrivateKey::from_pkcs8_der(der) { + let mod_bits = rsa_key.n().bits(); + let exp_u64 = if rsa_key.e().bits() <= 64 { + let bytes = rsa_key.e().to_bytes_be(); + let mut arr = [0u8; 8]; + let start = 8usize.saturating_sub(bytes.len()); + arr[start..].copy_from_slice(&bytes[..bytes.len().min(8)]); + u64::from_be_bytes(arr) + } else { + 65537 + }; + let info = serde_json::json!({ + "asymmetricKeyType": "rsa", + "modulusLength": mod_bits, + "publicExponent": exp_u64, + }); + return Ok(info.to_string()); + } + if p256::SecretKey::from_pkcs8_der(der).is_ok() { + let info = serde_json::json!({ + "asymmetricKeyType": "ec", + "namedCurve": "prime256v1", + }); + return Ok(info.to_string()); + } + if p384::SecretKey::from_pkcs8_der(der).is_ok() { + let info = serde_json::json!({ + "asymmetricKeyType": "ec", + "namedCurve": "secp384r1", + }); + return Ok(info.to_string()); + } + if p521::SecretKey::from_pkcs8_der(der).is_ok() { + let info = serde_json::json!({ + "asymmetricKeyType": "ec", + "namedCurve": "secp521r1", + }); + return Ok(info.to_string()); + } + if ed25519_dalek::SigningKey::from_pkcs8_der(der).is_ok() { + let info = serde_json::json!({"asymmetricKeyType": "ed25519"}); + return Ok(info.to_string()); + } + if x25519_pkcs8_to_secret(der).is_ok() { + let info = serde_json::json!({"asymmetricKeyType": "x25519"}); + return Ok(info.to_string()); + } + Err("private-pkcs8: unsupported key type".to_string()) + } + "public-spki" => { + if let Ok(rsa_key) = rsa::RsaPublicKey::from_public_key_der(der) { + let info = serde_json::json!({ + "asymmetricKeyType": "rsa", + "modulusLength": rsa_key.n().bits(), + "publicExponent": biguint_to_u64(rsa_key.e()), + }); + return Ok(info.to_string()); + } + if p256::PublicKey::from_public_key_der(der).is_ok() { + let info = serde_json::json!({ + "asymmetricKeyType": "ec", + "namedCurve": "prime256v1", + }); + return Ok(info.to_string()); + } + if p384::PublicKey::from_public_key_der(der).is_ok() { + let info = serde_json::json!({ + "asymmetricKeyType": "ec", + "namedCurve": "secp384r1", + }); + return Ok(info.to_string()); + } + if p521::PublicKey::from_public_key_der(der).is_ok() { + let info = serde_json::json!({ + "asymmetricKeyType": "ec", + "namedCurve": "secp521r1", + }); + return Ok(info.to_string()); + } + if ed25519_dalek::VerifyingKey::from_public_key_der(der).is_ok() { + let info = serde_json::json!({"asymmetricKeyType": "ed25519"}); + return Ok(info.to_string()); + } + if x25519_spki_to_public(der).is_ok() { + let info = serde_json::json!({"asymmetricKeyType": "x25519"}); + return Ok(info.to_string()); + } + Err("public-spki: unsupported key type".to_string()) + } + "rsa-pkcs1-priv" => { + use pkcs1::DecodeRsaPrivateKey; + let rsa_key = rsa::RsaPrivateKey::from_pkcs1_der(der) + .map_err(|e| format!("rsa-pkcs1-priv parse: {e}"))?; + let info = serde_json::json!({ + "asymmetricKeyType": "rsa", + "modulusLength": rsa_key.n().bits(), + "publicExponent": biguint_to_u64(rsa_key.e()), + }); + Ok(info.to_string()) + } + "rsa-pkcs1-pub" => { + use pkcs1::DecodeRsaPublicKey; + let rsa_key = rsa::RsaPublicKey::from_pkcs1_der(der) + .map_err(|e| format!("rsa-pkcs1-pub parse: {e}"))?; + let info = serde_json::json!({ + "asymmetricKeyType": "rsa", + "modulusLength": rsa_key.n().bits(), + "publicExponent": biguint_to_u64(rsa_key.e()), + }); + Ok(info.to_string()) + } + "ec-sec1" => { + if p256::SecretKey::from_sec1_der(der).is_ok() { + let info = serde_json::json!({ + "asymmetricKeyType": "ec", + "namedCurve": "prime256v1", + }); + return Ok(info.to_string()); + } + if p384::SecretKey::from_sec1_der(der).is_ok() { + let info = serde_json::json!({ + "asymmetricKeyType": "ec", + "namedCurve": "secp384r1", + }); + return Ok(info.to_string()); + } + if p521::SecretKey::from_sec1_der(der).is_ok() { + let info = serde_json::json!({ + "asymmetricKeyType": "ec", + "namedCurve": "secp521r1", + }); + return Ok(info.to_string()); } + Err("ec-sec1: unsupported curve".to_string()) } + other => Err(format!("unsupported kind: {other}")), } - modes } -fn parse_stdio_mode(s: &str) -> StdioMode { - match s { - "inherit" => StdioMode::Inherit, - "ignore" => StdioMode::Ignore, - _ => StdioMode::Pipe, +fn x25519_pkcs8_to_secret(der: &[u8]) -> Result { + use pkcs8::{PrivateKeyInfo, der::Decode}; + let pki = PrivateKeyInfo::from_der(der).map_err(|e| format!("pkcs8 decode: {e}"))?; + if pki.private_key.len() != 32 { + return Err(format!( + "x25519 secret must be 32 bytes, got {}", + pki.private_key.len() + )); } + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(pki.private_key); + Ok(x25519_dalek::StaticSecret::from(bytes)) } -fn exit_info_to_object<'s>( +fn x25519_spki_to_public(der: &[u8]) -> Result { + use spki::{SubjectPublicKeyInfoRef, der::Decode}; + let spki = SubjectPublicKeyInfoRef::from_der(der).map_err(|e| format!("spki decode: {e}"))?; + let key_bytes = spki.subject_public_key.raw_bytes(); + if key_bytes.len() != 32 { + return Err(format!( + "x25519 public must be 32 bytes, got {}", + key_bytes.len() + )); + } + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(key_bytes); + Ok(x25519_dalek::PublicKey::from(bytes)) +} + +fn op_crypto_key_convert<'s>( scope: &mut v8::PinScope<'s, '_>, - info: ExitInfo, -) -> v8::Local<'s, v8::Object> { - let obj = v8::Object::new(scope); - let code_key = v8::String::new(scope, "code").unwrap(); - let code_val: v8::Local = match info.code { - Some(c) => v8::Number::new(scope, f64::from(c)).into(), - None => v8::null(scope).into(), + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let input_kind = string_arg(scope, &args, 0); + let output_kind = string_arg(scope, &args, 1); + let Some(der) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "key_convert: der must be Uint8Array"); + return; }; - obj.set(scope, code_key.into(), code_val); - let signal_key = v8::String::new(scope, "signal").unwrap(); - let signal_val: v8::Local = match info.signal { - Some(s) => v8::Number::new(scope, f64::from(s)).into(), - None => v8::null(scope).into(), + let curve_hint = if args.length() >= 4 { + Some(string_arg(scope, &args, 3)) + } else { + None }; - obj.set(scope, signal_key.into(), signal_val); - obj + let result = key_convert_impl(&input_kind, &output_kind, &der, curve_hint.as_deref()); + match result { + Ok(out_der) => { + let arr = bytes_to_uint8array(scope, &out_der); + rv.set(arr.into()); + } + Err(e) => throw_error(scope, &e), + } } -fn make_node_error<'s>( +fn key_convert_impl( + input_kind: &str, + output_kind: &str, + der: &[u8], + curve_hint: Option<&str>, +) -> Result, String> { + use pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPrivateKey, EncodeRsaPublicKey}; + use pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey}; + match (input_kind, output_kind) { + ("pkcs1-priv", "private-pkcs8") => { + let rsa_key = rsa::RsaPrivateKey::from_pkcs1_der(der) + .map_err(|e| format!("pkcs1-priv parse: {e}"))?; + rsa_key + .to_pkcs8_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("pkcs8 encode: {e}")) + } + ("private-pkcs8", "pkcs1-priv") => { + let rsa_key = + rsa::RsaPrivateKey::from_pkcs8_der(der).map_err(|e| format!("pkcs8 parse: {e}"))?; + rsa_key + .to_pkcs1_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("pkcs1 encode: {e}")) + } + ("pkcs1-pub", "public-spki") => { + let rsa_key = rsa::RsaPublicKey::from_pkcs1_der(der) + .map_err(|e| format!("pkcs1-pub parse: {e}"))?; + rsa_key + .to_public_key_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("spki encode: {e}")) + } + ("public-spki", "pkcs1-pub") => { + let rsa_key = rsa::RsaPublicKey::from_public_key_der(der) + .map_err(|e| format!("spki parse: {e}"))?; + rsa_key + .to_pkcs1_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("pkcs1 encode: {e}")) + } + ("ec-sec1", "private-pkcs8") => { + let curve = curve_hint.unwrap_or("prime256v1"); + match curve { + "prime256v1" | "P-256" => { + let sk = p256::SecretKey::from_sec1_der(der) + .map_err(|e| format!("p256 sec1: {e}"))?; + sk.to_pkcs8_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("p256 pkcs8: {e}")) + } + "secp384r1" | "P-384" => { + let sk = p384::SecretKey::from_sec1_der(der) + .map_err(|e| format!("p384 sec1: {e}"))?; + sk.to_pkcs8_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("p384 pkcs8: {e}")) + } + "secp521r1" | "P-521" => { + let sk = p521::SecretKey::from_sec1_der(der) + .map_err(|e| format!("p521 sec1: {e}"))?; + sk.to_pkcs8_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("p521 pkcs8: {e}")) + } + other => Err(format!("unsupported curve for sec1: {other}")), + } + } + ("private-pkcs8", "ec-sec1") => { + if let Ok(sk) = p256::SecretKey::from_pkcs8_der(der) { + return sk + .to_sec1_der() + .map(|d| (*d).clone()) + .map_err(|e| format!("p256 sec1: {e}")); + } + if let Ok(sk) = p384::SecretKey::from_pkcs8_der(der) { + return sk + .to_sec1_der() + .map(|d| (*d).clone()) + .map_err(|e| format!("p384 sec1: {e}")); + } + if let Ok(sk) = p521::SecretKey::from_pkcs8_der(der) { + return sk + .to_sec1_der() + .map(|d| (*d).clone()) + .map_err(|e| format!("p521 sec1: {e}")); + } + Err("private-pkcs8 -> ec-sec1: not an EC key".to_string()) + } + ("private-pkcs8", "public-spki") => { + if let Ok(rsa_key) = rsa::RsaPrivateKey::from_pkcs8_der(der) { + return rsa_key + .to_public_key() + .to_public_key_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("rsa pub: {e}")); + } + if let Ok(sk) = p256::SecretKey::from_pkcs8_der(der) { + return sk + .public_key() + .to_public_key_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("p256 pub: {e}")); + } + if let Ok(sk) = p384::SecretKey::from_pkcs8_der(der) { + return sk + .public_key() + .to_public_key_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("p384 pub: {e}")); + } + if let Ok(sk) = p521::SecretKey::from_pkcs8_der(der) { + return sk + .public_key() + .to_public_key_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("p521 pub: {e}")); + } + if let Ok(signing) = ed25519_dalek::SigningKey::from_pkcs8_der(der) { + return signing + .verifying_key() + .to_public_key_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("ed25519 pub: {e}")); + } + if let Ok(secret) = x25519_pkcs8_to_secret(der) { + let public = x25519_dalek::PublicKey::from(&secret); + return x25519_public_to_spki(&public); + } + Err("private-pkcs8 -> public-spki: unsupported key type".to_string()) + } + _ => Err(format!( + "unsupported conversion: {input_kind} -> {output_kind}" + )), + } +} + +fn op_crypto_jwk_to_der<'s>( scope: &mut v8::PinScope<'s, '_>, - err: &NetError, -) -> v8::Local<'s, v8::Value> { - let msg = v8::String::new(scope, &err.message).unwrap_or_else(|| v8::String::empty(scope)); - let exc = v8::Exception::error(scope, msg); - if let Ok(obj) = TryInto::>::try_into(exc) { - set_string_field(scope, obj, "code", err.code); + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let jwk_json = string_arg(scope, &args, 0); + let want_kind = string_arg(scope, &args, 1); + let jwk: serde_json::Value = match serde_json::from_str(&jwk_json) { + Ok(j) => j, + Err(e) => { + throw_error(scope, &format!("jwk parse: {e}")); + return; + } + }; + let result = jwk_to_der_impl(&jwk, &want_kind); + match result { + Ok(der) => { + let arr = bytes_to_uint8array(scope, &der); + rv.set(arr.into()); + } + Err(e) => throw_error(scope, &e), + } +} + +fn jwk_to_der_impl(jwk: &serde_json::Value, want_kind: &str) -> Result, String> { + use pkcs8::{EncodePrivateKey, EncodePublicKey}; + let kty = jwk["kty"] + .as_str() + .ok_or_else(|| "jwk missing kty".to_string())?; + match kty { + "RSA" => { + let n_b64 = jwk["n"] + .as_str() + .ok_or_else(|| "RSA jwk missing n".to_string())?; + let e_b64 = jwk["e"] + .as_str() + .ok_or_else(|| "RSA jwk missing e".to_string())?; + let n_bytes = base64_url_decode(n_b64)?; + let e_bytes = base64_url_decode(e_b64)?; + let n = rsa::BigUint::from_bytes_be(&n_bytes); + let e = rsa::BigUint::from_bytes_be(&e_bytes); + if want_kind == "private-pkcs8" { + let d_b64 = jwk["d"] + .as_str() + .ok_or_else(|| "RSA private jwk missing d".to_string())?; + let d_bytes = base64_url_decode(d_b64)?; + let d = rsa::BigUint::from_bytes_be(&d_bytes); + let p_b64 = jwk["p"].as_str(); + let q_b64 = jwk["q"].as_str(); + let primes = if let (Some(p_str), Some(q_str)) = (p_b64, q_b64) { + let p_bytes = base64_url_decode(p_str)?; + let q_bytes = base64_url_decode(q_str)?; + let p = rsa::BigUint::from_bytes_be(&p_bytes); + let q = rsa::BigUint::from_bytes_be(&q_bytes); + vec![p, q] + } else { + Vec::new() + }; + let priv_key = if primes.is_empty() { + rsa::RsaPrivateKey::from_components(n, e, d, primes) + .map_err(|e| format!("RSA from components: {e}"))? + } else { + rsa::RsaPrivateKey::from_components(n, e, d, primes) + .map_err(|e| format!("RSA from components: {e}"))? + }; + priv_key + .to_pkcs8_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("RSA pkcs8: {e}")) + } else { + let pub_key = rsa::RsaPublicKey::new(n, e).map_err(|e| format!("RSA pub: {e}"))?; + pub_key + .to_public_key_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("RSA spki: {e}")) + } + } + "EC" => { + let crv = jwk["crv"] + .as_str() + .ok_or_else(|| "EC jwk missing crv".to_string())?; + let x_b64 = jwk["x"] + .as_str() + .ok_or_else(|| "EC jwk missing x".to_string())?; + let y_b64 = jwk["y"] + .as_str() + .ok_or_else(|| "EC jwk missing y".to_string())?; + let x_bytes = base64_url_decode(x_b64)?; + let y_bytes = base64_url_decode(y_b64)?; + match crv { + "P-256" => { + if want_kind == "private-pkcs8" { + let d_b64 = jwk["d"] + .as_str() + .ok_or_else(|| "EC private jwk missing d".to_string())?; + let d_bytes = base64_url_decode(d_b64)?; + let sk = p256::SecretKey::from_slice(&d_bytes) + .map_err(|e| format!("P-256 secret: {e}"))?; + sk.to_pkcs8_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("P-256 pkcs8: {e}")) + } else { + let mut pt = vec![0x04u8]; + pt.extend_from_slice(&x_bytes); + pt.extend_from_slice(&y_bytes); + let pk = p256::PublicKey::from_sec1_bytes(&pt) + .map_err(|e| format!("P-256 public: {e}"))?; + pk.to_public_key_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("P-256 spki: {e}")) + } + } + "P-384" => { + if want_kind == "private-pkcs8" { + let d_b64 = jwk["d"] + .as_str() + .ok_or_else(|| "EC private jwk missing d".to_string())?; + let d_bytes = base64_url_decode(d_b64)?; + let sk = p384::SecretKey::from_slice(&d_bytes) + .map_err(|e| format!("P-384 secret: {e}"))?; + sk.to_pkcs8_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("P-384 pkcs8: {e}")) + } else { + let mut pt = vec![0x04u8]; + pt.extend_from_slice(&x_bytes); + pt.extend_from_slice(&y_bytes); + let pk = p384::PublicKey::from_sec1_bytes(&pt) + .map_err(|e| format!("P-384 public: {e}"))?; + pk.to_public_key_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("P-384 spki: {e}")) + } + } + "P-521" => { + if want_kind == "private-pkcs8" { + let d_b64 = jwk["d"] + .as_str() + .ok_or_else(|| "EC private jwk missing d".to_string())?; + let d_bytes = base64_url_decode(d_b64)?; + let sk = p521::SecretKey::from_slice(&d_bytes) + .map_err(|e| format!("P-521 secret: {e}"))?; + sk.to_pkcs8_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("P-521 pkcs8: {e}")) + } else { + let mut pt = vec![0x04u8]; + pt.extend_from_slice(&x_bytes); + pt.extend_from_slice(&y_bytes); + let pk = p521::PublicKey::from_sec1_bytes(&pt) + .map_err(|e| format!("P-521 public: {e}"))?; + pk.to_public_key_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("P-521 spki: {e}")) + } + } + other => Err(format!("unsupported EC curve: {other}")), + } + } + "OKP" => { + let crv = jwk["crv"] + .as_str() + .ok_or_else(|| "OKP jwk missing crv".to_string())?; + let x_b64 = jwk["x"] + .as_str() + .ok_or_else(|| "OKP jwk missing x".to_string())?; + let x_bytes = base64_url_decode(x_b64)?; + match crv { + "Ed25519" => { + if want_kind == "private-pkcs8" { + let d_b64 = jwk["d"] + .as_str() + .ok_or_else(|| "OKP private jwk missing d".to_string())?; + let d_bytes = base64_url_decode(d_b64)?; + if d_bytes.len() != 32 { + return Err(format!( + "Ed25519 d must be 32 bytes, got {}", + d_bytes.len() + )); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&d_bytes); + let signing = ed25519_dalek::SigningKey::from_bytes(&arr); + signing + .to_pkcs8_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("Ed25519 pkcs8: {e}")) + } else { + if x_bytes.len() != 32 { + return Err(format!( + "Ed25519 x must be 32 bytes, got {}", + x_bytes.len() + )); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&x_bytes); + let verifying = ed25519_dalek::VerifyingKey::from_bytes(&arr) + .map_err(|e| format!("Ed25519 verifying: {e}"))?; + verifying + .to_public_key_der() + .map(|d| d.as_bytes().to_vec()) + .map_err(|e| format!("Ed25519 spki: {e}")) + } + } + "X25519" => { + if want_kind == "private-pkcs8" { + let d_b64 = jwk["d"] + .as_str() + .ok_or_else(|| "OKP private jwk missing d".to_string())?; + let d_bytes = base64_url_decode(d_b64)?; + if d_bytes.len() != 32 { + return Err(format!( + "X25519 d must be 32 bytes, got {}", + d_bytes.len() + )); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&d_bytes); + let secret = x25519_dalek::StaticSecret::from(arr); + x25519_secret_to_pkcs8(&secret) + } else { + if x_bytes.len() != 32 { + return Err(format!( + "X25519 x must be 32 bytes, got {}", + x_bytes.len() + )); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&x_bytes); + let public = x25519_dalek::PublicKey::from(arr); + x25519_public_to_spki(&public) + } + } + other => Err(format!("unsupported OKP curve: {other}")), + } + } + other => Err(format!("unsupported JWK kty: {other}")), } - exc } -fn set_bool_field<'s>( +fn base64_url_decode(s: &str) -> Result, String> { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(s) + .map_err(|e| format!("base64url decode: {e}")) +} + +fn base64_url_encode(b: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b) +} + +fn op_crypto_der_to_jwk<'s>( scope: &mut v8::PinScope<'s, '_>, - obj: v8::Local<'s, v8::Object>, - name: &str, - value: bool, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let key = v8::String::new(scope, name).unwrap(); - let val = v8::Boolean::new(scope, value); - obj.set(scope, key.into(), val.into()); + let Some(der) = bytes_arg(scope, &args, 0) else { + throw_error(scope, "der_to_jwk: der must be Uint8Array"); + return; + }; + let kind = string_arg(scope, &args, 1); + let result = der_to_jwk_impl(&der, &kind); + match result { + Ok(json_str) => { + let s = v8::String::new(scope, &json_str).unwrap(); + rv.set(s.into()); + } + Err(e) => throw_error(scope, &e), + } } -// ────────────────────────────────────────────────────────────────────── -// node:zlib streaming ops -// ────────────────────────────────────────────────────────────────────── - -use crate::ops::{ZlibStream, parse_zlib_kind}; +fn der_to_jwk_impl(der: &[u8], kind: &str) -> Result { + use p256::elliptic_curve::sec1::ToEncodedPoint; + use pkcs8::{DecodePrivateKey, DecodePublicKey}; + use rsa::traits::{PrivateKeyParts, PublicKeyParts}; + match kind { + "private-pkcs8" => { + if let Ok(rsa_key) = rsa::RsaPrivateKey::from_pkcs8_der(der) { + let n = base64_url_encode(&rsa_key.n().to_bytes_be()); + let e = base64_url_encode(&rsa_key.e().to_bytes_be()); + let d = base64_url_encode(&rsa_key.d().to_bytes_be()); + let primes = rsa_key.primes(); + let (p, q, dp, dq, qi) = if primes.len() >= 2 { + let p = base64_url_encode(&primes[0].to_bytes_be()); + let q = base64_url_encode(&primes[1].to_bytes_be()); + let dp_val = rsa_key.dp().ok_or("missing dp")?.to_bytes_be(); + let dq_val = rsa_key.dq().ok_or("missing dq")?.to_bytes_be(); + let qi_val = rsa_key.qinv().ok_or("missing qinv")?.to_bytes_be().1; + let dp = base64_url_encode(&dp_val); + let dq = base64_url_encode(&dq_val); + let qi = base64_url_encode(&qi_val); + (Some(p), Some(q), Some(dp), Some(dq), Some(qi)) + } else { + (None, None, None, None, None) + }; + let mut jwk = serde_json::json!({ + "kty": "RSA", + "n": n, + "e": e, + "d": d, + }); + if let Some(p_val) = p { + jwk["p"] = serde_json::Value::String(p_val); + jwk["q"] = serde_json::Value::String(q.unwrap()); + jwk["dp"] = serde_json::Value::String(dp.unwrap()); + jwk["dq"] = serde_json::Value::String(dq.unwrap()); + jwk["qi"] = serde_json::Value::String(qi.unwrap()); + } + return Ok(jwk.to_string()); + } + if let Ok(sk) = p256::SecretKey::from_pkcs8_der(der) { + let d = base64_url_encode(&sk.to_bytes()); + let pk = sk.public_key(); + let pt = pk.to_encoded_point(false); + let x = base64_url_encode(pt.x().ok_or("missing x")?); + let y = base64_url_encode(pt.y().ok_or("missing y")?); + let jwk = serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y, + "d": d, + }); + return Ok(jwk.to_string()); + } + if let Ok(sk) = p384::SecretKey::from_pkcs8_der(der) { + let d = base64_url_encode(&sk.to_bytes()); + let pk = sk.public_key(); + let pt = pk.to_encoded_point(false); + let x = base64_url_encode(pt.x().ok_or("missing x")?); + let y = base64_url_encode(pt.y().ok_or("missing y")?); + let jwk = serde_json::json!({ + "kty": "EC", + "crv": "P-384", + "x": x, + "y": y, + "d": d, + }); + return Ok(jwk.to_string()); + } + if let Ok(sk) = p521::SecretKey::from_pkcs8_der(der) { + let d = base64_url_encode(&sk.to_bytes()); + let pk = sk.public_key(); + let pt = pk.to_encoded_point(false); + let x = base64_url_encode(pt.x().ok_or("missing x")?); + let y = base64_url_encode(pt.y().ok_or("missing y")?); + let jwk = serde_json::json!({ + "kty": "EC", + "crv": "P-521", + "x": x, + "y": y, + "d": d, + }); + return Ok(jwk.to_string()); + } + if let Ok(signing) = ed25519_dalek::SigningKey::from_pkcs8_der(der) { + let d = base64_url_encode(&signing.to_bytes()); + let x = base64_url_encode(signing.verifying_key().as_bytes()); + let jwk = serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": x, + "d": d, + }); + return Ok(jwk.to_string()); + } + if let Ok(secret) = x25519_pkcs8_to_secret(der) { + let d = base64_url_encode(&secret.to_bytes()); + let public = x25519_dalek::PublicKey::from(&secret); + let x = base64_url_encode(public.as_bytes()); + let jwk = serde_json::json!({ + "kty": "OKP", + "crv": "X25519", + "x": x, + "d": d, + }); + return Ok(jwk.to_string()); + } + Err("private-pkcs8: unsupported key type for JWK".to_string()) + } + "public-spki" => { + if let Ok(rsa_key) = rsa::RsaPublicKey::from_public_key_der(der) { + let n = base64_url_encode(&rsa_key.n().to_bytes_be()); + let e = base64_url_encode(&rsa_key.e().to_bytes_be()); + let jwk = serde_json::json!({ + "kty": "RSA", + "n": n, + "e": e, + }); + return Ok(jwk.to_string()); + } + if let Ok(pk) = p256::PublicKey::from_public_key_der(der) { + let pt = pk.to_encoded_point(false); + let x = base64_url_encode(pt.x().ok_or("missing x")?); + let y = base64_url_encode(pt.y().ok_or("missing y")?); + let jwk = serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y, + }); + return Ok(jwk.to_string()); + } + if let Ok(pk) = p384::PublicKey::from_public_key_der(der) { + let pt = pk.to_encoded_point(false); + let x = base64_url_encode(pt.x().ok_or("missing x")?); + let y = base64_url_encode(pt.y().ok_or("missing y")?); + let jwk = serde_json::json!({ + "kty": "EC", + "crv": "P-384", + "x": x, + "y": y, + }); + return Ok(jwk.to_string()); + } + if let Ok(pk) = p521::PublicKey::from_public_key_der(der) { + let pt = pk.to_encoded_point(false); + let x = base64_url_encode(pt.x().ok_or("missing x")?); + let y = base64_url_encode(pt.y().ok_or("missing y")?); + let jwk = serde_json::json!({ + "kty": "EC", + "crv": "P-521", + "x": x, + "y": y, + }); + return Ok(jwk.to_string()); + } + if let Ok(verifying) = ed25519_dalek::VerifyingKey::from_public_key_der(der) { + let x = base64_url_encode(verifying.as_bytes()); + let jwk = serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": x, + }); + return Ok(jwk.to_string()); + } + if let Ok(public) = x25519_spki_to_public(der) { + let x = base64_url_encode(public.as_bytes()); + let jwk = serde_json::json!({ + "kty": "OKP", + "crv": "X25519", + "x": x, + }); + return Ok(jwk.to_string()); + } + Err("public-spki: unsupported key type for JWK".to_string()) + } + other => Err(format!("unsupported kind for JWK: {other}")), + } +} -/// Creates a streaming zlib state machine. `kind` is the kebab-case -/// identifier (`"deflate"`, `"gunzip"`, …) and `level` is the zlib -/// compression level (0..=9, ignored for decoders). -fn op_zlib_create<'s>( +fn op_crypto_rsa_encrypt<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let kind_str = args.get(0).to_rust_string_lossy(scope); - let level = args.get(1).uint32_value(scope).unwrap_or(6); - match parse_zlib_kind(&kind_str) { - Ok(kind) => { - let stream = ZlibStream::new(kind, level); - let handle = from_isolate(scope); - let table = handle.0.borrow().zlib_streams.clone(); - let id = table.insert(std::rc::Rc::new(std::cell::RefCell::new(Some(stream)))); - rv.set(v8::Number::new(scope, f64::from(id)).into()); + let Some(spki_der) = bytes_arg(scope, &args, 0) else { + throw_error(scope, "rsa_encrypt: spki_der must be Uint8Array"); + return; + }; + let Some(plaintext) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "rsa_encrypt: plaintext must be Uint8Array"); + return; + }; + let padding = string_arg(scope, &args, 2); + let oaep_hash = string_arg(scope, &args, 3); + let oaep_label = bytes_arg(scope, &args, 4); + let result = rsa_encrypt_impl( + &spki_der, + &plaintext, + &padding, + &oaep_hash, + oaep_label.as_deref(), + ); + match result { + Ok(ct) => { + let arr = bytes_to_uint8array(scope, &ct); + rv.set(arr.into()); } - Err(err) => { - let exc = make_node_error(scope, &err); - scope.throw_exception(exc); + Err(e) => throw_error(scope, &e), + } +} + +fn rsa_encrypt_impl( + spki_der: &[u8], + plaintext: &[u8], + padding: &str, + oaep_hash: &str, + oaep_label: Option<&[u8]>, +) -> Result, String> { + use pkcs8::DecodePublicKey; + use rsa::{Oaep, Pkcs1v15Encrypt}; + let pub_key = + rsa::RsaPublicKey::from_public_key_der(spki_der).map_err(|e| format!("rsa pub: {e}"))?; + let mut rng = rand_core::OsRng; + match padding { + "oaep" => { + let label_bytes = oaep_label.unwrap_or(&[]); + let label_str = if label_bytes.is_empty() { + String::new() + } else { + std::str::from_utf8(label_bytes) + .map_err(|_| "rsa oaep: label must be valid UTF-8".to_string())? + .to_string() + }; + let label = label_str.as_str(); + match oaep_hash { + "sha1" => { + let padding = if label.is_empty() { + Oaep::new::() + } else { + Oaep::new_with_label::(label) + }; + pub_key + .encrypt(&mut rng, padding, plaintext) + .map_err(|e| format!("rsa oaep encrypt: {e}")) + } + "sha256" => { + let padding = if label.is_empty() { + Oaep::new::() + } else { + Oaep::new_with_label::(label) + }; + pub_key + .encrypt(&mut rng, padding, plaintext) + .map_err(|e| format!("rsa oaep encrypt: {e}")) + } + "sha384" => { + let padding = if label.is_empty() { + Oaep::new::() + } else { + Oaep::new_with_label::(label) + }; + pub_key + .encrypt(&mut rng, padding, plaintext) + .map_err(|e| format!("rsa oaep encrypt: {e}")) + } + "sha512" => { + let padding = if label.is_empty() { + Oaep::new::() + } else { + Oaep::new_with_label::(label) + }; + pub_key + .encrypt(&mut rng, padding, plaintext) + .map_err(|e| format!("rsa oaep encrypt: {e}")) + } + other => Err(format!("unsupported oaep hash: {other}")), + } + } + "pkcs1" => { + let padding = Pkcs1v15Encrypt; + pub_key + .encrypt(&mut rng, padding, plaintext) + .map_err(|e| format!("rsa pkcs1 encrypt: {e}")) } + other => Err(format!("unsupported rsa padding: {other}")), } } -fn op_zlib_feed<'s>( +fn op_crypto_rsa_decrypt<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let id = args.get(0).uint32_value(scope).unwrap_or(0); - let data = match read_uint8_array(scope, args.get(1)) { - Some(d) => d, - None => { - let err = NetError::new("ERR_INVALID_ARG_TYPE", "expected Uint8Array"); - let exc = make_node_error(scope, &err); - scope.throw_exception(exc); - return; - } - }; - let handle = from_isolate(scope); - let table = handle.0.borrow().zlib_streams.clone(); - let Some(slot) = table.with(id, std::rc::Rc::clone) else { - let err = NetError::new("EBADF", "zlib stream is closed"); - let exc = make_node_error(scope, &err); - scope.throw_exception(exc); + let Some(pkcs8_der) = bytes_arg(scope, &args, 0) else { + throw_error(scope, "rsa_decrypt: pkcs8_der must be Uint8Array"); return; }; - let result = match slot.borrow_mut().as_mut() { - Some(stream) => stream.feed(&data), - None => Err(NetError::new("EBADF", "zlib stream is finalised")), + let Some(ciphertext) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "rsa_decrypt: ciphertext must be Uint8Array"); + return; }; + let padding = string_arg(scope, &args, 2); + let oaep_hash = string_arg(scope, &args, 3); + let oaep_label = bytes_arg(scope, &args, 4); + let result = rsa_decrypt_impl( + &pkcs8_der, + &ciphertext, + &padding, + &oaep_hash, + oaep_label.as_deref(), + ); match result { - Ok(out) => { - let view = bytes_to_uint8_array(scope, &out); - rv.set(view.into()); + Ok(pt) => { + let arr = bytes_to_uint8array(scope, &pt); + rv.set(arr.into()); } - Err(err) => { - let exc = make_node_error(scope, &err); - scope.throw_exception(exc); + Err(e) => throw_error(scope, &e), + } +} + +fn rsa_decrypt_impl( + pkcs8_der: &[u8], + ciphertext: &[u8], + padding: &str, + oaep_hash: &str, + oaep_label: Option<&[u8]>, +) -> Result, String> { + use pkcs8::DecodePrivateKey; + use rsa::{Oaep, Pkcs1v15Encrypt}; + let priv_key = + rsa::RsaPrivateKey::from_pkcs8_der(pkcs8_der).map_err(|e| format!("rsa priv: {e}"))?; + match padding { + "oaep" => { + let label_bytes = oaep_label.unwrap_or(&[]); + let label_str = if label_bytes.is_empty() { + String::new() + } else { + std::str::from_utf8(label_bytes) + .map_err(|_| "rsa oaep: label must be valid UTF-8".to_string())? + .to_string() + }; + let label = label_str.as_str(); + match oaep_hash { + "sha1" => { + let padding = if label.is_empty() { + Oaep::new::() + } else { + Oaep::new_with_label::(label) + }; + priv_key + .decrypt(padding, ciphertext) + .map_err(|e| format!("rsa oaep decrypt: {e}")) + } + "sha256" => { + let padding = if label.is_empty() { + Oaep::new::() + } else { + Oaep::new_with_label::(label) + }; + priv_key + .decrypt(padding, ciphertext) + .map_err(|e| format!("rsa oaep decrypt: {e}")) + } + "sha384" => { + let padding = if label.is_empty() { + Oaep::new::() + } else { + Oaep::new_with_label::(label) + }; + priv_key + .decrypt(padding, ciphertext) + .map_err(|e| format!("rsa oaep decrypt: {e}")) + } + "sha512" => { + let padding = if label.is_empty() { + Oaep::new::() + } else { + Oaep::new_with_label::(label) + }; + priv_key + .decrypt(padding, ciphertext) + .map_err(|e| format!("rsa oaep decrypt: {e}")) + } + other => Err(format!("unsupported oaep hash: {other}")), + } + } + "pkcs1" => { + let padding = Pkcs1v15Encrypt; + priv_key + .decrypt(padding, ciphertext) + .map_err(|e| format!("rsa pkcs1 decrypt: {e}")) } + other => Err(format!("unsupported rsa padding: {other}")), } } -fn op_zlib_finish<'s>( +fn op_crypto_sign_der<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let id = args.get(0).uint32_value(scope).unwrap_or(0); - let handle = from_isolate(scope); - let table = handle.0.borrow().zlib_streams.clone(); - let Some(slot) = table.with(id, std::rc::Rc::clone) else { - let err = NetError::new("EBADF", "zlib stream is closed"); - let exc = make_node_error(scope, &err); - scope.throw_exception(exc); + let algo = string_arg(scope, &args, 0); + let kind = string_arg(scope, &args, 1); + let Some(der) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "sign_der: der must be Uint8Array"); return; }; - let stream = slot.borrow_mut().take(); - let Some(stream) = stream else { - let err = NetError::new("EBADF", "zlib stream is already finalised"); - let exc = make_node_error(scope, &err); - scope.throw_exception(exc); + let Some(data) = bytes_arg(scope, &args, 3) else { + throw_error(scope, "sign_der: data must be Uint8Array"); return; }; - match stream.finish() { - Ok(out) => { - let view = bytes_to_uint8_array(scope, &out); - rv.set(view.into()); - } - Err(err) => { - let exc = make_node_error(scope, &err); - scope.throw_exception(exc); + let format = if args.length() >= 5 { + string_arg(scope, &args, 4) + } else { + "der".to_string() + }; + let result = sign_der_impl(&algo, &kind, &der, &data, &format); + match result { + Ok(sig) => { + let arr = bytes_to_uint8array(scope, &sig); + rv.set(arr.into()); } + Err(e) => throw_error(scope, &e), } } -/// Drops the zlib stream slot. -fn op_zlib_close<'s>( +fn sign_der_impl( + algo: &str, + kind: &str, + der: &[u8], + data: &[u8], + format: &str, +) -> Result, String> { + if kind != "private-pkcs8" { + return Err(format!("sign_der: unsupported kind {kind}")); + } + match algo { + "rsa-sha256" => rsa_sign_der::(der, data), + "rsa-sha384" => rsa_sign_der::(der, data), + "rsa-sha512" => rsa_sign_der::(der, data), + "rsa-pss-sha256" => rsa_pss_sign_der::(der, data), + "rsa-pss-sha384" => rsa_pss_sign_der::(der, data), + "rsa-pss-sha512" => rsa_pss_sign_der::(der, data), + "ecdsa-p256-sha256" => ecdsa_sign_der_p256(der, data, format), + "ecdsa-p384-sha384" => ecdsa_sign_der_p384(der, data, format), + "ecdsa-p521-sha512" => ecdsa_sign_der_p521(der, data, format), + "ed25519" => ed25519_sign_der(der, data), + other => Err(format!("unsupported sign_der algorithm: {other}")), + } +} + +fn rsa_sign_der(der: &[u8], data: &[u8]) -> Result, String> +where + D: digest::Digest + digest::const_oid::AssociatedOid, +{ + use pkcs8::DecodePrivateKey; + use rsa::pkcs1v15::SigningKey; + use rsa::signature::{SignatureEncoding, Signer}; + let priv_key = rsa::RsaPrivateKey::from_pkcs8_der(der).map_err(|e| format!("rsa priv: {e}"))?; + let signing_key = SigningKey::::new(priv_key); + let sig = signing_key.sign(data); + Ok(sig.to_bytes().into_vec()) +} + +fn rsa_pss_sign_der(der: &[u8], data: &[u8]) -> Result, String> +where + D: digest::Digest + digest::const_oid::AssociatedOid + digest::FixedOutputReset, +{ + use pkcs8::DecodePrivateKey; + use rsa::pss::SigningKey; + use rsa::signature::{RandomizedSigner, SignatureEncoding}; + let priv_key = rsa::RsaPrivateKey::from_pkcs8_der(der).map_err(|e| format!("rsa priv: {e}"))?; + let signing_key = SigningKey::::new(priv_key); + let mut rng = rand_core::OsRng; + let sig = signing_key.sign_with_rng(&mut rng, data); + Ok(sig.to_bytes().into_vec()) +} + +fn ecdsa_sign_der_p256(der: &[u8], data: &[u8], format: &str) -> Result, String> { + use p256::ecdsa::signature::Signer; + use p256::ecdsa::{Signature, SigningKey}; + use pkcs8::DecodePrivateKey; + let signing = SigningKey::from_pkcs8_der(der).map_err(|e| format!("p256 priv: {e}"))?; + let sig: Signature = signing.sign(data); + match format { + "der" => Ok(sig.to_der().as_bytes().to_vec()), + "ieee-p1363" => Ok(sig.to_bytes().to_vec()), + other => Err(format!("unsupported ecdsa format: {other}")), + } +} + +fn ecdsa_sign_der_p384(der: &[u8], data: &[u8], format: &str) -> Result, String> { + use p384::ecdsa::signature::Signer; + use p384::ecdsa::{Signature, SigningKey}; + use pkcs8::DecodePrivateKey; + let signing = SigningKey::from_pkcs8_der(der).map_err(|e| format!("p384 priv: {e}"))?; + let sig: Signature = signing.sign(data); + match format { + "der" => Ok(sig.to_der().as_bytes().to_vec()), + "ieee-p1363" => Ok(sig.to_bytes().to_vec()), + other => Err(format!("unsupported ecdsa format: {other}")), + } +} + +fn ecdsa_sign_der_p521(der: &[u8], data: &[u8], format: &str) -> Result, String> { + use p521::ecdsa::signature::Signer; + use p521::ecdsa::{Signature, SigningKey}; + use pkcs8::DecodePrivateKey; + let secret = p521::SecretKey::from_pkcs8_der(der).map_err(|e| format!("p521 priv: {e}"))?; + let signing = + SigningKey::from_slice(&secret.to_bytes()).map_err(|e| format!("p521 signing key: {e}"))?; + let sig: Signature = signing.sign(data); + match format { + "der" => Ok(sig.to_der().as_bytes().to_vec()), + "ieee-p1363" => Ok(sig.to_bytes().to_vec()), + other => Err(format!("unsupported ecdsa format: {other}")), + } +} + +fn ed25519_sign_der(der: &[u8], data: &[u8]) -> Result, String> { + use ed25519_dalek::Signer; + use pkcs8::DecodePrivateKey; + let signing = + ed25519_dalek::SigningKey::from_pkcs8_der(der).map_err(|e| format!("ed25519 priv: {e}"))?; + let sig = signing.sign(data); + Ok(sig.to_bytes().to_vec()) +} + +fn op_crypto_verify_der<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let id = args.get(0).uint32_value(scope).unwrap_or(0); - let handle = from_isolate(scope); - let removed = handle.0.borrow().zlib_streams.remove(id); - rv.set(v8::Boolean::new(scope, removed).into()); + let algo = string_arg(scope, &args, 0); + let kind = string_arg(scope, &args, 1); + let Some(der) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "verify_der: der must be Uint8Array"); + return; + }; + let Some(data) = bytes_arg(scope, &args, 3) else { + throw_error(scope, "verify_der: data must be Uint8Array"); + return; + }; + let Some(sig) = bytes_arg(scope, &args, 4) else { + throw_error(scope, "verify_der: signature must be Uint8Array"); + return; + }; + let format = if args.length() >= 6 { + string_arg(scope, &args, 5) + } else { + "auto".to_string() + }; + let result = verify_der_impl(&algo, &kind, &der, &data, &sig, &format); + match result { + Ok(ok) => rv.set(v8::Boolean::new(scope, ok).into()), + Err(e) => throw_error(scope, &e), + } +} + +fn verify_der_impl( + algo: &str, + kind: &str, + der: &[u8], + data: &[u8], + sig: &[u8], + format: &str, +) -> Result { + if kind != "public-spki" { + return Err(format!("verify_der: unsupported kind {kind}")); + } + match algo { + "rsa-sha256" => rsa_verify_der::(der, data, sig), + "rsa-sha384" => rsa_verify_der::(der, data, sig), + "rsa-sha512" => rsa_verify_der::(der, data, sig), + "rsa-pss-sha256" => rsa_pss_verify_der::(der, data, sig), + "rsa-pss-sha384" => rsa_pss_verify_der::(der, data, sig), + "rsa-pss-sha512" => rsa_pss_verify_der::(der, data, sig), + "ecdsa-p256-sha256" => ecdsa_verify_der_p256(der, data, sig, format), + "ecdsa-p384-sha384" => ecdsa_verify_der_p384(der, data, sig, format), + "ecdsa-p521-sha512" => ecdsa_verify_der_p521(der, data, sig, format), + "ed25519" => ed25519_verify_der(der, data, sig), + other => Err(format!("unsupported verify_der algorithm: {other}")), + } +} + +fn rsa_verify_der(der: &[u8], data: &[u8], sig: &[u8]) -> Result +where + D: digest::Digest + digest::const_oid::AssociatedOid, +{ + use pkcs8::DecodePublicKey; + use rsa::pkcs1v15::{Signature, VerifyingKey}; + use rsa::signature::Verifier; + let pub_key = + rsa::RsaPublicKey::from_public_key_der(der).map_err(|e| format!("rsa pub: {e}"))?; + let verifying = VerifyingKey::::new(pub_key); + let signature = Signature::try_from(sig).map_err(|e| format!("rsa sig: {e}"))?; + Ok(verifying.verify(data, &signature).is_ok()) +} + +fn rsa_pss_verify_der(der: &[u8], data: &[u8], sig: &[u8]) -> Result +where + D: digest::Digest + digest::const_oid::AssociatedOid + digest::FixedOutputReset, +{ + use pkcs8::DecodePublicKey; + use rsa::pss::{Signature, VerifyingKey}; + use rsa::signature::Verifier; + let pub_key = + rsa::RsaPublicKey::from_public_key_der(der).map_err(|e| format!("rsa pub: {e}"))?; + let verifying = VerifyingKey::::new(pub_key); + let signature = Signature::try_from(sig).map_err(|e| format!("rsa sig: {e}"))?; + Ok(verifying.verify(data, &signature).is_ok()) +} + +fn ecdsa_verify_der_p256( + der: &[u8], + data: &[u8], + sig: &[u8], + format: &str, +) -> Result { + use p256::ecdsa::signature::Verifier; + use p256::ecdsa::{Signature, VerifyingKey}; + use pkcs8::DecodePublicKey; + let verifying = VerifyingKey::from_public_key_der(der).map_err(|e| format!("p256 pub: {e}"))?; + match format { + "auto" => { + if let Ok(signature) = Signature::from_der(sig) + && verifying.verify(data, &signature).is_ok() + { + return Ok(true); + } + if let Ok(signature) = Signature::try_from(sig) + && verifying.verify(data, &signature).is_ok() + { + return Ok(true); + } + Ok(false) + } + "der" => { + let signature = Signature::from_der(sig).map_err(|e| format!("p256 sig der: {e}"))?; + Ok(verifying.verify(data, &signature).is_ok()) + } + "ieee-p1363" => { + let signature = Signature::try_from(sig).map_err(|e| format!("p256 sig ieee: {e}"))?; + Ok(verifying.verify(data, &signature).is_ok()) + } + other => Err(format!("unsupported ecdsa format: {other}")), + } +} + +fn ecdsa_verify_der_p384( + der: &[u8], + data: &[u8], + sig: &[u8], + format: &str, +) -> Result { + use p384::ecdsa::signature::Verifier; + use p384::ecdsa::{Signature, VerifyingKey}; + use pkcs8::DecodePublicKey; + let verifying = VerifyingKey::from_public_key_der(der).map_err(|e| format!("p384 pub: {e}"))?; + match format { + "auto" => { + if let Ok(signature) = Signature::from_der(sig) + && verifying.verify(data, &signature).is_ok() + { + return Ok(true); + } + if let Ok(signature) = Signature::try_from(sig) + && verifying.verify(data, &signature).is_ok() + { + return Ok(true); + } + Ok(false) + } + "der" => { + let signature = Signature::from_der(sig).map_err(|e| format!("p384 sig der: {e}"))?; + Ok(verifying.verify(data, &signature).is_ok()) + } + "ieee-p1363" => { + let signature = Signature::try_from(sig).map_err(|e| format!("p384 sig ieee: {e}"))?; + Ok(verifying.verify(data, &signature).is_ok()) + } + other => Err(format!("unsupported ecdsa format: {other}")), + } +} + +fn ecdsa_verify_der_p521( + der: &[u8], + data: &[u8], + sig: &[u8], + format: &str, +) -> Result { + use p521::ecdsa::signature::Verifier; + use p521::ecdsa::{Signature, VerifyingKey}; + use pkcs8::DecodePublicKey; + let public = p521::PublicKey::from_public_key_der(der).map_err(|e| format!("p521 pub: {e}"))?; + let verifying = VerifyingKey::from_sec1_bytes(&public.to_sec1_bytes()) + .map_err(|e| format!("p521 verifying key: {e}"))?; + match format { + "auto" => { + if let Ok(signature) = Signature::from_der(sig) + && verifying.verify(data, &signature).is_ok() + { + return Ok(true); + } + if let Ok(signature) = Signature::try_from(sig) + && verifying.verify(data, &signature).is_ok() + { + return Ok(true); + } + Ok(false) + } + "der" => { + let signature = Signature::from_der(sig).map_err(|e| format!("p521 sig der: {e}"))?; + Ok(verifying.verify(data, &signature).is_ok()) + } + "ieee-p1363" => { + let signature = Signature::try_from(sig).map_err(|e| format!("p521 sig ieee: {e}"))?; + Ok(verifying.verify(data, &signature).is_ok()) + } + other => Err(format!("unsupported ecdsa format: {other}")), + } } -// ────────────────────────────────────────────────────────────────────── -// crypto: KDFs, additional ciphers, sign/verify -// -// One-shot Rust ops backed by RustCrypto. The JS shells in -// `polyfills/node/crypto.js` accumulate `update()` chunks and call -// the matching op once during `final()` / `digest()`. -// ────────────────────────────────────────────────────────────────────── +fn ed25519_verify_der(der: &[u8], data: &[u8], sig: &[u8]) -> Result { + use ed25519_dalek::{Signature, Verifier}; + use pkcs8::DecodePublicKey; + let verifying = ed25519_dalek::VerifyingKey::from_public_key_der(der) + .map_err(|e| format!("ed25519 pub: {e}"))?; + if sig.len() != 64 { + return Err(format!("ed25519 sig must be 64 bytes, got {}", sig.len())); + } + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(sig); + let signature = Signature::from_bytes(&sig_arr); + Ok(verifying.verify(data, &signature).is_ok()) +} -fn op_crypto_pbkdf2<'s>( +fn op_crypto_ecdh_derive<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let Some(password) = bytes_arg(scope, &args, 0) else { - throw_error(scope, "pbkdf2: password must be Uint8Array"); + let curve = string_arg(scope, &args, 0); + let Some(priv_der) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "ecdh_derive: priv_der must be Uint8Array"); return; }; - let Some(salt) = bytes_arg(scope, &args, 1) else { - throw_error(scope, "pbkdf2: salt must be Uint8Array"); + let Some(pub_der) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "ecdh_derive: pub_der must be Uint8Array"); return; }; - let iterations = args.get(2).uint32_value(scope).unwrap_or(0); - let keylen = args.get(3).uint32_value(scope).unwrap_or(0) as usize; - let digest_name = string_arg(scope, &args, 4); - if iterations == 0 || keylen == 0 { - throw_error(scope, "pbkdf2: iterations and keylen must be > 0"); - return; - } - let mut out = vec![0u8; keylen]; - let result = match digest_name.as_str() { - "sha1" => pbkdf2::pbkdf2::>(&password, &salt, iterations, &mut out), - "sha256" => { - pbkdf2::pbkdf2::>(&password, &salt, iterations, &mut out) + let result = ecdh_derive_impl(&curve, &priv_der, &pub_der); + match result { + Ok(secret) => { + let arr = bytes_to_uint8array(scope, &secret); + rv.set(arr.into()); } - "sha384" => { - pbkdf2::pbkdf2::>(&password, &salt, iterations, &mut out) + Err(e) => throw_error(scope, &e), + } +} + +fn ecdh_derive_impl(curve: &str, priv_der: &[u8], pub_der: &[u8]) -> Result, String> { + use p256::elliptic_curve::ecdh::diffie_hellman; + use pkcs8::{DecodePrivateKey, DecodePublicKey}; + match curve { + "P-256" | "prime256v1" => { + let priv_key = + p256::SecretKey::from_pkcs8_der(priv_der).map_err(|e| format!("p256 priv: {e}"))?; + let pub_key = p256::PublicKey::from_public_key_der(pub_der) + .map_err(|e| format!("p256 pub: {e}"))?; + let shared = diffie_hellman(priv_key.to_nonzero_scalar(), pub_key.as_affine()); + Ok(shared.raw_secret_bytes().to_vec()) } - "sha512" => { - pbkdf2::pbkdf2::>(&password, &salt, iterations, &mut out) + "P-384" | "secp384r1" => { + let priv_key = + p384::SecretKey::from_pkcs8_der(priv_der).map_err(|e| format!("p384 priv: {e}"))?; + let pub_key = p384::PublicKey::from_public_key_der(pub_der) + .map_err(|e| format!("p384 pub: {e}"))?; + let shared = diffie_hellman(priv_key.to_nonzero_scalar(), pub_key.as_affine()); + Ok(shared.raw_secret_bytes().to_vec()) } - other => { - throw_error(scope, &format!("pbkdf2: unsupported digest {other}")); - return; + "P-521" | "secp521r1" => { + let priv_key = + p521::SecretKey::from_pkcs8_der(priv_der).map_err(|e| format!("p521 priv: {e}"))?; + let pub_key = p521::PublicKey::from_public_key_der(pub_der) + .map_err(|e| format!("p521 pub: {e}"))?; + let shared = diffie_hellman(priv_key.to_nonzero_scalar(), pub_key.as_affine()); + Ok(shared.raw_secret_bytes().to_vec()) } - }; - if result.is_err() { - throw_error(scope, "pbkdf2: invalid key length"); - return; + other => Err(format!("unsupported ecdh curve: {other}")), } - let arr = bytes_to_uint8array(scope, &out); - rv.set(arr.into()); } -fn op_crypto_scrypt<'s>( +fn op_crypto_x25519_derive<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let Some(password) = bytes_arg(scope, &args, 0) else { - throw_error(scope, "scrypt: password must be Uint8Array"); + let Some(priv_der) = bytes_arg(scope, &args, 0) else { + throw_error(scope, "x25519_derive: priv_der must be Uint8Array"); return; }; - let Some(salt) = bytes_arg(scope, &args, 1) else { - throw_error(scope, "scrypt: salt must be Uint8Array"); + let Some(pub_der) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "x25519_derive: pub_der must be Uint8Array"); return; }; - let keylen = args.get(2).uint32_value(scope).unwrap_or(0) as usize; - let n_raw = args.get(3).uint32_value(scope).unwrap_or(16384); - let r = args.get(4).uint32_value(scope).unwrap_or(8); - let p = args.get(5).uint32_value(scope).unwrap_or(1); - if keylen == 0 { - throw_error(scope, "scrypt: keylen must be > 0"); - return; - } - if !n_raw.is_power_of_two() || n_raw < 2 { - throw_error(scope, "scrypt: N must be a power of two >= 2"); - return; - } - let log_n = (31 - n_raw.leading_zeros()) as u8; - let params = match scrypt::Params::new(log_n, r, p, keylen) { - Ok(p) => p, - Err(err) => { - throw_error(scope, &format!("scrypt: invalid parameters: {err}")); - return; + let result = x25519_derive_impl(&priv_der, &pub_der); + match result { + Ok(secret) => { + let arr = bytes_to_uint8array(scope, &secret); + rv.set(arr.into()); } - }; - let mut out = vec![0u8; keylen]; - if let Err(err) = scrypt::scrypt(&password, &salt, ¶ms, &mut out) { - throw_error(scope, &format!("scrypt: derivation failed: {err}")); - return; + Err(e) => throw_error(scope, &e), } - let arr = bytes_to_uint8array(scope, &out); - rv.set(arr.into()); } -/// Encrypts a buffer with a non-AEAD AES mode (CBC, CTR). -/// -/// `algo` is the Node.js style identifier (e.g. `aes-256-cbc`). -/// CBC requests apply PKCS#7 padding to match Node's default behaviour. -fn op_crypto_aes_encrypt<'s>( +fn x25519_derive_impl(priv_der: &[u8], pub_der: &[u8]) -> Result, String> { + let secret = x25519_pkcs8_to_secret(priv_der)?; + let public = x25519_spki_to_public(pub_der)?; + let shared = secret.diffie_hellman(&public); + Ok(shared.as_bytes().to_vec()) +} + +fn op_crypto_ecdh_generate<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - use aes::cipher::{BlockEncryptMut, KeyIvInit, StreamCipher}; - let algo = string_arg(scope, &args, 0); - let Some(key) = bytes_arg(scope, &args, 1) else { - throw_error(scope, "aes encrypt: key must be Uint8Array"); - return; - }; - let Some(iv) = bytes_arg(scope, &args, 2) else { - throw_error(scope, "aes encrypt: iv must be Uint8Array"); - return; - }; - let Some(data) = bytes_arg(scope, &args, 3) else { - throw_error(scope, "aes encrypt: data must be Uint8Array"); - return; - }; - let result: Result, &'static str> = match algo.as_str() { - "aes-128-cbc" => { - if key.len() != 16 || iv.len() != 16 { - Err("aes-128-cbc requires 16-byte key and iv") - } else { - let enc = cbc::Encryptor::::new_from_slices(&key, &iv).unwrap(); - Ok(enc.encrypt_padded_vec_mut::(&data)) - } - } - "aes-192-cbc" => { - if key.len() != 24 || iv.len() != 16 { - Err("aes-192-cbc requires 24-byte key and 16-byte iv") - } else { - let enc = cbc::Encryptor::::new_from_slices(&key, &iv).unwrap(); - Ok(enc.encrypt_padded_vec_mut::(&data)) - } - } - "aes-256-cbc" => { - if key.len() != 32 || iv.len() != 16 { - Err("aes-256-cbc requires 32-byte key and 16-byte iv") - } else { - let enc = cbc::Encryptor::::new_from_slices(&key, &iv).unwrap(); - Ok(enc.encrypt_padded_vec_mut::(&data)) - } + let curve = string_arg(scope, &args, 0); + let result = ecdh_generate_impl(&curve); + match result { + Ok((pub_der, priv_der, pub_raw, priv_raw)) => { + let obj = v8::Object::new(scope); + let pub_key = v8::String::new(scope, "publicKey").unwrap(); + let pub_arr = bytes_to_uint8array(scope, &pub_der); + obj.set(scope, pub_key.into(), pub_arr.into()); + let priv_key = v8::String::new(scope, "privateKey").unwrap(); + let priv_arr = bytes_to_uint8array(scope, &priv_der); + obj.set(scope, priv_key.into(), priv_arr.into()); + let pub_raw_key = v8::String::new(scope, "publicRaw").unwrap(); + let pub_raw_arr = bytes_to_uint8array(scope, &pub_raw); + obj.set(scope, pub_raw_key.into(), pub_raw_arr.into()); + let priv_raw_key = v8::String::new(scope, "privateRaw").unwrap(); + let priv_raw_arr = bytes_to_uint8array(scope, &priv_raw); + obj.set(scope, priv_raw_key.into(), priv_raw_arr.into()); + rv.set(obj.into()); } - "aes-128-ctr" => { - if key.len() != 16 || iv.len() != 16 { - Err("aes-128-ctr requires 16-byte key and iv") - } else { - let mut buf = data.clone(); - let mut c = ctr::Ctr128BE::::new_from_slices(&key, &iv).unwrap(); - c.apply_keystream(&mut buf); - Ok(buf) - } + Err(e) => throw_error(scope, &e), + } +} + +type EcdhKeyPair = (Vec, Vec, Vec, Vec); + +fn ecdh_generate_impl(curve: &str) -> Result { + use p256::elliptic_curve::sec1::ToEncodedPoint; + use pkcs8::{EncodePrivateKey, EncodePublicKey}; + use rand_core::OsRng; + match curve { + "P-256" | "prime256v1" => { + let secret = p256::SecretKey::random(&mut OsRng); + let public = secret.public_key(); + let priv_der = secret + .to_pkcs8_der() + .map_err(|e| format!("p256 priv: {e}"))? + .as_bytes() + .to_vec(); + let pub_der = public + .to_public_key_der() + .map_err(|e| format!("p256 pub: {e}"))? + .as_bytes() + .to_vec(); + let priv_raw = secret.to_bytes().to_vec(); + let pub_raw = public.to_encoded_point(false).as_bytes().to_vec(); + Ok((pub_der, priv_der, pub_raw, priv_raw)) } - "aes-256-ctr" => { - if key.len() != 32 || iv.len() != 16 { - Err("aes-256-ctr requires 32-byte key and 16-byte iv") - } else { - let mut buf = data.clone(); - let mut c = ctr::Ctr128BE::::new_from_slices(&key, &iv).unwrap(); - c.apply_keystream(&mut buf); - Ok(buf) - } + "P-384" | "secp384r1" => { + let secret = p384::SecretKey::random(&mut OsRng); + let public = secret.public_key(); + let priv_der = secret + .to_pkcs8_der() + .map_err(|e| format!("p384 priv: {e}"))? + .as_bytes() + .to_vec(); + let pub_der = public + .to_public_key_der() + .map_err(|e| format!("p384 pub: {e}"))? + .as_bytes() + .to_vec(); + let priv_raw = secret.to_bytes().to_vec(); + let pub_raw = public.to_encoded_point(false).as_bytes().to_vec(); + Ok((pub_der, priv_der, pub_raw, priv_raw)) } - other => Err(Box::leak( - format!("unsupported cipher {other}").into_boxed_str(), - )), - }; - match result { - Ok(out) => { - let arr = bytes_to_uint8array(scope, &out); - rv.set(arr.into()); + "P-521" | "secp521r1" => { + let secret = p521::SecretKey::random(&mut OsRng); + let public = secret.public_key(); + let priv_der = secret + .to_pkcs8_der() + .map_err(|e| format!("p521 priv: {e}"))? + .as_bytes() + .to_vec(); + let pub_der = public + .to_public_key_der() + .map_err(|e| format!("p521 pub: {e}"))? + .as_bytes() + .to_vec(); + let priv_raw = secret.to_bytes().to_vec(); + let pub_raw = public.to_encoded_point(false).as_bytes().to_vec(); + Ok((pub_der, priv_der, pub_raw, priv_raw)) } - Err(msg) => throw_error(scope, msg), + other => Err(format!("unsupported ecdh curve: {other}")), } } -fn op_crypto_aes_decrypt<'s>( +fn op_crypto_ecdh_from_raw<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - use aes::cipher::{BlockDecryptMut, KeyIvInit, StreamCipher}; - let algo = string_arg(scope, &args, 0); - let Some(key) = bytes_arg(scope, &args, 1) else { - throw_error(scope, "aes decrypt: key must be Uint8Array"); - return; - }; - let Some(iv) = bytes_arg(scope, &args, 2) else { - throw_error(scope, "aes decrypt: iv must be Uint8Array"); - return; - }; - let Some(data) = bytes_arg(scope, &args, 3) else { - throw_error(scope, "aes decrypt: data must be Uint8Array"); + let curve = string_arg(scope, &args, 0); + let Some(priv_raw) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "ecdh_from_raw: priv_raw must be Uint8Array"); return; }; - let result: Result, String> = match algo.as_str() { - "aes-128-cbc" => cbc::Decryptor::::new_from_slices(&key, &iv) - .map_err(|e| e.to_string()) - .and_then(|dec| { - dec.decrypt_padded_vec_mut::(&data) - .map_err(|e| e.to_string()) - }), - "aes-192-cbc" => cbc::Decryptor::::new_from_slices(&key, &iv) - .map_err(|e| e.to_string()) - .and_then(|dec| { - dec.decrypt_padded_vec_mut::(&data) - .map_err(|e| e.to_string()) - }), - "aes-256-cbc" => cbc::Decryptor::::new_from_slices(&key, &iv) - .map_err(|e| e.to_string()) - .and_then(|dec| { - dec.decrypt_padded_vec_mut::(&data) - .map_err(|e| e.to_string()) - }), - "aes-128-ctr" => ctr::Ctr128BE::::new_from_slices(&key, &iv) - .map_err(|e| e.to_string()) - .map(|mut c| { - let mut buf = data.clone(); - c.apply_keystream(&mut buf); - buf - }), - "aes-256-ctr" => ctr::Ctr128BE::::new_from_slices(&key, &iv) - .map_err(|e| e.to_string()) - .map(|mut c| { - let mut buf = data.clone(); - c.apply_keystream(&mut buf); - buf - }), - other => Err(format!("unsupported cipher {other}")), - }; + let result = ecdh_from_raw_impl(&curve, &priv_raw); match result { - Ok(out) => { - let arr = bytes_to_uint8array(scope, &out); - rv.set(arr.into()); + Ok((pub_der, priv_der, pub_raw, priv_raw)) => { + let obj = v8::Object::new(scope); + let pub_key = v8::String::new(scope, "publicKey").unwrap(); + let pub_arr = bytes_to_uint8array(scope, &pub_der); + obj.set(scope, pub_key.into(), pub_arr.into()); + let priv_key = v8::String::new(scope, "privateKey").unwrap(); + let priv_arr = bytes_to_uint8array(scope, &priv_der); + obj.set(scope, priv_key.into(), priv_arr.into()); + let pub_raw_key = v8::String::new(scope, "publicRaw").unwrap(); + let pub_raw_arr = bytes_to_uint8array(scope, &pub_raw); + obj.set(scope, pub_raw_key.into(), pub_raw_arr.into()); + let priv_raw_key = v8::String::new(scope, "privateRaw").unwrap(); + let priv_raw_arr = bytes_to_uint8array(scope, &priv_raw); + obj.set(scope, priv_raw_key.into(), priv_raw_arr.into()); + rv.set(obj.into()); } - Err(err) => throw_error(scope, &err), + Err(e) => throw_error(scope, &e), } } -/// AEAD seal with `chacha20-poly1305`. Output is `ciphertext || tag(16)`. -fn op_crypto_chacha20_seal<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, -) { - use chacha20poly1305::{ChaCha20Poly1305, KeyInit, aead::Aead, aead::Payload}; - let Some(key) = bytes_arg(scope, &args, 0) else { - throw_error(scope, "chacha20 seal: key must be Uint8Array"); - return; - }; - let Some(nonce) = bytes_arg(scope, &args, 1) else { - throw_error(scope, "chacha20 seal: nonce must be Uint8Array"); - return; - }; - let Some(plaintext) = bytes_arg(scope, &args, 2) else { - throw_error(scope, "chacha20 seal: plaintext must be Uint8Array"); - return; - }; - let aad = bytes_arg(scope, &args, 3).unwrap_or_default(); - if key.len() != 32 || nonce.len() != 12 { - throw_error( - scope, - "chacha20-poly1305 requires 32-byte key and 12-byte nonce", - ); - return; - } - let cipher = ChaCha20Poly1305::new_from_slice(&key).unwrap(); - let nonce_arr = chacha20poly1305::Nonce::from_slice(&nonce); - match cipher.encrypt( - nonce_arr, - Payload { - msg: &plaintext, - aad: &aad, - }, - ) { - Ok(out) => { - let arr = bytes_to_uint8array(scope, &out); - rv.set(arr.into()); +fn ecdh_from_raw_impl(curve: &str, priv_raw: &[u8]) -> Result { + use p256::elliptic_curve::sec1::ToEncodedPoint; + use pkcs8::{EncodePrivateKey, EncodePublicKey}; + match curve { + "P-256" | "prime256v1" => { + let secret = + p256::SecretKey::from_slice(priv_raw).map_err(|e| format!("p256 from raw: {e}"))?; + let public = secret.public_key(); + let priv_der = secret + .to_pkcs8_der() + .map_err(|e| format!("p256 priv: {e}"))? + .as_bytes() + .to_vec(); + let pub_der = public + .to_public_key_der() + .map_err(|e| format!("p256 pub: {e}"))? + .as_bytes() + .to_vec(); + let priv_raw_out = secret.to_bytes().to_vec(); + let pub_raw = public.to_encoded_point(false).as_bytes().to_vec(); + Ok((pub_der, priv_der, pub_raw, priv_raw_out)) + } + "P-384" | "secp384r1" => { + let secret = + p384::SecretKey::from_slice(priv_raw).map_err(|e| format!("p384 from raw: {e}"))?; + let public = secret.public_key(); + let priv_der = secret + .to_pkcs8_der() + .map_err(|e| format!("p384 priv: {e}"))? + .as_bytes() + .to_vec(); + let pub_der = public + .to_public_key_der() + .map_err(|e| format!("p384 pub: {e}"))? + .as_bytes() + .to_vec(); + let priv_raw_out = secret.to_bytes().to_vec(); + let pub_raw = public.to_encoded_point(false).as_bytes().to_vec(); + Ok((pub_der, priv_der, pub_raw, priv_raw_out)) } - Err(err) => throw_error(scope, &format!("chacha20 seal: {err}")), + "P-521" | "secp521r1" => { + let secret = + p521::SecretKey::from_slice(priv_raw).map_err(|e| format!("p521 from raw: {e}"))?; + let public = secret.public_key(); + let priv_der = secret + .to_pkcs8_der() + .map_err(|e| format!("p521 priv: {e}"))? + .as_bytes() + .to_vec(); + let pub_der = public + .to_public_key_der() + .map_err(|e| format!("p521 pub: {e}"))? + .as_bytes() + .to_vec(); + let priv_raw_out = secret.to_bytes().to_vec(); + let pub_raw = public.to_encoded_point(false).as_bytes().to_vec(); + Ok((pub_der, priv_der, pub_raw, priv_raw_out)) + } + other => Err(format!("unsupported ecdh curve: {other}")), } } -fn op_crypto_chacha20_open<'s>( +fn op_crypto_ecdh_compute_raw<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - use chacha20poly1305::{ChaCha20Poly1305, KeyInit, aead::Aead, aead::Payload}; - let Some(key) = bytes_arg(scope, &args, 0) else { - throw_error(scope, "chacha20 open: key must be Uint8Array"); - return; - }; - let Some(nonce) = bytes_arg(scope, &args, 1) else { - throw_error(scope, "chacha20 open: nonce must be Uint8Array"); + let curve = string_arg(scope, &args, 0); + let Some(priv_raw) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "ecdh_compute_raw: priv_raw must be Uint8Array"); return; }; - let Some(ciphertext) = bytes_arg(scope, &args, 2) else { - throw_error(scope, "chacha20 open: ciphertext must be Uint8Array"); + let Some(pub_raw) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "ecdh_compute_raw: pub_raw must be Uint8Array"); return; }; - let aad = bytes_arg(scope, &args, 3).unwrap_or_default(); - if key.len() != 32 || nonce.len() != 12 { - throw_error( - scope, - "chacha20-poly1305 requires 32-byte key and 12-byte nonce", - ); - return; - } - let cipher = ChaCha20Poly1305::new_from_slice(&key).unwrap(); - let nonce_arr = chacha20poly1305::Nonce::from_slice(&nonce); - match cipher.decrypt( - nonce_arr, - Payload { - msg: &ciphertext, - aad: &aad, - }, - ) { - Ok(out) => { - let arr = bytes_to_uint8array(scope, &out); + let result = ecdh_compute_raw_impl(&curve, &priv_raw, &pub_raw); + match result { + Ok(secret) => { + let arr = bytes_to_uint8array(scope, &secret); rv.set(arr.into()); } - Err(err) => throw_error(scope, &format!("chacha20 open: {err}")), + Err(e) => throw_error(scope, &e), } } -/// Signs a message with a PEM-encoded private key. -/// -/// Supported algorithms (Node-style identifiers): -/// * `rsa-sha256`, `rsa-sha384`, `rsa-sha512` - RSASSA-PKCS1-v1_5 -/// * `ecdsa-p256-sha256` - DER-encoded ECDSA on the P-256 curve -/// * `ed25519` - pure EdDSA, no prehash -fn op_crypto_sign<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, -) { - let algo = string_arg(scope, &args, 0); - let key_pem = string_arg(scope, &args, 1); - let Some(data) = bytes_arg(scope, &args, 2) else { - throw_error(scope, "sign: data must be Uint8Array"); - return; - }; - let result: Result, String> = match algo.as_str() { - "rsa-sha256" => rsa_sign::(&key_pem, &data), - "rsa-sha384" => rsa_sign::(&key_pem, &data), - "rsa-sha512" => rsa_sign::(&key_pem, &data), - "ecdsa-p256-sha256" => ecdsa_p256_sign(&key_pem, &data), - "ed25519" => ed25519_sign(&key_pem, &data), - other => Err(format!("unsupported sign algorithm: {other}")), - }; - match result { - Ok(sig) => { - let arr = bytes_to_uint8array(scope, &sig); - rv.set(arr.into()); +fn ecdh_compute_raw_impl(curve: &str, priv_raw: &[u8], pub_raw: &[u8]) -> Result, String> { + use p256::elliptic_curve::ecdh::diffie_hellman; + match curve { + "P-256" | "prime256v1" => { + let secret = + p256::SecretKey::from_slice(priv_raw).map_err(|e| format!("p256 priv: {e}"))?; + let public = + p256::PublicKey::from_sec1_bytes(pub_raw).map_err(|e| format!("p256 pub: {e}"))?; + let shared = diffie_hellman(secret.to_nonzero_scalar(), public.as_affine()); + Ok(shared.raw_secret_bytes().to_vec()) } - Err(err) => throw_error(scope, &err), + "P-384" | "secp384r1" => { + let secret = + p384::SecretKey::from_slice(priv_raw).map_err(|e| format!("p384 priv: {e}"))?; + let public = + p384::PublicKey::from_sec1_bytes(pub_raw).map_err(|e| format!("p384 pub: {e}"))?; + let shared = diffie_hellman(secret.to_nonzero_scalar(), public.as_affine()); + Ok(shared.raw_secret_bytes().to_vec()) + } + "P-521" | "secp521r1" => { + let secret = + p521::SecretKey::from_slice(priv_raw).map_err(|e| format!("p521 priv: {e}"))?; + let public = + p521::PublicKey::from_sec1_bytes(pub_raw).map_err(|e| format!("p521 pub: {e}"))?; + let shared = diffie_hellman(secret.to_nonzero_scalar(), public.as_affine()); + Ok(shared.raw_secret_bytes().to_vec()) + } + other => Err(format!("unsupported ecdh curve: {other}")), } } -fn op_crypto_verify<'s>( +fn op_crypto_hkdf<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let algo = string_arg(scope, &args, 0); - let key_pem = string_arg(scope, &args, 1); - let Some(data) = bytes_arg(scope, &args, 2) else { - throw_error(scope, "verify: data must be Uint8Array"); + let digest = string_arg(scope, &args, 0); + let Some(ikm) = bytes_arg(scope, &args, 1) else { + throw_error(scope, "hkdf: ikm must be Uint8Array"); return; }; - let Some(signature) = bytes_arg(scope, &args, 3) else { - throw_error(scope, "verify: signature must be Uint8Array"); + let Some(salt) = bytes_arg(scope, &args, 2) else { + throw_error(scope, "hkdf: salt must be Uint8Array"); return; }; - let result: Result = match algo.as_str() { - "rsa-sha256" => rsa_verify::(&key_pem, &data, &signature), - "rsa-sha384" => rsa_verify::(&key_pem, &data, &signature), - "rsa-sha512" => rsa_verify::(&key_pem, &data, &signature), - "ecdsa-p256-sha256" => ecdsa_p256_verify(&key_pem, &data, &signature), - "ed25519" => ed25519_verify(&key_pem, &data, &signature), - other => Err(format!("unsupported verify algorithm: {other}")), + let Some(info) = bytes_arg(scope, &args, 3) else { + throw_error(scope, "hkdf: info must be Uint8Array"); + return; }; + let keylen = string_arg(scope, &args, 4).parse::().unwrap_or(32); + let result = hkdf_impl(&digest, &ikm, &salt, &info, keylen); match result { - Ok(ok) => rv.set(v8::Boolean::new(scope, ok).into()), - Err(err) => throw_error(scope, &err), + Ok(okm) => { + let arr = bytes_to_uint8array(scope, &okm); + rv.set(arr.into()); + } + Err(e) => throw_error(scope, &e), } } -fn rsa_sign(key_pem: &str, data: &[u8]) -> Result, String> -where - D: digest::Digest + digest::const_oid::AssociatedOid, -{ - use rsa::pkcs1v15::SigningKey; - use rsa::pkcs8::DecodePrivateKey; - use rsa::signature::{SignatureEncoding, Signer}; - let key = rsa::RsaPrivateKey::from_pkcs8_pem(key_pem) - .or_else(|_| { - use rsa::pkcs1::DecodeRsaPrivateKey; - rsa::RsaPrivateKey::from_pkcs1_pem(key_pem) - }) - .map_err(|e| format!("rsa private key parse: {e}"))?; - let signing_key = SigningKey::::new(key); - let sig = signing_key.sign(data); - Ok(sig.to_bytes().into_vec()) -} - -fn rsa_verify(key_pem: &str, data: &[u8], sig: &[u8]) -> Result -where - D: digest::Digest + digest::const_oid::AssociatedOid, -{ - use rsa::pkcs1v15::{Signature, VerifyingKey}; - use rsa::pkcs8::DecodePublicKey; - use rsa::signature::Verifier; - let pub_key = rsa::RsaPublicKey::from_public_key_pem(key_pem) - .or_else(|_| { - use rsa::pkcs1::DecodeRsaPublicKey; - rsa::RsaPublicKey::from_pkcs1_pem(key_pem) - }) - .map_err(|e| format!("rsa public key parse: {e}"))?; - let verifying = VerifyingKey::::new(pub_key); - let signature = Signature::try_from(sig).map_err(|e| format!("rsa signature decode: {e}"))?; - Ok(verifying.verify(data, &signature).is_ok()) -} - -fn ecdsa_p256_sign(key_pem: &str, data: &[u8]) -> Result, String> { - use p256::ecdsa::signature::Signer; - use p256::ecdsa::{Signature, SigningKey}; - use p256::pkcs8::DecodePrivateKey; - let signing = - SigningKey::from_pkcs8_pem(key_pem).map_err(|e| format!("p256 private key parse: {e}"))?; - let sig: Signature = signing.sign(data); - Ok(sig.to_der().as_bytes().to_vec()) -} - -fn ecdsa_p256_verify(key_pem: &str, data: &[u8], sig: &[u8]) -> Result { - use p256::ecdsa::signature::Verifier; - use p256::ecdsa::{Signature, VerifyingKey}; - use p256::pkcs8::DecodePublicKey; - let verifying = VerifyingKey::from_public_key_pem(key_pem) - .map_err(|e| format!("p256 public key parse: {e}"))?; - let signature = Signature::from_der(sig) - .or_else(|_| Signature::try_from(sig)) - .map_err(|e| format!("p256 signature decode: {e}"))?; - Ok(verifying.verify(data, &signature).is_ok()) -} - -fn ed25519_sign(key_pem: &str, data: &[u8]) -> Result, String> { - use ed25519_dalek::Signer; - use ed25519_dalek::SigningKey; - use ed25519_dalek::pkcs8::DecodePrivateKey; - let signing = SigningKey::from_pkcs8_pem(key_pem) - .map_err(|e| format!("ed25519 private key parse: {e}"))?; - let sig = signing.sign(data); - Ok(sig.to_bytes().to_vec()) -} - -fn ed25519_verify(key_pem: &str, data: &[u8], sig: &[u8]) -> Result { - use ed25519_dalek::pkcs8::DecodePublicKey; - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - let verifying = VerifyingKey::from_public_key_pem(key_pem) - .map_err(|e| format!("ed25519 public key parse: {e}"))?; - if sig.len() != 64 { - return Err(format!( - "ed25519 signature must be 64 bytes, got {}", - sig.len() - )); +fn hkdf_impl( + digest: &str, + ikm: &[u8], + salt: &[u8], + info: &[u8], + keylen: usize, +) -> Result, String> { + use hkdf::Hkdf; + match digest { + "sha1" => { + let h = Hkdf::::new(Some(salt), ikm); + let mut okm = vec![0u8; keylen]; + h.expand(info, &mut okm) + .map_err(|e| format!("hkdf sha1: {e}"))?; + Ok(okm) + } + "sha256" => { + let h = Hkdf::::new(Some(salt), ikm); + let mut okm = vec![0u8; keylen]; + h.expand(info, &mut okm) + .map_err(|e| format!("hkdf sha256: {e}"))?; + Ok(okm) + } + "sha384" => { + let h = Hkdf::::new(Some(salt), ikm); + let mut okm = vec![0u8; keylen]; + h.expand(info, &mut okm) + .map_err(|e| format!("hkdf sha384: {e}"))?; + Ok(okm) + } + "sha512" => { + let h = Hkdf::::new(Some(salt), ikm); + let mut okm = vec![0u8; keylen]; + h.expand(info, &mut okm) + .map_err(|e| format!("hkdf sha512: {e}"))?; + Ok(okm) + } + other => Err(format!("unsupported hkdf digest: {other}")), } - let mut sig_arr = [0u8; 64]; - sig_arr.copy_from_slice(sig); - let signature = Signature::from_bytes(&sig_arr); - Ok(verifying.verify(data, &signature).is_ok()) } // ────────────────────────────────────────────────────────────────────── @@ -4467,3 +7839,127 @@ fn read_vm_context_id<'s>( } Some(ptr as usize as u32) } + +// ────────────────────────────────────────────────────────────────── +// Upgrade socket ops (HTTP/1.1 `Upgrade` raw byte pass-through) +// ────────────────────────────────────────────────────────────────── +// +// These ops drive the raw post-handshake byte stream exposed to JS +// after the `node:http` Server's `'upgrade'` event fires. The +// underlying registry lives in `crate::ops::upgrade_socket`; here we +// only translate between V8 values and the Rust-side handle. + +/// Pulls one chunk from the inbound stream associated with `id`. +/// +/// Resolves with a `Uint8Array` carrying the bytes, `null` on EOF, +/// or rejects with a Node-shaped error (`code: 'EPIPE'` or +/// `'ECONNRESET'`) when the underlying transport failed. +fn op_upgrade_socket_read_async<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let id = args.get(0).number_value(scope).unwrap_or(0.0) as u64; + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + + let Some(socket) = crate::ops::upgrade_socket::handle(id) else { + let err = crate::ops::NetError::new("EPIPE", "upgrade socket is closed"); + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + }; + + tokio::task::spawn_local(async move { + let settler: super::async_ops::Settler = match socket.read().await { + Ok(Some(chunk)) => Box::new(move |scope, resolver| { + let view = bytes_to_uint8_array(scope, &chunk); + resolver.resolve(scope, view.into()); + }), + Ok(None) => Box::new(|scope, resolver| { + resolver.resolve(scope, v8::null(scope).into()); + }), + Err(crate::ops::upgrade_socket::UpgradeSocketError::Closed(_)) => net_settler_err( + crate::ops::NetError::new("EPIPE", "upgrade socket is closed"), + ), + Err(crate::ops::upgrade_socket::UpgradeSocketError::Aborted(_, msg)) => { + net_settler_err(crate::ops::NetError::new("ECONNRESET", &msg)) + } + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); +} + +/// Sends `bytes` on the upgrade socket. Resolves once the bytes have +/// been queued for delivery (the actual write completes +/// asynchronously on the writer task). Rejects if the slot is closed +/// or aborted. +fn op_upgrade_socket_write_async<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let id = args.get(0).number_value(scope).unwrap_or(0.0) as u64; + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let global = v8::Global::new(scope, resolver); + let handle = from_isolate(scope); + let tx = handle.0.borrow().async_completions_tx.clone(); + + let bytes = match read_bytes_arg(scope, args.get(1)) { + Some(b) => b.to_vec(), + None => { + let err = crate::ops::NetError::new("ERR_INVALID_ARG_TYPE", "expected Uint8Array"); + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + } + }; + + let Some(socket) = crate::ops::upgrade_socket::handle(id) else { + let err = crate::ops::NetError::new("EPIPE", "upgrade socket is closed"); + reject_net(scope, v8::Local::new(scope, &global), &err); + rv.set(promise.into()); + return; + }; + + tokio::task::spawn_local(async move { + let settler: super::async_ops::Settler = match socket.write(bytes).await { + Ok(()) => Box::new(|scope, resolver| { + resolver.resolve(scope, v8::undefined(scope).into()); + }), + Err(crate::ops::upgrade_socket::UpgradeSocketError::Closed(_)) => net_settler_err( + crate::ops::NetError::new("EPIPE", "upgrade socket is closed"), + ), + Err(crate::ops::upgrade_socket::UpgradeSocketError::Aborted(_, msg)) => { + net_settler_err(crate::ops::NetError::new("ECONNRESET", &msg)) + } + }; + let _ = tx.send(super::async_ops::Completion::new(global, settler)); + }); + rv.set(promise.into()); +} + +/// Closes the upgrade socket immediately. Subsequent reads/writes +/// reject with `EPIPE`. Idempotent. +fn op_upgrade_socket_close<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let id = args.get(0).number_value(scope).unwrap_or(0.0) as u64; + if let Some(socket) = crate::ops::upgrade_socket::handle(id) { + socket.close(); + } + rv.set_undefined(); +} diff --git a/crates/nexide/src/image/config.rs b/crates/nexide/src/image/config.rs index b40ae45..4dabf31 100644 --- a/crates/nexide/src/image/config.rs +++ b/crates/nexide/src/image/config.rs @@ -170,13 +170,28 @@ impl Default for ImageConfig { } impl ImageConfig { - /// Loads `images` from `/.next/required-server-files.json`. + /// Loads `images` from the standalone bundle's + /// `required-server-files.json`. The file is emitted at the + /// **workspace root** of the bundle (`/.next/required-server-files.json`), + /// not under `.next/server/app`. Callers pass the resolved + /// `app_dir` (`.next/server/app`); we walk up two parents to get + /// to `.next/`. + /// /// Returns [`Self::default`] when the file is absent or malformed - /// the optimizer should never block server startup. #[must_use] pub fn from_app_dir(app_dir: &Path) -> Self { - let path = app_dir.join(".next").join("required-server-files.json"); - let Ok(text) = std::fs::read_to_string(&path) else { + let dot_next = app_dir + .parent() + .and_then(Path::parent) + .map(Path::to_path_buf) + .unwrap_or_else(|| app_dir.join(".next")); + let mut candidates = vec![dot_next.join("required-server-files.json")]; + candidates.push(app_dir.join(".next").join("required-server-files.json")); + let text = candidates + .iter() + .find_map(|p| std::fs::read_to_string(p).ok()); + let Some(text) = text else { return Self::default(); }; let Ok(root) = serde_json::from_str::(&text) else { diff --git a/crates/nexide/src/image/handler.rs b/crates/nexide/src/image/handler.rs index 154a5b5..55bc7cd 100644 --- a/crates/nexide/src/image/handler.rs +++ b/crates/nexide/src/image/handler.rs @@ -37,14 +37,38 @@ pub(crate) type NextImageService = BoxCloneSyncService, Response>, } /// Builds the `/_next/image` handler service. #[must_use] -pub fn next_image_service(app_dir: PathBuf, public_dir: PathBuf) -> NextImageService { +pub fn next_image_service( + app_dir: PathBuf, + public_dir: PathBuf, + next_static_dir: PathBuf, + bind_addr: std::net::SocketAddr, +) -> NextImageService { + next_image_service_with_dynamic(app_dir, public_dir, next_static_dir, bind_addr, None) +} + +/// Same as [`next_image_service`] but also receives the dynamic +/// (Next.js bridge) handler so internal `/_next/image?url=/api/...` +/// fetches resolve in-process instead of looping through TCP. Avoids +/// connection-pool deadlocks and the 7-second timeout penalty when +/// the Next bridge is busy. +#[must_use] +pub fn next_image_service_with_dynamic( + app_dir: PathBuf, + public_dir: PathBuf, + next_static_dir: PathBuf, + bind_addr: std::net::SocketAddr, + dynamic: Option>, +) -> NextImageService { let config = ImageConfig::from_app_dir(&app_dir); let http = reqwest::Client::builder() .timeout(Duration::from_secs(7)) @@ -54,9 +78,12 @@ pub fn next_image_service(app_dir: PathBuf, public_dir: PathBuf) -> NextImageSer let ctx = Arc::new(Ctx { app_dir, public_dir, + next_static_dir, + bind_addr, config, http, mem: super::memory::MemCache::new(), + dynamic, }); let svc = service_fn(move |req: Request| { let ctx = ctx.clone(); @@ -138,7 +165,7 @@ async fn handle(ctx: &Arc, req: Request) -> Result, Ha } } - let source = resolve_source(ctx, ¶ms).await?; + let source = resolve_source(ctx, ¶ms, &accept).await?; let detected = pipeline::detect_format(&source.bytes); if !detected.is_image() { return Err(HandlerError::new( @@ -187,7 +214,12 @@ async fn handle(ctx: &Arc, req: Request) -> Result, Ha } else { "STALE" }; - let hot = Arc::new(super::memory::HotEntry::from_disk(&hit)); + let hot = Arc::new(super::memory::HotEntry::from_disk( + &hit, + chosen.mime(), + ¶ms.url, + &ctx.config, + )); ctx.mem.put(key.clone(), Arc::clone(&hot)); return Ok(serve_hot( &hot, @@ -203,11 +235,11 @@ async fn handle(ctx: &Arc, req: Request) -> Result, Ha let url_owned = params.url.clone(); let width = params.width; let quality = params.quality; - let optimized = tokio::task::spawn_blocking(move || { - produce_optimized(&bytes_owned, width, quality, chosen) - }) - .await - .map_err(|_| { + let (tx, rx) = tokio::sync::oneshot::channel(); + rayon::spawn(move || { + let _ = tx.send(produce_optimized(&bytes_owned, width, quality, chosen)); + }); + let optimized = rx.await.map_err(|_| { HandlerError::new( StatusCode::INTERNAL_SERVER_ERROR, "image pipeline join failed", @@ -230,7 +262,12 @@ async fn handle(ctx: &Arc, req: Request) -> Result, Ha } else { debug!(target: "nexide::image", url = %url_owned, "cached optimized image"); } - let hot = Arc::new(super::memory::HotEntry::from_disk(&entry)); + let hot = Arc::new(super::memory::HotEntry::from_disk( + &entry, + chosen.mime(), + ¶ms.url, + &ctx.config, + )); ctx.mem.put(key, Arc::clone(&hot)); Ok(serve_hot( @@ -295,7 +332,7 @@ fn serve_hot( chosen: OutputFormat, cache_state: &'static str, if_none_match: &Option, - url: &str, + _url: &str, cfg: &ImageConfig, ) -> Response { if let Some(client_etag) = if_none_match @@ -303,31 +340,14 @@ fn serve_hot( { let mut resp = Response::new(Body::empty()); *resp.status_mut() = StatusCode::NOT_MODIFIED; - attach_headers( - resp.headers_mut(), - chosen.mime(), - entry.max_age, - &entry.etag, - cache_state, - url, - cfg, - ); + attach_headers_from_hot(resp.headers_mut(), chosen.mime(), cache_state, entry, cfg); return resp; } let len = entry.bytes.len(); let mut resp = Response::new(Body::from(entry.bytes.clone())); - attach_headers( - resp.headers_mut(), - chosen.mime(), - entry.max_age, - &entry.etag, - cache_state, - url, - cfg, - ); - if let Ok(v) = HeaderValue::from_str(&len.to_string()) { - resp.headers_mut().insert(CONTENT_LENGTH, v); - } + attach_headers_from_hot(resp.headers_mut(), chosen.mime(), cache_state, entry, cfg); + resp.headers_mut() + .insert(CONTENT_LENGTH, HeaderValue::from(len as u64)); resp } @@ -352,9 +372,8 @@ fn serve_bypass( ¶ms.url, &source.config_snapshot, ); - if let Ok(v) = HeaderValue::from_str(&len.to_string()) { - resp.headers_mut().insert(CONTENT_LENGTH, v); - } + resp.headers_mut() + .insert(CONTENT_LENGTH, HeaderValue::from(len as u64)); resp } @@ -367,19 +386,18 @@ fn attach_headers( url: &str, cfg: &ImageConfig, ) { - headers.insert(VARY, HeaderValue::from_static("Accept")); - if let Ok(v) = HeaderValue::from_str(mime) { - headers.insert(CONTENT_TYPE, v); - } + const HV_VARY_ACCEPT: HeaderValue = HeaderValue::from_static("Accept"); + const HN_X_NEXTJS_CACHE_K: axum::http::HeaderName = + axum::http::HeaderName::from_static(X_NEXTJS_CACHE); + headers.insert(VARY, HV_VARY_ACCEPT); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(mime)); if let Ok(v) = HeaderValue::from_str(&format!("public, max-age={max_age}, must-revalidate")) { headers.insert(CACHE_CONTROL, v); } if let Ok(v) = HeaderValue::from_str(&format!("\"{etag}\"")) { headers.insert(ETAG, v); } - if let Ok(v) = HeaderValue::from_str(cache_state) { - let _ = headers.insert(axum::http::HeaderName::from_static(X_NEXTJS_CACHE), v); - } + headers.insert(HN_X_NEXTJS_CACHE_K, HeaderValue::from_static(cache_state)); if let Ok(v) = HeaderValue::from_str(&cfg.content_security_policy) { headers.insert(CONTENT_SECURITY_POLICY, v); } @@ -389,7 +407,28 @@ fn attach_headers( } } -fn build_content_disposition(url: &str, mime: &str, disposition_type: &str) -> String { +fn attach_headers_from_hot( + headers: &mut HeaderMap, + mime: &'static str, + cache_state: &'static str, + entry: &super::memory::HotEntry, + cfg: &ImageConfig, +) { + const HV_VARY_ACCEPT: HeaderValue = HeaderValue::from_static("Accept"); + const HN_X_NEXTJS_CACHE_K: axum::http::HeaderName = + axum::http::HeaderName::from_static(X_NEXTJS_CACHE); + headers.insert(VARY, HV_VARY_ACCEPT); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(mime)); + headers.insert(CACHE_CONTROL, entry.cache_control_hv.clone()); + headers.insert(ETAG, entry.etag_hv.clone()); + headers.insert(HN_X_NEXTJS_CACHE_K, HeaderValue::from_static(cache_state)); + if let Ok(v) = HeaderValue::from_str(&cfg.content_security_policy) { + headers.insert(CONTENT_SECURITY_POLICY, v); + } + headers.insert(CONTENT_DISPOSITION, entry.disposition_hv.clone()); +} + +pub(super) fn build_content_disposition(url: &str, mime: &str, disposition_type: &str) -> String { let filename = filename_from_url(url, mime); format!("{disposition_type}; filename=\"{filename}\"") } @@ -548,7 +587,11 @@ struct Source { config_snapshot: ImageConfig, } -async fn resolve_source(ctx: &Arc, params: &ValidatedParams) -> Result { +async fn resolve_source( + ctx: &Arc, + params: &ValidatedParams, + accept: &str, +) -> Result { if params.url.starts_with("http://") || params.url.starts_with("https://") { return fetch_remote(ctx, ¶ms.url).await; } @@ -562,24 +605,226 @@ async fn resolve_source(ctx: &Arc, params: &ValidatedParams) -> Result, href: &str, accept: &str) -> Result { + if let Some(handler) = ctx.dynamic.as_ref() { + return fetch_via_handler(ctx, handler.as_ref(), href, accept).await; + } + fetch_via_loopback(ctx, href, accept).await +} + +async fn fetch_via_handler( + ctx: &Arc, + handler: &dyn crate::server::fallback::DynamicHandler, + href: &str, + accept: &str, +) -> Result { + let mut builder = Request::builder().method(Method::GET).uri(href).header( + axum::http::header::USER_AGENT, + "nexide-image-optimizer/1 (internal)", + ); + if !accept.is_empty() { + builder = builder.header(ACCEPT, accept); + } + let req = builder.body(Body::empty()).map_err(|err| { + warn!(target: "nexide::image", url = %href, error = %err, "internal request build failed"); + HandlerError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "\"url\" parameter is valid but upstream response is invalid", + ) + })?; + let resp = handler.handle(req).await.map_err(|err| { + warn!(target: "nexide::image", url = %href, error = %err, "internal handler failed"); + HandlerError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "\"url\" parameter is valid but upstream response is invalid", + ) + })?; + let status = resp.status(); + if !status.is_success() { + let mapped = if status == StatusCode::NOT_FOUND { + StatusCode::NOT_FOUND + } else { + StatusCode::from_u16(508).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) + }; + return Err(HandlerError::new( + mapped, + "\"url\" parameter is valid but upstream response is invalid", + )); + } + let upstream_etag = resp + .headers() + .get(ETAG) + .and_then(|v| v.to_str().ok()) + .map(|s| s.trim_matches('"').to_owned()) + .unwrap_or_default(); + let upstream_max_age = resp + .headers() + .get(CACHE_CONTROL) + .and_then(|v| v.to_str().ok()) + .map(parse_cache_control_max_age) + .unwrap_or(0); + let limit = ctx.config.maximum_response_body; + let bytes_full = match axum::body::to_bytes(resp.into_body(), limit as usize).await { + Ok(b) => b, + Err(err) => { + warn!(target: "nexide::image", error = %err, "internal handler body read failed"); + return Err(HandlerError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "\"url\" parameter is valid but upstream response is invalid", + )); + } + }; + if (bytes_full.len() as u64) > limit { + return Err(HandlerError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "\"url\" parameter is valid but upstream response is too large", + )); } - let bytes = std::fs::read(&canonical_candidate) - .map_err(|_| HandlerError::new(StatusCode::NOT_FOUND, "source not found"))?; Ok(Source { - bytes, - upstream_etag: String::new(), - upstream_max_age: 0, + bytes: bytes_full.to_vec(), + upstream_etag, + upstream_max_age, + config_snapshot: ctx.config.clone(), + }) +} + +async fn fetch_via_loopback( + ctx: &Arc, + href: &str, + accept: &str, +) -> Result { + let host = match ctx.bind_addr { + std::net::SocketAddr::V4(v4) => { + let ip = v4.ip(); + if ip.is_unspecified() { + "127.0.0.1".to_owned() + } else { + ip.to_string() + } + } + std::net::SocketAddr::V6(v6) => { + let ip = v6.ip(); + if ip.is_unspecified() { + "[::1]".to_owned() + } else { + format!("[{ip}]") + } + } + }; + let url = format!("http://{}:{}{}", host, ctx.bind_addr.port(), href); + let mut req = ctx.http.get(&url); + if !accept.is_empty() { + req = req.header(reqwest::header::ACCEPT, accept); + } + req = req.header( + reqwest::header::USER_AGENT, + "nexide-image-optimizer/1 (internal)", + ); + let resp = req.send().await.map_err(|err| { + warn!(target: "nexide::image", url = %url, error = %err, "internal fetch failed"); + HandlerError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "\"url\" parameter is valid but upstream response is invalid", + ) + })?; + let status = resp.status(); + if !status.is_success() { + let mapped = if status == reqwest::StatusCode::NOT_FOUND { + StatusCode::NOT_FOUND + } else { + StatusCode::from_u16(508).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) + }; + return Err(HandlerError::new( + mapped, + "\"url\" parameter is valid but upstream response is invalid", + )); + } + let upstream_etag = resp + .headers() + .get(reqwest::header::ETAG) + .and_then(|v| v.to_str().ok()) + .map(|s| s.trim_matches('"').to_owned()) + .unwrap_or_default(); + let upstream_max_age = resp + .headers() + .get(reqwest::header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()) + .map(parse_cache_control_max_age) + .unwrap_or(0); + let limit = ctx.config.maximum_response_body; + let bytes_full = resp.bytes().await.map_err(|err| { + warn!(target: "nexide::image", error = %err, "internal fetch body read failed"); + HandlerError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "\"url\" parameter is valid but upstream response is invalid", + ) + })?; + if (bytes_full.len() as u64) > limit { + return Err(HandlerError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "\"url\" parameter is valid but upstream response is too large", + )); + } + Ok(Source { + bytes: bytes_full.to_vec(), + upstream_etag, + upstream_max_age, config_snapshot: ctx.config.clone(), }) } diff --git a/crates/nexide/src/image/memory.rs b/crates/nexide/src/image/memory.rs index da0bc51..80fac18 100644 --- a/crates/nexide/src/image/memory.rs +++ b/crates/nexide/src/image/memory.rs @@ -7,11 +7,16 @@ //! shared across all worker threads via [`MemCache::handle`]. use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use axum::http::HeaderValue; use bytes::Bytes; +use parking_lot::RwLock; use super::cache::CacheEntry; +use super::config::ImageConfig; +use super::handler::build_content_disposition; const DEFAULT_MAX_ENTRIES: usize = 256; const DEFAULT_MAX_BYTES: usize = 64 * 1024 * 1024; @@ -19,40 +24,67 @@ const DEFAULT_MAX_BYTES: usize = 64 * 1024 * 1024; /// Cheap-to-clone shared cached entry: bytes plus the metadata the /// HTTP layer needs to attach response headers without re-parsing the /// filename. +/// +/// Carries precomputed `HeaderValue`s for `Cache-Control`, `ETag` and +/// `Content-Disposition` so the hot cache-hit path skips four +/// `format!()` allocations + four `HeaderValue::from_str` validations +/// per request. #[derive(Debug, Clone)] pub(crate) struct HotEntry { pub(crate) bytes: Bytes, - pub(crate) max_age: u64, pub(crate) expire_at_ms: u128, pub(crate) etag: String, - #[allow(dead_code)] - pub(crate) extension: &'static str, + pub(crate) cache_control_hv: HeaderValue, + pub(crate) etag_hv: HeaderValue, + pub(crate) disposition_hv: HeaderValue, } impl HotEntry { - pub(crate) fn from_disk(entry: &CacheEntry) -> Self { + pub(crate) fn from_disk( + entry: &CacheEntry, + mime: &'static str, + url: &str, + cfg: &ImageConfig, + ) -> Self { + let cache_control_hv = HeaderValue::try_from(format!( + "public, max-age={}, must-revalidate", + entry.max_age + )) + .unwrap_or_else(|_| HeaderValue::from_static("public, must-revalidate")); + let etag_hv = HeaderValue::try_from(format!("\"{}\"", entry.etag)) + .unwrap_or_else(|_| HeaderValue::from_static("\"\"")); + let disposition_str = build_content_disposition(url, mime, &cfg.content_disposition_type); + let disposition_hv = HeaderValue::try_from(disposition_str) + .unwrap_or_else(|_| HeaderValue::from_static("inline")); Self { bytes: Bytes::from(entry.bytes.clone()), - max_age: entry.max_age, expire_at_ms: entry.expire_at_ms, etag: entry.etag.clone(), - extension: entry.extension, + cache_control_hv, + etag_hv, + disposition_hv, } } } +#[derive(Debug)] +struct Slot { + entry: Arc, + order: AtomicU64, +} + #[derive(Debug)] struct Inner { - map: HashMap, u64)>, - order: u64, - bytes: usize, + map: RwLock>>, + order_counter: AtomicU64, + bytes: AtomicUsize, max_entries: usize, max_bytes: usize, } #[derive(Debug, Clone)] pub(crate) struct MemCache { - inner: Arc>, + inner: Arc, } impl MemCache { @@ -61,53 +93,80 @@ impl MemCache { } pub(crate) fn with_limits(max_entries: usize, max_bytes: usize) -> Self { - Self { - inner: Arc::new(Mutex::new(Inner { - map: HashMap::new(), - order: 0, - bytes: 0, - max_entries, - max_bytes, - })), - } + let inner = Arc::new(Inner { + map: RwLock::new(HashMap::new()), + order_counter: AtomicU64::new(0), + bytes: AtomicUsize::new(0), + max_entries, + max_bytes, + }); + let weak = Arc::downgrade(&inner); + crate::pool::idle_shrink::register(move || { + if let Some(strong) = weak.upgrade() { + let mut g = strong.map.write(); + g.clear(); + strong.bytes.store(0, Ordering::Relaxed); + } + }); + Self { inner } } pub(crate) fn get(&self, key: &str) -> Option> { - let mut g = self.inner.lock().ok()?; - g.order += 1; - let next_order = g.order; - let slot = g.map.get_mut(key)?; - slot.1 = next_order; - Some(Arc::clone(&slot.0)) + let g = match self.inner.map.try_read() { + Some(g) => { + crate::diagnostics::contention::record_fast( + &crate::diagnostics::contention::MEM_CACHE_FAST, + ); + g + } + None => { + crate::diagnostics::contention::record_contended( + &crate::diagnostics::contention::MEM_CACHE_CONTENDED, + ); + self.inner.map.read() + } + }; + let slot = g.get(key)?; + let next_order = self.inner.order_counter.fetch_add(1, Ordering::Relaxed) + 1; + slot.order.store(next_order, Ordering::Relaxed); + Some(Arc::clone(&slot.entry)) } pub(crate) fn put(&self, key: String, entry: Arc) { - let Ok(mut g) = self.inner.lock() else { - return; - }; - g.order += 1; - let next_order = g.order; + let next_order = self.inner.order_counter.fetch_add(1, Ordering::Relaxed) + 1; let added = entry.bytes.len(); - if let Some(prev) = g.map.insert(key, (entry, next_order)) { - g.bytes = g.bytes.saturating_sub(prev.0.bytes.len()); + let mut g = self.inner.map.write(); + let slot = Arc::new(Slot { + entry, + order: AtomicU64::new(next_order), + }); + if let Some(prev) = g.insert(key, slot) { + self.inner + .bytes + .fetch_sub(prev.entry.bytes.len(), Ordering::Relaxed); } - g.bytes = g.bytes.saturating_add(added); - evict(&mut g); + self.inner.bytes.fetch_add(added, Ordering::Relaxed); + evict(&self.inner, &mut g); } } -fn evict(g: &mut Inner) { - while g.map.len() > g.max_entries || g.bytes > g.max_bytes { +fn evict(inner: &Inner, g: &mut HashMap>) { + loop { + let bytes = inner.bytes.load(Ordering::Relaxed); + if g.len() <= inner.max_entries && bytes <= inner.max_bytes { + break; + } let Some((victim, _)) = g - .map .iter() - .min_by_key(|(_, (_, ord))| *ord) - .map(|(k, (_, ord))| (k.clone(), *ord)) + .min_by_key(|(_, slot)| slot.order.load(Ordering::Relaxed)) + .map(|(k, slot)| (k.clone(), slot.order.load(Ordering::Relaxed))) else { break; }; - if let Some((evicted, _)) = g.map.remove(&victim) { - g.bytes = g.bytes.saturating_sub(evicted.bytes.len()); + if let Some(evicted) = g.remove(&victim) { + inner + .bytes + .fetch_sub(evicted.entry.bytes.len(), Ordering::Relaxed); } } } @@ -119,10 +178,11 @@ mod tests { fn entry(n: usize) -> Arc { Arc::new(HotEntry { bytes: Bytes::from(vec![0u8; n]), - max_age: 60, expire_at_ms: 0, etag: "x".into(), - extension: "webp", + cache_control_hv: HeaderValue::from_static("public, max-age=60, must-revalidate"), + etag_hv: HeaderValue::from_static("\"x\""), + disposition_hv: HeaderValue::from_static("inline; filename=\"image.webp\""), }) } diff --git a/crates/nexide/src/image/mod.rs b/crates/nexide/src/image/mod.rs index 25947f5..bfb3c1d 100644 --- a/crates/nexide/src/image/mod.rs +++ b/crates/nexide/src/image/mod.rs @@ -13,4 +13,4 @@ mod memory; mod pipeline; pub use config::ImageConfig; -pub use handler::next_image_service; +pub use handler::{next_image_service, next_image_service_with_dynamic}; diff --git a/crates/nexide/src/image/pipeline.rs b/crates/nexide/src/image/pipeline.rs index 30e069b..233c715 100644 --- a/crates/nexide/src/image/pipeline.rs +++ b/crates/nexide/src/image/pipeline.rs @@ -11,7 +11,7 @@ use fast_image_resize::{ images::{Image, ImageRef}, }; use image::codecs::{jpeg::JpegEncoder, png::PngEncoder, webp::WebPEncoder}; -use image::{DynamicImage, ImageEncoder, ImageFormat, ImageReader}; +use image::{DynamicImage, ImageEncoder, ImageReader}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SourceFormat { @@ -283,20 +283,6 @@ fn accept_contains(accept: &str, mime: &str) -> bool { .any(|t| t.eq_ignore_ascii_case(mime)) } -/// Maps an [`ImageFormat`] returned by the `image` crate into the -/// matching [`SourceFormat`]; used when we have already decoded. -#[allow(dead_code)] -pub(crate) const fn from_image_format(fmt: ImageFormat) -> SourceFormat { - match fmt { - ImageFormat::Png => SourceFormat::Png, - ImageFormat::Jpeg => SourceFormat::Jpeg, - ImageFormat::Gif => SourceFormat::Gif, - ImageFormat::WebP => SourceFormat::Webp, - ImageFormat::Bmp => SourceFormat::Bmp, - _ => SourceFormat::Unknown, - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/nexide/src/lib.rs b/crates/nexide/src/lib.rs index 32e557e..cef2c04 100644 --- a/crates/nexide/src/lib.rs +++ b/crates/nexide/src/lib.rs @@ -10,11 +10,15 @@ use std::future::Future; use std::net::SocketAddr; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use thiserror::Error; use tracing_subscriber::EnvFilter; +use crate::engine::HeapLimitConfig; + pub mod cli; +pub mod diagnostics; pub mod dispatch; pub mod engine; pub mod entrypoint; @@ -243,25 +247,42 @@ impl AppLayout { // app subdirectory: `.next/static` is copied as a sibling of // `node_modules/` by every conventional Dockerfile, and a // shared `public/` may live at either level depending on the - // operator's preference. Try the app dir first, then fall back - // to the sandbox root so both layouts work without flags. - let sandbox_root = std::env::var(SANDBOX_ROOT_ENV) + // operator's preference. + // + // Resolution order: explicit `NEXIDE_SANDBOX_ROOT` env var, + // then process CWD, then app_root. We probe every candidate + // and pick the most "complete" one for each role - e.g. a + // `.next/static/chunks/` populated tree wins over an empty + // sibling. This handles the common Dockerfile that copies + // `/.next/standalone/` to `/app/` and + // `/.next/static/` to `/app/.next/static/`, + // leaving an empty `/app//.next/static/` next to + // `server.js`. + let env_root = std::env::var(SANDBOX_ROOT_ENV) .ok() .map(PathBuf::from) .filter(|p| p.is_absolute() && p.is_dir()); - let alt = |rel: &str| -> Vec { - let mut v = vec![app_root.join(rel)]; - if let Some(root) = sandbox_root.as_ref() { - let candidate = root.join(rel); - if candidate != v[0] { - v.push(candidate); - } + let cwd_root = std::env::current_dir().ok(); + let mut roots: Vec = Vec::with_capacity(3); + let push_unique = |v: &mut Vec, p: PathBuf| { + if !v.iter().any(|existing| existing == &p) { + v.push(p); } - v }; - let app_dir = first_existing_or_err(&alt(".next/server/app"))?; - let next_static_dir = first_existing_or_err(&alt(".next/static"))?; - let public_dir = first_existing_or_default(&alt("public")); + push_unique(&mut roots, app_root.clone()); + if let Some(env) = env_root { + push_unique(&mut roots, env); + } + if let Some(cwd) = cwd_root { + push_unique(&mut roots, cwd); + } + let alt = |rel: &str| -> Vec { roots.iter().map(|r| r.join(rel)).collect() }; + let app_dir = pick_existing_dir(&alt(".next/server/app"), &["page.js", "_not-found"]) + .ok_or_else(|| RuntimeError::MissingDir(app_root.join(".next/server/app")))?; + let next_static_dir = pick_existing_dir(&alt(".next/static"), &["chunks"]) + .ok_or_else(|| RuntimeError::MissingDir(app_root.join(".next/static")))?; + let public_dir = + pick_existing_dir(&alt("public"), &[]).unwrap_or_else(|| app_root.join("public")); let config = ServerConfig::try_new(bind, public_dir, next_static_dir, app_dir)?; Ok(Self { config, @@ -273,6 +294,26 @@ impl AppLayout { } } +/// Picks the candidate directory that contains every `marker` (file +/// or directory name). When no candidate satisfies the markers, falls +/// back to the first existing directory. Returns `None` only when +/// every candidate is missing on disk. +/// +/// Used by [`AppLayout::from_entrypoint`] to disambiguate between +/// multiple plausible roots in standalone deploys where the operator +/// copies build outputs to different layouts (`/app//.next/static` +/// vs `/app/.next/static`). +fn pick_existing_dir(candidates: &[PathBuf], markers: &[&str]) -> Option { + if !markers.is_empty() + && let Some(p) = candidates + .iter() + .find(|p| p.is_dir() && markers.iter().all(|m| p.join(m).exists())) + { + return Some(p.clone()); + } + candidates.iter().find(|p| p.is_dir()).cloned() +} + /// Three on-disk shapes a Next.js `output: "standalone"` deploy can /// take, relative to the user-supplied `root`. /// @@ -401,6 +442,7 @@ where RuntimeMode::MultiThread => default_pool_size(), }; apply_v8_flags(worker_count); + let _contention_logger = diagnostics::spawn_periodic_logger(); tracing::info!( bind = %config.bind(), workers = worker_count, @@ -539,18 +581,17 @@ pub fn resolve_runtime_mode(env_value: Option<&str>, available_cpus: Option usize { /// Returns `None` if `NEXIDE_POOL_MEMORY_BUDGET_MB` is unset, blank or /// invalid (a `warn!` is logged in the invalid case so misconfigured /// deployments are not silent). The companion env -/// `NEXIDE_HEAP_PER_ISOLATE_MB` (default -/// [`DEFAULT_HEAP_PER_ISOLATE_MB`]) tunes the assumed steady-state -/// RSS per isolate; `128` MiB is the empirical observation from -/// `scripts/bench.sh` on the production Next.js bundle. +/// `NEXIDE_HEAP_PER_ISOLATE_MB` overrides the heap band selected by +/// [`adaptive_per_isolate_heap_mb`]; otherwise the heap is picked +/// from the budget so smaller containers do not waste reserve on +/// heap V8 will never use. fn pool_size_from_memory_budget_env() -> Option { let budget_raw = std::env::var("NEXIDE_POOL_MEMORY_BUDGET_MB").ok(); let per_iso_raw = std::env::var("NEXIDE_HEAP_PER_ISOLATE_MB").ok(); @@ -630,32 +671,115 @@ fn pool_size_from_memory_budget_env() -> Option { ); } let budget_mb = budget_mb?; - let per_iso_mb = mb_from_env(per_iso_raw.as_deref()).unwrap_or(DEFAULT_HEAP_PER_ISOLATE_MB); + let per_iso_mb = mb_from_env(per_iso_raw.as_deref()) + .unwrap_or_else(|| adaptive_per_isolate_heap_mb(budget_mb)); Some(pool_size_from_memory_budget(budget_mb, per_iso_mb)) } -/// Derives a pool size from a memory budget and a per-isolate heap -/// estimate, reserving one isolate's worth of headroom for the recycle -/// peak (the recycler boots a fresh isolate **before** retiring the -/// outgoing one). -/// -/// Returns at least `1` to keep the runtime live even when the budget -/// is below `2 × per_isolate`; in that degenerate case a `warn!` is -/// logged from [`pool_size_from_memory_budget_env`] so the operator -/// learns the configured budget was not honoured. `per_isolate_mb` -/// is treated as `1` if the operator sets it to `0` to avoid a -/// division-by-zero. -fn pool_size_from_memory_budget(budget_mb: u64, per_isolate_mb: u64) -> usize { - let per_iso = per_isolate_mb.max(1); - let reserve = per_iso; - let usable = budget_mb.saturating_sub(reserve); - let raw = usable / per_iso; +/// Per-isolate RSS the runtime adds on top of the V8 old-generation +/// heap: V8 native code, generated JIT pages, code cache, semi-space, +/// embedder data and the worker thread stack. Empirically `~40` MiB +/// for the production Next.js standalone bundle - measured by +/// subtracting reported `heap_size_limit` from the steady-state RSS +/// of a single saturated worker. +const PER_ISOLATE_RSS_OVERHEAD_MB: u64 = 40; + +/// Floor for the runtime-wide overhead unrelated to V8 isolates: +/// the tokio scheduler, hyper / rustls buffers, the prerender cache, +/// the JS bundle text, snapshot blobs, cgroup-side bookkeeping and +/// kernel networking buffers. +/// +/// Empirically calibrated against the Next.js standalone benchmark +/// on a 256 MiB / 1 CPU container: total RSS ≈ 189 MiB with one +/// isolate at heap ≈ 117 MiB and `PER_ISOLATE_RSS_OVERHEAD_MB = 40` +/// → leftover for everything else ≈ 32 MiB. A floor any higher +/// (the previous `80` MiB) starves V8 of old-space and triggers the +/// `Ineffective mark-compacts near heap limit` fatal abort under +/// load, since the live working set genuinely exceeds the cap that +/// gets back-derived. Bigger containers still get an additive +/// margin via `budget / 16` so prerender cache + tail allocations +/// have room (see [`fixed_runtime_overhead_mb`]). +const MIN_FIXED_RUNTIME_OVERHEAD_MB: u64 = 32; + +/// Cap on the additive scaling component so a 16 GiB container does +/// not reserve a full GiB for "overhead" it will never use. +const MAX_FIXED_RUNTIME_OVERHEAD_MB: u64 = 256; + +/// Returns the runtime-wide fixed overhead (Rust heap, V8 native, +/// JS bundle, kernel buffers) for the given container memory budget. +/// +/// Floor at [`MIN_FIXED_RUNTIME_OVERHEAD_MB`] for tight containers; +/// adds `budget / 16` so larger deployments get proportional headroom +/// for hyper/tokio buffers, the prerender cache and tail allocations, +/// up to [`MAX_FIXED_RUNTIME_OVERHEAD_MB`]. +fn fixed_runtime_overhead_mb(budget_mb: u64) -> u64 { + MIN_FIXED_RUNTIME_OVERHEAD_MB + .saturating_add(budget_mb / 16) + .min(MAX_FIXED_RUNTIME_OVERHEAD_MB) +} + +/// Returns the per-isolate V8 old-generation heap size to use for +/// pool sizing arithmetic, picked adaptively from the container +/// budget so tight presets do not waste reserve on heap V8 will +/// never use. +/// +/// Bands (re-tuned for dedicated Next.js workloads — RSC module +/// graph + i18n catalogs + prerender cache routinely hit `150 – +/// 350 MiB` working set, so the previous bands of `64 – 128 MiB` +/// produced fatal `Reached heap limit` aborts on real apps): +/// * `≤ 256` MiB → `96` MiB (tight: floors to +/// [`MIN_OLD_SPACE_CAP_MB`] in the V8 cap because V8 rejects smaller +/// old-spaces; the smaller value here is what the pool sizer +/// *reserves* per isolate so the pool stays correctly conservative). +/// * `≤ 512` MiB → `128` MiB (small: a single dedicated Next.js +/// isolate dominates the budget, so reserving more per-slot stops +/// the pool sizer from over-committing on `512 MiB / 1 cpu` pods). +/// * `≤ 1024` MiB → `192` MiB (mid). +/// * `> 1024` MiB → `256` MiB (generous: hot working set fits without +/// triggering frequent major GC). +fn adaptive_per_isolate_heap_mb(budget_mb: u64) -> u64 { + if budget_mb <= 256 { + 96 + } else if budget_mb <= 512 { + 128 + } else if budget_mb <= 1024 { + 192 + } else { + 256 + } +} + +/// Derives a pool size from a **container** memory budget, accounting +/// for fixed runtime overhead, per-isolate RSS overhead beyond the V8 +/// heap, and a recycle reserve worth one isolate (the recycler boots +/// a fresh isolate **before** retiring the outgoing one). +/// +/// `per_isolate_heap_mb` is the V8 old-generation heap size used to +/// size each isolate's RSS contribution +/// (`per_isolate_heap_mb + PER_ISOLATE_RSS_OVERHEAD_MB`). Operators +/// can override the heap via `NEXIDE_HEAP_PER_ISOLATE_MB`; otherwise +/// [`adaptive_per_isolate_heap_mb`] picks a value from the budget. +/// +/// Returns at least `1` to keep the runtime live even when the +/// budget cannot satisfy a single isolate plus reserve; in that +/// degenerate case a `warn!` is logged so the operator learns the +/// configured budget was not honoured. +fn pool_size_from_memory_budget(budget_mb: u64, per_isolate_heap_mb: u64) -> usize { + let heap = per_isolate_heap_mb.max(1); + let per_iso_rss = heap.saturating_add(PER_ISOLATE_RSS_OVERHEAD_MB); + let fixed_oh = fixed_runtime_overhead_mb(budget_mb); + let usable = budget_mb.saturating_sub(fixed_oh); + let reserve = per_iso_rss; + let after_reserve = usable.saturating_sub(reserve); + let raw = after_reserve / per_iso_rss; if raw == 0 { tracing::warn!( budget_mb, - per_isolate_mb = per_iso, + fixed_overhead_mb = fixed_oh, + per_isolate_heap_mb = heap, + per_isolate_rss_mb = per_iso_rss, reserve_mb = reserve, - "NEXIDE_POOL_MEMORY_BUDGET_MB cannot satisfy a single isolate plus recycle headroom; \ + "memory budget cannot satisfy fixed overhead + one isolate + recycle reserve; \ starting with 1 worker - actual RSS will exceed the requested budget" ); return 1; @@ -672,7 +796,7 @@ fn pool_size_from_memory_budget(budget_mb: u64, per_isolate_mb: u64) -> usize { fn pool_size_from_cgroup_memory() -> Option { let budget_mb = cgroup_memory_limit_mb()?; let per_iso_mb = mb_from_env(std::env::var("NEXIDE_HEAP_PER_ISOLATE_MB").ok().as_deref()) - .unwrap_or(DEFAULT_HEAP_PER_ISOLATE_MB); + .unwrap_or_else(|| adaptive_per_isolate_heap_mb(budget_mb)); Some(pool_size_from_memory_budget(budget_mb, per_iso_mb)) } @@ -726,15 +850,39 @@ fn read_cgroup_v1_memory_limit() -> Option { Some(value) } -/// RSS each isolate consumes when serving the production Next.js -/// standalone bundle under sustained load. +/// Returns the per-isolate in-flight cap to use when no operator +/// override is supplied via [`MAX_INFLIGHT_PER_ISOLATE_ENV`], picked +/// adaptively from the container memory budget so tight presets do +/// not let the JS heap death-spiral on bursty traffic. +/// +/// Each Next.js render context (request → handler → response) costs +/// roughly `1-2 MiB` of *transient* old-space heap. With the timer +/// polyfill closure leak fixed (see `runtime/polyfills/timers.js`), +/// retained per-request memory is bounded by what V8's GC can sweep +/// inside the active request lifetime — so we mostly just need to +/// keep concurrency below the point where 64 simultaneous renders +/// would temporarily fill the heap before any can settle. /// -/// Reduced from a previous conservative `128` MiB after the -/// `nexide-bench docker-suite` run revealed that idle isolates settle -/// at ~50 MiB RSS and active ones at ~70 MiB; `64` is the rounded -/// midpoint that lets a 256 MiB container host 3 workers (vs 1 -/// before) without OOM, and a 512 MiB container host 7 (vs 3). -const DEFAULT_HEAP_PER_ISOLATE_MB: u64 = 64; +/// Bands (validated empirically on the docker bench harness): +/// * `≤ 256` MiB → `16` (tight: V8 cap ≈ 168 MiB; dispatch latency +/// ≪ accept latency on small handlers, so 16 lets us saturate a +/// single CPU without queueing). +/// * `≤ 512` MiB → `32`. +/// * `≤ 1024` MiB → `64`. +/// * `> 1024` MiB → `128` (effectively unlimited for `64 conns` +/// bench but still bounds runaway burst). +#[must_use] +pub fn adaptive_max_inflight_per_isolate(budget_mb: u64) -> u32 { + if budget_mb <= 256 { + 16 + } else if budget_mb <= 512 { + 32 + } else if budget_mb <= 1024 { + 64 + } else { + 128 + } +} /// Default cap on concurrent in-flight HTTP requests per V8 isolate. /// @@ -748,7 +896,11 @@ const DEFAULT_HEAP_PER_ISOLATE_MB: u64 = 64; /// /// Operators can override via [`MAX_INFLIGHT_PER_ISOLATE_ENV`] /// (`NEXIDE_MAX_INFLIGHT_PER_ISOLATE`); values below `1` are clamped -/// to `1` so the runtime always remains live. +/// to `1` so the runtime always remains live. When no override is +/// set the production code path picks an adaptive value via +/// [`adaptive_max_inflight_per_isolate`] from the container budget, +/// so this constant is the *fallback* used only when the budget is +/// unknown. pub const DEFAULT_MAX_INFLIGHT_PER_ISOLATE: u32 = 16; /// Environment variable that overrides @@ -798,6 +950,26 @@ pub fn max_inflight_per_isolate() -> u32 { resolve_max_inflight_per_isolate(std::env::var(MAX_INFLIGHT_PER_ISOLATE_ENV).ok().as_deref()) } +/// Effective per-isolate in-flight cap, combining (in priority +/// order): the [`MAX_INFLIGHT_PER_ISOLATE_ENV`] override, the +/// adaptive band derived from the cgroup memory limit, and finally +/// the rubber-duck default. +/// +/// Production server boot path call this once per worker so the +/// chosen cap is recorded in startup logs and applied to the +/// `NextBridgeHandler` semaphore. +#[must_use] +pub fn effective_max_inflight_per_isolate() -> u32 { + if let Some(raw) = std::env::var(MAX_INFLIGHT_PER_ISOLATE_ENV).ok().as_deref() + && !raw.trim().is_empty() + { + return resolve_max_inflight_per_isolate(Some(raw)); + } + cgroup_memory_limit_mb() + .map(adaptive_max_inflight_per_isolate) + .unwrap_or(DEFAULT_MAX_INFLIGHT_PER_ISOLATE) +} + /// Parses an unsigned integer "MB" env value. fn mb_from_env(raw: Option<&str>) -> Option { raw.map(str::trim) @@ -1126,9 +1298,7 @@ fn find_unique_nested_app(root: &Path) -> Option { } async fn wait_for_ctrl_c() { - if let Err(error) = tokio::signal::ctrl_c().await { - tracing::error!(%error, "failed to listen for ctrl-c"); - } + crate::ops::bind_termination_signals().await; } /// Environment variable that injects V8 process-wide flags @@ -1144,23 +1314,30 @@ const V8_FLAGS_ENV: &str = "NEXIDE_V8_FLAGS"; /// Default V8 young-generation cap shared by every preset. /// -/// Matches V8's stock semi-space cap; documented here so the field is -/// not silently inherited from the V8 default and so changes show up -/// in source control. -const DEFAULT_SEMI_SPACE_CAP_MB: u64 = 16; - -/// Per-worker safety margin reserved on top of the V8 old-generation -/// cap to leave headroom for non-V8 allocations (Rust-side `BytesMut`, -/// mpsc buffers, isolate metadata). -/// -/// Empirically ~64 MiB is enough for the steady-state working set -/// outside V8 on a saturated worker. -const NON_V8_SAFETY_MB: u64 = 64; +/// Bumped from V8's stock 16 MiB to **32 MiB** because Next.js API +/// handlers that allocate per-request objects (e.g. `new Date()`, +/// `NextResponse.json({...})`, RSC payload builders) flood the young +/// generation under sustained load. With the smaller default V8 +/// promotes short-lived objects to old-gen prematurely, which inflates +/// major-mark-sweep frequency and shows up as a 30-90 ms p99 spike +/// on the `api-time` route. Doubling the semi-space cap roughly halves +/// the promotion rate at the cost of ~32 MiB extra young-gen RSS per +/// isolate - a worthwhile trade for the tail-latency improvement, +/// especially since the young generation is collected with a fast +/// scavenger (sub-millisecond pauses) instead of the major GC. +const DEFAULT_SEMI_SPACE_CAP_MB: u64 = 32; /// Lower bound for the V8 old-generation cap when one is computed /// from a container budget. Below this V8 cannot finish booting the /// snapshot and `CommonJS` module graph reliably. -const MIN_OLD_SPACE_CAP_MB: u64 = 96; +/// +/// Bumped from 96 -> 128 in the K1 adaptive-heap pass: bench +/// `paste-1777879637709.txt` showed that even the tightest single-worker +/// container (1cpu/256 MiB) reliably mem-maxes at ~213 MiB while V8 +/// stays clamped to 96 MiB old-space, so the extra 32 MiB of old-gen +/// headroom is safe and removes the major-GC pause that drove +/// `api-time` p99 to 107 ms. +const MIN_OLD_SPACE_CAP_MB: u64 = 128; /// Upper bound for the adaptive V8 old-generation cap. /// @@ -1187,20 +1364,66 @@ const MIN_OLD_SPACE_CAP_MB: u64 = 96; /// where the historical tail-latency win lives. const HARD_OLD_SPACE_CAP_MB: u64 = 256; +/// Upper bound for the V8 old-generation cap when a **single** worker +/// owns the whole container memory budget. +/// +/// The multi-worker [`HARD_OLD_SPACE_CAP_MB`] (`256 MiB`) was tuned +/// for tail-latency on shared-pool deployments where each isolate +/// holds a slice of the budget and a long mark-sweep on one isolate +/// stalls the others' shared dispatcher. In a *dedicated* Next.js +/// single-worker pod (`workers == 1`), there is nobody else to stall: +/// mark-sweep latency is bounded only by the working set itself, +/// and modern V8 concurrent marking keeps real-world pauses under +/// `25 ms` even at `~1 GiB` old-space. Capping at `256 MiB` instead +/// caused fatal `Reached heap limit` aborts on real Next.js apps +/// whose hot working set (i18n catalogs, RSC module graph, prerender +/// cache) exceeds the historical band-derived target. +/// +/// `1 GiB` covers every realistic Next.js workload while still giving +/// V8 a deterministic ceiling that keeps mark-sweep latency bounded. +const SINGLE_WORKER_HARD_OLD_SPACE_CAP_MB: u64 = 1024; + +/// Target old-space cap for multi-worker presets with a generous +/// memory budget (`>= 1024 MiB`). +/// +/// Single-worker setups stick with `adaptive_per_isolate_heap_mb`, +/// but on multi-worker presets the per-isolate heap that the band +/// picks (`96 MiB` for `1024 MiB`) is small enough that V8 hits +/// `--max-old-space-size` every few hundred milliseconds under +/// sustained API load and the resulting mark-sweep finalisations +/// dominate p99 tail latency. +/// +/// The target is set equal to [`HARD_OLD_SPACE_CAP_MB`] so that +/// every multi-worker isolate with enough head-room runs at the +/// bench-validated p99 sweet-spot (`256 MiB`): smaller heaps trip +/// "Reached heap limit" fatal aborts on real Next.js apps that +/// keep 250-300 MiB of working set (i18n catalogs, prerender cache, +/// React Server Component module graph), while larger heaps lengthen +/// each major mark-sweep enough to dominate `api-*` p99 again. +/// `target.min(head_room)` in [`compose_default_v8_flags`] still +/// prevents over-commit on tighter worker shares (e.g. `1024/4`, +/// where `head_room == 192 MiB` snaps the cap back down). +const MULTI_WORKER_OLD_SPACE_TARGET_MB: u64 = HARD_OLD_SPACE_CAP_MB; + /// Composes the default V8 flag string adaptively from the container /// memory budget and the worker count. /// /// * No budget → no `--max-old-space-size` (V8 grows lazily, which /// keeps GC pressure low on dev machines that don't expose a /// container memory limit). -/// * Budget present → cap each isolate at -/// `clamp(raw, MIN_OLD_SPACE_CAP_MB, max(HARD_OLD_SPACE_CAP_MB, raw/2))` -/// where `raw = budget/workers - NON_V8_SAFETY_MB`. The ceiling -/// adaptively loosens once the per-worker share is more than -/// 2× the hard floor, so generously-provisioned containers -/// (e.g. 1cpu/1024 MiB) regain JIT-cache headroom without -/// re-introducing major-GC tail latency on tight presets -/// (phase A; supersedes the historical hard 256 MiB clamp). +/// * Budget present → cap each isolate at the same per-isolate heap +/// that [`pool_size_from_memory_budget`] uses to size the pool: +/// `cap = clamp(adaptive_per_isolate_heap_mb(budget), MIN, ceiling)` +/// where `MIN = MIN_OLD_SPACE_CAP_MB` and +/// `ceiling = max(HARD_OLD_SPACE_CAP_MB, available_per_worker / 2)`. +/// Crucially, the cap is **not** derived from +/// `(budget / workers) - PER_ISOLATE_RSS_OVERHEAD_MB`: that formula +/// assumed all workers share the budget evenly and ignored the +/// recycle reserve, so on a `pool=1` 256 MiB container it produced a +/// 168 MiB old-space cap that exceeded what the pool sizer reserved +/// (`64 + 40 = 104` MiB per isolate) and triggered "Ineffective +/// mark-compacts near heap limit" fatal aborts under load. Using +/// the same adaptive band keeps V8 and the pool sizer in lockstep. /// /// `--max-semi-space-size=N` (with `N` from [`DEFAULT_SEMI_SPACE_CAP_MB`]) /// is always set so the young generation never grows faster than V8's @@ -1211,39 +1434,150 @@ const HARD_OLD_SPACE_CAP_MB: u64 = 256; /// mutating V8's process-global flag table. fn compose_default_v8_flags(budget_mb: Option, workers: usize) -> String { use std::fmt::Write as _; - let workers = workers.max(1) as u64; let mut flags = format!("--max-semi-space-size={DEFAULT_SEMI_SPACE_CAP_MB}"); if let Some(budget) = budget_mb { - let raw = budget - .saturating_div(workers) - .saturating_sub(NON_V8_SAFETY_MB); - let ceiling = HARD_OLD_SPACE_CAP_MB.max(raw / 2); - let cap = raw.clamp(MIN_OLD_SPACE_CAP_MB, ceiling); + let cap = compute_old_space_cap_mb(budget, workers); let _ = write!(flags, " --max-old-space-size={cap}"); } flags } +/// Computes the V8 `--max-old-space-size` value (in MiB) for a given +/// container memory budget and worker count. +/// +/// Pure helper extracted from [`compose_default_v8_flags`] so the same +/// number can drive both the process-wide V8 flag *and* the per-isolate +/// `Isolate::create_params().heap_limits(...)` cap (see +/// [`effective_heap_limit`]). Keeping the two caps in lockstep prevents +/// the historical bug where the V8 flag allowed e.g. `346 MiB` but the +/// per-isolate hard cap silently clamped each isolate to the +/// `HeapLimitConfig::default()` value of `256 MiB`. +/// +/// # Single-worker behaviour (`workers == 1`) +/// +/// A dedicated Next.js single-worker pod has no peer isolate to stall, +/// so it takes the dominant share of `head_room` (`85 %`) up to +/// [`SINGLE_WORKER_HARD_OLD_SPACE_CAP_MB`]. The historical +/// `30 %`-of-budget proportional rule was tuned for the bench suite's +/// p99 sweet-spot but produced caps too small (`153 MiB` for a +/// `512 MiB` container) to hold real Next.js working sets, triggering +/// `Reached heap limit` fatal aborts. +/// +/// # Multi-worker behaviour (`workers >= 2`) +/// +/// Unchanged from the previous implementation: targets +/// [`MULTI_WORKER_OLD_SPACE_TARGET_MB`] when budget allows, otherwise +/// the band picked by [`adaptive_per_isolate_heap_mb`], clamped to +/// `[MIN_OLD_SPACE_CAP_MB, ceiling]` where the ceiling is +/// `max(HARD_OLD_SPACE_CAP_MB, head_room / 2)`. Tail-latency wins from +/// the bench suite are preserved. +fn compute_old_space_cap_mb(budget_mb: u64, workers: usize) -> u64 { + let workers = (workers.max(1)) as u64; + let fixed_oh = fixed_runtime_overhead_mb(budget_mb); + let usable = budget_mb.saturating_sub(fixed_oh); + let per_worker_share = usable.saturating_div(workers); + let head_room = per_worker_share.saturating_sub(PER_ISOLATE_RSS_OVERHEAD_MB); + let band = adaptive_per_isolate_heap_mb(budget_mb); + let (target, ceiling) = if workers >= 2 && budget_mb >= 1024 { + ( + band.max(MULTI_WORKER_OLD_SPACE_TARGET_MB), + HARD_OLD_SPACE_CAP_MB.max(head_room / 2), + ) + } else if workers == 1 && budget_mb >= 256 { + let proportional = head_room.saturating_mul(85).saturating_div(100); + let ceiling = head_room + .saturating_sub(16) + .clamp(MIN_OLD_SPACE_CAP_MB, SINGLE_WORKER_HARD_OLD_SPACE_CAP_MB); + (band.max(proportional), ceiling) + } else { + (band, HARD_OLD_SPACE_CAP_MB.max(head_room / 2)) + }; + target.min(head_room).clamp(MIN_OLD_SPACE_CAP_MB, ceiling) +} + /// Applies the V8 process-wide flags before any isolate is created. /// /// Must be called before the first [`pool::IsolatePool`] boot - /// once `v8::V8::initialize` runs (lazily on the first /// `JsRuntime::try_new`), flags become read-only. The function is /// idempotent (guarded by a `Once`). +/// +/// In addition to setting V8's process-wide flags, this also seeds +/// [`EFFECTIVE_HEAP_LIMIT`] with the matching per-isolate +/// `HeapLimitConfig` so that +/// [`crate::engine::v8_engine::BootContext::with_heap_limit`] can pin +/// every isolate's `heap_size_limit` to the same number reported in +/// `--max-old-space-size`. Without this synchronisation the V8 flag +/// could allow e.g. `346 MiB` while each isolate's `create_params` +/// silently clamped to the project default of `256 MiB`, producing +/// the same `Reached heap limit` aborts the flag was supposed to +/// avoid. fn apply_v8_flags(workers: usize) { use std::sync::Once; static ONCE: Once = Once::new(); ONCE.call_once(|| { - let flags = resolve_v8_flags( - std::env::var(V8_FLAGS_ENV).ok().as_deref(), - cgroup_memory_limit_mb(), + let budget = cgroup_memory_limit_mb(); + let flags = resolve_v8_flags(std::env::var(V8_FLAGS_ENV).ok().as_deref(), budget, workers); + let heap_limit = resolve_effective_heap_limit(budget, workers); + let _ = EFFECTIVE_HEAP_LIMIT.set(heap_limit); + tracing::info!( + %flags, + heap_initial_mb = heap_limit.initial_mb(), + heap_max_mb = heap_limit.max_mb(), + budget_mb = ?budget, workers, + "applying V8 flags", ); - tracing::info!(%flags, "applying V8 flags"); v8::V8::set_flags_from_string(&flags); }); } +/// Process-wide cache of the per-isolate heap budget computed at +/// startup by [`apply_v8_flags`]. `OnceLock` keeps the wiring +/// lock-free on the dispatch hot path. +static EFFECTIVE_HEAP_LIMIT: OnceLock = OnceLock::new(); + +/// Returns the per-isolate [`HeapLimitConfig`] computed at startup. +/// +/// Wired into every [`crate::engine::v8_engine::BootContext`] so the +/// per-isolate `Isolate::create_params().heap_limits(...)` cap stays +/// in lockstep with the process-wide `--max-old-space-size` flag set +/// in [`apply_v8_flags`]. +/// +/// Falls back to [`HeapLimitConfig::default()`] when `apply_v8_flags` +/// has not run yet - relevant for unit tests that boot a `V8Engine` +/// directly without going through [`serve_app_until`]. +#[must_use] +pub fn effective_heap_limit() -> HeapLimitConfig { + EFFECTIVE_HEAP_LIMIT.get().copied().unwrap_or_default() +} + +/// Resolves the per-isolate [`HeapLimitConfig`] from the detected +/// container budget and the worker count. +/// +/// Resolution order (mirrors [`compose_default_v8_flags`]): +/// 1. `NEXIDE_HEAP_LIMIT_MB` operator override (escape hatch). +/// 2. Container budget detected via cgroup, fed through +/// [`compute_old_space_cap_mb`] so the per-isolate cap matches +/// the V8 flag exactly. +/// 3. [`HeapLimitConfig::default`] when no budget is detected +/// (development on bare metal). +fn resolve_effective_heap_limit(budget_mb: Option, workers: usize) -> HeapLimitConfig { + if let Some(env_cfg) = + crate::engine::heap_limit_from_env(std::env::var("NEXIDE_HEAP_LIMIT_MB").ok().as_deref()) + { + return env_cfg; + } + match budget_mb { + Some(budget) => { + let cap_mb = compute_old_space_cap_mb(budget, workers) as usize; + let initial = HeapLimitConfig::DEFAULT_INITIAL_MB.min(cap_mb); + HeapLimitConfig::new(initial, cap_mb) + } + None => HeapLimitConfig::default(), + } +} + /// Picks the effective V8 flag string given the raw env value and the /// detected container budget. /// @@ -1265,12 +1599,14 @@ fn resolve_v8_flags(env_value: Option<&str>, budget_mb: Option, workers: us #[cfg(test)] mod tests { use super::{ - AppLayout, BIND_ENV, DEFAULT_BIND, DEFAULT_HEAP_PER_ISOLATE_MB, - DEFAULT_MAX_INFLIGHT_PER_ISOLATE, HARD_OLD_SPACE_CAP_MB, MIN_OLD_SPACE_CAP_MB, - RUNTIME_MODE_ENV, RuntimeError, RuntimeMode, absolute_dir, blocking_cap_from_env, - compose_default_v8_flags, detected_blocking_cap, detected_pool_size, install_tracing, - mb_from_env, parse_bind, pool_size_from_env, pool_size_from_memory_budget, - pool_size_from_perf_cores, resolve_default_bind, resolve_max_inflight_per_isolate, + AppLayout, BIND_ENV, DEFAULT_BIND, DEFAULT_MAX_INFLIGHT_PER_ISOLATE, + MAX_FIXED_RUNTIME_OVERHEAD_MB, MIN_FIXED_RUNTIME_OVERHEAD_MB, MIN_OLD_SPACE_CAP_MB, + RUNTIME_MODE_ENV, RuntimeError, RuntimeMode, absolute_dir, + adaptive_max_inflight_per_isolate, adaptive_per_isolate_heap_mb, blocking_cap_from_env, + compose_default_v8_flags, compute_old_space_cap_mb, detected_blocking_cap, + detected_pool_size, fixed_runtime_overhead_mb, install_tracing, mb_from_env, parse_bind, + pool_size_from_env, pool_size_from_memory_budget, pool_size_from_perf_cores, + resolve_default_bind, resolve_effective_heap_limit, resolve_max_inflight_per_isolate, resolve_runtime_mode, resolve_v8_flags, }; @@ -1410,8 +1746,10 @@ mod tests { #[test] fn pool_size_from_memory_budget_reserves_one_isolate_for_recycle_peak() { - assert_eq!(pool_size_from_memory_budget(1024, 128), 7); - assert_eq!(pool_size_from_memory_budget(640, 128), 4); + // 1024/128: fixed=144, usable=880, after_reserve=880-168=712, 712/168=4. + assert_eq!(pool_size_from_memory_budget(1024, 128), 4); + // 640/128: fixed=120, usable=520, after_reserve=520-168=352, 352/168=2. + assert_eq!(pool_size_from_memory_budget(640, 128), 2); } #[test] @@ -1428,57 +1766,179 @@ mod tests { } #[test] - fn default_heap_per_isolate_mb_is_documented_empirical_value() { - assert_eq!(DEFAULT_HEAP_PER_ISOLATE_MB, 64); + fn adaptive_per_isolate_heap_picks_band_from_budget() { + assert_eq!(adaptive_per_isolate_heap_mb(128), 96); + assert_eq!(adaptive_per_isolate_heap_mb(256), 96); + assert_eq!(adaptive_per_isolate_heap_mb(257), 128); + assert_eq!(adaptive_per_isolate_heap_mb(512), 128); + assert_eq!(adaptive_per_isolate_heap_mb(513), 192); + assert_eq!(adaptive_per_isolate_heap_mb(1024), 192); + assert_eq!(adaptive_per_isolate_heap_mb(1025), 256); + assert_eq!(adaptive_per_isolate_heap_mb(8192), 256); } #[test] - fn small_container_budget_unlocks_multiple_workers_after_p1() { - assert_eq!( - pool_size_from_memory_budget(256, DEFAULT_HEAP_PER_ISOLATE_MB), - 3 - ); + fn fixed_runtime_overhead_scales_then_caps() { + assert_eq!(fixed_runtime_overhead_mb(0), MIN_FIXED_RUNTIME_OVERHEAD_MB); + assert_eq!(fixed_runtime_overhead_mb(256), 32 + 16); + assert_eq!(fixed_runtime_overhead_mb(1024), 32 + 64); assert_eq!( - pool_size_from_memory_budget(512, DEFAULT_HEAP_PER_ISOLATE_MB), - 7 + fixed_runtime_overhead_mb(64 * 1024), + MAX_FIXED_RUNTIME_OVERHEAD_MB ); + } + + #[test] + fn pool_sizing_respects_fixed_overhead_and_rss_overhead() { + let p = pool_size_from_memory_budget(256, adaptive_per_isolate_heap_mb(256)); + assert_eq!(p, 1, "256 MiB only fits one isolate after fixed overhead"); + let p = pool_size_from_memory_budget(512, adaptive_per_isolate_heap_mb(512)); assert_eq!( - pool_size_from_memory_budget(1024, DEFAULT_HEAP_PER_ISOLATE_MB), - 15 + p, 1, + "512 MiB fits one fat Next.js isolate (band raised to 128 MiB)" ); + let p = pool_size_from_memory_budget(1024, adaptive_per_isolate_heap_mb(1024)); + assert_eq!(p, 3, "1 GiB fits three 192-MiB-heap isolates"); + let p = pool_size_from_memory_budget(8192, adaptive_per_isolate_heap_mb(8192)); + assert!(p >= 24, "8 GiB should fit at least 24 isolates (got {p})"); + } + + #[test] + fn pool_sizing_floors_at_one_when_budget_under_overhead() { + assert_eq!(pool_size_from_memory_budget(96, 64), 1); + assert_eq!(pool_size_from_memory_budget(128, 64), 1); } #[test] fn compose_default_v8_flags_omits_old_space_when_no_budget() { let flags = compose_default_v8_flags(None, 4); - assert!(flags.contains("--max-semi-space-size=16")); + assert!(flags.contains("--max-semi-space-size=32")); assert!(!flags.contains("--max-old-space-size")); } #[test] fn compose_default_v8_flags_scales_old_space_with_budget_and_workers() { + // 1024/4 multi-worker: target=max(band=192, MULTI=256)=256, + // head_room=192 clamps min, ceiling=256, cap=192. let flags = compose_default_v8_flags(Some(1024), 4); - assert!(flags.contains("--max-old-space-size=192")); + assert!( + flags.contains("--max-old-space-size=192"), + "actual flags: {flags}" + ); + // 256/1 dedicated: head_room=168, target=max(band=96, 168*0.85=142)=142, + // ceiling=min(max(152, 128), 1024)=152, cap=142. let flags = compose_default_v8_flags(Some(256), 1); - assert!(flags.contains("--max-old-space-size=192")); + assert!( + flags.contains("--max-old-space-size=142"), + "actual flags: {flags}" + ); + // 1024/2 multi-worker: target=max(band=192, MULTI=256)=256, + // head_room=424, ceiling=256, cap=256. let flags = compose_default_v8_flags(Some(1024), 2); - assert!(flags.contains(&format!("--max-old-space-size={HARD_OLD_SPACE_CAP_MB}"))); + assert!(flags.contains("--max-old-space-size=256")); } #[test] - fn compose_default_v8_flags_loosens_ceiling_with_large_budget() { + fn compose_default_v8_flags_takes_dominant_share_for_dedicated_single_worker() { + // 512/1 dedicated Next.js: head_room=408, target=max(128, 408*0.85=346)=346, + // ceiling=min(max(392, 128), 1024)=392, cap=346. + // Previously: 153 — too tight, fatal aborts on real Next.js apps. + let flags = compose_default_v8_flags(Some(512), 1); + assert!( + flags.contains("--max-old-space-size=346"), + "512/1 must take dominant share of head_room; flags: {flags}" + ); + // 1024/1 dedicated: head_room=888, target=max(192, 754)=754, ceiling=872, + // cap=754. Previously clamped to HARD=256. let flags = compose_default_v8_flags(Some(1024), 1); - assert!(flags.contains("--max-old-space-size=480")); + assert!( + flags.contains("--max-old-space-size=754"), + "actual flags: {flags}" + ); + // 8192/1 dedicated: head_room=7896, target=6711, ceiling clamps to + // SINGLE_WORKER_HARD=1024. Previously clamped to HARD=256. let flags = compose_default_v8_flags(Some(8192), 1); - assert!(flags.contains("--max-old-space-size=4064")); + assert!( + flags.contains("--max-old-space-size=1024"), + "actual flags: {flags}" + ); + } + + #[test] + fn compose_default_v8_flags_aligns_with_pool_reserve_on_tight_container() { + // 256/1 dedicated: see scales_old_space_with_budget_and_workers — cap=142. + let flags = compose_default_v8_flags(Some(256), 1); + assert!( + flags.contains("--max-old-space-size=142"), + "tight container computes 85% of head_room; flags: {flags}" + ); } #[test] fn compose_default_v8_flags_floors_at_min_cap() { + // 96/1: budget < 256 falls into the else-branch where target=band=96, + // head_room=18, cap clamps up to MIN=128. let flags = compose_default_v8_flags(Some(96), 1); assert!(flags.contains(&format!("--max-old-space-size={MIN_OLD_SPACE_CAP_MB}"))); - let flags = compose_default_v8_flags(Some(512), 0); - assert!(flags.contains(&format!("--max-old-space-size={HARD_OLD_SPACE_CAP_MB}"))); + // workers=0 normalised to 1; budget<256 still falls back to plain band. + let flags = compose_default_v8_flags(Some(128), 0); + assert!(flags.contains(&format!("--max-old-space-size={MIN_OLD_SPACE_CAP_MB}"))); + } + + #[test] + fn compute_old_space_cap_matches_compose_default() { + // The pure helper must agree with the embedded number in the + // composed flag string for every interesting (budget, workers) + // input. This keeps the V8 flag and the per-isolate + // `HeapLimitConfig` (driven by `compute_old_space_cap_mb`) in + // lockstep — the whole point of extracting the helper. + for (budget, workers) in [ + (96u64, 1usize), + (256, 1), + (256, 2), + (512, 1), + (512, 2), + (1024, 1), + (1024, 2), + (1024, 4), + (8192, 1), + (8192, 8), + ] { + let cap = compute_old_space_cap_mb(budget, workers); + let flags = compose_default_v8_flags(Some(budget), workers); + assert!( + flags.contains(&format!("--max-old-space-size={cap}")), + "cap mismatch for budget={budget} workers={workers}: \ + helper={cap}, flags={flags}" + ); + } + } + + #[test] + fn resolve_effective_heap_limit_uses_compute_when_budget_present() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + unsafe { std::env::remove_var("NEXIDE_HEAP_LIMIT_MB") }; + let cfg = resolve_effective_heap_limit(Some(512), 1); + let expected_max = compute_old_space_cap_mb(512, 1) as usize; + assert_eq!(cfg.max_mb(), expected_max); + assert!(cfg.initial_mb() <= cfg.max_mb()); + } + + #[test] + fn resolve_effective_heap_limit_honours_env_override() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + unsafe { std::env::set_var("NEXIDE_HEAP_LIMIT_MB", "192") }; + let cfg = resolve_effective_heap_limit(Some(8192), 8); + assert_eq!(cfg.max_mb(), 192, "env must override cgroup-derived cap"); + unsafe { std::env::remove_var("NEXIDE_HEAP_LIMIT_MB") }; + } + + #[test] + fn resolve_effective_heap_limit_falls_back_to_default_without_budget() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + unsafe { std::env::remove_var("NEXIDE_HEAP_LIMIT_MB") }; + let cfg = resolve_effective_heap_limit(None, 1); + assert_eq!(cfg.max_mb(), crate::engine::HeapLimitConfig::DEFAULT_MAX_MB); } #[test] @@ -1546,6 +2006,22 @@ mod tests { assert_eq!(DEFAULT_MAX_INFLIGHT_PER_ISOLATE, 16); } + #[test] + fn adaptive_max_inflight_picks_tight_cap_for_small_containers() { + assert_eq!(adaptive_max_inflight_per_isolate(128), 16); + assert_eq!(adaptive_max_inflight_per_isolate(256), 16); + } + + #[test] + fn adaptive_max_inflight_scales_through_bands() { + assert_eq!(adaptive_max_inflight_per_isolate(257), 32); + assert_eq!(adaptive_max_inflight_per_isolate(512), 32); + assert_eq!(adaptive_max_inflight_per_isolate(513), 64); + assert_eq!(adaptive_max_inflight_per_isolate(1024), 64); + assert_eq!(adaptive_max_inflight_per_isolate(1025), 128); + assert_eq!(adaptive_max_inflight_per_isolate(8192), 128); + } + #[test] fn runtime_mode_env_pins_single_thread() { for raw in [ diff --git a/crates/nexide/src/napi/bindings.rs b/crates/nexide/src/napi/bindings.rs index 2161b2a..016fd65 100644 --- a/crates/nexide/src/napi/bindings.rs +++ b/crates/nexide/src/napi/bindings.rs @@ -543,23 +543,57 @@ pub unsafe extern "C" fn napi_module_register(module: *mut crate::napi::types::N #[unsafe(no_mangle)] pub unsafe extern "C" fn napi_set_instance_data( - _env: napi_env, - _data: *mut c_void, - _finalize_cb: *mut c_void, - _finalize_hint: *mut c_void, + env: napi_env, + data: *mut c_void, + finalize_cb: *mut c_void, + finalize_hint: *mut c_void, ) -> NapiStatus { + let Some(env) = (unsafe { env_ref(env) }) else { + return NapiStatus::InvalidArg; + }; + if env.ctx.is_null() { + return NapiStatus::GenericFailure; + } + let ctx = unsafe { &*env.ctx }; + let finalize = if finalize_cb.is_null() { + None + } else { + Some(unsafe { + std::mem::transmute::< + *mut c_void, + unsafe extern "C" fn(*mut c_void, *mut c_void, *mut c_void), + >(finalize_cb) + }) + }; + *ctx.instance_data.borrow_mut() = Some(crate::napi::env::InstanceData { + data, + finalize, + finalize_hint, + }); NapiStatus::Ok } #[unsafe(no_mangle)] pub unsafe extern "C" fn napi_get_instance_data( - _env: napi_env, + env: napi_env, data: *mut *mut c_void, ) -> NapiStatus { if data.is_null() { return NapiStatus::InvalidArg; } - unsafe { ptr::write(data, ptr::null_mut()) }; + let Some(env) = (unsafe { env_ref(env) }) else { + return NapiStatus::InvalidArg; + }; + let ptr = if env.ctx.is_null() { + ptr::null_mut() + } else { + let ctx = unsafe { &*env.ctx }; + ctx.instance_data + .borrow() + .as_ref() + .map_or(ptr::null_mut(), |id| id.data) + }; + unsafe { ptr::write(data, ptr) }; NapiStatus::Ok } @@ -2719,19 +2753,44 @@ pub unsafe extern "C" fn napi_fatal_exception(env: napi_env, err: napi_value) -> #[unsafe(no_mangle)] pub unsafe extern "C" fn napi_add_env_cleanup_hook( - _env: napi_env, - _fun: Option, - _arg: *mut c_void, + env: napi_env, + fun: Option, + arg: *mut c_void, ) -> NapiStatus { + let Some(env) = (unsafe { env_ref(env) }) else { + return NapiStatus::InvalidArg; + }; + if env.ctx.is_null() { + return NapiStatus::GenericFailure; + } + let ctx = unsafe { &*env.ctx }; + ctx.cleanup_hooks + .borrow_mut() + .push(crate::napi::env::CleanupHook { fun, arg }); NapiStatus::Ok } #[unsafe(no_mangle)] pub unsafe extern "C" fn napi_remove_env_cleanup_hook( - _env: napi_env, - _fun: Option, - _arg: *mut c_void, + env: napi_env, + fun: Option, + arg: *mut c_void, ) -> NapiStatus { + let Some(env) = (unsafe { env_ref(env) }) else { + return NapiStatus::InvalidArg; + }; + if env.ctx.is_null() { + return NapiStatus::GenericFailure; + } + let ctx = unsafe { &*env.ctx }; + let mut hooks = ctx.cleanup_hooks.borrow_mut(); + let target_fp = fun.map(|f| f as usize); + if let Some(pos) = hooks + .iter() + .position(|h| h.fun.map(|f| f as usize) == target_fp && h.arg == arg) + { + hooks.remove(pos); + } NapiStatus::Ok } diff --git a/crates/nexide/src/napi/env.rs b/crates/nexide/src/napi/env.rs index f82eefc..180cc67 100644 --- a/crates/nexide/src/napi/env.rs +++ b/crates/nexide/src/napi/env.rs @@ -1,14 +1,32 @@ //! Per-FFI-call binding handed to addons as the opaque `napi_env*`. use std::cell::RefCell; +use std::ffi::c_void; use v8::Global; use crate::napi::types::napi_value; +/// Per-addon-instance data stored via `napi_set_instance_data`. +pub struct InstanceData { + pub data: *mut c_void, + pub finalize: + Option, + pub finalize_hint: *mut c_void, +} + +/// One env-cleanup hook registered via `napi_add_env_cleanup_hook`. +#[derive(Clone, Copy)] +pub struct CleanupHook { + pub fun: Option, + pub arg: *mut c_void, +} + pub struct NapiContext { pub module_name: Option, pub refs: RefCell>>>, + pub instance_data: RefCell>, + pub cleanup_hooks: RefCell>, } impl Default for NapiContext { @@ -23,6 +41,8 @@ impl NapiContext { Self { module_name: None, refs: RefCell::new(Vec::new()), + instance_data: RefCell::new(None), + cleanup_hooks: RefCell::new(Vec::new()), } } } diff --git a/crates/nexide/src/ops/dispatch_table.rs b/crates/nexide/src/ops/dispatch_table.rs index 0b24bc0..b445e92 100644 --- a/crates/nexide/src/ops/dispatch_table.rs +++ b/crates/nexide/src/ops/dispatch_table.rs @@ -1,9 +1,9 @@ //! Generational, per-isolate table of in-flight HTTP request slots. //! -//! Multiplexed dispatch (TASK Plan-B / B-3) requires that every -//! `op_nexide_*` op be parameterised by a `RequestId` so that several -//! handlers can run concurrently inside the same V8 isolate without -//! stomping on each other's [`RequestSlot`] / [`ResponseSlot`]. +//! Multiplexed dispatch requires that every `op_nexide_*` op be +//! parameterised by a `RequestId` so that several handlers can run +//! concurrently inside the same V8 isolate without stomping on each +//! other's [`RequestSlot`] / [`ResponseSlot`]. //! //! The table uses an arena-style backing store with explicit //! generation counters so a stale id (e.g. an op invoked after a @@ -16,11 +16,12 @@ //! No internal locking is needed; access is serialised by the //! `Rc>` style table held in the engine. +use bytes::Bytes; use thiserror::Error; -use tokio::sync::oneshot; +use tokio::sync::{mpsc, oneshot}; use super::request::RequestSlot; -use super::response::{ResponsePayload, ResponseSlot}; +use super::response::{ResponseHead, ResponsePayload, ResponseSlot}; /// Outcome returned to the dispatcher's awaiter when a request /// completes. @@ -70,6 +71,90 @@ pub struct InFlight { request: RequestSlot, response: ResponseSlot, completion: Option, + stream_taps: Option, +} + +/// Optional streaming hooks attached to an in-flight request. +/// +/// When present, `op_nexide_send_head` fires `head_tx` and +/// `op_nexide_send_chunk` forwards chunks via `body_tx` so the HTTP +/// shield can stream bytes to the client immediately, instead of +/// waiting for the full `ResponsePayload` to be assembled. Closing +/// `body_tx` (drop) signals end-of-stream; sending `Err(...)` carries +/// a mid-stream handler error to the consumer. +#[derive(Debug)] +pub struct StreamTaps { + head_tx: Option>, + body_tx: Option>>, +} + +/// Reason a streaming chunk failed to flow. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum StreamTapError { + /// Receiver was dropped (client gone) before the chunk could be sent. + #[error("response stream receiver dropped")] + ReceiverDropped, +} + +impl StreamTaps { + /// Builds a tap pair from the channel halves owned by the HTTP + /// shield. + #[must_use] + pub const fn new( + head_tx: oneshot::Sender, + body_tx: mpsc::UnboundedSender>, + ) -> Self { + Self { + head_tx: Some(head_tx), + body_tx: Some(body_tx), + } + } + + /// Fires the head channel (no-op if already fired or dropped). + pub fn fire_head(&mut self, head: ResponseHead) { + if let Some(tx) = self.head_tx.take() { + let _ = tx.send(head); + } + } + + /// Returns `true` when the body sender is still open. Used to + /// gate buffering on the response slot - once we have a live tap + /// we forward every chunk and skip the `Vec` aggregation. + #[must_use] + pub const fn body_open(&self) -> bool { + self.body_tx.is_some() + } + + /// Pushes a chunk through the streaming tap. + /// + /// # Errors + /// + /// [`StreamTapError::ReceiverDropped`] if the receiver has been + /// dropped (client gone) so the caller can surface the + /// cancellation to V8. + pub fn push_chunk(&mut self, chunk: Bytes) -> Result<(), StreamTapError> { + let Some(tx) = self.body_tx.as_ref() else { + return Err(StreamTapError::ReceiverDropped); + }; + if tx.send(Ok(chunk)).is_err() { + self.body_tx = None; + return Err(StreamTapError::ReceiverDropped); + } + Ok(()) + } + + /// Closes the body channel cleanly (end-of-stream). + pub fn finish(&mut self) { + self.body_tx = None; + } + + /// Pushes an error and closes the body channel. + pub fn finish_error(&mut self, err: RequestFailure) { + if let Some(tx) = self.body_tx.take() { + let _ = tx.send(Err(err)); + } + self.head_tx = None; + } } impl InFlight { @@ -89,6 +174,17 @@ impl InFlight { &mut self.response } + /// Returns the streaming tap if attached at insertion time. + pub const fn stream_taps_mut(&mut self) -> Option<&mut StreamTaps> { + self.stream_taps.as_mut() + } + + /// Attaches streaming taps to an existing slot. Used by the + /// engine's `enqueue_streaming` path right after `insert`. + pub fn attach_taps(&mut self, taps: StreamTaps) { + self.stream_taps = Some(taps); + } + /// Removes the completion sender so the caller can resolve the /// awaiter exactly once. Subsequent calls return `None`. pub const fn take_completion(&mut self) -> Option { @@ -101,6 +197,7 @@ impl InFlight { request, response: ResponseSlot::new(), completion: Some(completion), + stream_taps: None, } } } @@ -146,6 +243,7 @@ impl RequestId { } /// Internal slot states for the arena. +#[allow(clippy::large_enum_variant)] #[derive(Debug)] enum Slot { /// Free slot ready to receive a new request. `generation` is the @@ -373,6 +471,9 @@ impl DispatchTable { }; self.free_head = Some(u32::try_from(raw_index).expect("slot index fits in u32")); let mut inflight = inflight; + if let Some(taps) = inflight.stream_taps_mut() { + taps.finish_error(failure()); + } if let Some(completion) = inflight.take_completion() { let _ = completion.send(Err(failure())); failed += 1; @@ -434,6 +535,7 @@ mod tests { use super::*; use crate::ops::request::RequestMeta; use bytes::Bytes; + use tokio::sync::mpsc; fn slot() -> RequestSlot { RequestSlot::new( @@ -654,4 +756,68 @@ mod tests { Err(DispatchError::Stale(_, _) | DispatchError::Released(_)) )); } + + #[test] + fn stream_taps_fire_head_once_then_no_op() { + let (head_tx, head_rx) = oneshot::channel(); + let (body_tx, _body_rx) = mpsc::unbounded_channel(); + let mut taps = StreamTaps::new(head_tx, body_tx); + let head = ResponseHead { + status: 200, + headers: vec![("x-test".to_owned(), "1".to_owned())], + }; + taps.fire_head(head.clone()); + taps.fire_head(ResponseHead { + status: 500, + headers: vec![], + }); + let received = head_rx.blocking_recv().expect("head delivered"); + assert_eq!(received.status, 200); + } + + #[test] + fn stream_taps_push_chunk_forwards_until_receiver_drops() { + let (head_tx, _head_rx) = oneshot::channel(); + let (body_tx, mut body_rx) = mpsc::unbounded_channel(); + let mut taps = StreamTaps::new(head_tx, body_tx); + assert!(taps.push_chunk(Bytes::from_static(b"a")).is_ok()); + assert!(taps.push_chunk(Bytes::from_static(b"b")).is_ok()); + let first = body_rx.blocking_recv().expect("first chunk"); + assert_eq!(first.unwrap(), Bytes::from_static(b"a")); + drop(body_rx); + assert_eq!( + taps.push_chunk(Bytes::from_static(b"c")), + Err(StreamTapError::ReceiverDropped) + ); + assert!(!taps.body_open()); + } + + #[test] + fn stream_taps_finish_error_closes_with_error_payload() { + let (head_tx, _head_rx) = oneshot::channel(); + let (body_tx, mut body_rx) = mpsc::unbounded_channel(); + let mut taps = StreamTaps::new(head_tx, body_tx); + taps.finish_error(RequestFailure::Handler("boom".to_owned())); + let received = body_rx.blocking_recv().expect("err frame delivered"); + assert!(matches!(received, Err(RequestFailure::Handler(_)))); + assert!(body_rx.blocking_recv().is_none()); + } + + #[test] + fn dispatch_table_attach_taps_routes_through_inflight() { + let mut table = DispatchTable::new(); + let (reply_tx, _reply_rx) = oneshot::channel(); + let id = table.insert(slot(), reply_tx); + let (head_tx, head_rx) = oneshot::channel(); + let (body_tx, _body_rx) = mpsc::unbounded_channel(); + let taps = StreamTaps::new(head_tx, body_tx); + let inflight = table.get_mut(id).expect("freshly inserted"); + inflight.attach_taps(taps); + let taps_ref = inflight.stream_taps_mut().expect("attached"); + taps_ref.fire_head(ResponseHead { + status: 201, + headers: vec![], + }); + assert_eq!(head_rx.blocking_recv().expect("head").status, 201); + } } diff --git a/crates/nexide/src/ops/dns.rs b/crates/nexide/src/ops/dns.rs index 9b6af77..677440c 100644 --- a/crates/nexide/src/ops/dns.rs +++ b/crates/nexide/src/ops/dns.rs @@ -13,6 +13,7 @@ //! `ESERVFAIL`, …) so caller code can pattern-match on `err.code` //! exactly the way it would on Node. +use std::fmt; use std::io; use std::net::IpAddr; use std::sync::OnceLock; @@ -21,6 +22,8 @@ use hickory_resolver::TokioAsyncResolver; use hickory_resolver::config::{ResolverConfig, ResolverOpts}; use hickory_resolver::error::ResolveError; +const LOG_TARGET: &str = "nexide::ops::dns"; + /// Hickory's API is fully `Send + Sync` and internally backed by an /// `Arc`, so a single resolver shared across all worker isolates is /// enough - and avoids the cost of re-reading `resolv.conf` per @@ -76,6 +79,14 @@ impl DnsError { } } +impl fmt::Display for DnsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for DnsError {} + /// Result of [`lookup`]. #[derive(Debug, Clone)] pub struct LookupResult { @@ -118,6 +129,14 @@ impl LookupFamily { /// hickory-backed [`resolve4`] / [`resolve6`] because Node's /// `dns.lookup` is documented to honour `nsswitch.conf`, hostfile /// and mDNS - none of which hickory consults. +#[tracing::instrument( + target = "nexide::ops::dns", + level = "debug", + name = "dns_lookup", + skip_all, + fields(host = %host, family = ?family, max), + err(level = "warn", Display), +)] pub async fn lookup( host: &str, family: LookupFamily, @@ -152,27 +171,48 @@ pub async fn lookup( message: format!("getaddrinfo ENOTFOUND {host}"), }); } + tracing::debug!(target: LOG_TARGET, count = out.len(), "lookup resolved"); Ok(out) } /// Returns the IPv4 addresses for `host` (`A` records only). +#[tracing::instrument( + target = "nexide::ops::dns", + level = "debug", + name = "dns_resolve4", + skip_all, + fields(host = %host), + err(level = "warn", Display), +)] pub async fn resolve4(host: &str) -> Result, DnsError> { let resolver = shared_resolver(); let lookup = resolver .ipv4_lookup(host) .await .map_err(DnsError::from_resolve)?; - Ok(lookup.iter().map(|a| IpAddr::V4(a.0)).collect()) + let out: Vec = lookup.iter().map(|a| IpAddr::V4(a.0)).collect(); + tracing::debug!(target: LOG_TARGET, count = out.len(), "A records resolved"); + Ok(out) } /// Returns the IPv6 addresses for `host` (`AAAA` records only). +#[tracing::instrument( + target = "nexide::ops::dns", + level = "debug", + name = "dns_resolve6", + skip_all, + fields(host = %host), + err(level = "warn", Display), +)] pub async fn resolve6(host: &str) -> Result, DnsError> { let resolver = shared_resolver(); let lookup = resolver .ipv6_lookup(host) .await .map_err(DnsError::from_resolve)?; - Ok(lookup.iter().map(|a| IpAddr::V6(a.0)).collect()) + let out: Vec = lookup.iter().map(|a| IpAddr::V6(a.0)).collect(); + tracing::debug!(target: LOG_TARGET, count = out.len(), "AAAA records resolved"); + Ok(out) } /// One mail-exchange record, mapping a priority to a target host. @@ -185,6 +225,14 @@ pub struct MxRecord { } /// Returns the MX records for `host`. +#[tracing::instrument( + target = "nexide::ops::dns", + level = "debug", + name = "dns_resolve_mx", + skip_all, + fields(host = %host), + err(level = "warn", Display), +)] pub async fn resolve_mx(host: &str) -> Result, DnsError> { let resolver = shared_resolver(); let lookup = resolver @@ -205,6 +253,14 @@ pub async fn resolve_mx(host: &str) -> Result, DnsError> { /// Node's `dns.resolveTxt` shape - a `string[]` per record, but we /// flatten to one `string` per record because that is how the /// polyfill exposes them). +#[tracing::instrument( + target = "nexide::ops::dns", + level = "debug", + name = "dns_resolve_txt", + skip_all, + fields(host = %host), + err(level = "warn", Display), +)] pub async fn resolve_txt(host: &str) -> Result>, DnsError> { let resolver = shared_resolver(); let lookup = resolver @@ -222,6 +278,14 @@ pub async fn resolve_txt(host: &str) -> Result>, DnsError> { } /// Returns the canonical-name records for `host`. +#[tracing::instrument( + target = "nexide::ops::dns", + level = "debug", + name = "dns_resolve_cname", + skip_all, + fields(host = %host), + err(level = "warn", Display), +)] pub async fn resolve_cname(host: &str) -> Result, DnsError> { let resolver = shared_resolver(); let lookup = resolver @@ -235,6 +299,14 @@ pub async fn resolve_cname(host: &str) -> Result, DnsError> { } /// Returns the authoritative name servers for `host`. +#[tracing::instrument( + target = "nexide::ops::dns", + level = "debug", + name = "dns_resolve_ns", + skip_all, + fields(host = %host), + err(level = "warn", Display), +)] pub async fn resolve_ns(host: &str) -> Result, DnsError> { let resolver = shared_resolver(); let lookup = resolver @@ -258,6 +330,14 @@ pub struct SrvRecord { } /// Returns the SRV records for `host`. +#[tracing::instrument( + target = "nexide::ops::dns", + level = "debug", + name = "dns_resolve_srv", + skip_all, + fields(host = %host), + err(level = "warn", Display), +)] pub async fn resolve_srv(host: &str) -> Result, DnsError> { let resolver = shared_resolver(); let lookup = resolver @@ -276,6 +356,14 @@ pub async fn resolve_srv(host: &str) -> Result, DnsError> { } /// Reverse DNS lookup - returns the PTR names for `ip`. +#[tracing::instrument( + target = "nexide::ops::dns", + level = "debug", + name = "dns_reverse", + skip_all, + fields(ip = %ip), + err(level = "warn", Display), +)] pub async fn reverse(ip: IpAddr) -> Result, DnsError> { let resolver = shared_resolver(); let lookup = resolver diff --git a/crates/nexide/src/ops/fs_sync.rs b/crates/nexide/src/ops/fs_sync.rs index d2d1e51..c8274f9 100644 --- a/crates/nexide/src/ops/fs_sync.rs +++ b/crates/nexide/src/ops/fs_sync.rs @@ -19,6 +19,19 @@ use std::time::SystemTime; use serde::Serialize; +const LOG_TARGET: &str = "nexide::ops::fs"; + +fn log_err(op: &'static str, path: &str, err: &FsError) { + tracing::trace!( + target: LOG_TARGET, + op, + path, + code = err.code, + message = %err.message, + "fs op error", + ); +} + /// Recoverable filesystem error converted to a Node-style code/message /// pair on the JS boundary. #[derive(Debug, Clone, PartialEq, Eq)] @@ -135,6 +148,20 @@ pub trait FsBackend: Send + Sync + 'static { /// # Errors /// Propagates host I/O failures. fn realpath(&self, path: &Path) -> Result; + /// Atomically renames `from` to `to` (replacing any existing file at + /// `to` on POSIX). Required by the Next.js ISR cache to publish a + /// freshly-rendered page without intermediate readers seeing + /// half-written contents. + /// + /// # Errors + /// Propagates host I/O failures. + fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError>; + /// Appends `data` to `path`, creating it with mode `0o644` if absent. + /// Mirrors Node's `fs.appendFile` semantics. + /// + /// # Errors + /// Propagates host I/O failures. + fn append(&self, path: &Path, data: &[u8]) -> Result<(), FsError>; } /// Standard-library backed [`FsBackend`]. @@ -232,6 +259,18 @@ impl FsBackend for RealFs { fn realpath(&self, path: &Path) -> Result { std::fs::canonicalize(path).map_err(|e| FsError::from_io(&e, path)) } + fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError> { + std::fs::rename(from, to).map_err(|e| FsError::from_io(&e, from)) + } + fn append(&self, path: &Path, data: &[u8]) -> Result<(), FsError> { + use std::io::Write as _; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| FsError::from_io(&e, path))?; + f.write_all(data).map_err(|e| FsError::from_io(&e, path)) + } } /// Path admission policy (security shield). @@ -332,18 +371,32 @@ impl FsHandle { Self::new(Arc::new(RealFs), Arc::new(PathSandbox::new(roots))) } - fn check(&self, path: &str) -> Result { + /// Sandbox-checked path admission. + /// + /// Exposed publicly so async ops can run the sandbox check on the + /// isolate thread (cheap, sync) before spawning the actual I/O on + /// a `tokio::fs` worker thread, keeping the JS thread non-blocked. + /// + /// # Errors + /// `EACCES` when `path` escapes any allowed root. + pub fn admit(&self, path: &str) -> Result { self.sandbox.admit(Path::new(path)) } + fn check(&self, path: &str) -> Result { + self.admit(path) + } + /// Sandbox-checked file read. Returns the bytes on success. /// /// # Errors /// `EACCES` when `path` escapes the sandbox; otherwise propagates /// backend I/O failures. pub fn read(&self, path: &str) -> Result, FsError> { - let p = self.check(path)?; - self.backend.read(&p) + let p = self.check(path).inspect_err(|e| log_err("read", path, e))?; + self.backend + .read(&p) + .inspect_err(|e| log_err("read", path, e)) } /// Sandbox-checked file write. @@ -352,8 +405,12 @@ impl FsHandle { /// `EACCES` when `path` escapes the sandbox; otherwise propagates /// backend I/O failures. pub fn write(&self, path: &str, data: &[u8]) -> Result<(), FsError> { - let p = self.check(path)?; - self.backend.write(&p, data) + let p = self + .check(path) + .inspect_err(|e| log_err("write", path, e))?; + self.backend + .write(&p, data) + .inspect_err(|e| log_err("write", path, e)) } /// Sandbox-checked metadata lookup. @@ -362,8 +419,10 @@ impl FsHandle { /// `EACCES` when `path` escapes the sandbox; otherwise propagates /// backend I/O failures. pub fn stat(&self, path: &str, follow: bool) -> Result { - let p = self.check(path)?; - self.backend.stat(&p, follow) + let p = self.check(path).inspect_err(|e| log_err("stat", path, e))?; + self.backend + .stat(&p, follow) + .inspect_err(|e| log_err("stat", path, e)) } /// Sandbox-checked existence probe - returns `false` when the path @@ -381,8 +440,12 @@ impl FsHandle { /// `EACCES` when `path` escapes the sandbox; otherwise propagates /// backend I/O failures. pub fn read_dir(&self, path: &str) -> Result, FsError> { - let p = self.check(path)?; - self.backend.read_dir(&p) + let p = self + .check(path) + .inspect_err(|e| log_err("read_dir", path, e))?; + self.backend + .read_dir(&p) + .inspect_err(|e| log_err("read_dir", path, e)) } /// Sandbox-checked directory creation. @@ -391,8 +454,12 @@ impl FsHandle { /// `EACCES` when `path` escapes the sandbox; otherwise propagates /// backend I/O failures. pub fn mkdir(&self, path: &str, recursive: bool) -> Result<(), FsError> { - let p = self.check(path)?; - self.backend.mkdir(&p, recursive) + let p = self + .check(path) + .inspect_err(|e| log_err("mkdir", path, e))?; + self.backend + .mkdir(&p, recursive) + .inspect_err(|e| log_err("mkdir", path, e)) } /// Sandbox-checked file or directory removal. Missing paths are a @@ -402,11 +469,15 @@ impl FsHandle { /// `EACCES` when `path` escapes the sandbox; otherwise propagates /// backend I/O failures. pub fn remove(&self, path: &str, recursive: bool) -> Result<(), FsError> { - let p = self.check(path)?; + let p = self + .check(path) + .inspect_err(|e| log_err("remove", path, e))?; if !self.backend.exists(&p) { return Ok(()); } - self.backend.remove(&p, recursive) + self.backend + .remove(&p, recursive) + .inspect_err(|e| log_err("remove", path, e)) } /// Sandbox-checked copy - both endpoints must be admissible. @@ -415,9 +486,13 @@ impl FsHandle { /// `EACCES` when either endpoint escapes the sandbox; otherwise /// propagates backend I/O failures. pub fn copy(&self, from: &str, to: &str) -> Result<(), FsError> { - let src = self.check(from)?; - let dst = self.check(to)?; - self.backend.copy(&src, &dst) + let src = self + .check(from) + .inspect_err(|e| log_err("copy_src", from, e))?; + let dst = self.check(to).inspect_err(|e| log_err("copy_dst", to, e))?; + self.backend + .copy(&src, &dst) + .inspect_err(|e| log_err("copy", from, e)) } /// Sandbox-checked symlink target read. @@ -426,8 +501,12 @@ impl FsHandle { /// `EACCES` when `path` escapes the sandbox; otherwise propagates /// backend I/O failures. pub fn read_link(&self, path: &str) -> Result { - let p = self.check(path)?; - self.backend.read_link(&p) + let p = self + .check(path) + .inspect_err(|e| log_err("read_link", path, e))?; + self.backend + .read_link(&p) + .inspect_err(|e| log_err("read_link", path, e)) } /// Sandbox-checked canonical path lookup. @@ -436,8 +515,43 @@ impl FsHandle { /// `EACCES` when `path` escapes the sandbox; otherwise propagates /// backend I/O failures. pub fn realpath(&self, path: &str) -> Result { - let p = self.check(path)?; - self.backend.realpath(&p) + let p = self + .check(path) + .inspect_err(|e| log_err("realpath", path, e))?; + self.backend + .realpath(&p) + .inspect_err(|e| log_err("realpath", path, e)) + } + + /// Sandbox-checked rename - both endpoints must be admissible. + /// + /// # Errors + /// `EACCES` when either endpoint escapes the sandbox; otherwise + /// propagates backend I/O failures. + pub fn rename(&self, from: &str, to: &str) -> Result<(), FsError> { + let src = self + .check(from) + .inspect_err(|e| log_err("rename_src", from, e))?; + let dst = self + .check(to) + .inspect_err(|e| log_err("rename_dst", to, e))?; + self.backend + .rename(&src, &dst) + .inspect_err(|e| log_err("rename", from, e)) + } + + /// Sandbox-checked append. + /// + /// # Errors + /// `EACCES` when `path` escapes the sandbox; otherwise propagates + /// backend I/O failures. + pub fn append(&self, path: &str, data: &[u8]) -> Result<(), FsError> { + let p = self + .check(path) + .inspect_err(|e| log_err("append", path, e))?; + self.backend + .append(&p, data) + .inspect_err(|e| log_err("append", path, e)) } } @@ -563,6 +677,23 @@ impl FsBackend for MemoryFs { fn realpath(&self, path: &Path) -> Result { Ok(path.to_path_buf()) } + fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError> { + let mut map = self.inner.lock().expect("memfs lock"); + let Some(data) = map.remove(from) else { + return Err(FsError::new( + "ENOENT", + format!("ENOENT: {}", from.display()), + )); + }; + map.insert(to.to_path_buf(), data); + Ok(()) + } + fn append(&self, path: &Path, data: &[u8]) -> Result<(), FsError> { + let mut map = self.inner.lock().expect("memfs lock"); + let entry = map.entry(path.to_path_buf()).or_default(); + entry.extend_from_slice(data); + Ok(()) + } } #[cfg(test)] @@ -645,4 +776,43 @@ mod tests { let mapped = FsError::from_io(&err, dir.path()); assert_eq!(mapped.code, "ENOENT"); } + + #[test] + fn memory_fs_rename_moves_entry_atomically() { + let fs = MemoryFs::new(); + fs.write(Path::new("/a.tmp"), b"payload").unwrap(); + fs.rename(Path::new("/a.tmp"), Path::new("/a.final")) + .unwrap(); + assert!(fs.read(Path::new("/a.tmp")).is_err()); + assert_eq!(fs.read(Path::new("/a.final")).unwrap(), b"payload"); + } + + #[test] + fn memory_fs_append_creates_then_grows() { + let fs = MemoryFs::new(); + fs.append(Path::new("/log.txt"), b"hello ").unwrap(); + fs.append(Path::new("/log.txt"), b"world").unwrap(); + assert_eq!(fs.read(Path::new("/log.txt")).unwrap(), b"hello world"); + } + + #[test] + fn real_fs_rename_replaces_target() { + let dir = tempfile::tempdir().expect("tmp"); + let src = dir.path().join("src.txt"); + let dst = dir.path().join("dst.txt"); + std::fs::write(&src, b"x").unwrap(); + std::fs::write(&dst, b"y").unwrap(); + RealFs.rename(&src, &dst).expect("rename"); + assert!(!src.exists()); + assert_eq!(std::fs::read(&dst).unwrap(), b"x"); + } + + #[test] + fn real_fs_append_appends_to_existing_file() { + let dir = tempfile::tempdir().expect("tmp"); + let p = dir.path().join("a.txt"); + RealFs.append(&p, b"hello ").expect("append"); + RealFs.append(&p, b"world").expect("append"); + assert_eq!(std::fs::read(&p).unwrap(), b"hello world"); + } } diff --git a/crates/nexide/src/ops/http_client.rs b/crates/nexide/src/ops/http_client.rs index 97ed535..b99ef2d 100644 --- a/crates/nexide/src/ops/http_client.rs +++ b/crates/nexide/src/ops/http_client.rs @@ -11,6 +11,11 @@ //! All TLS verification flows through the rustls trust store //! configured in [`super::tls`]. Plain HTTP works on the same code //! path because reqwest auto-selects the scheme from the URL. +//! +//! `tracing` records emit on `nexide::ops::http`. Request lifecycle +//! (dispatch, response headers received, body completed) logs at +//! `debug`; per-chunk delivery at `trace`; transport / decode +//! failures at `warn`. use std::sync::OnceLock; use std::time::Duration; @@ -20,6 +25,8 @@ use tokio::sync::mpsc; use super::net::NetError; +const LOG_TARGET: &str = "nexide::ops::http"; + fn shared_client() -> &'static Client { static CLIENT: OnceLock = OnceLock::new(); CLIENT.get_or_init(|| { @@ -80,6 +87,14 @@ pub struct HttpRequest { /// Returns `NetError` when URL parsing, header construction, or /// the request itself fail. Body chunk errors are surfaced through /// the `body` channel rather than returned synchronously. +#[tracing::instrument( + target = "nexide::ops::http", + level = "debug", + name = "http_request", + skip_all, + fields(method = %req.method, url = %req.url, body_bytes = req.body.len()), + err(level = "warn", Display), +)] pub async fn request(req: HttpRequest) -> Result { use reqwest::Method; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; @@ -108,15 +123,18 @@ pub async fn request(req: HttpRequest) -> Result { headers.append(name, value); } + let url_for_body = req.url.clone(); let mut builder = shared_client().request(method, &req.url).headers(headers); if !req.body.is_empty() { builder = builder.body(req.body); } - let response = builder - .send() - .await - .map_err(|e| NetError::new(reqwest_error_code(&e), e.to_string()))?; + tracing::trace!(target: LOG_TARGET, "request dispatched to transport"); + + let response = builder.send().await.map_err(|e| { + let code = reqwest_error_code(&e); + NetError::new(code, e.to_string()) + })?; let status = response.status().as_u16(); let status_text = response @@ -134,8 +152,15 @@ pub async fn request(req: HttpRequest) -> Result { } } + tracing::debug!( + target: LOG_TARGET, + status, + headers = response_headers.len(), + "response head received", + ); + let (tx, rx) = mpsc::unbounded_channel(); - tokio::task::spawn_local(stream_body(response, tx)); + tokio::task::spawn_local(stream_body(response, tx, url_for_body)); Ok(ResponseHandle { status, @@ -148,20 +173,57 @@ pub async fn request(req: HttpRequest) -> Result { async fn stream_body( mut response: reqwest::Response, tx: mpsc::UnboundedSender, NetError>>, + url: String, ) { + let trace_enabled = tracing::enabled!(target: LOG_TARGET, tracing::Level::TRACE); + let mut chunks: u32 = 0; + let mut bytes: u64 = 0; loop { match response.chunk().await { - Ok(Some(bytes)) => { - if tx.send(Ok(bytes.to_vec())).is_err() { + Ok(Some(buf)) => { + if trace_enabled { + chunks = chunks.saturating_add(1); + bytes = bytes.saturating_add(buf.len() as u64); + tracing::trace!( + target: LOG_TARGET, + chunk_bytes = buf.len(), + chunk_index = chunks, + "response body chunk", + ); + } + if tx.send(Ok(buf.to_vec())).is_err() { + tracing::debug!( + target: LOG_TARGET, + url = %url, + chunks, + bytes, + "response body receiver dropped; aborting stream", + ); return; } } - Ok(None) => return, + Ok(None) => { + tracing::debug!( + target: LOG_TARGET, + url = %url, + chunks, + bytes, + "response body stream completed", + ); + return; + } Err(err) => { - let _ = tx.send(Err(NetError::new( - reqwest_error_code(&err), - err.to_string(), - ))); + let code = reqwest_error_code(&err); + tracing::warn!( + target: LOG_TARGET, + url = %url, + code, + error = %err, + chunks, + bytes, + "response body transport error", + ); + let _ = tx.send(Err(NetError::new(code, err.to_string()))); return; } } diff --git a/crates/nexide/src/ops/mod.rs b/crates/nexide/src/ops/mod.rs index dccbada..b66ff5a 100644 --- a/crates/nexide/src/ops/mod.rs +++ b/crates/nexide/src/ops/mod.rs @@ -19,11 +19,13 @@ mod process_spawn; mod queue; mod request; mod response; +mod signals; mod tls; +pub mod upgrade_socket; mod zlib_stream; pub use dispatch_table::{ - CompletionResult, DispatchError, DispatchTable, InFlight, RequestFailure, RequestId, + CompletionResult, DispatchError, DispatchTable, InFlight, RequestFailure, RequestId, StreamTaps, }; pub use dns::{ DnsError, LookupFamily, LookupResult, MxRecord, SrvRecord, lookup as dns_lookup, @@ -54,9 +56,10 @@ pub use request::{ HeaderPair, REQUEST_META_MAX_LEN, RequestMeta, RequestMetaError, RequestSlot, RequestSource, }; pub use response::{ResponseError, ResponseHead, ResponsePayload, ResponseSink, ResponseSlot}; +pub use signals::{bind_termination_signals, drain as drain_signals, push as push_signal}; pub use tls::{ connect as tls_connect, read_chunk as tls_read_chunk, shutdown as tls_shutdown, - write_all as tls_write_all, + upgrade as tls_upgrade, write_all as tls_write_all, }; pub use zlib_stream::{ZlibKind, ZlibStream, parse_kind as parse_zlib_kind}; diff --git a/crates/nexide/src/ops/net.rs b/crates/nexide/src/ops/net.rs index 8308ea1..39ff97d 100644 --- a/crates/nexide/src/ops/net.rs +++ b/crates/nexide/src/ops/net.rs @@ -5,13 +5,20 @@ //! free of I/O details. Errors are mapped to Node-canonical codes //! (`ECONNREFUSED`, `ETIMEDOUT`, …) so JavaScript can pattern-match //! on `err.code`. +//! +//! All public entry points emit structured `tracing` records on the +//! `nexide::ops::net` target. Lifecycle events (connect, listen, +//! accept, close) are logged at `debug`; per-chunk I/O at `trace`; +//! recoverable failures at `warn`; transport corruption at `error`. +use std::fmt; use std::io; use std::net::SocketAddr; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; +const LOG_TARGET: &str = "nexide::ops::net"; + /// Node-shaped error: a string code (`ECONNREFUSED`, `ENOTFOUND`, /// …) plus a human-readable message. #[derive(Debug, Clone)] @@ -64,6 +71,14 @@ impl From for NetError { } } +impl fmt::Display for NetError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for NetError {} + /// Address summary (`host`, `port`, `family`) returned by the /// `address()` and connection-establishment ops. #[derive(Debug, Clone)] @@ -86,55 +101,189 @@ impl From for AddressInfo { } } +impl fmt::Display for AddressInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.family == 6 { + write!(f, "[{}]:{}", self.address, self.port) + } else { + write!(f, "{}:{}", self.address, self.port) + } + } +} + /// Opens an outbound TCP connection. /// /// `host` is resolved through the OS resolver; the first address /// reachable is used. Returns `(stream, local, remote)` so the caller /// can populate the JS-facing socket properties immediately. +#[tracing::instrument( + target = "nexide::ops::net", + level = "debug", + name = "tcp_connect", + skip_all, + fields(host = %host, port), + err(level = "warn", Display), +)] pub async fn connect( host: &str, port: u16, ) -> Result<(TcpStream, AddressInfo, AddressInfo), NetError> { let target = format!("{host}:{port}"); + tracing::trace!(target: LOG_TARGET, target_addr = %target, "dialing"); let stream = TcpStream::connect(&target).await?; let local = stream.local_addr()?; let remote = stream.peer_addr()?; + tracing::debug!( + target: LOG_TARGET, + local = %local, + remote = %remote, + "tcp connection established", + ); Ok((stream, local.into(), remote.into())) } /// Binds a TCP listener on `host:port`. Use `host = "0.0.0.0"` for /// dual-stack semantics that mirror Node's defaults. +#[tracing::instrument( + target = "nexide::ops::net", + level = "debug", + name = "tcp_listen", + skip_all, + fields(host = %host, port), + err(level = "warn", Display), +)] pub async fn listen(host: &str, port: u16) -> Result<(TcpListener, AddressInfo), NetError> { let target = format!("{host}:{port}"); let listener = TcpListener::bind(&target).await?; let local = listener.local_addr()?; + tracing::debug!(target: LOG_TARGET, local = %local, "tcp listener bound"); Ok((listener, local.into())) } /// Awaits the next inbound connection on `listener`. +#[tracing::instrument( + target = "nexide::ops::net", + level = "trace", + name = "tcp_accept", + skip_all, + err(level = "warn", Display) +)] pub async fn accept( listener: &TcpListener, ) -> Result<(TcpStream, AddressInfo, AddressInfo), NetError> { let (stream, peer) = listener.accept().await?; let local = stream.local_addr()?; + tracing::debug!( + target: LOG_TARGET, + local = %local, + remote = %peer, + "tcp connection accepted", + ); Ok((stream, local.into(), peer.into())) } /// Reads up to `max` bytes from `stream`. Returns an empty `Vec` on /// EOF - JavaScript can detect the half-close by checking `len === 0`. -pub async fn read_chunk(stream: &mut TcpStream, max: usize) -> Result, NetError> { +/// +/// Uses `readable().await` + `try_read` so reading does not require +/// exclusive ownership of the stream and concurrent writes can make +/// progress on the same FD (Node's net.Socket semantics). +pub async fn read_chunk(stream: &TcpStream, max: usize) -> Result, NetError> { let cap = max.clamp(1, 64 * 1024); let mut buf = vec![0u8; cap]; - let n = stream.read(&mut buf).await?; - buf.truncate(n); - Ok(buf) + let trace_enabled = tracing::enabled!(target: LOG_TARGET, tracing::Level::TRACE); + let mut spurious_wakeups: u32 = 0; + loop { + stream.readable().await?; + match stream.try_read(&mut buf) { + Ok(n) => { + buf.truncate(n); + if n == 0 { + tracing::debug!(target: LOG_TARGET, "tcp peer half-closed"); + } else if trace_enabled { + tracing::trace!( + target: LOG_TARGET, + bytes = n, + capacity = cap, + spurious_wakeups, + "tcp read", + ); + } + return Ok(buf); + } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + if trace_enabled { + spurious_wakeups = spurious_wakeups.saturating_add(1); + } + continue; + } + Err(e) => { + let mapped = NetError::from_io(&e); + tracing::warn!( + target: LOG_TARGET, + code = mapped.code, + error = %e, + "tcp read failed", + ); + return Err(mapped); + } + } + } } /// Writes `data` to `stream`, flushing the kernel send buffer. Node /// callers only ever observe a successful write or a definitive /// failure; partial writes are masked by the loop. -pub async fn write_all(stream: &mut TcpStream, data: &[u8]) -> Result<(), NetError> { - stream.write_all(data).await?; +/// +/// Uses `writable().await` + `try_write` so writing does not require +/// exclusive ownership of the stream and concurrent reads can make +/// progress on the same FD. +pub async fn write_all(stream: &TcpStream, data: &[u8]) -> Result<(), NetError> { + let total = data.len(); + let mut written = 0usize; + let trace_enabled = tracing::enabled!(target: LOG_TARGET, tracing::Level::TRACE); + let mut iterations: u32 = 0; + while written < total { + stream.writable().await?; + match stream.try_write(&data[written..]) { + Ok(0) => { + tracing::warn!( + target: LOG_TARGET, + written, + total, + "tcp write returned 0 bytes; treating as EPIPE", + ); + return Err(NetError::new("EPIPE", "tcp write returned 0 bytes")); + } + Ok(n) => written += n, + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + if trace_enabled { + iterations = iterations.saturating_add(1); + } + continue; + } + Err(e) => { + let mapped = NetError::from_io(&e); + tracing::warn!( + target: LOG_TARGET, + code = mapped.code, + written, + total, + error = %e, + "tcp write failed", + ); + return Err(mapped); + } + } + } + if trace_enabled { + tracing::trace!( + target: LOG_TARGET, + bytes = total, + iterations, + "tcp write completed", + ); + } Ok(()) } @@ -147,11 +296,11 @@ mod tests { let (listener, addr) = listen("127.0.0.1", 0).await.expect("listen"); let port = addr.port; let server = tokio::spawn(async move { - let (mut s, _, _) = accept(&listener).await.expect("accept"); - write_all(&mut s, b"hello").await.expect("write"); + let (s, _, _) = accept(&listener).await.expect("accept"); + write_all(&s, b"hello").await.expect("write"); }); - let (mut client, _, _) = connect("127.0.0.1", port).await.expect("connect"); - let chunk = read_chunk(&mut client, 64).await.expect("read"); + let (client, _, _) = connect("127.0.0.1", port).await.expect("connect"); + let chunk = read_chunk(&client, 64).await.expect("read"); assert_eq!(chunk, b"hello"); server.await.expect("join"); } diff --git a/crates/nexide/src/ops/process.rs b/crates/nexide/src/ops/process.rs index ff0d930..3d5e5b9 100644 --- a/crates/nexide/src/ops/process.rs +++ b/crates/nexide/src/ops/process.rs @@ -87,8 +87,10 @@ impl EnvOverlay { /// Stable identifiers used internally by [`ProcessConfig`] when /// constructing the visibility whitelist. Kept as constants so the /// list is easy to audit and extend. -const DEFAULT_PREFIXES: &[&str] = &["NEXT_", "NODE_", "NEXT_PUBLIC_"]; -const DEFAULT_KEYS: &[&str] = &["TZ", "LANG", "LC_ALL", "PATH", "HOME", "PWD"]; +const DEFAULT_PREFIXES: &[&str] = &["NEXT_", "NODE_", "NEXT_PUBLIC_", "NEXIDE_"]; +const DEFAULT_KEYS: &[&str] = &[ + "TZ", "LANG", "LC_ALL", "PATH", "HOME", "PWD", "PORT", "HOSTNAME", +]; /// Read-only view over an environment variable backend. /// diff --git a/crates/nexide/src/ops/process_spawn.rs b/crates/nexide/src/ops/process_spawn.rs index cf39444..d5f8254 100644 --- a/crates/nexide/src/ops/process_spawn.rs +++ b/crates/nexide/src/ops/process_spawn.rs @@ -18,6 +18,8 @@ use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; use super::net::NetError; +const LOG_TARGET: &str = "nexide::ops::process"; + /// Stdio routing requested by the JS caller. #[derive(Debug, Clone, Copy)] pub enum StdioMode { @@ -134,11 +136,32 @@ pub fn spawn(req: SpawnRequest) -> Result { cmd.stderr(req.stdio[2].into_stdio()); cmd.kill_on_drop(false); - let mut child = cmd.spawn().map_err(map_io_err)?; + let mut child = cmd.spawn().map_err(|e| { + let mapped = map_io_err(e); + tracing::warn!( + target: LOG_TARGET, + command = %req.command, + argc = req.args.len(), + code = mapped.code, + message = %mapped.message, + "child spawn failed", + ); + mapped + })?; let pid = child.id().unwrap_or(0); let stdin = child.stdin.take(); let stdout = child.stdout.take(); let stderr = child.stderr.take(); + tracing::debug!( + target: LOG_TARGET, + command = %req.command, + argc = req.args.len(), + pid, + stdin = stdin.is_some(), + stdout = stdout.is_some(), + stderr = stderr.is_some(), + "child spawned", + ); Ok(ChildHandle { pid, child, @@ -192,14 +215,23 @@ pub struct ExitInfo { /// # Errors /// Returns a `NetError` if the OS reports a wait failure. pub async fn wait(child: &mut Child) -> Result { + let pid = child.id().unwrap_or(0); let status = child.wait().await.map_err(map_io_err)?; - Ok(ExitInfo { + let info = ExitInfo { code: status.code(), #[cfg(unix)] signal: std::os::unix::process::ExitStatusExt::signal(&status), #[cfg(not(unix))] signal: None, - }) + }; + tracing::debug!( + target: LOG_TARGET, + pid, + code = ?info.code, + signal = ?info.signal, + "child exited", + ); + Ok(info) } /// Sends a signal-equivalent kill to the child. diff --git a/crates/nexide/src/ops/signals.rs b/crates/nexide/src/ops/signals.rs new file mode 100644 index 0000000..f472d81 --- /dev/null +++ b/crates/nexide/src/ops/signals.rs @@ -0,0 +1,143 @@ +//! Process-wide queue of OS-delivered signals waiting to be observed +//! by the JavaScript `process` event emitter. +//! +//! ## Why a queue +//! +//! V8 isolates run on dedicated `LocalSet`s; we can't synchronously +//! invoke JS handlers from a Tokio signal task without crossing the +//! `!Send` boundary. The bridge instead pushes signal *names* +//! ("SIGTERM", "SIGINT", …) into a shared mailbox; the +//! `process` polyfill polls it from a 100 ms `setInterval` and emits +//! the corresponding events on its [`EventEmitter`]. Latency is +//! bounded by the polling cadence (well under any sensible +//! `terminationGracePeriodSeconds` on Kubernetes). +//! +//! ## Hands-off use +//! +//! [`bind_termination_signals`] arms `tokio::signal::unix` for +//! SIGTERM, SIGINT, and SIGHUP and pushes the canonical names. The +//! returned future resolves the **first** time SIGTERM or SIGINT +//! arrives so the embedder can flip its graceful-shutdown watch +//! channel - SIGHUP is observed but does not by itself trigger +//! shutdown (Node doesn't shut down on SIGHUP either; long-running +//! services use it for reload-style notifications). + +use std::sync::Mutex; +use std::sync::OnceLock; + +/// Process-wide FIFO of signal names yet to be drained by the JS +/// polyfill. Each entry is a `'static` Node-style identifier +/// (`SIGTERM`, `SIGINT`, …); the `&'static str` choice keeps the +/// allocation footprint independent of the queue depth. +fn queue() -> &'static Mutex> { + static Q: OnceLock>> = OnceLock::new(); + Q.get_or_init(|| Mutex::new(Vec::new())) +} + +/// Records that signal `name` was delivered to the host process. +/// +/// Safe to call from a Tokio signal task or any other thread; the +/// queue uses a mutex with poison-tolerant access (poisoned guard +/// recovered through `into_inner`) so a panicking JS poll cannot +/// permanently silence the bridge. +pub fn push(name: &'static str) { + let mut g = queue() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + g.push(name); +} + +/// Drains the queue, returning every signal observed since the +/// previous call (in arrival order). +#[must_use] +pub fn drain() -> Vec<&'static str> { + let mut g = queue() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + std::mem::take(&mut *g) +} + +/// Listens for SIGTERM / SIGINT / SIGHUP on Unix (or Ctrl-C on other +/// platforms) and pushes the canonical signal name onto the queue. +/// +/// Resolves the first time SIGTERM or SIGINT is observed - the caller +/// is expected to flip the runtime's shutdown watch channel after the +/// future returns. SIGHUP is recorded but doesn't trigger shutdown. +#[cfg(unix)] +pub async fn bind_termination_signals() { + use tokio::signal::unix::{SignalKind, signal}; + + let mut sigterm = match signal(SignalKind::terminate()) { + Ok(s) => s, + Err(err) => { + tracing::error!(%err, "failed to install SIGTERM handler"); + return; + } + }; + let mut sigint = match signal(SignalKind::interrupt()) { + Ok(s) => s, + Err(err) => { + tracing::error!(%err, "failed to install SIGINT handler"); + return; + } + }; + let mut sighup = match signal(SignalKind::hangup()) { + Ok(s) => s, + Err(err) => { + tracing::warn!(%err, "failed to install SIGHUP handler"); + // SIGHUP is non-essential; keep waiting for SIGTERM/SIGINT. + tokio::select! { + _ = sigterm.recv() => { + push("SIGTERM"); + } + _ = sigint.recv() => { + push("SIGINT"); + } + } + return; + } + }; + loop { + tokio::select! { + _ = sigterm.recv() => { + push("SIGTERM"); + return; + } + _ = sigint.recv() => { + push("SIGINT"); + return; + } + _ = sighup.recv() => { + push("SIGHUP"); + // SIGHUP doesn't end the wait - keep listening so a + // subsequent SIGTERM still triggers shutdown. + } + } + } +} + +/// Non-Unix fallback: only Ctrl-C (SIGINT-equivalent) is observed. +#[cfg(not(unix))] +pub async fn bind_termination_signals() { + if let Err(err) = tokio::signal::ctrl_c().await { + tracing::error!(%err, "failed to listen for ctrl-c"); + return; + } + push("SIGINT"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drain_returns_pushed_signals_in_fifo_order() { + // Drain anything queued by other tests first. + let _ = drain(); + push("SIGTERM"); + push("SIGINT"); + let drained = drain(); + assert_eq!(drained, vec!["SIGTERM", "SIGINT"]); + assert!(drain().is_empty(), "queue should be empty after drain"); + } +} diff --git a/crates/nexide/src/ops/tls.rs b/crates/nexide/src/ops/tls.rs index 828e1c8..54bbcd5 100644 --- a/crates/nexide/src/ops/tls.rs +++ b/crates/nexide/src/ops/tls.rs @@ -7,6 +7,11 @@ //! drop-in replacement for `TcpStream` from the JS side: read / //! write semantics are identical and all errors are mapped to the //! same Node-canonical codes used by [`super::net`]. +//! +//! Structured `tracing` records emit on the `nexide::ops::tls` +//! target. Handshake lifecycle (dial, upgrade, established) logs at +//! `debug`; per-chunk I/O at `trace`; certificate / handshake +//! failures at `warn`. use std::io; use std::sync::Arc; @@ -21,6 +26,8 @@ use tokio_rustls::rustls::{ClientConfig, RootCertStore}; use super::net::{AddressInfo, NetError}; +const LOG_TARGET: &str = "nexide::ops::tls"; + fn shared_config() -> Arc { static CFG: OnceLock> = OnceLock::new(); CFG.get_or_init(|| { @@ -48,6 +55,51 @@ fn tls_error(err: &io::Error) -> NetError { mapped } +/// Upgrades an existing TCP stream to TLS by performing a client +/// handshake over it. Used for protocols that negotiate TLS over a +/// plain TCP connection (e.g. PostgreSQL `SSLRequest`, SMTP +/// `STARTTLS`, IMAP/POP3 `STARTTLS`). +/// +/// # Errors +/// Returns `NetError` if the underlying socket address cannot be +/// queried or the TLS handshake fails (handshake errors are mapped +/// to canonical Node codes via [`tls_error`]). +#[tracing::instrument( + target = "nexide::ops::tls", + level = "debug", + name = "tls_upgrade", + skip_all, + fields(host = %host), + err(level = "warn", Display), +)] +pub async fn upgrade( + tcp: TcpStream, + host: &str, +) -> Result<(TlsStream, AddressInfo, AddressInfo), NetError> { + let local = tcp.local_addr().map_err(|e| tls_error(&e))?; + let remote = tcp.peer_addr().map_err(|e| tls_error(&e))?; + tracing::trace!( + target: LOG_TARGET, + local = %local, + remote = %remote, + "starting handshake on existing tcp stream", + ); + let server_name = ServerName::try_from(host.to_owned()) + .map_err(|_| NetError::new("ERR_INVALID_HOSTNAME", format!("invalid host {host}")))?; + let connector = TlsConnector::from(shared_config()); + let stream = connector + .connect(server_name, tcp) + .await + .map_err(|e| tls_error(&e))?; + tracing::debug!( + target: LOG_TARGET, + local = %local, + remote = %remote, + "tls handshake complete", + ); + Ok((stream, local.into(), remote.into())) +} + /// Performs a TLS client handshake against `host:port` and returns /// the live stream plus address info pulled from the underlying TCP /// socket. @@ -56,6 +108,14 @@ fn tls_error(err: &io::Error) -> NetError { /// Returns `NetError` on DNS, TCP or TLS handshake failures. The /// mapped error code mirrors what Node would expose under the same /// circumstances. +#[tracing::instrument( + target = "nexide::ops::tls", + level = "debug", + name = "tls_connect", + skip_all, + fields(host = %host, port), + err(level = "warn", Display), +)] pub async fn connect( host: &str, port: u16, @@ -64,16 +124,7 @@ pub async fn connect( let tcp = TcpStream::connect(&target) .await .map_err(|e| tls_error(&e))?; - let local = tcp.local_addr().map_err(|e| tls_error(&e))?; - let remote = tcp.peer_addr().map_err(|e| tls_error(&e))?; - let server_name = ServerName::try_from(host.to_owned()) - .map_err(|_| NetError::new("ERR_INVALID_HOSTNAME", format!("invalid host {host}")))?; - let connector = TlsConnector::from(shared_config()); - let stream = connector - .connect(server_name, tcp) - .await - .map_err(|e| tls_error(&e))?; - Ok((stream, local.into(), remote.into())) + upgrade(tcp, host).await } /// Reads up to `max` bytes from `stream`. Returns an empty `Vec` on @@ -84,22 +135,90 @@ pub async fn read_chunk( ) -> Result, NetError> { let cap = max.clamp(1, 64 * 1024); let mut buf = vec![0u8; cap]; - let n = stream.read(&mut buf).await.map_err(|e| tls_error(&e))?; - buf.truncate(n); - Ok(buf) + match stream.read(&mut buf).await { + Ok(n) => { + buf.truncate(n); + if n == 0 { + tracing::debug!(target: LOG_TARGET, "tls peer half-closed"); + } else { + tracing::trace!( + target: LOG_TARGET, + bytes = n, + capacity = cap, + "tls read", + ); + } + Ok(buf) + } + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => { + tracing::debug!( + target: LOG_TARGET, + error = %e, + "tls peer closed without close_notify; treating as clean eof", + ); + Ok(Vec::new()) + } + Err(e) => { + let mapped = tls_error(&e); + tracing::warn!( + target: LOG_TARGET, + code = mapped.code, + error = %e, + "tls read failed", + ); + Err(mapped) + } + } } /// Writes `data` to `stream` and waits for the write half to flush. pub async fn write_all(stream: &mut TlsStream, data: &[u8]) -> Result<(), NetError> { - stream.write_all(data).await.map_err(|e| tls_error(&e))?; - stream.flush().await.map_err(|e| tls_error(&e))?; + let total = data.len(); + if let Err(e) = stream.write_all(data).await { + let mapped = tls_error(&e); + tracing::warn!( + target: LOG_TARGET, + code = mapped.code, + total, + error = %e, + "tls write failed", + ); + return Err(mapped); + } + if let Err(e) = stream.flush().await { + let mapped = tls_error(&e); + tracing::warn!( + target: LOG_TARGET, + code = mapped.code, + total, + error = %e, + "tls flush failed", + ); + return Err(mapped); + } + tracing::trace!(target: LOG_TARGET, bytes = total, "tls write completed"); Ok(()) } /// Cleanly shuts the TLS layer down, sending `close_notify` to the /// peer so the connection is not torn down ungracefully. pub async fn shutdown(stream: &mut TlsStream) -> Result<(), NetError> { - stream.shutdown().await.map_err(|e| tls_error(&e)) + match stream.shutdown().await { + Ok(()) => { + tracing::debug!(target: LOG_TARGET, "tls shutdown clean"); + Ok(()) + } + Err(e) => { + let mapped = tls_error(&e); + tracing::warn!( + target: LOG_TARGET, + code = mapped.code, + error = %e, + "tls shutdown failed", + ); + Err(mapped) + } + } } #[cfg(test)] diff --git a/crates/nexide/src/ops/upgrade_socket.rs b/crates/nexide/src/ops/upgrade_socket.rs new file mode 100644 index 0000000..9105676 --- /dev/null +++ b/crates/nexide/src/ops/upgrade_socket.rs @@ -0,0 +1,398 @@ +//! Raw post-handshake socket bridge for HTTP `Upgrade` requests +//! (WebSockets, HTTP/2 cleartext upgrade, custom protocols). +//! +//! ## Why this exists +//! +//! Node's `node:http` Server emits an `'upgrade'` event with a *raw* +//! `net.Socket` so libraries like `ws` and `socket.io` can drive +//! their own framing on top of the post-101 byte stream. nexide +//! terminates HTTP/1.1 at hyper, so by the time JS sees a request +//! the connection is already inside an HTTP framer; the `Upgraded` +//! handle is only obtainable *after* hyper writes a 101 response. +//! +//! This module bridges the gap. Each upgrade request is registered +//! with a fresh socket id; JS interacts with the registry via three +//! ops: +//! +//! - [`op_upgrade_socket_write_async`](super::super::engine::v8_engine::ops_bridge) +//! buffers bytes in a shared queue. Until the upgrade completes +//! (the server has flushed 101 to the wire and `OnUpgrade` has +//! resolved) writes accumulate in `pending_writes`; once +//! [`attach_upgraded`] is called the buffer is drained onto the +//! real upgraded stream and subsequent writes are forwarded +//! directly. +//! +//! - `op_upgrade_socket_read_async` pulls one chunk from the +//! inbound channel; before the upgrade resolves it parks until +//! [`attach_upgraded`] runs; afterwards it returns frame-agnostic +//! bytes from the upgraded stream. +//! +//! - `op_upgrade_socket_close` removes the slot, dropping driver +//! tasks which closes both halves of the socket cleanly. +//! +//! ## Lifecycle +//! +//! 1. The HTTP shield ([`crate::server::next_bridge`]) detects an +//! upgrade request, calls [`allocate`] to reserve an id, and +//! injects `x-nexide-upgrade-socket-id: ` into the +//! [`crate::dispatch::ProtoRequest`] passed to JS. +//! 2. The shield removes [`hyper::upgrade::OnUpgrade`] from the +//! request extensions and spawns a task that awaits it. On +//! success it calls [`attach_upgraded`]; on failure it calls +//! [`abort`] so JS-side reads / writes resolve with EPIPE. +//! 3. The JS handler emits `'upgrade'` and synthesises a 101 +//! response using `res.writeHead(101, …)` so hyper sends the +//! 101 on the wire. That flush is what causes `OnUpgrade` to +//! resolve. +//! 4. JS runs its protocol (ws-frames, etc.) entirely on top of the +//! socket-id read/write ops. + +use std::collections::HashMap; +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; + +use hyper::upgrade::Upgraded; +use hyper_util::rt::TokioIo; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{Notify, mpsc}; + +const LOG_TARGET: &str = "nexide::ops::upgrade_socket"; +const READ_CHUNK_BYTES: usize = 16 * 1024; + +/// Outcome of a JS-side read/write op when the slot is unknown. +#[derive(Debug, thiserror::Error)] +pub enum UpgradeSocketError { + /// Socket id was never allocated, or has been closed. + #[error("upgrade socket {0} is closed")] + Closed(u64), + /// The OnUpgrade future failed before the socket was attached. + #[error("upgrade socket {0} aborted: {1}")] + Aborted(u64, String), +} + +/// JS-facing handle to one upgrade socket. +pub struct UpgradeSocketHandle { + id: u64, + inner: Arc, +} + +impl UpgradeSocketHandle { + /// Returns the numeric socket id (used as the JS-facing key). + #[must_use] + pub const fn id(&self) -> u64 { + self.id + } + + /// Schedules `bytes` for delivery on the socket. + /// + /// Before the upgrade has resolved the bytes are queued in the + /// pre-handshake buffer; after [`attach_upgraded`] runs they go + /// directly to the upgraded stream. + pub async fn write(&self, bytes: Vec) -> Result<(), UpgradeSocketError> { + if self.inner.closed.load(Ordering::Acquire) { + return Err(UpgradeSocketError::Closed(self.id)); + } + if let Some(err) = self + .inner + .error + .lock() + .expect("upgrade-socket lock") + .clone() + { + return Err(UpgradeSocketError::Aborted(self.id, err)); + } + if self.inner.attached.load(Ordering::Acquire) { + self.inner + .out_tx + .lock() + .expect("upgrade-socket lock") + .as_ref() + .ok_or(UpgradeSocketError::Closed(self.id))? + .send(bytes) + .map_err(|_| UpgradeSocketError::Closed(self.id))?; + return Ok(()); + } + self.inner + .pending_writes + .lock() + .expect("upgrade-socket lock") + .push_back(bytes); + Ok(()) + } + + /// Reads up to one chunk from the inbound stream. `Ok(None)` + /// signals graceful EOF. + pub async fn read(&self) -> Result>, UpgradeSocketError> { + if self.inner.closed.load(Ordering::Acquire) { + return Err(UpgradeSocketError::Closed(self.id)); + } + if !self.inner.attached.load(Ordering::Acquire) { + self.inner.attached_notify.notified().await; + if let Some(err) = self + .inner + .error + .lock() + .expect("upgrade-socket lock") + .clone() + { + return Err(UpgradeSocketError::Aborted(self.id, err)); + } + if self.inner.closed.load(Ordering::Acquire) { + return Err(UpgradeSocketError::Closed(self.id)); + } + } + let mut guard = self.inner.in_rx.lock().await; + let rx = guard.as_mut().ok_or(UpgradeSocketError::Closed(self.id))?; + Ok(rx.recv().await) + } + + /// Marks the socket as closed and drops its driver tasks. + pub fn close(&self) { + self.inner.closed.store(true, Ordering::Release); + + self.inner + .out_tx + .lock() + .expect("upgrade-socket lock") + .take(); + + self.inner.attached_notify.notify_waiters(); + unregister(self.id); + } +} + +struct UpgradeSocketInner { + attached: AtomicBool, + closed: AtomicBool, + attached_notify: Notify, + /// Pre-handshake outbound buffer. Drained onto the upgraded + /// stream by `attach_upgraded` once the 101 has been flushed. + pending_writes: Mutex>>, + /// Post-handshake outbound channel; producer side. Replaced by + /// `attach_upgraded`. `None` once closed. + out_tx: Mutex>>>, + /// Inbound chunks. Populated by the driver task spawned in + /// `attach_upgraded`. + in_rx: tokio::sync::Mutex>>>, + in_tx: Mutex>>>, + /// Captured driver error (e.g. OnUpgrade failure or transport + /// error). Surfaced to JS through the next read/write op. + error: Mutex>, +} + +static REGISTRY: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +fn register(inner: Arc) -> u64 { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + REGISTRY + .lock() + .expect("upgrade-socket registry") + .insert(id, inner); + id +} + +fn unregister(id: u64) { + REGISTRY + .lock() + .expect("upgrade-socket registry") + .remove(&id); +} + +fn lookup(id: u64) -> Option> { + REGISTRY + .lock() + .expect("upgrade-socket registry") + .get(&id) + .cloned() +} + +/// Reserves a fresh socket id and returns the JS-facing handle. +/// The id is the integer the shield injects into request headers. +#[must_use] +pub fn allocate() -> UpgradeSocketHandle { + let inner = Arc::new(UpgradeSocketInner { + attached: AtomicBool::new(false), + closed: AtomicBool::new(false), + attached_notify: Notify::new(), + pending_writes: Mutex::new(VecDeque::new()), + out_tx: Mutex::new(None), + in_rx: tokio::sync::Mutex::new(None), + in_tx: Mutex::new(None), + error: Mutex::new(None), + }); + let id = register(Arc::clone(&inner)); + tracing::debug!(target: LOG_TARGET, socket_id = id, "upgrade socket allocated"); + UpgradeSocketHandle { id, inner } +} + +/// Returns a handle for an existing socket id. Returns `None` if +/// the slot has been closed or never allocated. +#[must_use] +pub fn handle(id: u64) -> Option { + lookup(id).map(|inner| UpgradeSocketHandle { id, inner }) +} + +/// Drives the post-handshake socket: spawns one task that pumps +/// inbound bytes from the upgraded stream into the JS-side channel +/// and another that pumps JS-side writes back onto the wire. Drains +/// any pre-handshake buffered writes onto the wire first so the +/// `ws` library's "write 101 + immediately send first frame" idiom +/// works. +/// +/// # Panics +/// +/// Panics if `id` does not correspond to a previously allocated +/// socket. +pub fn attach_upgraded(id: u64, upgraded: Upgraded) { + let Some(inner) = lookup(id) else { + tracing::warn!(target: LOG_TARGET, socket_id = id, "attach on unknown socket"); + return; + }; + if inner.closed.load(Ordering::Acquire) { + return; + } + let pending: VecDeque> = + std::mem::take(&mut *inner.pending_writes.lock().expect("upgrade-socket lock")); + + let (out_tx, out_rx) = mpsc::unbounded_channel::>(); + let (in_tx, in_rx) = mpsc::unbounded_channel::>(); + *inner.out_tx.lock().expect("upgrade-socket lock") = Some(out_tx); + *inner.in_tx.lock().expect("upgrade-socket lock") = Some(in_tx.clone()); + { + let mut guard = inner + .in_rx + .try_lock() + .expect("upgrade-socket in_rx must be untouched until attached_notify fires"); + *guard = Some(in_rx); + } + + let io = TokioIo::new(upgraded); + let (read_half, write_half) = tokio::io::split(io); + spawn_reader(id, Arc::clone(&inner), read_half); + spawn_writer(id, Arc::clone(&inner), write_half, pending, out_rx); + + inner.attached.store(true, Ordering::Release); + inner.attached_notify.notify_waiters(); + tracing::debug!(target: LOG_TARGET, socket_id = id, "upgrade socket attached"); +} + +/// Marks the socket as failed (`OnUpgrade` resolved with an error). +/// Pending JS reads/writes complete with [`UpgradeSocketError::Aborted`]. +pub fn abort(id: u64, reason: String) { + if let Some(inner) = lookup(id) { + *inner.error.lock().expect("upgrade-socket lock") = Some(reason); + inner.closed.store(true, Ordering::Release); + inner.attached_notify.notify_waiters(); + } + unregister(id); +} + +fn spawn_reader(id: u64, inner: Arc, mut read_half: R) +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, +{ + tokio::spawn(async move { + let in_tx = match inner.in_tx.lock().expect("upgrade-socket lock").clone() { + Some(tx) => tx, + None => return, + }; + let mut buf = vec![0u8; READ_CHUNK_BYTES]; + loop { + if inner.closed.load(Ordering::Acquire) { + break; + } + match read_half.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + if in_tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + Err(err) => { + *inner.error.lock().expect("upgrade-socket lock") = Some(err.to_string()); + break; + } + } + } + // Closing in_tx by dropping it signals EOF to the JS reader. + drop(in_tx); + inner.in_tx.lock().expect("upgrade-socket lock").take(); + tracing::debug!(target: LOG_TARGET, socket_id = id, "upgrade socket reader done"); + }); +} + +fn spawn_writer( + id: u64, + inner: Arc, + mut write_half: W, + pending: VecDeque>, + mut out_rx: mpsc::UnboundedReceiver>, +) where + W: tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + tokio::spawn(async move { + for chunk in pending { + if let Err(err) = write_half.write_all(&chunk).await { + *inner.error.lock().expect("upgrade-socket lock") = Some(err.to_string()); + return; + } + } + while let Some(chunk) = out_rx.recv().await { + if let Err(err) = write_half.write_all(&chunk).await { + *inner.error.lock().expect("upgrade-socket lock") = Some(err.to_string()); + break; + } + } + let _ = write_half.shutdown().await; + tracing::debug!(target: LOG_TARGET, socket_id = id, "upgrade socket writer done"); + }); +} + +/// Synthetic header injected into the JS-facing request so the +/// `node:http` adapter can route the request to the `'upgrade'` +/// listener instead of the regular request handler. +pub const UPGRADE_SOCKET_ID_HEADER: &str = "x-nexide-upgrade-socket-id"; + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn pre_handshake_writes_buffer_until_attach() { + let h = allocate(); + h.write(b"first".to_vec()).await.unwrap(); + h.write(b"second".to_vec()).await.unwrap(); + assert!(!h.inner.attached.load(Ordering::Acquire)); + let pending = h.inner.pending_writes.lock().unwrap(); + assert_eq!(pending.len(), 2); + } + + #[tokio::test] + async fn close_marks_slot_unreachable() { + let h = allocate(); + let id = h.id(); + h.close(); + assert!(handle(id).is_none()); + } + + #[tokio::test] + async fn abort_surfaces_error_to_reader() { + let h = allocate(); + let id = h.id(); + let reader = tokio::spawn(async move { h.read().await }); + // Give the reader a moment to park. + tokio::task::yield_now().await; + abort(id, "onupgrade failed".to_owned()); + let res = reader.await.unwrap(); + match res { + Err(UpgradeSocketError::Aborted(got_id, msg)) => { + assert_eq!(got_id, id); + assert_eq!(msg, "onupgrade failed"); + } + other => panic!("unexpected outcome: {other:?}"), + } + } +} diff --git a/crates/nexide/src/ops/zlib_stream.rs b/crates/nexide/src/ops/zlib_stream.rs index 2f79e4c..c0da860 100644 --- a/crates/nexide/src/ops/zlib_stream.rs +++ b/crates/nexide/src/ops/zlib_stream.rs @@ -9,6 +9,9 @@ use std::io::Write; +use brotli::{ + CompressorWriter as BrotliCompressorWriter, DecompressorWriter as BrotliDecompressorWriter, +}; use flate2::Compression; use flate2::write::{ DeflateDecoder, DeflateEncoder, GzDecoder, GzEncoder, ZlibDecoder, ZlibEncoder, @@ -16,6 +19,8 @@ use flate2::write::{ use super::net::NetError; +const LOG_TARGET: &str = "nexide::ops::zlib"; + /// Kind of stream to instantiate. Mirrors the Node `zlib` factory /// surface (`createDeflate`, `createGzip`, …). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -32,6 +37,10 @@ pub enum ZlibKind { Gzip, /// `Gunzip` - gzip decoder. Gunzip, + /// `BrotliCompress` - streaming brotli encoder. + BrotliCompress, + /// `BrotliDecompress` - streaming brotli decoder. + BrotliDecompress, } /// Streaming engine. The `flate2` write-side adapters consume the @@ -50,6 +59,11 @@ pub enum ZlibStream { InflateRaw(DeflateDecoder>), /// Gzip decoder. Gunzip(GzDecoder>), + /// Brotli encoder. The CompressorWriter buffers into the inner + /// Vec; we drain after every write to surface output bytes. + BrotliCompress(Box>>), + /// Brotli decoder. + BrotliDecompress(Box>>), } impl ZlibStream { @@ -57,14 +71,30 @@ impl ZlibStream { /// `0..=9` range honoured by Node. #[must_use] pub fn new(kind: ZlibKind, level: u32) -> Self { - let level = Compression::new(level.min(9)); + let flate_level = Compression::new(level.min(9)); match kind { - ZlibKind::Deflate => Self::Deflate(ZlibEncoder::new(Vec::new(), level)), - ZlibKind::DeflateRaw => Self::DeflateRaw(DeflateEncoder::new(Vec::new(), level)), - ZlibKind::Gzip => Self::Gzip(GzEncoder::new(Vec::new(), level)), + ZlibKind::Deflate => Self::Deflate(ZlibEncoder::new(Vec::new(), flate_level)), + ZlibKind::DeflateRaw => Self::DeflateRaw(DeflateEncoder::new(Vec::new(), flate_level)), + ZlibKind::Gzip => Self::Gzip(GzEncoder::new(Vec::new(), flate_level)), ZlibKind::Inflate => Self::Inflate(ZlibDecoder::new(Vec::new())), ZlibKind::InflateRaw => Self::InflateRaw(DeflateDecoder::new(Vec::new())), ZlibKind::Gunzip => Self::Gunzip(GzDecoder::new(Vec::new())), + ZlibKind::BrotliCompress => { + // quality 0..=11, lgwin default 22 (matches Node's default + // BROTLI_DEFAULT_WINDOW). Node's default quality is 11 + // for one-shot but level=6 maps reasonably well for + // streaming. + let quality = level.min(11); + Self::BrotliCompress(Box::new(BrotliCompressorWriter::new( + Vec::new(), + 4096, + quality, + 22, + ))) + } + ZlibKind::BrotliDecompress => { + Self::BrotliDecompress(Box::new(BrotliDecompressorWriter::new(Vec::new(), 4096))) + } } } @@ -75,14 +105,32 @@ impl ZlibStream { /// Returns a [`NetError`] tagged with `Z_DATA_ERROR` when the /// encoder/decoder reports a fatal transition. pub fn feed(&mut self, input: &[u8]) -> Result, NetError> { - match self { + let result = match self { Self::Deflate(e) => write_and_drain(e, input, |e| e.get_mut()), Self::DeflateRaw(e) => write_and_drain(e, input, |e| e.get_mut()), Self::Gzip(e) => write_and_drain(e, input, |e| e.get_mut()), Self::Inflate(d) => write_and_drain(d, input, |d| d.get_mut()), Self::InflateRaw(d) => write_and_drain(d, input, |d| d.get_mut()), Self::Gunzip(d) => write_and_drain(d, input, |d| d.get_mut()), + Self::BrotliCompress(e) => write_and_drain(e, input, |e| e.get_mut()), + Self::BrotliDecompress(d) => write_and_drain(d, input, |d| d.get_mut()), + }; + match &result { + Ok(out) => tracing::trace!( + target: LOG_TARGET, + in_bytes = input.len(), + out_bytes = out.len(), + "zlib feed", + ), + Err(err) => tracing::warn!( + target: LOG_TARGET, + in_bytes = input.len(), + code = err.code, + message = %err.message, + "zlib feed failed", + ), } + result } /// Flushes any internal buffer, signalling end-of-input. @@ -91,14 +139,42 @@ impl ZlibStream { /// # Errors /// Returns a [`NetError`] when finalisation fails. pub fn finish(self) -> Result, NetError> { - match self { + let result = match self { Self::Deflate(e) => e.finish().map_err(io_to_net), Self::DeflateRaw(e) => e.finish().map_err(io_to_net), Self::Gzip(e) => e.finish().map_err(io_to_net), Self::Inflate(d) => d.finish().map_err(io_to_net), Self::InflateRaw(d) => d.finish().map_err(io_to_net), Self::Gunzip(d) => d.finish().map_err(io_to_net), + Self::BrotliCompress(mut e) => { + e.flush().map_err(io_to_net)?; + drop(e); + // CompressorWriter doesn't expose into_inner; flush is + // sufficient because brotli emits its final block on + // flush. The drained bytes were already returned by + // earlier feeds and the trailing flush. + Ok(Vec::new()) + } + Self::BrotliDecompress(mut d) => { + d.flush().map_err(io_to_net)?; + let tail = std::mem::take(d.get_mut()); + Ok(tail) + } + }; + match &result { + Ok(out) => tracing::debug!( + target: LOG_TARGET, + tail_bytes = out.len(), + "zlib stream finished", + ), + Err(err) => tracing::warn!( + target: LOG_TARGET, + code = err.code, + message = %err.message, + "zlib finish failed", + ), } + result } } @@ -130,6 +206,8 @@ pub fn parse_kind(name: &str) -> Result { "inflate-raw" => ZlibKind::InflateRaw, "gzip" => ZlibKind::Gzip, "gunzip" => ZlibKind::Gunzip, + "brotli-compress" => ZlibKind::BrotliCompress, + "brotli-decompress" => ZlibKind::BrotliDecompress, other => { return Err(NetError::new( "EINVAL", @@ -184,7 +262,22 @@ mod tests { #[test] fn parse_kind_rejects_unknown() { - assert!(parse_kind("brotli").is_err()); + assert!(parse_kind("nope").is_err()); assert!(parse_kind("deflate").is_ok()); + assert!(parse_kind("brotli-compress").is_ok()); + assert!(parse_kind("brotli-decompress").is_ok()); + } + + #[test] + fn brotli_round_trip() { + let payload = b"streaming brotli test payload that compresses".repeat(4); + let mut enc = ZlibStream::new(ZlibKind::BrotliCompress, 4); + let mut compressed = enc.feed(&payload).unwrap(); + compressed.extend(enc.finish().unwrap()); + + let mut dec = ZlibStream::new(ZlibKind::BrotliDecompress, 0); + let mut out = dec.feed(&compressed).unwrap(); + out.extend(dec.finish().unwrap()); + assert_eq!(out, payload); } } diff --git a/crates/nexide/src/pool/engine_pump.rs b/crates/nexide/src/pool/engine_pump.rs index dedadbb..787a9af 100644 --- a/crates/nexide/src/pool/engine_pump.rs +++ b/crates/nexide/src/pool/engine_pump.rs @@ -49,15 +49,27 @@ use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use std::sync::OnceLock; + use tokio::sync::Notify; use super::pump_strategy::pump_strategy_from_env; use super::worker::{DispatchJob, WorkerError, WorkerHealth}; use crate::engine::cjs::{FsResolver, ROOT_PARENT}; -use crate::engine::{BootContext, IsolateHandle, V8Engine}; -use crate::ops::{RequestMeta, RequestSlot}; +use crate::engine::{BootContext, CodeCache, IsolateHandle, V8Engine}; +use crate::ops::{OsEnv, ProcessConfig, RequestMeta, RequestSlot}; use crate::sandbox_root_for; +/// Process-wide V8 bytecode cache shared across every worker isolate. +/// +/// Lazily built from environment on first reference. Keeping a single +/// instance means counters aggregate across workers and on-disk +/// writes share the same atomic-rename rendezvous. +fn process_code_cache() -> CodeCache { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(CodeCache::from_env).clone() +} + /// Boots a fresh [`V8Engine`], starts the JS pump matching the /// configured strategy, and wraps the engine for shared /// pump/recv access on a single [`tokio::task::LocalSet`]. @@ -85,7 +97,10 @@ pub(super) async fn boot_engine( .with_cjs(resolver) .with_cjs_root(ROOT_PARENT) .with_worker_id(worker_id) - .with_fs(crate::ops::FsHandle::real(vec![project_root])); + .with_fs(crate::ops::FsHandle::real(vec![project_root])) + .with_process(ProcessConfig::builder(Arc::new(OsEnv)).build()) + .with_code_cache(process_code_cache()) + .with_heap_limit(crate::effective_heap_limit()); V8Engine::boot_with(entrypoint, ctx) .await .map_err(|err| WorkerError::Engine(err.to_string()))? @@ -117,11 +132,25 @@ pub(super) async fn boot_engine( /// enqueue new slots, and parks on `pump_signal` once V8 reports the /// event loop drained - avoids spinning on an idle isolate. /// +/// While parked the pump arms a single-shot `idle GC` timer +/// configured by [`idle_gc_threshold`]: if no request arrives within +/// the threshold the worker calls +/// [`V8Engine::notify_low_memory`](super::V8Engine::notify_low_memory) +/// (and on Linux/jemalloc also nudges the allocator to return dirty +/// pages to the OS via [`purge_jemalloc_arenas`]). The timer rearms +/// only after the next request - so we pay *at most one* major GC per +/// idle period regardless of how long the silence lasts. The default +/// (`30_000` ms) keeps the GC out of the hot path while letting an +/// idle worker shed `~80-120` MiB of reclaimable heap before the +/// container scaler ever observes the pressure. +/// /// Returns when V8 reports an event-loop error (the supervisor is /// expected to recycle/rebuild the worker in that case) or the task /// is cancelled. pub(super) async fn run_pump(engine: Rc>, pump_signal: Rc) { let napi_wakeup = engine.borrow().napi_wakeup(); + let idle_threshold = idle_gc_threshold(); + let mut idle_gc_armed = true; loop { engine.borrow_mut().pump_once(); let queue_empty = { @@ -129,16 +158,110 @@ pub(super) async fn run_pump(engine: Rc>, pump_signal: Rc, +) -> bool { + match idle_threshold { + Some(d) => { + let work = async { + tokio::select! { + () = pump_signal.notified() => {}, + () = napi_wakeup.notified() => {}, + } + }; + tokio::time::timeout(d, work).await.is_ok() + } + None => { tokio::select! { () = pump_signal.notified() => {}, () = napi_wakeup.notified() => {}, } - } else { - tokio::task::yield_now().await; + true } } } +/// Asks V8 to free reclaimable heap. Called at most once per idle +/// period from the pump task. +/// +/// We intentionally do not poke jemalloc / glibc directly here: the +/// `jemalloc` feature already configures aggressive decay +/// (`dirty_decay_ms` / `muzzy_decay_ms`), which returns pages to the +/// OS within a few seconds of being freed without any explicit +/// `purge` call. The V8 notification triggers a major GC that +/// *frees* the pages in the first place, so the allocator decay can +/// follow up naturally - one notification, two layers of reclaim. +fn run_idle_reclaim(engine: &Rc>) { + let before = engine.borrow().heap_stats(); + engine.borrow_mut().notify_low_memory(); + let after = engine.borrow().heap_stats(); + let cache = process_code_cache(); + let evicted = if cache.is_enabled() { + cache.evict_to_quota() + } else { + 0 + }; + let snap = cache.metrics().snapshot(); + let shrunk = super::idle_shrink::shrink_all(); + tracing::debug!( + heap_before = before.used_heap_size, + heap_after = after.used_heap_size, + reclaimed = before.used_heap_size.saturating_sub(after.used_heap_size), + cache_hits = snap.hits, + cache_misses = snap.misses, + cache_rejects = snap.rejects, + cache_writes = snap.writes, + cache_evicted = evicted, + ram_shrinkers = shrunk, + "idle reclaim: V8 low-memory notification" + ); +} + +/// Resolves the idle-GC threshold from `NEXIDE_IDLE_GC_MS`. +/// +/// `0` (or any unparseable value) disables the idle-GC path entirely +/// for operators who would rather spend RSS on a hot path that needs +/// it. Default `30_000` ms - long enough that the GC pause never +/// lands on a real request after the bench harness's 2-second +/// cooldown, short enough that idle deployments shed memory before +/// the autoscaler reads RSS. +fn idle_gc_threshold() -> Option { + parse_idle_gc_threshold(std::env::var("NEXIDE_IDLE_GC_MS").ok().as_deref()) +} + +/// Pure parser for the `NEXIDE_IDLE_GC_MS` env value, exposed for +/// unit tests so we can pin the precedence rules without poking +/// process-global state. +fn parse_idle_gc_threshold(raw: Option<&str>) -> Option { + let trimmed = raw.map(str::trim).filter(|s| !s.is_empty()); + let ms: u64 = trimmed.and_then(|s| s.parse().ok()).unwrap_or(30_000); + if ms == 0 { + None + } else { + Some(std::time::Duration::from_millis(ms)) + } +} + /// Registers `job` with the engine by handing the dispatcher's reply /// oneshot directly to the in-isolate /// [`crate::ops::DispatchTable`]. The JS handler completes the @@ -223,4 +346,49 @@ mod tests { let result = build_slot(good); assert!(result.is_ok()); } + + #[test] + fn idle_gc_threshold_defaults_to_thirty_seconds_when_unset_or_blank() { + assert_eq!( + parse_idle_gc_threshold(None), + Some(std::time::Duration::from_millis(30_000)) + ); + assert_eq!( + parse_idle_gc_threshold(Some("")), + Some(std::time::Duration::from_millis(30_000)) + ); + assert_eq!( + parse_idle_gc_threshold(Some(" ")), + Some(std::time::Duration::from_millis(30_000)) + ); + } + + #[test] + fn idle_gc_threshold_zero_disables_path() { + assert_eq!(parse_idle_gc_threshold(Some("0")), None); + } + + #[test] + fn idle_gc_threshold_parses_explicit_millis() { + assert_eq!( + parse_idle_gc_threshold(Some("1500")), + Some(std::time::Duration::from_millis(1500)) + ); + assert_eq!( + parse_idle_gc_threshold(Some(" 60000 ")), + Some(std::time::Duration::from_millis(60_000)) + ); + } + + #[test] + fn idle_gc_threshold_falls_back_to_default_when_unparseable() { + assert_eq!( + parse_idle_gc_threshold(Some("not-a-number")), + Some(std::time::Duration::from_millis(30_000)) + ); + assert_eq!( + parse_idle_gc_threshold(Some("-5")), + Some(std::time::Duration::from_millis(30_000)) + ); + } } diff --git a/crates/nexide/src/pool/idle_shrink.rs b/crates/nexide/src/pool/idle_shrink.rs new file mode 100644 index 0000000..028e94f --- /dev/null +++ b/crates/nexide/src/pool/idle_shrink.rs @@ -0,0 +1,91 @@ +//! Process-wide registry of idle-time RAM shrinkers. +//! +//! Long-lived caches (prerender, static RAM, image hot cache) register +//! a callback here at construction time. When the pump's idle path +//! fires, it walks the registry and asks each cache to evict its +//! contents. Subsequent requests refill the cache lazily from disk — +//! we trade a few extra `fs::read` calls for `O(100 MiB)` of RSS shed +//! during quiet periods. +//! +//! Each callback is a `Box` capturing an +//! `Arc`; the Arc keeps the cache alive for the lifetime of +//! the process, so we never observe a stale closure. + +use std::sync::OnceLock; +use std::time::Instant; + +use parking_lot::Mutex; + +type Shrinker = Box; + +struct Registry { + shrinkers: Vec, + last_run: Option, +} + +static REGISTRY: OnceLock> = OnceLock::new(); + +fn registry() -> &'static Mutex { + REGISTRY.get_or_init(|| { + Mutex::new(Registry { + shrinkers: Vec::new(), + last_run: None, + }) + }) +} + +/// Registers `shrink` to be invoked whenever the pump enters its idle +/// shrink path. Safe to call from any thread. +pub fn register(shrink: F) { + registry().lock().shrinkers.push(Box::new(shrink)); +} + +/// Invokes every registered shrinker. Returns the number invoked. +pub fn shrink_all() -> usize { + let mut guard = registry().lock(); + guard.last_run = Some(Instant::now()); + for f in &guard.shrinkers { + (f)(); + } + guard.shrinkers.len() +} + +/// Telemetry hook (Query) — last time `shrink_all` was invoked. +#[cfg(test)] +pub fn last_run() -> Option { + registry().lock().last_run +} + +/// Test helper — clears the registry between unit tests so cases stay +/// isolated. Not exposed in release builds. +#[cfg(test)] +pub fn reset_for_tests() { + let mut g = registry().lock(); + g.shrinkers.clear(); + g.last_run = None; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[test] + fn shrink_all_invokes_every_registered_callback() { + reset_for_tests(); + let counter = Arc::new(AtomicUsize::new(0)); + let c1 = Arc::clone(&counter); + register(move || { + c1.fetch_add(1, Ordering::SeqCst); + }); + let c2 = Arc::clone(&counter); + register(move || { + c2.fetch_add(10, Ordering::SeqCst); + }); + assert_eq!(shrink_all(), 2); + assert_eq!(counter.load(Ordering::SeqCst), 11); + assert!(last_run().is_some()); + reset_for_tests(); + } +} diff --git a/crates/nexide/src/pool/mod.rs b/crates/nexide/src/pool/mod.rs index cbacbc3..3a9434b 100644 --- a/crates/nexide/src/pool/mod.rs +++ b/crates/nexide/src/pool/mod.rs @@ -8,6 +8,7 @@ //! and replaced with a freshly booted one. mod engine_pump; +pub mod idle_shrink; mod isolate_pool; mod isolate_worker; mod local_isolate_worker; diff --git a/crates/nexide/src/pool/recycle.rs b/crates/nexide/src/pool/recycle.rs index 77c560a..3b2fa7a 100644 --- a/crates/nexide/src/pool/recycle.rs +++ b/crates/nexide/src/pool/recycle.rs @@ -237,11 +237,32 @@ pub fn reap_rss_bytes_from_env(raw: Option<&str>) -> Option { /// configuration, applying the project-wide defaults when an env var /// is absent. /// -/// Defaults: `heap_ratio = 0.8`, `request_count = 50_000`. Either -/// component is **omitted** (not constructed with a degenerate `0`) -/// when the corresponding env value is exactly `0`, because policies -/// such as [`RequestCount::new(0)`] would fire on every dispatch and -/// turn the recycler into a busy loop. +/// Defaults: `heap_ratio = 0.95`, `request_count = 0` (disabled). +/// +/// Rationale: the recycler is a **safety net** for pathological +/// memory leaks, not a regular maintenance event. Each recycle boots +/// a fresh V8 isolate on the worker's `current_thread` runtime - the +/// same runtime that serves dispatches - so a recycle pause stalls +/// every in-flight request on that worker. Earlier defaults +/// (`0.8` / `50_000`) caused 2+ recycles per 30 s benchmark window; +/// each rebuild blocked dispatch for hundreds of milliseconds and +/// turned recycle events into clusters of p99 spikes (the `api-time` +/// route showed `91 ms` p99 versus `57 ms` for `api-ping` precisely +/// because allocation pressure tripped the heap-ratio trigger). +/// +/// `RequestCount` is intentionally disabled by default: the count +/// after which "something might leak" is workload-specific - the +/// value is unknowable without per-deployment measurement, and +/// guessing inflicts deterministic recycle pauses on perfectly +/// healthy isolates. `HeapThreshold` (`95%` of the V8 cap) is a +/// strictly better safety net because it triggers only when +/// real heap pressure exists. Operators who *do* have a known leak +/// can set `NEXIDE_RECYCLE_REQUESTS=N` for their workload. +/// +/// Either component is **omitted** (not constructed with a +/// degenerate `0`) when the corresponding env value is exactly `0`, +/// because policies such as [`RequestCount::new(0)`] would fire on +/// every dispatch and turn the recycler into a busy loop. /// /// Returning a zero-policy [`Composite`] (both disabled) is well /// defined - [`Composite::should_recycle`] returns `false` for an @@ -272,8 +293,8 @@ pub fn build_default_recycle_policy_with( rss_bytes: Option, sampler: Option>, ) -> Arc { - const DEFAULT_HEAP_RATIO: f64 = 0.8; - const DEFAULT_REQUEST_COUNT: u64 = 50_000; + const DEFAULT_HEAP_RATIO: f64 = 0.95; + const DEFAULT_REQUEST_COUNT: u64 = 0; let heap = heap_ratio.unwrap_or(DEFAULT_HEAP_RATIO); let requests = request_count.unwrap_or(DEFAULT_REQUEST_COUNT); let mut policies: Vec> = Vec::new(); @@ -398,9 +419,9 @@ mod tests { #[test] fn build_default_recycle_policy_uses_project_defaults() { let policy = build_default_recycle_policy(None, None); - assert!(!policy.should_recycle(&health(50, 100, 49_999))); - assert!(policy.should_recycle(&health(50, 100, 50_000))); - assert!(policy.should_recycle(&health(81, 100, 0))); + assert!(!policy.should_recycle(&health(50, 100, 999_999_999))); + assert!(!policy.should_recycle(&health(94, 100, 0))); + assert!(policy.should_recycle(&health(96, 100, 0))); } #[test] diff --git a/crates/nexide/src/server/error_page.rs b/crates/nexide/src/server/error_page.rs new file mode 100644 index 0000000..0640e8a --- /dev/null +++ b/crates/nexide/src/server/error_page.rs @@ -0,0 +1,395 @@ +//! Friendly, self-contained error pages for the HTTP shield. +//! +//! Every response is content-negotiated against the request's `Accept` +//! header so that browsers see polished HTML, API consumers see a small +//! JSON envelope and `curl` (or anything else) keeps the legacy plain +//! text. Pages are zero-dependency: inline CSS, inline SVG, no external +//! fetches, safe to render even when the upstream engine is dead. + +use axum::body::Body; +use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, HeaderValue}; +use axum::http::{Response, StatusCode}; + +/// Negotiated representation for an error response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Wants { + Html, + Json, + Text, +} + +/// Negotiates the response representation from the request's `Accept` +/// header value alone. +/// +/// Taking the bare [`HeaderValue`] (rather than the whole [`HeaderMap`]) +/// keeps the hot path allocation-free: callers clone only the single +/// header (a `Bytes`-backed value, ref-counted) instead of duplicating +/// the entire request header table just for content negotiation that +/// rarely fires (`error_page::render` is the error path). +fn negotiate(accept: Option<&HeaderValue>) -> Wants { + let Some(accept) = accept.and_then(|v| v.to_str().ok()) else { + return Wants::Html; + }; + let lower = accept.to_ascii_lowercase(); + if lower.contains("text/html") { + Wants::Html + } else if lower.contains("application/json") { + Wants::Json + } else if lower.contains("*/*") || lower.is_empty() { + Wants::Html + } else { + Wants::Text + } +} + +/// Builds an error response from a status code, optionally tailored by +/// the original request's `Accept` header (used for content +/// negotiation) and an internal `detail` string that will be exposed +/// only in the JSON envelope (HTML/text intentionally hide it from end +/// users). +pub(super) fn render( + status: StatusCode, + accept: Option<&HeaderValue>, + detail: Option<&str>, +) -> Response { + let copy = copy_for(status); + let mode = negotiate(accept); + let (content_type, body) = match mode { + Wants::Html => ( + HeaderValue::from_static("text/html; charset=utf-8"), + Body::from(render_html(status, ©)), + ), + Wants::Json => ( + HeaderValue::from_static("application/json; charset=utf-8"), + Body::from(render_json(status, ©, detail)), + ), + Wants::Text => ( + HeaderValue::from_static("text/plain; charset=utf-8"), + Body::from(render_text(status, ©)), + ), + }; + let mut response = Response::new(body); + *response.status_mut() = status; + response.headers_mut().insert(CONTENT_TYPE, content_type); + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +#[derive(Debug, Clone, Copy)] +struct Copy<'a> { + title: &'a str, + summary: &'a str, + advice: &'a str, + accent: &'a str, +} + +const fn copy_for(status: StatusCode) -> Copy<'static> { + match status.as_u16() { + 400 => Copy { + title: "We couldn't read this request", + summary: "Something about the request didn't look quite right on our side.", + advice: "Try refreshing the page. If the link came from somewhere else, double-check it for typos.", + accent: "#f59e0b", + }, + 401 => Copy { + title: "You need to sign in", + summary: "This page is only available to signed-in users.", + advice: "Sign in and try again. If you already are signed in, your session may have expired.", + accent: "#0ea5e9", + }, + 403 => Copy { + title: "This area isn't open to you", + summary: "You're signed in, but this page isn't part of what your account can access.", + advice: "If you think you should have access, contact whoever set up the account.", + accent: "#0ea5e9", + }, + 404 => Copy { + title: "We can't find that page", + summary: "The link may be old, or the page may have moved.", + advice: "Try the home page, or use search to find what you were looking for.", + accent: "#6366f1", + }, + 408 | 504 => Copy { + title: "This is taking longer than expected", + summary: "The page didn't finish loading in time. The slowdown is on our side, not yours.", + advice: "Give it a moment and try again - we're already working on it.", + accent: "#f97316", + }, + 413 => Copy { + title: "That request was too large to handle", + summary: "The data you sent is bigger than what we accept in one go.", + advice: "Try splitting it into smaller pieces or compressing it before resending.", + accent: "#f59e0b", + }, + 429 => Copy { + title: "Too many requests in a short time", + summary: "We're rate-limiting traffic to keep things fair for everyone.", + advice: "Wait a few seconds and try again. Automated tools should slow down their request rate.", + accent: "#f59e0b", + }, + 500 => Copy { + title: "Something went wrong on our end", + summary: "An unexpected error stopped this page from rendering. This isn't your fault.", + advice: "We've been notified and are looking into it. Reloading in a moment usually helps.", + accent: "#ef4444", + }, + 501 => Copy { + title: "We haven't built this yet", + summary: "This route exists, but the feature behind it isn't ready.", + advice: "Check back soon - if you were expecting this to work, let support know.", + accent: "#6366f1", + }, + 502 => Copy { + title: "We can't reach our backend right now", + summary: "An upstream service didn't respond the way we expected. Nothing about this is on you.", + advice: "Reload in a few seconds. If it sticks around, our on-call team is already on it.", + accent: "#ef4444", + }, + 503 => Copy { + title: "We're warming up", + summary: "The service is starting or briefly unavailable. This is on us, not on you.", + advice: "Hang tight and try again in a few seconds.", + accent: "#f59e0b", + }, + _ => Copy { + title: "Something didn't go as planned", + summary: "We hit an unexpected issue while handling your request. This isn't your fault.", + advice: "Reloading the page usually helps. If it doesn't, please get in touch with support.", + accent: "#6366f1", + }, + } +} + +fn render_html(status: StatusCode, c: &Copy<'_>) -> String { + let code = status.as_u16(); + let phrase = status.canonical_reason().unwrap_or("Error"); + format!( + concat!( + "", + "", + "", + "", + "", + "{code} {phrase}", + "", + "
", + "

Error {code} · {phrase}

", + "

{title}

{summary}

{advice}

", + "
", + "Try again", + "Go home", + "
", + "
nexide shieldHTTP {code}
", + "
" + ), + code = code, + phrase = phrase, + title = html_escape(c.title), + summary = html_escape(c.summary), + advice = html_escape(c.advice), + accent = c.accent, + ) +} + +fn render_json(status: StatusCode, c: &Copy<'_>, detail: Option<&str>) -> String { + let phrase = status.canonical_reason().unwrap_or("Error"); + let detail_segment = detail.map_or(String::new(), |d| { + format!(",\"detail\":\"{}\"", json_escape(d)) + }); + format!( + "{{\"error\":{{\"status\":{code},\"code\":\"{phrase}\",\"title\":\"{title}\",\"summary\":\"{summary}\",\"advice\":\"{advice}\"{detail}}}}}", + code = status.as_u16(), + phrase = json_escape(phrase), + title = json_escape(c.title), + summary = json_escape(c.summary), + advice = json_escape(c.advice), + detail = detail_segment, + ) +} + +fn render_text(status: StatusCode, c: &Copy<'_>) -> String { + let phrase = status.canonical_reason().unwrap_or("Error"); + format!( + "{} {}\n\n{}\n{}\n{}\n", + status.as_u16(), + phrase, + c.title, + c.summary, + c.advice + ) +} + +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + for ch in s.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::header::{ACCEPT, HeaderMap}; + use http_body_util::BodyExt; + + async fn body_string(resp: Response) -> (StatusCode, String, String) { + let status = resp.status(); + let ct = resp + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + (status, ct, String::from_utf8_lossy(&bytes).into_owned()) + } + + fn accept_value(value: &'static str) -> HeaderValue { + HeaderValue::from_static(value) + } + + fn headers_with_accept(value: &'static str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(ACCEPT, HeaderValue::from_static(value)); + h + } + + #[tokio::test] + async fn html_negotiation_returns_inline_page() { + let v = accept_value("text/html,application/xhtml+xml,*/*;q=0.8"); + let resp = render(StatusCode::INTERNAL_SERVER_ERROR, Some(&v), None); + let (status, ct, body) = body_string(resp).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(ct.starts_with("text/html")); + assert!(body.contains("")); + assert!(body.contains("Error 500")); + assert!(body.contains("not your fault") || body.contains("isn't your fault")); + } + + #[tokio::test] + async fn json_negotiation_returns_envelope() { + let v = accept_value("application/json"); + let resp = render( + StatusCode::BAD_GATEWAY, + Some(&v), + Some("upstream connection refused"), + ); + let (status, ct, body) = body_string(resp).await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert!(ct.starts_with("application/json")); + assert!(body.contains("\"status\":502")); + assert!(body.contains("upstream connection refused")); + } + + #[tokio::test] + async fn unknown_accept_falls_back_to_text() { + let v = accept_value("application/octet-stream"); + let resp = render(StatusCode::SERVICE_UNAVAILABLE, Some(&v), None); + let (_status, ct, body) = body_string(resp).await; + assert!(ct.starts_with("text/plain")); + assert!(body.starts_with("503 ")); + } + + #[tokio::test] + async fn missing_accept_defaults_to_html() { + let resp = render(StatusCode::NOT_FOUND, None, None); + let (_status, ct, body) = body_string(resp).await; + assert!(ct.starts_with("text/html")); + assert!(body.contains("Error 404")); + } + + #[test] + fn html_escapes_user_visible_strings() { + let escaped = html_escape(""); + assert_eq!(escaped, "<script>x</script>"); + } + + #[tokio::test] + async fn json_escapes_detail() { + let v = accept_value("application/json"); + let resp = render( + StatusCode::INTERNAL_SERVER_ERROR, + Some(&v), + Some("a \"quoted\"\n line"), + ); + let (_status, _ct, body) = body_string(resp).await; + assert!(body.contains("a \\\"quoted\\\"\\n line")); + } + + #[tokio::test] + async fn cache_control_is_no_store() { + let resp = render(StatusCode::BAD_GATEWAY, None, None); + assert_eq!( + resp.headers() + .get(CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("no-store") + ); + } + + #[test] + fn negotiate_helper_uses_only_accept_value() { + let h = headers_with_accept("application/json"); + let accept = h.get(ACCEPT); + assert_eq!(negotiate(accept), Wants::Json); + assert_eq!(negotiate(None), Wants::Html); + } +} diff --git a/crates/nexide/src/server/mod.rs b/crates/nexide/src/server/mod.rs index 41d861a..f0e0ac6 100644 --- a/crates/nexide/src/server/mod.rs +++ b/crates/nexide/src/server/mod.rs @@ -5,10 +5,12 @@ pub mod accept_loop; pub mod config; +mod error_page; pub mod fallback; mod next_bridge; mod prerender; mod static_assets; +mod static_ram_cache; mod stream_listener; pub mod worker_runtime; @@ -21,6 +23,9 @@ use axum::http::header::CACHE_CONTROL; use thiserror::Error; use tokio::net::TcpListener; use tower::ServiceBuilder; +use tower_http::CompressionLevel; +use tower_http::compression::CompressionLayer; +use tower_http::compression::predicate::{NotForContentType, Predicate, SizeAbove}; use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; @@ -69,12 +74,15 @@ pub enum ServerError { /// from the historical handler implementation; tests inject [`NotImplementedHandler`] or a recording /// double. pub fn build_router(cfg: &ServerConfig, handler: Arc) -> Router { - let dynamic = static_assets::dynamic_service(handler); + let dynamic = static_assets::dynamic_service(handler.clone()); let prerender = prerender::prerender_with_fallback(cfg.app_dir().to_path_buf(), dynamic); let public = static_assets::public_with_fallback_service(cfg.public_dir(), prerender); - let next_image = crate::image::next_image_service( + let next_image = crate::image::next_image_service_with_dynamic( cfg.app_dir().to_path_buf(), cfg.public_dir().to_path_buf(), + cfg.next_static_dir().to_path_buf(), + cfg.bind(), + Some(handler), ); let immutable_cache = ServiceBuilder::new().layer(SetResponseHeaderLayer::overriding( CACHE_CONTROL, @@ -83,11 +91,59 @@ pub fn build_router(cfg: &ServerConfig, handler: Arc) -> Rou Router::new() .nest_service( "/_next/static", - immutable_cache.service(static_assets::next_static_only(cfg.next_static_dir())), + immutable_cache.service(static_ram_cache::RamCachedService::new( + static_assets::next_static_only(cfg.next_static_dir()), + )), ) .nest_service("/_next/image", next_image) .fallback_service(public) .layer(TraceLayer::new_for_http()) + .layer(response_compression_layer()) +} + +/// Builds the response compression layer used by the shield. +/// +/// Negotiates `br` (preferred for text), `gzip` and `zstd` from the +/// client's `Accept-Encoding`. The HTML payload Next.js returns is +/// uncompressed by default once the standalone Node entrypoint is +/// replaced by our shield, so without this layer 80 KiB pages travel +/// the wire raw - both inflating content-download time and starving +/// TCP slow-start of headroom (more round-trips before `cwnd` opens). +/// Brotli q4 (the level used here) compresses HTML / JSON / RSC +/// payloads ~5-7×, gzip ~3-4×, with sub-millisecond CPU per request +/// for typical Next.js page sizes. +/// +/// We deliberately avoid the maximum compression level: q11 brotli +/// would shave another ~10% off the wire bytes but adds 30-100 ms of +/// CPU per response, blowing the TTFB budget. q4 hits the sweet spot +/// for dynamic responses; a future optimisation can bake brotli q11 +/// into the prerender RAM cache so static HTML pays the high CPU cost +/// at warmup, not per request. +/// +/// `SizeAbove(256)` skips compression for tiny bodies where the +/// gzip / brotli framing overhead exceeds the savings (`Server-Timing` +/// pings, 204s, redirects). `NotForContentType` blocks payloads that +/// are already compressed by their encoder (`image/*`, `video/*`, +/// `font/woff2`, `application/zip`, `application/wasm`), preventing +/// wasted CPU and pathological "negative compression" for entropy-rich +/// blobs. +fn response_compression_layer() -> CompressionLayer { + let predicate = SizeAbove::new(256) + .and(NotForContentType::IMAGES) + .and(NotForContentType::const_new("video/")) + .and(NotForContentType::const_new("audio/")) + .and(NotForContentType::const_new("font/woff2")) + .and(NotForContentType::const_new("application/zip")) + .and(NotForContentType::const_new("application/x-7z-compressed")) + .and(NotForContentType::const_new("application/x-rar-compressed")) + .and(NotForContentType::const_new("application/wasm")) + .and(NotForContentType::const_new("application/octet-stream")); + CompressionLayer::new() + .br(true) + .gzip(true) + .zstd(true) + .quality(CompressionLevel::Precise(4)) + .compress_when(predicate) } /// Runs the HTTP shield until `shutdown` resolves. @@ -402,6 +458,66 @@ mod tests { (pub_dir, static_dir, app_dir, cfg) } + #[tokio::test] + async fn router_compresses_large_html_with_brotli_when_accepted() { + let (_p, _s, app_dir, cfg) = fixture(); + let html = format!("{}", "x".repeat(2048)); + std::fs::write(app_dir.path().join("big.html"), html.as_bytes()).expect("write big"); + let router = build_router(&cfg, Arc::new(NotImplementedHandler)); + let response = router + .oneshot( + Request::builder() + .uri("/big") + .header("accept-encoding", "br, gzip") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("infallible"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("content-encoding") + .map(|h| h.to_str().unwrap()), + Some("br"), + "expected brotli encoding for large HTML body" + ); + let bytes = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + assert!( + bytes.len() < html.len() / 2, + "compressed body must be substantially smaller than the source ({} vs {})", + bytes.len(), + html.len(), + ); + } + + #[tokio::test] + async fn router_skips_compression_for_tiny_responses() { + let (_p, _s, _a, cfg) = fixture(); + let router = build_router(&cfg, Arc::new(NotImplementedHandler)); + let response = router + .oneshot( + Request::builder() + .uri("/hello.txt") + .header("accept-encoding", "br, gzip") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("infallible"); + assert_eq!(response.status(), StatusCode::OK); + assert!( + response.headers().get("content-encoding").is_none(), + "5-byte response must not be compressed - framing overhead would dominate" + ); + } + #[tokio::test] async fn router_serves_public_files() { let (_p, _s, _a, cfg) = fixture(); diff --git a/crates/nexide/src/server/next_bridge.rs b/crates/nexide/src/server/next_bridge.rs index 9bb3dd6..0f1aeee 100644 --- a/crates/nexide/src/server/next_bridge.rs +++ b/crates/nexide/src/server/next_bridge.rs @@ -8,17 +8,21 @@ //! (Dependency Inversion Principle). use std::sync::Arc; +use std::sync::OnceLock; use std::time::Instant; use async_trait::async_trait; use axum::body::Body; -use axum::http::header::{HeaderName, HeaderValue}; +use axum::http::header::{ACCEPT, HeaderName, HeaderValue}; use axum::http::{HeaderMap, Request, Response, StatusCode}; use http_body_util::BodyExt; +use tokio::sync::Semaphore; +use tokio_stream::StreamExt; use super::fallback::{DynamicHandler, HandlerError}; -use crate::dispatch::{DispatchError, EngineDispatcher, ProtoRequest}; +use crate::dispatch::{DispatchError, EngineDispatcher, ProtoRequest, StreamingResponse}; use crate::ops::HeaderPair; +use crate::ops::upgrade_socket::{self, UPGRADE_SOCKET_ID_HEADER}; /// Maximum buffered request body size. Larger bodies are rejected /// with `413 Payload Too Large` to keep the worker thread bounded. @@ -29,18 +33,57 @@ pub const MAX_REQUEST_BODY_BYTES: usize = 8 * 1024 * 1024; /// Generic over the dispatcher to keep the production wiring testable; /// the `next_bridge.rs` integration test substitutes an in-memory /// [`EngineDispatcher`] double. +/// +/// Concurrency: when constructed via [`Self::with_inflight_limit`], +/// the handler gates the JS dispatch path behind a [`Semaphore`]. +/// This is the *only* memory-bounded backpressure between the kernel +/// accept queue and the per-isolate JS heap; without it every +/// concurrent connection materialises a `DispatchJob` plus a Next.js +/// render context (~2-3 MiB live heap each), and a tight container +/// (e.g. `1 CPU / 256 MiB`) hits `Fatal JavaScript out of memory: +/// Ineffective mark-compacts near heap limit` long before V8's +/// `--max-old-space-size` cap is reached, because the GC death-spiral +/// triggers when reclamation cannot catch up with allocation. With a +/// permit cap of `N`, the steady-state working set is bounded at +/// roughly `N * per_render_mb`, regardless of how many TCP +/// connections hyper accepts. pub struct NextBridgeHandler { dispatcher: Arc, + inflight_limit: Option>, } impl NextBridgeHandler where D: EngineDispatcher, { - /// Wraps `dispatcher` in an Axum-compatible handler. + /// Wraps `dispatcher` in an Axum-compatible handler with no + /// inflight cap. + /// + /// Prefer [`Self::with_inflight_limit`] in production to keep the + /// JS heap bounded under bursty traffic; this constructor exists + /// for unit tests where the dispatcher itself is a synchronous + /// double and concurrency is bounded by the test harness. #[must_use] pub const fn new(dispatcher: Arc) -> Self { - Self { dispatcher } + Self { + dispatcher, + inflight_limit: None, + } + } + + /// Wraps `dispatcher` and gates JS dispatch behind a + /// [`Semaphore`] capped at `permits`. + /// + /// `permits == 0` is silently clamped to `1` so the runtime + /// always remains live (operators sometimes mis-key the env + /// var). Pass `None` for unlimited concurrency. + #[must_use] + pub fn with_inflight_limit(dispatcher: Arc, permits: Option) -> Self { + let inflight_limit = permits.map(|n| Arc::new(Semaphore::new(n.max(1)))); + Self { + dispatcher, + inflight_limit, + } } /// Returns the underlying dispatcher (Query - used by tests for @@ -57,34 +100,76 @@ where D: EngineDispatcher, { async fn handle(&self, req: Request) -> Result, HandlerError> { - let t_accept_start = Instant::now(); + let breakdown = phase_breakdown_enabled(); + let t_accept_start = if breakdown { + Some(Instant::now()) + } else { + None + }; + let accept_header = req.headers().get(ACCEPT).cloned(); let proto = match build_proto_request(req).await { Ok(p) => p, - Err(err) => return Ok(error_response(&err)), + Err(err) => return Ok(error_response(&err, accept_header.as_ref())), }; - let accept_elapsed = t_accept_start.elapsed(); - let t_dispatch_start = Instant::now(); - let outcome = self.dispatcher.dispatch(proto).await; - let dispatch_elapsed = t_dispatch_start.elapsed(); + let accept_elapsed = t_accept_start.map(|t| t.elapsed()); - let t_respond_start = Instant::now(); + let _permit = match &self.inflight_limit { + Some(sem) => Some(sem.acquire().await.expect("semaphore live")), + None => None, + }; + + let t_dispatch_start = if breakdown { + Some(Instant::now()) + } else { + None + }; + let outcome = self.dispatcher.dispatch_streaming(proto).await; + let dispatch_elapsed = t_dispatch_start.map(|t| t.elapsed()); + + let t_respond_start = if breakdown { + Some(Instant::now()) + } else { + None + }; let mut response = match outcome { - Ok(payload) => payload_to_response(payload), - Err(err) => error_response(&err), + Ok(streaming) => streaming_to_response(streaming), + Err(err) => error_response(&err, accept_header.as_ref()), }; - let respond_elapsed = t_respond_start.elapsed(); + let respond_elapsed = t_respond_start.map(|t| t.elapsed()); - stamp_phase_breakdown( - response.headers_mut(), - accept_elapsed, - dispatch_elapsed, - respond_elapsed, - ); + if breakdown { + stamp_phase_breakdown( + response.headers_mut(), + accept_elapsed.unwrap_or_default(), + dispatch_elapsed.unwrap_or_default(), + respond_elapsed.unwrap_or_default(), + ); + } Ok(response) } } +/// Returns `true` when `NEXIDE_PHASE_BREAKDOWN=1` is set. +/// +/// Resolved exactly once per process via [`OnceLock`] so the hot path +/// reads a cached `bool` instead of touching the env on every request. +/// The breakdown header is a developer aid (it stamps a multi-segment +/// `Server-Timing` value with `accept`/`dispatch_inner`/`respond` +/// durations) and adds a `format!()` + `HeaderValue::from_str()` plus a +/// header-table append per response, which shows up at high RPS. We +/// keep it disabled by default so production traffic pays nothing for +/// it; observability stacks that need the data set the env flag. +fn phase_breakdown_enabled() -> bool { + static FLAG: OnceLock = OnceLock::new(); + *FLAG.get_or_init(|| { + matches!( + std::env::var("NEXIDE_PHASE_BREAKDOWN").as_deref(), + Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") + ) + }) +} + /// Appends per-phase Server-Timing metrics (`accept`, `dispatch_inner`, /// `respond`) so a downstream stamp from [`crate::server::prerender`] /// can prepend the canonical `srv;desc="v8-dispatch";dur=...` total @@ -102,7 +187,8 @@ fn stamp_phase_breakdown( duration_ms(respond), ); if let Ok(v) = HeaderValue::from_str(&value) { - headers.append("server-timing", v); + const HN: HeaderName = HeaderName::from_static("server-timing"); + headers.append(HN, v); } } @@ -113,26 +199,116 @@ fn duration_ms(d: std::time::Duration) -> f64 { ms } +/// Heuristic for "this is an HTTP/1.1 Upgrade request the JS layer +/// should service via the `'upgrade'` event". We require both the +/// `Upgrade` header and a `Connection: upgrade` token because hyper +/// only triggers `OnUpgrade` for requests that satisfy both — sending +/// just one of them is a malformed upgrade, and the JS side has no +/// recourse if `OnUpgrade` never resolves. +fn is_upgrade_request(headers: &axum::http::HeaderMap) -> bool { + let has_upgrade_header = headers.get("upgrade").is_some(); + if !has_upgrade_header { + return false; + } + headers + .get("connection") + .and_then(|v| v.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .map(str::trim) + .any(|tok| tok.eq_ignore_ascii_case("upgrade")) + }) +} + async fn build_proto_request(req: Request) -> Result { - let (parts, body) = req.into_parts(); - let method = parts.method.to_string(); + let (mut parts, body) = req.into_parts(); + let method = parts.method.as_str().to_owned(); let uri = parts .uri .path_and_query() .map_or_else(|| parts.uri.to_string(), ToString::to_string); + let upgrade = is_upgrade_request(&parts.headers); + let mut headers = Vec::with_capacity(parts.headers.len()); for (name, value) in &parts.headers { + let name_str = name.as_str(); + // For Upgrade requests we must keep `connection`, `upgrade`, + // and the `sec-websocket-*` family visible to JS so user code + // (e.g. the `ws` library) can validate the handshake. We still + // strip the truly hop-by-hop headers that have no place in + // the JS view (`keep-alive`, `te`, `trailer`, + // `transfer-encoding`, `proxy-*`). + let strip = if upgrade { + is_hop_by_hop_strict(name_str) + } else { + is_hop_by_hop(name_str) + }; + if strip { + continue; + } let value_str = match value.to_str() { Ok(v) => v.to_owned(), Err(_) => continue, }; headers.push(HeaderPair { - name: name.as_str().to_ascii_lowercase(), + name: name_str.to_owned(), value: value_str, }); } + if upgrade { + // Take the `OnUpgrade` future before any body collection so + // hyper retains the post-101 socket for us. Body collection is + // skipped: HTTP/1.1 Upgrade requests carry no separate body + // (any post-handshake bytes belong to the upgraded protocol). + let on_upgrade = parts.extensions.remove::(); + let socket = upgrade_socket::allocate(); + let socket_id = socket.id(); + headers.push(HeaderPair { + name: UPGRADE_SOCKET_ID_HEADER.to_owned(), + value: socket_id.to_string(), + }); + if let Some(on_upgrade) = on_upgrade { + // Drop our handle: the registry holds the canonical Arc + // so this only releases the cloned reference. The shield + // does not need to read/write through the handle itself — + // it just needs to keep the slot alive long enough for JS + // to discover it via the synthetic header. + drop(socket); + tokio::spawn(async move { + match on_upgrade.await { + Ok(upgraded) => upgrade_socket::attach_upgraded(socket_id, upgraded), + Err(err) => { + tracing::warn!( + target: "nexide::server::next_bridge", + socket_id, + error = %err, + "OnUpgrade resolution failed", + ); + upgrade_socket::abort(socket_id, err.to_string()); + } + } + }); + } else { + // No OnUpgrade extension means hyper has nothing to hand + // back to us (e.g. HTTP/2 or already-upgraded request). + // Surface it as an aborted slot so JS sees a clean error. + drop(socket); + upgrade_socket::abort( + socket_id, + "request did not carry hyper::upgrade::OnUpgrade".to_owned(), + ); + } + return Ok(ProtoRequest { + method, + uri, + headers, + body: bytes::Bytes::new(), + }); + } + let collected = body .collect() .await @@ -153,47 +329,122 @@ async fn build_proto_request(req: Request) -> Result Response { - let status = StatusCode::from_u16(payload.head.status).unwrap_or(StatusCode::BAD_GATEWAY); +fn streaming_to_response(streaming: StreamingResponse) -> Response { + let StreamingResponse { head, body } = streaming; + let status = StatusCode::from_u16(head.status).unwrap_or(StatusCode::BAD_GATEWAY); let mut builder = Response::builder().status(status); let headers_mut = builder .headers_mut() .expect("response builder must accept headers"); - for (name, value) in payload.head.headers { - let Ok(header_name) = HeaderName::try_from(name) else { - continue; + for (name, value) in head.headers { + let header_name = match canonical_header_name(&name) { + Some(n) => n, + None => match HeaderName::try_from(name) { + Ok(n) => n, + Err(_) => continue, + }, }; let Ok(header_value) = HeaderValue::try_from(value) else { continue; }; headers_mut.append(header_name, header_value); } - builder - .body(Body::from(payload.body)) - .unwrap_or_else(|_| infallible_502()) + let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(body) + .map(|res| res.map_err(|err| std::io::Error::other(err.to_string()))); + let axum_body = Body::from_stream(stream); + builder.body(axum_body).unwrap_or_else(|_| infallible_502()) } -fn error_response(err: &DispatchError) -> Response { +#[inline] +fn canonical_header_name(name: &str) -> Option { + use axum::http::header::{ + ACCEPT_RANGES, AGE, CACHE_CONTROL, CONNECTION, CONTENT_DISPOSITION, CONTENT_ENCODING, + CONTENT_LANGUAGE, CONTENT_LENGTH, CONTENT_LOCATION, CONTENT_RANGE, CONTENT_SECURITY_POLICY, + CONTENT_TYPE, DATE, ETAG, EXPIRES, LAST_MODIFIED, LINK, LOCATION, PRAGMA, REFERRER_POLICY, + SERVER, SET_COOKIE, STRICT_TRANSPORT_SECURITY, TRANSFER_ENCODING, VARY, + X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS, X_XSS_PROTECTION, + }; + let lc = match name.as_bytes().first()? { + b'a'..=b'z' => name, + _ => return None, + }; + Some(match lc { + "accept-ranges" => ACCEPT_RANGES, + "age" => AGE, + "cache-control" => CACHE_CONTROL, + "connection" => CONNECTION, + "content-disposition" => CONTENT_DISPOSITION, + "content-encoding" => CONTENT_ENCODING, + "content-language" => CONTENT_LANGUAGE, + "content-length" => CONTENT_LENGTH, + "content-location" => CONTENT_LOCATION, + "content-range" => CONTENT_RANGE, + "content-security-policy" => CONTENT_SECURITY_POLICY, + "content-type" => CONTENT_TYPE, + "date" => DATE, + "etag" => ETAG, + "expires" => EXPIRES, + "last-modified" => LAST_MODIFIED, + "link" => LINK, + "location" => LOCATION, + "pragma" => PRAGMA, + "referrer-policy" => REFERRER_POLICY, + "server" => SERVER, + "set-cookie" => SET_COOKIE, + "strict-transport-security" => STRICT_TRANSPORT_SECURITY, + "transfer-encoding" => TRANSFER_ENCODING, + "vary" => VARY, + "x-content-type-options" => X_CONTENT_TYPE_OPTIONS, + "x-frame-options" => X_FRAME_OPTIONS, + "x-xss-protection" => X_XSS_PROTECTION, + _ => return None, + }) +} + +#[inline] +fn is_hop_by_hop(name: &str) -> bool { + matches!( + name, + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} + +/// Variant used on Upgrade requests: keeps `connection` and +/// `upgrade` visible to JS so the handshake can be validated by +/// user code (e.g. the `ws` library). +#[inline] +fn is_hop_by_hop_strict(name: &str) -> bool { + matches!( + name, + "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + ) +} + +fn error_response(err: &DispatchError, accept: Option<&HeaderValue>) -> Response { tracing::error!(error = %err, "next bridge dispatch failed"); - let (status, message) = match err { - DispatchError::BadRequest(_) => (StatusCode::BAD_REQUEST, err.to_string()), - DispatchError::WorkerGone | DispatchError::NoResponse => ( - StatusCode::SERVICE_UNAVAILABLE, - "engine worker unavailable".to_owned(), - ), - _ => (StatusCode::BAD_GATEWAY, err.to_string()), + let status = match err { + DispatchError::BadRequest(_) => StatusCode::BAD_REQUEST, + DispatchError::WorkerGone | DispatchError::NoResponse => StatusCode::SERVICE_UNAVAILABLE, + _ => StatusCode::BAD_GATEWAY, }; - Response::builder() - .status(status) - .header("content-type", "text/plain; charset=utf-8") - .body(Body::from(message)) - .unwrap_or_else(|_| infallible_502()) + let detail = err.to_string(); + super::error_page::render(status, accept, Some(&detail)) } fn infallible_502() -> Response { - let mut response = Response::new(Body::from("internal error")); - *response.status_mut() = StatusCode::BAD_GATEWAY; - response + super::error_page::render(StatusCode::BAD_GATEWAY, None, None) } #[cfg(test)] @@ -305,4 +556,270 @@ mod tests { let response = handler.handle(req).await.expect("infallible"); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); } + + #[tokio::test] + async fn streaming_dispatcher_yields_chunks_progressively() { + use crate::ops::RequestFailure; + + struct StreamingDispatcher { + count: Arc, + } + + #[async_trait] + impl EngineDispatcher for StreamingDispatcher { + async fn dispatch( + &self, + _request: ProtoRequest, + ) -> Result { + unreachable!("streaming path takes precedence"); + } + + async fn dispatch_streaming( + &self, + _request: ProtoRequest, + ) -> Result { + self.count.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + let _ = tx.send(Ok::<_, RequestFailure>(Bytes::from_static(b"chunk-1|"))); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + let _ = tx.send(Ok(Bytes::from_static(b"chunk-2|"))); + let _ = tx.send(Ok(Bytes::from_static(b"chunk-3"))); + drop(tx); + }); + Ok(StreamingResponse { + head: ResponseHead { + status: 200, + headers: vec![("content-type".into(), "text/plain".into())], + }, + body: rx, + }) + } + + fn dispatch_count(&self) -> usize { + self.count.load(Ordering::Relaxed) + } + } + + let dispatcher = Arc::new(StreamingDispatcher { + count: Arc::new(AtomicUsize::new(0)), + }); + let handler = NextBridgeHandler::new(dispatcher); + let req = Request::builder() + .uri("/stream") + .body(Body::empty()) + .expect("request"); + let response = handler.handle(req).await.expect("infallible"); + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("collect") + .to_bytes(); + assert_eq!(&body[..], b"chunk-1|chunk-2|chunk-3"); + } + + #[tokio::test] + async fn streaming_dispatcher_propagates_mid_stream_error_via_body_close() { + use crate::ops::RequestFailure; + + struct ErroringDispatcher; + + #[async_trait] + impl EngineDispatcher for ErroringDispatcher { + async fn dispatch( + &self, + _request: ProtoRequest, + ) -> Result { + unreachable!() + } + + async fn dispatch_streaming( + &self, + _request: ProtoRequest, + ) -> Result { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + let _ = tx.send(Ok::<_, RequestFailure>(Bytes::from_static(b"partial"))); + let _ = tx.send(Err(RequestFailure::Handler("boom".into()))); + }); + Ok(StreamingResponse { + head: ResponseHead { + status: 200, + headers: vec![], + }, + body: rx, + }) + } + + fn dispatch_count(&self) -> usize { + 0 + } + } + + let handler = NextBridgeHandler::new(Arc::new(ErroringDispatcher)); + let req = Request::builder() + .uri("/") + .body(Body::empty()) + .expect("request"); + let response = handler.handle(req).await.expect("infallible"); + assert_eq!(response.status(), StatusCode::OK); + let result = response.into_body().collect().await; + assert!(result.is_err(), "body must surface mid-stream error"); + } + + #[tokio::test] + async fn inflight_semaphore_caps_concurrent_dispatches() { + use std::sync::atomic::AtomicUsize; + use std::time::Duration; + + // Dispatcher that parks every call on a shared release + // counter, lets us observe how many handler tasks are in + // `dispatch()` simultaneously. With permits=2, never more + // than 2 should be parked at once even when we fire 8 + // concurrent requests. + struct ParkDispatcher { + parked: Arc, + peak: Arc, + release: Arc, + } + #[async_trait] + impl EngineDispatcher for ParkDispatcher { + async fn dispatch( + &self, + _request: ProtoRequest, + ) -> Result { + let now = self.parked.fetch_add(1, Ordering::SeqCst) + 1; + let prev = self.peak.load(Ordering::SeqCst); + if now > prev { + self.peak.store(now, Ordering::SeqCst); + } + let _ = self.release.acquire().await.expect("live").forget(); + self.parked.fetch_sub(1, Ordering::SeqCst); + Ok(ResponsePayload { + head: ResponseHead { + status: 200, + headers: vec![], + }, + body: Vec::new().into(), + }) + } + fn dispatch_count(&self) -> usize { + 0 + } + } + + let parked = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let dispatcher = Arc::new(ParkDispatcher { + parked: Arc::clone(&parked), + peak: Arc::clone(&peak), + release: Arc::clone(&release), + }); + let handler = Arc::new(NextBridgeHandler::with_inflight_limit(dispatcher, Some(2))); + let mut joins = Vec::new(); + for _ in 0..8 { + let h = Arc::clone(&handler); + joins.push(tokio::spawn(async move { + let req = Request::builder() + .uri("/") + .body(Body::empty()) + .expect("request"); + h.handle(req).await.expect("infallible") + })); + } + // Let tasks attempt to enter dispatch. + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + peak.load(Ordering::SeqCst) <= 2, + "peak concurrent dispatches must not exceed permit cap" + ); + // Drain all 8 by releasing 8 permits to the dispatcher gate. + release.add_permits(8); + for j in joins { + j.await.expect("task"); + } + assert!( + peak.load(Ordering::SeqCst) <= 2, + "peak after drain must still respect the cap" + ); + } + + #[tokio::test] + async fn inflight_semaphore_clamps_zero_to_one() { + let dispatcher = Arc::new(EchoDispatcher { + count: Arc::new(AtomicUsize::new(0)), + }); + let handler = NextBridgeHandler::with_inflight_limit(Arc::clone(&dispatcher), Some(0)); + let req = Request::builder() + .uri("/") + .body(Body::from("ok")) + .expect("request"); + let response = handler.handle(req).await.expect("infallible"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(dispatcher.dispatch_count(), 1); + } + + #[test] + fn is_hop_by_hop_filters_known_hop_by_hop_headers() { + assert!(is_hop_by_hop("connection")); + assert!(is_hop_by_hop("keep-alive")); + assert!(is_hop_by_hop("transfer-encoding")); + assert!(is_hop_by_hop("te")); + assert!(is_hop_by_hop("upgrade")); + assert!(is_hop_by_hop("proxy-authenticate")); + assert!(is_hop_by_hop("proxy-authorization")); + assert!(is_hop_by_hop("trailer")); + assert!(!is_hop_by_hop("content-type")); + assert!(!is_hop_by_hop("accept")); + assert!(!is_hop_by_hop("user-agent")); + } + + #[test] + fn canonical_header_name_returns_static_for_common_lowercase() { + assert_eq!( + canonical_header_name("content-type").unwrap().as_str(), + "content-type" + ); + assert_eq!( + canonical_header_name("cache-control").unwrap().as_str(), + "cache-control" + ); + assert_eq!( + canonical_header_name("set-cookie").unwrap().as_str(), + "set-cookie" + ); + } + + #[test] + fn canonical_header_name_returns_none_for_unknown_or_uppercase() { + assert!(canonical_header_name("Content-Type").is_none()); + assert!(canonical_header_name("x-custom-header").is_none()); + assert!(canonical_header_name("").is_none()); + } + + #[tokio::test] + async fn build_proto_request_drops_hop_by_hop_headers() { + let req = Request::builder() + .method("GET") + .uri("/") + .header("host", "example.com") + .header("connection", "close") + .header("keep-alive", "timeout=5") + .header("transfer-encoding", "chunked") + .header("content-type", "text/plain") + .header("upgrade", "websocket") + .body(Body::empty()) + .expect("request"); + let proto = build_proto_request(req).await.expect("proto"); + let names: Vec<&str> = proto.headers.iter().map(|h| h.name.as_str()).collect(); + assert!(names.contains(&"host")); + assert!(names.contains(&"content-type")); + assert!(!names.contains(&"connection")); + assert!(!names.contains(&"keep-alive")); + assert!(!names.contains(&"transfer-encoding")); + assert!(!names.contains(&"upgrade")); + } } diff --git a/crates/nexide/src/server/prerender.rs b/crates/nexide/src/server/prerender.rs index 697fd4e..6beb4f7 100644 --- a/crates/nexide/src/server/prerender.rs +++ b/crates/nexide/src/server/prerender.rs @@ -12,13 +12,33 @@ use std::collections::HashMap; use std::convert::Infallible; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Arc, RwLock}; +use std::sync::Arc; use std::time::{Instant, SystemTime}; +use parking_lot::RwLock; + use axum::body::Body; -use axum::http::header::{HeaderName, HeaderValue}; -use axum::http::{Method, Request, Response, StatusCode}; +use axum::http::header::{ + ACCEPT_ENCODING, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, + HeaderName, HeaderValue, VARY, +}; +use axum::http::{HeaderMap, Method, Request, Response, StatusCode}; + +const HV_VARY_RSC: HeaderValue = + HeaderValue::from_static("rsc, next-router-state-tree, next-router-prefetch, accept-encoding"); +const HV_CACHE_CONTROL_PRERENDER: HeaderValue = HeaderValue::from_static("s-maxage=31536000"); +const HV_X_NEXTJS_CACHE_HIT: HeaderValue = HeaderValue::from_static("HIT"); +const HV_TIMING_ALLOW_ORIGIN_ANY: HeaderValue = HeaderValue::from_static("*"); +const HV_ENCODING_BR: HeaderValue = HeaderValue::from_static("br"); +const HV_ENCODING_GZIP: HeaderValue = HeaderValue::from_static("gzip"); +const HN_X_NEXTJS_CACHE: HeaderName = HeaderName::from_static("x-nextjs-cache"); +const HN_SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing"); +const HN_TIMING_ALLOW_ORIGIN: HeaderName = HeaderName::from_static("timing-allow-origin"); + +const PRECOMPRESS_MIN_BYTES: usize = 256; +use brotli::enc::BrotliEncoderParams; use bytes::Bytes; use serde::Deserialize; use sha1::{Digest, Sha1}; @@ -48,6 +68,14 @@ pub(super) fn prerender_with_fallback( fallback: DynamicService, ) -> PrerenderService { let inner = Arc::new(PrerenderInner::new(app_dir)); + { + let weak = Arc::downgrade(&inner); + crate::pool::idle_shrink::register(move || { + if let Some(strong) = weak.upgrade() { + strong.shrink(); + } + }); + } let svc = service_fn(move |req: Request| { let inner = inner.clone(); let mut fallback = fallback.clone(); @@ -69,6 +97,18 @@ pub(super) fn prerender_with_fallback( struct PrerenderInner { root: PathBuf, cache: RwLock>, + total_bytes: std::sync::atomic::AtomicU64, + byte_cap: u64, +} + +const DEFAULT_PRERENDER_RAM_MB: u64 = 32; + +fn prerender_byte_cap() -> u64 { + std::env::var("NEXIDE_PRERENDER_RAM_MB") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_PRERENDER_RAM_MB) + .saturating_mul(1024 * 1024) } impl PrerenderInner { @@ -76,8 +116,28 @@ impl PrerenderInner { Self { root, cache: RwLock::new(HashMap::with_capacity(64)), + total_bytes: std::sync::atomic::AtomicU64::new(0), + byte_cap: prerender_byte_cap(), } } + + fn shrink(&self) { + let mut guard = self.cache.write(); + guard.clear(); + self.total_bytes + .store(0, std::sync::atomic::Ordering::Relaxed); + } +} + +fn entry_bytes(asset: &CachedAsset) -> u64 { + let mut n = asset.bytes.len() as u64; + if let Some(b) = asset.br_q11.as_ref() { + n += b.len() as u64; + } + if let Some(g) = asset.gzip9.as_ref() { + n += g.len() as u64; + } + n } /// Stamps `Server-Timing` on the outbound response. The canonical @@ -99,7 +159,7 @@ fn stamp_server_timing( let ms = micros as f64 / 1000.0; let mut value = format!("srv;desc=\"{desc}\";dur={ms:.3}"); let existing = headers - .get("server-timing") + .get(&HN_SERVER_TIMING) .and_then(|v| v.to_str().ok()) .map(ToOwned::to_owned); if let Some(rest) = existing @@ -109,15 +169,16 @@ fn stamp_server_timing( value.push_str(&rest); } if let Ok(v) = HeaderValue::from_str(&value) { - headers.insert("server-timing", v); + headers.insert(&HN_SERVER_TIMING, v); } - headers.insert("timing-allow-origin", HeaderValue::from_static("*")); + headers.insert(&HN_TIMING_ALLOW_ORIGIN, HV_TIMING_ALLOW_ORIGIN_ANY); } -/// Cached payload plus the metadata required to detect ISR rewrites. #[derive(Clone)] struct CachedAsset { bytes: Bytes, + br_q11: Option, + gzip9: Option, etag: String, content_type: &'static str, extra_headers: Vec<(HeaderName, HeaderValue)>, @@ -141,7 +202,12 @@ fn try_serve(inner: &PrerenderInner, req: &Request) -> Option Option { /// Cache lookup with mtime/size revalidation. On miss or staleness, /// reloads from disk and inserts the fresh entry. fn resolve_asset(inner: &PrerenderInner, key: &str, is_rsc: bool) -> Option { - if let Ok(guard) = inner.cache.read() - && let Some(hit) = guard.get(key) - && file_matches(&inner.root, key, hit) { - return Some(hit.clone()); + let guard = match inner.cache.try_read() { + Some(g) => { + crate::diagnostics::contention::record_fast( + &crate::diagnostics::contention::PRERENDER_READ_FAST, + ); + g + } + None => { + crate::diagnostics::contention::record_contended( + &crate::diagnostics::contention::PRERENDER_READ_CONTENDED, + ); + inner.cache.read() + } + }; + if let Some(hit) = guard.get(key) + && file_matches(&inner.root, key, hit) + { + return Some(hit.clone()); + } } let fresh = load_asset(&inner.root, key, is_rsc)?; - if let Ok(mut guard) = inner.cache.write() { + let fresh_bytes = entry_bytes(&fresh); + { + let mut guard = match inner.cache.try_write() { + Some(g) => { + crate::diagnostics::contention::record_fast( + &crate::diagnostics::contention::PRERENDER_WRITE_FAST, + ); + g + } + None => { + crate::diagnostics::contention::record_contended( + &crate::diagnostics::contention::PRERENDER_WRITE_CONTENDED, + ); + inner.cache.write() + } + }; if guard.len() >= CACHE_CAPACITY { + inner + .total_bytes + .store(0, std::sync::atomic::Ordering::Relaxed); guard.clear(); } - guard.insert(key.to_owned(), fresh.clone()); + let cap = inner.byte_cap; + if cap > 0 { + let mut current = inner.total_bytes.load(std::sync::atomic::Ordering::Relaxed); + if current.saturating_add(fresh_bytes) > cap && !guard.is_empty() { + let mut by_mtime: Vec<(SystemTime, String, u64)> = guard + .iter() + .map(|(k, v)| (v.mtime, k.clone(), entry_bytes(v))) + .collect(); + by_mtime.sort_by_key(|t| t.0); + let target = cap / 2; + for (_, k, sz) in by_mtime { + if current.saturating_add(fresh_bytes) <= target { + break; + } + if guard.remove(&k).is_some() { + current = current.saturating_sub(sz); + } + } + inner + .total_bytes + .store(current, std::sync::atomic::Ordering::Relaxed); + } + } + if let Some(prev) = guard.insert(key.to_owned(), fresh.clone()) { + inner + .total_bytes + .fetch_sub(entry_bytes(&prev), std::sync::atomic::Ordering::Relaxed); + } + inner + .total_bytes + .fetch_add(fresh_bytes, std::sync::atomic::Ordering::Relaxed); } Some(fresh) } @@ -210,8 +339,15 @@ fn load_asset(root: &Path, key: &str, is_rsc: bool) -> Option { } else { "text/html; charset=utf-8" }; + let (br_q11, gzip9) = if bytes.len() >= PRECOMPRESS_MIN_BYTES { + (brotli_q11(&bytes), gzip_q9(&bytes)) + } else { + (None, None) + }; Some(CachedAsset { bytes, + br_q11, + gzip9, etag, content_type, extra_headers, @@ -220,6 +356,81 @@ fn load_asset(root: &Path, key: &str, is_rsc: bool) -> Option { }) } +fn brotli_q11(data: &[u8]) -> Option { + let params = BrotliEncoderParams { + quality: 11, + ..Default::default() + }; + let mut out = Vec::with_capacity(data.len() / 2); + let mut cursor = data; + if brotli::BrotliCompress(&mut cursor, &mut out, ¶ms).is_err() { + return None; + } + if out.len() >= data.len() { + return None; + } + Some(Bytes::from(out)) +} + +fn gzip_q9(data: &[u8]) -> Option { + let mut encoder = flate2::write::GzEncoder::new( + Vec::with_capacity(data.len() / 2), + flate2::Compression::new(9), + ); + if encoder.write_all(data).is_err() { + return None; + } + let out = encoder.finish().ok()?; + if out.len() >= data.len() { + return None; + } + Some(Bytes::from(out)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PickedEncoding { + Br, + Gzip, + Identity, +} + +fn pick_encoding(headers: &HeaderMap) -> PickedEncoding { + let Some(v) = headers.get(ACCEPT_ENCODING) else { + return PickedEncoding::Identity; + }; + let Ok(text) = v.to_str() else { + return PickedEncoding::Identity; + }; + let lower = text.to_ascii_lowercase(); + if accepts_token(&lower, "br") { + PickedEncoding::Br + } else if accepts_token(&lower, "gzip") { + PickedEncoding::Gzip + } else { + PickedEncoding::Identity + } +} + +fn accepts_token(header: &str, token: &str) -> bool { + header.split(',').any(|part| { + let part = part.trim(); + let name = part.split(';').next().unwrap_or("").trim(); + if name != token { + return false; + } + for attr in part.split(';').skip(1) { + let attr = attr.trim(); + if let Some(rest) = attr.strip_prefix("q=") { + if let Ok(q) = rest.parse::() { + return q > 0.0; + } + return true; + } + } + true + }) +} + /// Reads the `.meta` JSON sidecar and converts /// recognized header keys into `(HeaderName, HeaderValue)` pairs. /// Unknown / malformed keys are silently dropped - Next.js owns this @@ -259,32 +470,53 @@ fn compute_etag(bytes: &[u8]) -> String { /// Assembles the outbound HTTP response. `head_only` returns the /// header set with an empty body (HEAD requests / 304s out-of-scope /// for MVP). -fn build_response(asset: &CachedAsset, head_only: bool) -> Response { - let mut builder = Response::builder() - .status(StatusCode::OK) - .header("content-type", asset.content_type) - .header("etag", asset.etag.as_str()) - .header("vary", "rsc, next-router-state-tree, next-router-prefetch") - .header("cache-control", "s-maxage=31536000") - .header("x-nextjs-cache", "HIT") - .header("content-length", asset.size.to_string()); - for (name, value) in &asset.extra_headers { - builder = builder.header(name.clone(), value.clone()); - } +fn build_response( + asset: &CachedAsset, + encoding: PickedEncoding, + head_only: bool, +) -> Response { + let (chosen_bytes, encoding_header): (Bytes, Option) = match encoding { + PickedEncoding::Br => match &asset.br_q11 { + Some(b) => (b.clone(), Some(HV_ENCODING_BR)), + None => (asset.bytes.clone(), None), + }, + PickedEncoding::Gzip => match &asset.gzip9 { + Some(g) => (g.clone(), Some(HV_ENCODING_GZIP)), + None => (asset.bytes.clone(), None), + }, + PickedEncoding::Identity => (asset.bytes.clone(), None), + }; + let len = chosen_bytes.len() as u64; let body = if head_only { Body::empty() } else { - Body::from(asset.bytes.clone()) + Body::from(chosen_bytes) }; - builder - .body(body) - .expect("static response builder cannot fail") + let mut resp = Response::new(body); + *resp.status_mut() = StatusCode::OK; + let headers = resp.headers_mut(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(asset.content_type)); + if let Ok(v) = HeaderValue::try_from(asset.etag.as_str()) { + headers.insert(ETAG, v); + } + headers.insert(VARY, HV_VARY_RSC); + headers.insert(CACHE_CONTROL, HV_CACHE_CONTROL_PRERENDER); + headers.insert(HN_X_NEXTJS_CACHE, HV_X_NEXTJS_CACHE_HIT); + headers.insert(CONTENT_LENGTH, HeaderValue::from(len)); + if let Some(enc) = encoding_header { + headers.insert(CONTENT_ENCODING, enc); + } + for (name, value) in &asset.extra_headers { + headers.append(name.clone(), value.clone()); + } + resp } #[cfg(test)] mod tests { use super::{ - CachedAsset, compute_etag, file_matches, load_asset, lookup_key, prerender_with_fallback, + CachedAsset, PrerenderInner, compute_etag, entry_bytes, file_matches, load_asset, + lookup_key, prerender_with_fallback, resolve_asset, }; use crate::server::fallback::NotImplementedHandler; use crate::server::static_assets::dynamic_service; @@ -395,6 +627,8 @@ mod tests { fn file_matches_returns_false_when_payload_disappeared() { let asset = CachedAsset { bytes: Bytes::from_static(b"x"), + br_q11: None, + gzip9: None, etag: "\"deadbeef\"".into(), content_type: "text/html; charset=utf-8", extra_headers: Vec::new(), @@ -450,6 +684,36 @@ mod tests { assert_eq!(body.as_ref(), b"about"); } + #[tokio::test] + async fn service_returns_brotli_when_accept_encoding_br() { + let tmp = TempDir::new().expect("tempdir"); + let html = "".to_owned() + &"hello world".repeat(200) + ""; + write_prerender(tmp.path(), "big", &html, None); + let svc = prerender_with_fallback( + tmp.path().to_path_buf(), + dynamic_service(Arc::new(NotImplementedHandler)), + ); + let resp = svc + .oneshot( + Request::builder() + .uri("/big") + .header("accept-encoding", "br, gzip") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("infallible"); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers() + .get("content-encoding") + .map(|h| h.to_str().unwrap()), + Some("br"), + ); + let body = resp.into_body().collect().await.expect("body").to_bytes(); + assert!(body.len() < html.len()); + } + #[tokio::test] async fn service_falls_back_for_dynamic_routes() { let tmp = TempDir::new().expect("tempdir"); @@ -545,4 +809,60 @@ mod tests { let body = resp.into_body().collect().await.expect("body").to_bytes(); assert!(body.is_empty()); } + + #[test] + fn entry_bytes_sums_all_compressed_variants() { + let asset = CachedAsset { + bytes: Bytes::from_static(b"hello"), + br_q11: Some(Bytes::from_static(b"abc")), + gzip9: Some(Bytes::from_static(b"de")), + etag: "\"x\"".into(), + content_type: "text/html; charset=utf-8", + extra_headers: Vec::new(), + mtime: SystemTime::UNIX_EPOCH, + size: 5, + }; + assert_eq!(entry_bytes(&asset), 10); + } + + #[test] + fn resolve_asset_evicts_when_byte_cap_exceeded() { + let tmp = TempDir::new().expect("tempdir"); + let big = "x".repeat(4096); + for i in 0..16 { + write_prerender(tmp.path(), &format!("p{i}"), &big, None); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let mut inner = PrerenderInner::new(tmp.path().to_path_buf()); + inner.byte_cap = 16 * 1024; + for i in 0..16 { + let _ = resolve_asset(&inner, &format!("p{i}.html"), false); + } + let total = inner.total_bytes.load(std::sync::atomic::Ordering::Relaxed); + assert!( + total <= 16 * 1024, + "total {total} must stay within cap after evictions", + ); + let len = inner.cache.read().len(); + assert!( + len < 16, + "cache must be smaller than full set after evictions, got {len}" + ); + } + + #[test] + fn shrink_drops_all_cached_entries_and_resets_total() { + let tmp = TempDir::new().expect("tempdir"); + write_prerender(tmp.path(), "about", "about", None); + let inner = PrerenderInner::new(tmp.path().to_path_buf()); + let _ = resolve_asset(&inner, "about.html", false); + assert_eq!(inner.cache.read().len(), 1); + assert!(inner.total_bytes.load(std::sync::atomic::Ordering::Relaxed) > 0); + inner.shrink(); + assert_eq!(inner.cache.read().len(), 0); + assert_eq!( + inner.total_bytes.load(std::sync::atomic::Ordering::Relaxed), + 0 + ); + } } diff --git a/crates/nexide/src/server/static_assets.rs b/crates/nexide/src/server/static_assets.rs index 8929663..8847bea 100644 --- a/crates/nexide/src/server/static_assets.rs +++ b/crates/nexide/src/server/static_assets.rs @@ -55,9 +55,10 @@ pub(super) fn dynamic_service(handler: Arc) -> DynamicServic let inner = service_fn(move |req: Request| { let handler = handler.clone(); async move { + let accept = req.headers().get(axum::http::header::ACCEPT).cloned(); let response = match handler.handle(req).await { Ok(response) => response, - Err(error) => bad_gateway(&error.to_string()), + Err(error) => bad_gateway(&error.to_string(), accept.as_ref()), }; Ok::<_, Infallible>(response) } @@ -65,12 +66,8 @@ pub(super) fn dynamic_service(handler: Arc) -> DynamicServic BoxCloneSyncService::new(inner) } -fn bad_gateway(message: &str) -> Response { - Response::builder() - .status(StatusCode::BAD_GATEWAY) - .header("content-type", "text/plain; charset=utf-8") - .body(Body::from(message.to_owned())) - .expect("static builder cannot fail") +fn bad_gateway(message: &str, accept: Option<&axum::http::HeaderValue>) -> Response { + super::error_page::render(StatusCode::BAD_GATEWAY, accept, Some(message)) } #[cfg(test)] @@ -86,7 +83,7 @@ mod tests { #[test] fn bad_gateway_carries_status() { - let response = bad_gateway("boom"); + let response = bad_gateway("boom", None); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); } diff --git a/crates/nexide/src/server/static_ram_cache.rs b/crates/nexide/src/server/static_ram_cache.rs new file mode 100644 index 0000000..1b205af --- /dev/null +++ b/crates/nexide/src/server/static_ram_cache.rs @@ -0,0 +1,653 @@ +//! In-RAM cache for `/_next/static/*` immutable assets. +//! +//! Wraps an inner [`tower::Service`] (typically a [`ServeDir`]) and +//! caches successful (`200 OK`) responses keyed by request path. Since +//! Next.js content-hashes every chunk under `/_next/static`, paths are +//! safely treated as immutable — no mtime validation needed. +//! +//! On cache hit the service: +//! * skips the disk syscall path entirely (no `open`/`fstat`/`read`); +//! * picks the best precomputed encoding from `Accept-Encoding` +//! (`br q11` > `gzip 9` > `identity`) and stamps `Content-Encoding` +//! so the outer [`CompressionLayer`] does not re-compress. +//! +//! [`ServeDir`]: tower_http::services::ServeDir +//! [`CompressionLayer`]: tower_http::compression::CompressionLayer + +use std::collections::HashMap; +use std::convert::Infallible; +use std::env; +use std::future::Future; +use std::io::Write; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::task::{Context, Poll}; +use std::time::Instant; + +use axum::body::Body; +use axum::http::header::{ + ACCEPT_ENCODING, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, + HeaderName, LAST_MODIFIED, VARY, +}; +use axum::http::{HeaderMap, HeaderValue, Request, Response, StatusCode}; +use brotli::enc::BrotliEncoderParams; +use bytes::Bytes; +use http_body_util::BodyExt; +use parking_lot::Mutex; +use tower::Service; + +const VARY_ACCEPT_ENCODING: HeaderValue = HeaderValue::from_static("accept-encoding"); +const ENCODING_BR: HeaderValue = HeaderValue::from_static("br"); +const ENCODING_GZIP: HeaderValue = HeaderValue::from_static("gzip"); +const HN_X_NEXIDE_STATIC: HeaderName = HeaderName::from_static("x-nexide-static-cache"); +const HV_HIT: HeaderValue = HeaderValue::from_static("HIT"); +const HV_MISS: HeaderValue = HeaderValue::from_static("MISS"); + +const DEFAULT_CACHE_MB: u64 = 64; +const MAX_ENTRY_BYTES: u64 = 8 * 1024 * 1024; +const MIN_COMPRESS_BYTES: usize = 256; + +#[derive(Clone)] +struct CachedAsset { + identity: Bytes, + br_q11: Option, + gzip9: Option, + content_type: Option, + etag: Option, + last_modified: Option, + cache_control: Option, +} + +impl CachedAsset { + fn footprint(&self) -> u64 { + let mut bytes = self.identity.len() as u64; + if let Some(b) = &self.br_q11 { + bytes += b.len() as u64; + } + if let Some(g) = &self.gzip9 { + bytes += g.len() as u64; + } + bytes + } +} + +#[derive(Default)] +struct Inner { + entries: HashMap>, + order: Vec, + bytes: u64, +} + +/// Shared state of the RAM cache. +pub(super) struct RamCacheState { + inner: Mutex, + cap_bytes: u64, + pub hits: AtomicU64, + pub misses: AtomicU64, + pub stored_bytes: AtomicU64, +} + +impl RamCacheState { + fn new(cap_bytes: u64) -> Self { + Self { + inner: Mutex::new(Inner::default()), + cap_bytes, + hits: AtomicU64::new(0), + misses: AtomicU64::new(0), + stored_bytes: AtomicU64::new(0), + } + } + + fn shrink(&self) { + let mut inner = self.inner.lock(); + inner.entries.clear(); + inner.order.clear(); + inner.bytes = 0; + self.stored_bytes.store(0, Ordering::Relaxed); + } + + fn lookup(&self, key: &str) -> Option> { + let inner = match self.inner.try_lock() { + Some(g) => { + crate::diagnostics::contention::record_fast( + &crate::diagnostics::contention::RAM_CACHE_FAST, + ); + g + } + None => { + crate::diagnostics::contention::record_contended( + &crate::diagnostics::contention::RAM_CACHE_CONTENDED, + ); + self.inner.lock() + } + }; + inner.entries.get(key).cloned() + } + + fn touch(&self, key: &str) { + let mut inner = match self.inner.try_lock() { + Some(g) => { + crate::diagnostics::contention::record_fast( + &crate::diagnostics::contention::RAM_CACHE_FAST, + ); + g + } + None => { + crate::diagnostics::contention::record_contended( + &crate::diagnostics::contention::RAM_CACHE_CONTENDED, + ); + self.inner.lock() + } + }; + if let Some(idx) = inner.order.iter().position(|k| k == key) { + let k = inner.order.remove(idx); + inner.order.push(k); + } + } + + fn insert(&self, key: String, asset: Arc) { + let footprint = asset.footprint(); + if footprint > self.cap_bytes { + return; + } + let mut inner = match self.inner.try_lock() { + Some(g) => { + crate::diagnostics::contention::record_fast( + &crate::diagnostics::contention::RAM_CACHE_FAST, + ); + g + } + None => { + crate::diagnostics::contention::record_contended( + &crate::diagnostics::contention::RAM_CACHE_CONTENDED, + ); + self.inner.lock() + } + }; + if let Some(prev) = inner.entries.remove(&key) { + inner.bytes = inner.bytes.saturating_sub(prev.footprint()); + if let Some(idx) = inner.order.iter().position(|k| k == &key) { + inner.order.remove(idx); + } + } + while inner.bytes + footprint > self.cap_bytes { + let Some(victim) = inner.order.first().cloned() else { + break; + }; + inner.order.remove(0); + if let Some(prev) = inner.entries.remove(&victim) { + inner.bytes = inner.bytes.saturating_sub(prev.footprint()); + } + } + inner.bytes += footprint; + inner.order.push(key.clone()); + inner.entries.insert(key, asset); + self.stored_bytes.store(inner.bytes, Ordering::Relaxed); + } + + #[cfg(test)] + fn len(&self) -> usize { + self.inner.lock().entries.len() + } + + #[cfg(test)] + fn current_bytes(&self) -> u64 { + self.inner.lock().bytes + } +} + +fn cache_capacity_bytes() -> u64 { + let mb = env::var("NEXIDE_STATIC_RAM_MB") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_CACHE_MB); + mb.saturating_mul(1024 * 1024) +} + +/// A Tower service that wraps an inner static-file service with a +/// content-immutable RAM cache. +pub(super) struct RamCachedService { + inner: S, + state: Arc, +} + +impl Clone for RamCachedService { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + state: self.state.clone(), + } + } +} + +impl RamCachedService { + pub(super) fn new(inner: S) -> Self { + Self::with_capacity(inner, cache_capacity_bytes()) + } + + pub(super) fn with_capacity(inner: S, cap_bytes: u64) -> Self { + let state = Arc::new(RamCacheState::new(cap_bytes)); + let weak = Arc::downgrade(&state); + crate::pool::idle_shrink::register(move || { + if let Some(strong) = weak.upgrade() { + strong.shrink(); + } + }); + Self { inner, state } + } + + #[cfg(test)] + pub(super) fn state(&self) -> Arc { + self.state.clone() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Encoding { + Br, + Gzip, + Identity, +} + +fn pick_encoding(headers: &HeaderMap) -> Encoding { + let Some(value) = headers.get(ACCEPT_ENCODING) else { + return Encoding::Identity; + }; + let Ok(text) = value.to_str() else { + return Encoding::Identity; + }; + let lower = text.to_ascii_lowercase(); + if accepts(&lower, "br") { + Encoding::Br + } else if accepts(&lower, "gzip") { + Encoding::Gzip + } else { + Encoding::Identity + } +} + +fn accepts(header: &str, token: &str) -> bool { + header.split(',').any(|part| { + let part = part.trim(); + let name = part.split(';').next().unwrap_or("").trim(); + if name != token { + return false; + } + for attr in part.split(';').skip(1) { + let attr = attr.trim(); + if let Some(rest) = attr.strip_prefix("q=") { + if let Ok(q) = rest.parse::() { + return q > 0.0; + } + return true; + } + } + true + }) +} + +fn brotli_q11(data: &[u8]) -> Option { + let params = BrotliEncoderParams { + quality: 11, + ..Default::default() + }; + let mut out = Vec::with_capacity(data.len() / 2); + let mut cursor = data; + if brotli::BrotliCompress(&mut cursor, &mut out, ¶ms).is_err() { + return None; + } + if out.len() >= data.len() { + return None; + } + Some(Bytes::from(out)) +} + +fn gzip_q9(data: &[u8]) -> Option { + let mut encoder = flate2::write::GzEncoder::new( + Vec::with_capacity(data.len() / 2), + flate2::Compression::new(9), + ); + if encoder.write_all(data).is_err() { + return None; + } + let out = match encoder.finish() { + Ok(v) => v, + Err(_) => return None, + }; + if out.len() >= data.len() { + return None; + } + Some(Bytes::from(out)) +} + +fn should_compress(content_type: Option<&HeaderValue>, len: usize) -> bool { + if len < MIN_COMPRESS_BYTES { + return false; + } + let Some(ct) = content_type else { + return false; + }; + let Ok(s) = ct.to_str() else { + return false; + }; + let s = s.to_ascii_lowercase(); + if s.starts_with("image/") + || s.starts_with("video/") + || s.starts_with("audio/") + || s.starts_with("font/woff2") + || s == "application/wasm" + || s == "application/zip" + || s == "application/octet-stream" + { + return false; + } + true +} + +fn build_response_from_cache(asset: &CachedAsset, encoding: Encoding) -> Response { + let (body_bytes, encoding_header) = match encoding { + Encoding::Br => match &asset.br_q11 { + Some(b) => (b.clone(), Some(ENCODING_BR)), + None => (asset.identity.clone(), None), + }, + Encoding::Gzip => match &asset.gzip9 { + Some(g) => (g.clone(), Some(ENCODING_GZIP)), + None => (asset.identity.clone(), None), + }, + Encoding::Identity => (asset.identity.clone(), None), + }; + let len = body_bytes.len() as u64; + let mut resp = Response::new(Body::from(body_bytes)); + *resp.status_mut() = StatusCode::OK; + let headers = resp.headers_mut(); + if let Some(ct) = &asset.content_type { + headers.insert(CONTENT_TYPE, ct.clone()); + } + if let Some(etag) = &asset.etag { + headers.insert(ETAG, etag.clone()); + } + if let Some(lm) = &asset.last_modified { + headers.insert(LAST_MODIFIED, lm.clone()); + } + if let Some(cc) = &asset.cache_control { + headers.insert(CACHE_CONTROL, cc.clone()); + } + headers.insert(CONTENT_LENGTH, HeaderValue::from(len)); + headers.insert(VARY, VARY_ACCEPT_ENCODING); + if let Some(enc) = encoding_header { + headers.insert(CONTENT_ENCODING, enc); + } + headers.insert(HN_X_NEXIDE_STATIC, HV_HIT); + resp +} + +impl Service> for RamCachedService +where + S: Service, Response = Response, Error = Infallible> + Clone + Send + 'static, + S::Future: Send + 'static, + B: http_body::Body + Send + 'static, + B::Error: Into> + Send + Sync, +{ + type Response = Response; + type Error = Infallible; + type Future = Pin, Infallible>> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let state = self.state.clone(); + let mut inner = self.inner.clone(); + std::mem::swap(&mut inner, &mut self.inner); + let key = req.uri().path().to_string(); + let encoding = pick_encoding(req.headers()); + Box::pin(async move { + if let Some(asset) = state.lookup(&key) { + state.touch(&key); + state.hits.fetch_add(1, Ordering::Relaxed); + return Ok(build_response_from_cache(&asset, encoding)); + } + state.misses.fetch_add(1, Ordering::Relaxed); + let started = Instant::now(); + let response = inner.call(req).await?; + let elapsed = started.elapsed(); + if response.status() != StatusCode::OK { + let (parts, body) = response.into_parts(); + return Ok(Response::from_parts(parts, Body::new(body))); + } + let (parts, body) = response.into_parts(); + if parts.headers.get(CONTENT_ENCODING).is_some() { + return Ok(Response::from_parts(parts, Body::new(body))); + } + let collected = match body.collect().await { + Ok(c) => c.to_bytes(), + Err(_) => { + let mut resp = Response::new(Body::empty()); + *resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + return Ok(resp); + } + }; + if (collected.len() as u64) > MAX_ENTRY_BYTES { + let len = collected.len() as u64; + let mut resp = Response::from_parts(parts, Body::from(collected)); + resp.headers_mut() + .insert(CONTENT_LENGTH, HeaderValue::from(len)); + resp.headers_mut().insert(HN_X_NEXIDE_STATIC, HV_MISS); + return Ok(resp); + } + let content_type = parts.headers.get(CONTENT_TYPE).cloned(); + let etag = parts.headers.get(ETAG).cloned(); + let last_modified = parts.headers.get(LAST_MODIFIED).cloned(); + let cache_control = parts.headers.get(CACHE_CONTROL).cloned(); + let bytes = collected.clone(); + let (br_q11, gzip9) = if should_compress(content_type.as_ref(), bytes.len()) { + let bytes_for_br = bytes.clone(); + let bytes_for_gz = bytes.clone(); + let br_handle = tokio::task::spawn_blocking(move || brotli_q11(&bytes_for_br)); + let gz_handle = tokio::task::spawn_blocking(move || gzip_q9(&bytes_for_gz)); + let br = br_handle.await.ok().flatten(); + let gz = gz_handle.await.ok().flatten(); + (br, gz) + } else { + (None, None) + }; + let asset = Arc::new(CachedAsset { + identity: bytes.clone(), + br_q11, + gzip9, + content_type, + etag, + last_modified, + cache_control, + }); + state.insert(key, asset.clone()); + tracing::trace!( + bytes = bytes.len(), + br = asset.br_q11.as_ref().map(|b| b.len()).unwrap_or(0), + gz = asset.gzip9.as_ref().map(|g| g.len()).unwrap_or(0), + miss_ms = elapsed.as_millis() as u64, + "static ram cache populate" + ); + Ok(build_response_from_cache(&asset, encoding)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::static_assets::next_static_only; + use http_body_util::BodyExt; + use tempfile::TempDir; + use tower::ServiceExt; + + fn make_request(path: &str, accept_encoding: Option<&str>) -> Request { + let mut b = Request::builder().uri(path); + if let Some(ae) = accept_encoding { + b = b.header(ACCEPT_ENCODING, ae); + } + b.body(Body::empty()).expect("request") + } + + #[test] + fn pick_encoding_prefers_br() { + let mut h = HeaderMap::new(); + h.insert( + ACCEPT_ENCODING, + HeaderValue::from_static("gzip, deflate, br"), + ); + assert_eq!(pick_encoding(&h), Encoding::Br); + } + + #[test] + fn pick_encoding_falls_back_to_gzip() { + let mut h = HeaderMap::new(); + h.insert(ACCEPT_ENCODING, HeaderValue::from_static("gzip, deflate")); + assert_eq!(pick_encoding(&h), Encoding::Gzip); + } + + #[test] + fn pick_encoding_identity_when_q_zero() { + let mut h = HeaderMap::new(); + h.insert( + ACCEPT_ENCODING, + HeaderValue::from_static("br;q=0, gzip;q=0"), + ); + assert_eq!(pick_encoding(&h), Encoding::Identity); + } + + #[tokio::test] + async fn cache_miss_then_hit_serves_identity() { + let tmp = TempDir::new().expect("tempdir"); + let payload = "x".repeat(2048); + std::fs::write(tmp.path().join("chunk.js"), &payload).expect("write"); + let svc = RamCachedService::with_capacity(next_static_only(tmp.path()), 1 << 20); + let state = svc.state(); + + let resp = svc + .clone() + .oneshot(make_request("/chunk.js", None)) + .await + .expect("infallible"); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.expect("body").to_bytes(); + assert_eq!(body.as_ref(), payload.as_bytes()); + assert_eq!(state.misses.load(Ordering::Relaxed), 1); + assert_eq!(state.hits.load(Ordering::Relaxed), 0); + assert_eq!(state.len(), 1); + + let resp2 = svc + .clone() + .oneshot(make_request("/chunk.js", None)) + .await + .expect("infallible"); + assert_eq!(resp2.status(), StatusCode::OK); + assert_eq!( + resp2.headers().get(HN_X_NEXIDE_STATIC).expect("header"), + HV_HIT + ); + assert_eq!(state.hits.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn cache_hit_returns_brotli_body() { + let tmp = TempDir::new().expect("tempdir"); + let payload = "console.log('hello world');".repeat(200); + std::fs::write(tmp.path().join("app.js"), &payload).expect("write"); + let svc = RamCachedService::with_capacity(next_static_only(tmp.path()), 1 << 20); + + let _ = svc + .clone() + .oneshot(make_request("/app.js", None)) + .await + .expect("infallible"); + let resp = svc + .clone() + .oneshot(make_request("/app.js", Some("br"))) + .await + .expect("infallible"); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(CONTENT_ENCODING).expect("encoding"), + ENCODING_BR + ); + let body = resp.into_body().collect().await.expect("body").to_bytes(); + assert!(body.len() < payload.len()); + } + + #[tokio::test] + async fn cache_hit_returns_gzip_body_when_br_unavailable() { + let tmp = TempDir::new().expect("tempdir"); + let payload = "abc".repeat(4096); + std::fs::write(tmp.path().join("g.js"), &payload).expect("write"); + let svc = RamCachedService::with_capacity(next_static_only(tmp.path()), 1 << 20); + let _ = svc + .clone() + .oneshot(make_request("/g.js", None)) + .await + .expect("infallible"); + let resp = svc + .clone() + .oneshot(make_request("/g.js", Some("gzip"))) + .await + .expect("infallible"); + assert_eq!( + resp.headers().get(CONTENT_ENCODING).expect("encoding"), + ENCODING_GZIP + ); + } + + #[tokio::test] + async fn lru_evicts_oldest_under_pressure() { + let tmp = TempDir::new().expect("tempdir"); + let payload = "y".repeat(4 * 1024); + std::fs::write(tmp.path().join("a.js"), &payload).expect("write"); + std::fs::write(tmp.path().join("b.js"), &payload).expect("write"); + std::fs::write(tmp.path().join("c.js"), &payload).expect("write"); + let svc = RamCachedService::with_capacity(next_static_only(tmp.path()), 8 * 1024); + let state = svc.state(); + for n in ["/a.js", "/b.js", "/c.js"] { + let _ = svc + .clone() + .oneshot(make_request(n, None)) + .await + .expect("ok"); + } + assert!(state.current_bytes() <= 8 * 1024); + assert!(state.len() < 3); + } + + #[tokio::test] + async fn missing_path_passes_through() { + let tmp = TempDir::new().expect("tempdir"); + let svc = RamCachedService::with_capacity(next_static_only(tmp.path()), 1 << 20); + let state = svc.state(); + let resp = svc + .clone() + .oneshot(make_request("/nope.js", None)) + .await + .expect("ok"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + assert_eq!(state.len(), 0); + } + + #[tokio::test] + async fn small_body_is_not_compressed() { + let tmp = TempDir::new().expect("tempdir"); + std::fs::write(tmp.path().join("tiny.js"), "ok").expect("write"); + let svc = RamCachedService::with_capacity(next_static_only(tmp.path()), 1 << 20); + let _ = svc + .clone() + .oneshot(make_request("/tiny.js", None)) + .await + .expect("ok"); + let resp = svc + .clone() + .oneshot(make_request("/tiny.js", Some("br"))) + .await + .expect("ok"); + assert!(resp.headers().get(CONTENT_ENCODING).is_none()); + } +} diff --git a/crates/nexide/src/server/worker_runtime.rs b/crates/nexide/src/server/worker_runtime.rs index d1efc70..dfe3387 100644 --- a/crates/nexide/src/server/worker_runtime.rs +++ b/crates/nexide/src/server/worker_runtime.rs @@ -430,7 +430,16 @@ async fn run_worker_local( tracing::debug!(worker = idx, "nexide worker ready"); - let handler: Arc = Arc::new(NextBridgeHandler::new(Arc::new(pool))); + let inflight_cap = crate::effective_max_inflight_per_isolate(); + tracing::info!( + worker = idx, + inflight_cap, + "nexide worker inflight cap configured" + ); + let handler: Arc = Arc::new(NextBridgeHandler::with_inflight_limit( + Arc::new(pool), + Some(inflight_cap as usize), + )); let router = build_router(&cfg, handler); let shutdown = async move { diff --git a/crates/nexide/tests/cjs_loader.rs b/crates/nexide/tests/cjs_loader.rs index 13e6a1b..ff18261 100644 --- a/crates/nexide/tests/cjs_loader.rs +++ b/crates/nexide/tests/cjs_loader.rs @@ -172,3 +172,90 @@ async fn require_resolves_alias_for_bare_node_specifier() { ) .await; } + +#[tokio::test(flavor = "current_thread")] +async fn dynamic_import_returns_synthetic_namespace_for_cjs() { + assert_passes( + &[( + "addon.cjs", + "module.exports = { name: 'addon', add: (a, b) => a + b };", + )], + "let captured = null;\n\ + let failed = null;\n\ + import('./addon.cjs').then(\n\ + ns => { captured = ns; },\n\ + err => { failed = err; },\n\ + );\n\ + queueMicrotask(() => {\n\ + if (failed) throw failed;\n\ + if (!captured) throw new Error('dynamic import did not resolve');\n\ + if (captured.name !== 'addon') throw new Error('name: ' + captured.name);\n\ + if (captured.add(2, 3) !== 5) throw new Error('add: ' + captured.add(2, 3));\n\ + if (!captured.default || captured.default.name !== 'addon') {\n\ + throw new Error('default missing');\n\ + }\n\ + });\n", + ) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn dynamic_import_of_node_builtin_works() { + assert_passes( + &[], + "let captured = null;\n\ + import('node:path').then(ns => { captured = ns; });\n\ + queueMicrotask(() => {\n\ + if (!captured) throw new Error('builtin import did not resolve');\n\ + if (typeof captured.join !== 'function') throw new Error('no join');\n\ + if (captured.join('a', 'b') !== 'a/b') throw new Error('join: ' + captured.join('a', 'b'));\n\ + });\n", + ) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn dynamic_import_of_unknown_specifier_rejects() { + assert_passes( + &[], + "let rejected = null;\n\ + import('node:does-not-exist').then(\n\ + () => { rejected = false; },\n\ + err => { rejected = err; },\n\ + );\n\ + queueMicrotask(() => {\n\ + if (rejected === null) throw new Error('promise still pending');\n\ + if (rejected === false) throw new Error('expected rejection');\n\ + const msg = (rejected && rejected.message) || String(rejected);\n\ + if (!msg.includes('MODULE_NOT_FOUND') && !msg.includes('does-not-exist')) {\n\ + throw new Error('unexpected error: ' + msg);\n\ + }\n\ + });\n", + ) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn dynamic_import_resolves_relative_to_calling_module() { + assert_passes( + &[ + ( + "nested.cjs", + "let captured = null;\n\ + import('./addon.cjs').then(ns => { captured = ns; });\n\ + module.exports = { getCaptured: () => captured };", + ), + ( + "addon.cjs", + "module.exports = { name: 'addon-from-sibling' };", + ), + ], + "const m = require('./nested.cjs');\n\ + queueMicrotask(() => {\n\ + const c = m.getCaptured();\n\ + if (!c) throw new Error('sibling import did not resolve');\n\ + if (c.name !== 'addon-from-sibling') throw new Error('name: ' + c.name);\n\ + });\n", + ) + .await; +} diff --git a/crates/nexide/tests/code_cache_roundtrip.rs b/crates/nexide/tests/code_cache_roundtrip.rs new file mode 100644 index 0000000..4fcc1f1 --- /dev/null +++ b/crates/nexide/tests/code_cache_roundtrip.rs @@ -0,0 +1,193 @@ +//! End-to-end test for the V8 bytecode code-cache. +//! +//! Boots a real isolate against a `CodeCache` rooted at a `TempDir`, +//! runs a CJS entry that `require()`s a sibling module, and asserts: +//! +//! 1. First boot is a miss + write (cache file appears on disk). +//! 2. Second boot with the same source is a hit (no extra writes). +//! 3. Mutating the required module forces a fresh miss. +//! 4. Corrupting an existing cache file produces a reject + rewrite. + +#![allow(clippy::future_not_send, clippy::significant_drop_tightening)] + +use std::path::Path; +use std::sync::Arc; + +use nexide::engine::cjs::{FsResolver, default_registry}; +use nexide::engine::{BootContext, CodeCache, V8Engine}; + +async fn boot(dir: &Path, entry: &Path, cache: CodeCache) -> Result<(), String> { + let registry = Arc::new(default_registry().expect("default registry")); + let resolver = Arc::new(FsResolver::new(vec![dir.to_path_buf()], registry)); + let ctx = BootContext::new().with_cjs(resolver).with_code_cache(cache); + V8Engine::boot_with(entry, ctx) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +async fn run_with_cache(dir: &Path, entry: &Path, cache: CodeCache) -> CodeCache { + let local = tokio::task::LocalSet::new(); + let dir_buf = dir.to_path_buf(); + let entry_buf = entry.to_path_buf(); + let cache_clone = cache.clone(); + let result = local + .run_until(async move { boot(&dir_buf, &entry_buf, cache_clone).await }) + .await; + result.unwrap_or_else(|e| panic!("boot failed: {e}")); + cache +} + +fn make_cache(root: &Path) -> CodeCache { + CodeCache::with_root_lazy(root.to_path_buf(), 64 * 1024 * 1024) +} + +fn count_cache_files(root: &Path) -> usize { + fn walk(p: &Path, acc: &mut usize) { + let Ok(rd) = std::fs::read_dir(p) else { return }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, acc); + } else if path.extension().is_some_and(|e| e == "bin") { + *acc += 1; + } + } + } + let mut acc = 0; + walk(root, &mut acc); + acc +} + +fn collect_cache_files(root: &Path) -> Vec { + fn walk(p: &Path, acc: &mut Vec) { + let Ok(rd) = std::fs::read_dir(p) else { return }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, acc); + } else if path.extension().is_some_and(|e| e == "bin") { + acc.push(path); + } + } + } + let mut acc = Vec::new(); + walk(root, &mut acc); + acc +} + +#[tokio::test(flavor = "current_thread")] +async fn cache_roundtrip_hit_after_first_boot() { + let dir = tempfile::tempdir().expect("tempdir"); + let cache_dir = tempfile::tempdir().expect("cache tempdir"); + + std::fs::write( + dir.path().join("dep.cjs"), + "module.exports = { add: (a, b) => a + b };", + ) + .expect("write dep"); + let entry = dir.path().join("entry.cjs"); + std::fs::write( + &entry, + r#" + const dep = require('./dep.cjs'); + if (dep.add(2, 3) !== 5) { + throw new Error('arith broken'); + } + "#, + ) + .expect("write entry"); + + let cache = make_cache(cache_dir.path()); + let cache = run_with_cache(dir.path(), &entry, cache).await; + let snap1 = cache.metrics().snapshot(); + assert_eq!(snap1.hits, 0, "first boot must not see cache hits"); + assert!( + snap1.writes >= 2, + "first boot must persist entries: {snap1:?}" + ); + assert!( + count_cache_files(cache_dir.path()) >= 2, + "expected cache files on disk after first boot" + ); + + let cache2 = make_cache(cache_dir.path()); + let cache2 = run_with_cache(dir.path(), &entry, cache2).await; + let snap2 = cache2.metrics().snapshot(); + assert!( + snap2.hits >= 2, + "second boot must hit cache for entry+dep, got {snap2:?}" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn mutating_source_forces_miss() { + let dir = tempfile::tempdir().expect("tempdir"); + let cache_dir = tempfile::tempdir().expect("cache tempdir"); + + let entry = dir.path().join("entry.cjs"); + std::fs::write(&entry, "module.exports = 1;").expect("write entry v1"); + + let cache = make_cache(cache_dir.path()); + let _ = run_with_cache(dir.path(), &entry, cache).await; + let files_v1 = count_cache_files(cache_dir.path()); + assert!(files_v1 >= 1, "v1 should write at least one entry"); + + std::fs::write(&entry, "module.exports = 2; // mutated").expect("write entry v2"); + let cache2 = make_cache(cache_dir.path()); + let cache2 = run_with_cache(dir.path(), &entry, cache2).await; + let snap = cache2.metrics().snapshot(); + assert!(snap.misses >= 1, "mutated source must miss, snap={snap:?}"); + let files_v2 = count_cache_files(cache_dir.path()); + assert!( + files_v2 > files_v1, + "mutated source must add a new cache file (v1={files_v1}, v2={files_v2})" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn corrupted_cache_file_rejected_and_rewritten() { + let dir = tempfile::tempdir().expect("tempdir"); + let cache_dir = tempfile::tempdir().expect("cache tempdir"); + + let entry = dir.path().join("entry.cjs"); + std::fs::write(&entry, "module.exports = 'hello';").expect("write entry"); + + let cache = make_cache(cache_dir.path()); + let _ = run_with_cache(dir.path(), &entry, cache).await; + + let files = collect_cache_files(cache_dir.path()); + assert!(!files.is_empty(), "expected at least one cache file"); + for f in &files { + std::fs::write(f, b"\x00\x01\x02garbage-not-v8-bytecode\xff\xff\xff").expect("corrupt"); + } + + let cache2 = make_cache(cache_dir.path()); + let cache2 = run_with_cache(dir.path(), &entry, cache2).await; + let snap = cache2.metrics().snapshot(); + assert!( + snap.rejects >= 1, + "corrupted cache must produce rejects, snap={snap:?}" + ); + assert!( + snap.writes >= 1, + "rejected entry must be rewritten, snap={snap:?}" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn disabled_cache_records_no_io() { + let dir = tempfile::tempdir().expect("tempdir"); + let cache_dir = tempfile::tempdir().expect("cache tempdir"); + + let entry = dir.path().join("entry.cjs"); + std::fs::write(&entry, "module.exports = 42;").expect("write entry"); + + let cache = CodeCache::disabled(); + let cache = run_with_cache(dir.path(), &entry, cache).await; + let snap = cache.metrics().snapshot(); + assert_eq!(snap.hits, 0); + assert_eq!(snap.misses, 0); + assert_eq!(snap.writes, 0); + assert_eq!(count_cache_files(cache_dir.path()), 0); +} diff --git a/crates/nexide/tests/esm_dynamic_import.rs b/crates/nexide/tests/esm_dynamic_import.rs new file mode 100644 index 0000000..4728123 --- /dev/null +++ b/crates/nexide/tests/esm_dynamic_import.rs @@ -0,0 +1,154 @@ +//! Integration tests for real ESM dynamic-import support. +//! +//! Each test seeds a tempdir, boots a [`V8Engine`] via the CJS entry +//! path (matching production), and exercises `await import(...)` to +//! make sure pure-ESM packages, ESM-imports-CJS, and bare-specifier +//! resolution through `node_modules` all work. + +#![allow(clippy::future_not_send, clippy::significant_drop_tightening)] + +use std::path::Path; +use std::sync::Arc; + +use nexide::engine::cjs::{FsResolver, default_registry}; +use nexide::engine::{BootContext, V8Engine}; + +async fn run_module(dir: &Path, entry: &Path) -> Result<(), String> { + let registry = Arc::new(default_registry().expect("default registry")); + let resolver = Arc::new(FsResolver::new(vec![dir.to_path_buf()], registry)); + let ctx = BootContext::new().with_cjs(resolver); + V8Engine::boot_with(entry, ctx) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +async fn assert_passes(extra_files: &[(&str, &str)], body: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + for (name, src) in extra_files { + let target = dir.path().join(name); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + std::fs::write(&target, src).expect("seed file"); + } + let entry = dir.path().join("entry.cjs"); + std::fs::write(&entry, body).expect("write entry"); + let dir_path = dir.path().to_path_buf(); + let local = tokio::task::LocalSet::new(); + let result = local + .run_until(async move { run_module(&dir_path, &entry).await }) + .await; + drop(dir); + if let Err(err) = result { + panic!("module failed: {err}"); + } +} + +/// Confirms `await import('./esm-mod.mjs')` exposes both directly +/// declared and re-exported bindings on the namespace. +#[tokio::test(flavor = "current_thread")] +async fn esm_dynamic_import_relative_mjs_with_reexport() { + assert_passes( + &[ + ( + "util.mjs", + "export const helper = (n) => 'h:' + n;\n\ + export const VERSION = 7;\n", + ), + ( + "esm-mod.mjs", + "export const value = 42;\n\ + export { helper, VERSION } from './util.mjs';\n", + ), + ], + "(async () => {\n\ + const ns = await import('./esm-mod.mjs');\n\ + if (ns.value !== 42) throw new Error('value=' + ns.value);\n\ + if (ns.VERSION !== 7) throw new Error('VERSION=' + ns.VERSION);\n\ + if (ns.helper('x') !== 'h:x') throw new Error('helper');\n\ + globalThis.__nexide_esm_marker = true;\n\ + })().catch((e) => { throw e; });\n", + ) + .await; +} + +/// ESM-side dynamic import of a CJS file: namespace must have +/// `default = module.exports` and named props mirroring enumerable +/// own keys. +#[tokio::test(flavor = "current_thread")] +async fn esm_dynamic_import_cjs_pkg() { + assert_passes( + &[( + "pkg-cjs/index.js", + "module.exports = { foo: 1, bar: 'b' };\n", + )], + "(async () => {\n\ + const ns = await import('./pkg-cjs/index.js');\n\ + if (!ns.default) throw new Error('default missing');\n\ + if (ns.default.foo !== 1) throw new Error('default.foo');\n\ + if (ns.foo !== 1) throw new Error('foo on ns: ' + ns.foo);\n\ + if (ns.bar !== 'b') throw new Error('bar on ns');\n\ + })().catch((e) => { throw e; });\n", + ) + .await; +} + +/// Bare specifier into a node_modules-installed pure-ESM package +/// (`type: module`, only `import` condition exposed). +#[tokio::test(flavor = "current_thread")] +async fn esm_dynamic_import_bare_node_modules_package() { + assert_passes( + &[ + ( + "node_modules/tinypkg/package.json", + "{\n\ + \"name\": \"tinypkg\",\n\ + \"type\": \"module\",\n\ + \"main\": \"./index.mjs\",\n\ + \"exports\": {\n\ + \".\": { \"import\": \"./index.mjs\", \"default\": \"./index.mjs\" }\n\ + }\n\ + }\n", + ), + ( + "node_modules/tinypkg/index.mjs", + "export const tinypkgValue = 'tiny-' + 1;\n\ + export default { kind: 'tinypkg' };\n", + ), + ], + "(async () => {\n\ + const ns = await import('tinypkg');\n\ + if (ns.tinypkgValue !== 'tiny-1') {\n\ + throw new Error('tinypkgValue=' + ns.tinypkgValue);\n\ + }\n\ + if (!ns.default || ns.default.kind !== 'tinypkg') {\n\ + throw new Error('default missing');\n\ + }\n\ + })().catch((e) => { throw e; });\n", + ) + .await; +} + +/// ESM module that statically imports a sibling ESM AND a CJS file, +/// invoked through `await import(...)` from the CJS root entry. +#[tokio::test(flavor = "current_thread")] +async fn esm_static_imports_mix_of_esm_and_cjs() { + assert_passes( + &[ + ("util.mjs", "export const tag = (n) => 'T<' + n + '>';\n"), + ("legacy.cjs", "module.exports = { legacy: 'yes' };\n"), + ( + "root.mjs", + "import { tag } from './util.mjs';\n\ + import legacy from './legacy.cjs';\n\ + export const result = tag(legacy.legacy);\n", + ), + ], + "(async () => {\n\ + const ns = await import('./root.mjs');\n\ + if (ns.result !== 'T') throw new Error('result=' + ns.result);\n\ + })().catch((e) => { throw e; });\n", + ) + .await; +} diff --git a/crates/nexide/tests/fixtures/op_roundtrip_handler.mjs b/crates/nexide/tests/fixtures/op_roundtrip_handler.mjs index 2fead5e..604810b 100644 --- a/crates/nexide/tests/fixtures/op_roundtrip_handler.mjs +++ b/crates/nexide/tests/fixtures/op_roundtrip_handler.mjs @@ -9,6 +9,8 @@ globalThis.__nexideRunHandler = function (idx, gen) { const meta = globalThis.__nexide.getMeta(idx, gen); + const method = meta[0]; + const uri = meta[1]; const buf = new Uint8Array(64); const n = globalThis.__nexide.readBody(idx, gen, buf); @@ -16,8 +18,8 @@ globalThis.__nexideRunHandler = function (idx, gen) { globalThis.__nexide.sendHead(idx, gen, 200, [ ["content-type", "text/plain"], - ["x-method", meta.method], - ["x-uri", meta.uri], + ["x-method", method], + ["x-uri", uri], ]); const prefix = new Uint8Array([0x70, 0x6f, 0x6e, 0x67, 0x3a]); diff --git a/crates/nexide/tests/fixtures/pump_error_handler.mjs b/crates/nexide/tests/fixtures/pump_error_handler.mjs index 61fea2a..71fa143 100644 --- a/crates/nexide/tests/fixtures/pump_error_handler.mjs +++ b/crates/nexide/tests/fixtures/pump_error_handler.mjs @@ -13,15 +13,16 @@ globalThis.__nexide.__dispatch = function (idx, gen) { const meta = globalThis.__nexide.getMeta(idx, gen); - if (meta.uri === "/sync-throw") { + const uri = meta[1]; + if (uri === "/sync-throw") { throw new Error("sync-boom"); } - if (meta.uri === "/async-reject") { + if (uri === "/async-reject") { return Promise.resolve().then(() => { throw new Error("async-boom"); }); } - globalThis.__nexide.sendHead(idx, gen, 200, [["x-uri", meta.uri]]); + globalThis.__nexide.sendHead(idx, gen, 200, [["x-uri", uri]]); globalThis.__nexide.sendEnd(idx, gen); return undefined; }; diff --git a/crates/nexide/tests/node_crypto_keys.rs b/crates/nexide/tests/node_crypto_keys.rs new file mode 100644 index 0000000..3e02b31 --- /dev/null +++ b/crates/nexide/tests/node_crypto_keys.rs @@ -0,0 +1,167 @@ +//! Integration tests for the `node:crypto` key/sign/verify/ecdh/hkdf surface. + +#![allow(clippy::future_not_send, clippy::significant_drop_tightening)] + +use std::path::Path; +use std::sync::Arc; + +use nexide::engine::cjs::{FsResolver, default_registry}; +use nexide::engine::{BootContext, V8Engine}; +use nexide::ops::{MapEnv, ProcessConfig}; + +async fn run_module(dir: &Path, entry: &Path) -> Result<(), String> { + let registry = Arc::new(default_registry().map_err(|e| e.to_string())?); + let resolver = Arc::new(FsResolver::new(vec![dir.to_path_buf()], registry)); + let env = Arc::new(MapEnv::from_pairs(std::iter::empty::<(String, String)>())); + let process = ProcessConfig::builder(env).build(); + let ctx = BootContext::new().with_cjs(resolver).with_process(process); + V8Engine::boot_with(entry, ctx) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +async fn assert_passes(body: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("entry.cjs"); + std::fs::write(&entry, body).expect("write entry"); + let dir_path = dir.path().to_path_buf(); + let local = tokio::task::LocalSet::new(); + let result = local + .run_until(async move { run_module(&dir_path, &entry).await }) + .await; + drop(dir); + if let Err(err) = result { + panic!("module failed: {err}"); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn rsa_generate_sign_verify_roundtrip() { + assert_passes( + "const c = require('node:crypto');\n\ + const { publicKey, privateKey } = c.generateKeyPairSync('rsa', { modulusLength: 2048 });\n\ + const data = Buffer.from('hello rsa');\n\ + const sig = c.sign('sha256', data, privateKey);\n\ + if (!c.verify('sha256', data, publicKey, sig)) throw new Error('rsa verify failed');\n\ + if (c.verify('sha256', Buffer.from('tampered'), publicKey, sig)) throw new Error('verify must fail on tampered data');\n", + ).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn ec_p256_sign_verify_roundtrip() { + assert_passes( + "const c = require('node:crypto');\n\ + const { publicKey, privateKey } = c.generateKeyPairSync('ec', { namedCurve: 'P-256' });\n\ + const data = Buffer.from('hello ec');\n\ + const sig = c.sign('sha256', data, privateKey);\n\ + if (!c.verify('sha256', data, publicKey, sig)) throw new Error('ec verify failed');\n", + ) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn ed25519_sign_verify_roundtrip() { + assert_passes( + "const c = require('node:crypto');\n\ + const { publicKey, privateKey } = c.generateKeyPairSync('ed25519');\n\ + const data = Buffer.from('hello ed25519');\n\ + const sig = c.sign(null, data, privateKey);\n\ + if (!c.verify(null, data, publicKey, sig)) throw new Error('ed25519 verify failed');\n", + ) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn rsa_oaep_encrypt_decrypt_roundtrip() { + assert_passes( + "const c = require('node:crypto');\n\ + const { publicKey, privateKey } = c.generateKeyPairSync('rsa', { modulusLength: 2048 });\n\ + const msg = Buffer.from('secret oaep payload');\n\ + const ct = c.publicEncrypt({ key: publicKey, padding: c.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, msg);\n\ + const pt = c.privateDecrypt({ key: privateKey, padding: c.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, ct);\n\ + if (pt.toString() !== msg.toString()) throw new Error('oaep mismatch: ' + pt.toString());\n", + ).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn rsa_pkcs1_encrypt_decrypt_roundtrip() { + assert_passes( + "const c = require('node:crypto');\n\ + const { publicKey, privateKey } = c.generateKeyPairSync('rsa', { modulusLength: 2048 });\n\ + const msg = Buffer.from('pkcs1 v1.5');\n\ + const ct = c.publicEncrypt({ key: publicKey, padding: c.constants.RSA_PKCS1_PADDING }, msg);\n\ + const pt = c.privateDecrypt({ key: privateKey, padding: c.constants.RSA_PKCS1_PADDING }, ct);\n\ + if (pt.toString() !== msg.toString()) throw new Error('pkcs1 mismatch: ' + pt.toString());\n", + ).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn ecdh_p256_two_party_shared_secret() { + assert_passes( + "const c = require('node:crypto');\n\ + const a = c.createECDH('prime256v1'); a.generateKeys();\n\ + const b = c.createECDH('prime256v1'); b.generateKeys();\n\ + const s1 = a.computeSecret(b.getPublicKey()).toString('hex');\n\ + const s2 = b.computeSecret(a.getPublicKey()).toString('hex');\n\ + if (s1 !== s2) throw new Error('ecdh secrets differ');\n\ + if (s1.length !== 64) throw new Error('expected 32-byte secret');\n", + ) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn x25519_diffie_hellman_two_party_shared_secret() { + assert_passes( + "const c = require('node:crypto');\n\ + const a = c.generateKeyPairSync('x25519');\n\ + const b = c.generateKeyPairSync('x25519');\n\ + const s1 = c.diffieHellman({ privateKey: a.privateKey, publicKey: b.publicKey }).toString('hex');\n\ + const s2 = c.diffieHellman({ privateKey: b.privateKey, publicKey: a.publicKey }).toString('hex');\n\ + if (s1 !== s2) throw new Error('x25519 secrets differ');\n", + ).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn hkdf_sha256_rfc5869_test_case_1() { + assert_passes( + "const c = require('node:crypto');\n\ + const ikm = Buffer.from('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b', 'hex');\n\ + const salt = Buffer.from('000102030405060708090a0b0c', 'hex');\n\ + const info = Buffer.from('f0f1f2f3f4f5f6f7f8f9', 'hex');\n\ + const out = Buffer.from(c.hkdfSync('sha256', ikm, salt, info, 42));\n\ + const expected = '3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865';\n\ + if (out.toString('hex') !== expected) throw new Error('hkdf mismatch: ' + out.toString('hex'));\n", + ).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn keyobject_pem_export_reimport_roundtrip() { + assert_passes( + "const c = require('node:crypto');\n\ + const { publicKey, privateKey } = c.generateKeyPairSync('ec', { namedCurve: 'P-256' });\n\ + const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' });\n\ + const pubPem = publicKey.export({ type: 'spki', format: 'pem' });\n\ + if (typeof privPem !== 'string' || !privPem.includes('PRIVATE KEY')) throw new Error('bad priv pem');\n\ + if (typeof pubPem !== 'string' || !pubPem.includes('PUBLIC KEY')) throw new Error('bad pub pem');\n\ + const priv2 = c.createPrivateKey(privPem);\n\ + const pub2 = c.createPublicKey(pubPem);\n\ + const data = Buffer.from('roundtrip');\n\ + const sig = c.sign('sha256', data, priv2);\n\ + if (!c.verify('sha256', data, pub2, sig)) throw new Error('roundtrip verify failed');\n", + ).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn keyobject_jwk_export_rsa_private_has_crt_fields() { + assert_passes( + "const c = require('node:crypto');\n\ + const { privateKey } = c.generateKeyPairSync('rsa', { modulusLength: 2048 });\n\ + const jwk = privateKey.export({ format: 'jwk' });\n\ + for (const k of ['kty','n','e','d','p','q','dp','dq','qi']) {\n\ + if (typeof jwk[k] !== 'string') throw new Error('missing jwk field ' + k);\n\ + }\n\ + if (jwk.kty !== 'RSA') throw new Error('expected kty=RSA');\n", + ) + .await; +} diff --git a/crates/nexide/tests/node_http_upgrade.rs b/crates/nexide/tests/node_http_upgrade.rs new file mode 100644 index 0000000..6729a23 --- /dev/null +++ b/crates/nexide/tests/node_http_upgrade.rs @@ -0,0 +1,251 @@ +//! Integration tests for the HTTP/1.1 `Upgrade` raw-socket bridge. +//! +//! These tests boot a real V8 engine, register a `node:http` Server +//! with an `'upgrade'` listener, and verify two things end-to-end: +//! +//! 1. When a request carrying the synthetic +//! `x-nexide-upgrade-socket-id` header arrives, the JS adapter +//! routes it to `'upgrade'` listeners (not `'request'` listeners) +//! and exposes a Duplex socket bound to the registry. +//! 2. When the listener writes an HTTP/1.1 101 response head onto the +//! socket (the `ws` library's pattern), the bytes are parsed and +//! converted into a real `synthRes.writeHead(101, …)` + +//! `synthRes.end()` so the Rust shield emits the 101 on the wire. +//! +//! Post-handshake byte plumbing is exercised by the Rust-side unit +//! tests in `ops::upgrade_socket::tests`; here we focus on the +//! JS-facing handshake commit. + +#![allow(clippy::future_not_send, clippy::doc_markdown)] + +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use nexide::engine::cjs::{FsResolver, ROOT_PARENT, default_registry}; +use nexide::engine::{BootContext, V8Engine}; +use nexide::ops::upgrade_socket; +use nexide::ops::{HeaderPair, RequestMeta, RequestSlot, ResponsePayload}; +use tempfile::TempDir; + +struct Booted { + engine: V8Engine, + _sandbox: TempDir, +} + +async fn boot_with_entry(body: &str) -> Booted { + let sandbox = tempfile::tempdir().expect("tempdir"); + let entry = sandbox.path().join("entry.cjs"); + std::fs::write(&entry, body).expect("write entry"); + let registry = Arc::new(default_registry().expect("registry")); + let resolver = Arc::new(FsResolver::new( + vec![sandbox.path().to_path_buf()], + registry, + )); + let ctx = BootContext::new() + .with_cjs(resolver) + .with_cjs_root(ROOT_PARENT); + let mut engine = V8Engine::boot_with(&entry, ctx).await.expect("boot"); + engine.start_pump(0).expect("start pump"); + Booted { + engine, + _sandbox: sandbox, + } +} + +async fn dispatch_with_headers( + engine: &mut V8Engine, + method: &str, + uri: &str, + headers: Vec, +) -> ResponsePayload { + let meta = RequestMeta::try_new(method, uri).expect("meta"); + let slot = RequestSlot::new(meta, headers, Bytes::new()); + let mut rx = engine.enqueue(slot); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + engine.pump_once(); + match rx.try_recv() { + Ok(result) => return result.expect("handler succeeded"), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) => { + assert!( + std::time::Instant::now() <= deadline, + "handler did not complete within 10s", + ); + tokio::time::sleep(Duration::from_millis(2)).await; + } + Err(other) => panic!("oneshot closed: {other:?}"), + } + } +} + +fn header_value<'a>(payload: &'a ResponsePayload, name: &str) -> Option<&'a str> { + payload + .head + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) +} + +async fn run_local(f: F) -> T +where + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + let local = tokio::task::LocalSet::new(); + local.run_until(f()).await +} + +#[tokio::test(flavor = "current_thread")] +async fn upgrade_listener_handshake_commits_101_response() { + run_local(|| async { + // Pre-allocate a socket id on the Rust side so the JS adapter + // sees a valid registry slot. In production this is done by + // `next_bridge::build_proto_request`. + let socket = upgrade_socket::allocate(); + let socket_id = socket.id(); + // Drop the handle: we don't drive I/O from the test harness. + // The slot stays alive in the registry until JS closes it or + // `abort` is called. + drop(socket); + + let mut booted = boot_with_entry( + r" + const http = require('node:http'); + const srv = http.createServer(); + srv.on('upgrade', (req, socket, head) => { + // Mirror the `ws` library's pattern: write the 101 + // status + handshake headers as one CRLF-terminated + // buffer onto the raw socket. + const accept = 'computed-accept-key'; + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + 'Sec-WebSocket-Accept: ' + accept + '\r\n' + + '\r\n' + ); + }); + srv.listen(0); + globalThis.__srv = srv; + ", + ) + .await; + + let headers = vec![ + HeaderPair { + name: "upgrade".to_owned(), + value: "websocket".to_owned(), + }, + HeaderPair { + name: "connection".to_owned(), + value: "Upgrade".to_owned(), + }, + HeaderPair { + name: "sec-websocket-key".to_owned(), + value: "dGhlIHNhbXBsZSBub25jZQ==".to_owned(), + }, + HeaderPair { + name: "sec-websocket-version".to_owned(), + value: "13".to_owned(), + }, + HeaderPair { + name: upgrade_socket::UPGRADE_SOCKET_ID_HEADER.to_owned(), + value: socket_id.to_string(), + }, + ]; + + let payload = dispatch_with_headers(&mut booted.engine, "GET", "/ws", headers).await; + assert_eq!(payload.head.status, 101); + assert_eq!(header_value(&payload, "upgrade"), Some("websocket")); + assert_eq!(header_value(&payload, "connection"), Some("Upgrade")); + assert_eq!( + header_value(&payload, "sec-websocket-accept"), + Some("computed-accept-key"), + ); + assert!(payload.body.is_empty(), "101 must have empty body"); + + // Cleanup: close the slot so we don't leak it across tests. + if let Some(handle) = upgrade_socket::handle(socket_id) { + handle.close(); + } + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn upgrade_without_socket_id_falls_back_to_501() { + run_local(|| async { + let mut booted = boot_with_entry( + r" + const http = require('node:http'); + const srv = http.createServer(); + srv.on('upgrade', (req, socket, head) => { + // Listener present but socket is null — the adapter + // must close out the response cleanly. + }); + srv.listen(0); + globalThis.__srv = srv; + ", + ) + .await; + + let headers = vec![ + HeaderPair { + name: "upgrade".to_owned(), + value: "websocket".to_owned(), + }, + HeaderPair { + name: "connection".to_owned(), + value: "Upgrade".to_owned(), + }, + ]; + + let payload = dispatch_with_headers(&mut booted.engine, "GET", "/ws", headers).await; + assert_eq!(payload.head.status, 501); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn non_upgrade_request_with_synthetic_header_is_ignored() { + // Sanity check: the synthetic header alone (without `Upgrade`) + // must not be enough to trigger the upgrade path. In production + // `next_bridge` only injects the header when both `Upgrade` and + // `Connection: upgrade` are present, but we verify the JS adapter + // is defensively gated as well. + run_local(|| async { + let socket = upgrade_socket::allocate(); + let socket_id = socket.id(); + drop(socket); + + let mut booted = boot_with_entry( + r" + const http = require('node:http'); + const srv = http.createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('plain'); + }); + srv.on('upgrade', () => { throw new Error('upgrade should not fire'); }); + srv.listen(0); + globalThis.__srv = srv; + ", + ) + .await; + + let headers = vec![HeaderPair { + name: upgrade_socket::UPGRADE_SOCKET_ID_HEADER.to_owned(), + value: socket_id.to_string(), + }]; + + let payload = dispatch_with_headers(&mut booted.engine, "GET", "/x", headers).await; + assert_eq!(payload.head.status, 200); + assert_eq!(&payload.body[..], b"plain"); + + if let Some(handle) = upgrade_socket::handle(socket_id) { + handle.close(); + } + }) + .await; +} diff --git a/crates/nexide/tests/node_polyfills_extra.rs b/crates/nexide/tests/node_polyfills_extra.rs index 589f8f1..d0cfd27 100644 --- a/crates/nexide/tests/node_polyfills_extra.rs +++ b/crates/nexide/tests/node_polyfills_extra.rs @@ -278,15 +278,20 @@ async fn http2_loads_and_exposes_constants() { } #[tokio::test(flavor = "current_thread")] -async fn http2_throws_on_actual_use() { +async fn http2_server_throws_client_returns_session() { assert_passes( "const h2 = require('node:http2');\n\ let threw = false;\n\ try { h2.createServer(); } catch { threw = true; }\n\ if (!threw) throw new Error('createServer should throw');\n\ threw = false;\n\ - try { h2.connect('https://example.com'); } catch { threw = true; }\n\ - if (!threw) throw new Error('connect should throw');\n", + try { h2.createSecureServer(); } catch { threw = true; }\n\ + if (!threw) throw new Error('createSecureServer should throw');\n\ + const session = h2.connect('https://example.com');\n\ + if (!session || typeof session.request !== 'function') {\n\ + throw new Error('connect should return a session with request()');\n\ + }\n\ + session.destroy();\n", ) .await; } diff --git a/crates/nexide/tests/op_roundtrip.rs b/crates/nexide/tests/op_roundtrip.rs index 0e7f3e9..ce9fbe4 100644 --- a/crates/nexide/tests/op_roundtrip.rs +++ b/crates/nexide/tests/op_roundtrip.rs @@ -25,6 +25,8 @@ const __nx = globalThis.__nexide; __nx.__dispatch = function (idx, gen) { const meta = __nx.getMeta(idx, gen); + const method = meta[0]; + const uri = meta[1]; const buf = new Uint8Array(64); const n = __nx.readBody(idx, gen, buf); @@ -32,8 +34,8 @@ __nx.__dispatch = function (idx, gen) { __nx.sendHead(idx, gen, 200, [ ["content-type", "text/plain"], - ["x-method", meta.method], - ["x-uri", meta.uri], + ["x-method", method], + ["x-uri", uri], ]); const prefix = new Uint8Array([0x70, 0x6f, 0x6e, 0x67, 0x3a]); // "pong:" diff --git a/crates/nexide/tests/pump_error_path.rs b/crates/nexide/tests/pump_error_path.rs index bfb90fe..6868a97 100644 --- a/crates/nexide/tests/pump_error_path.rs +++ b/crates/nexide/tests/pump_error_path.rs @@ -18,10 +18,11 @@ const __nx = globalThis.__nexide; __nx.__dispatch = function (idx, gen) { const meta = __nx.getMeta(idx, gen); - if (meta.uri === '/sync-throw') { + const uri = meta[1]; + if (uri === '/sync-throw') { throw new Error('handler exploded synchronously'); } - if (meta.uri === '/async-reject') { + if (uri === '/async-reject') { return Promise.reject(new Error('handler rejected')); } __nx.sendHead(idx, gen, 200, []); diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 12ddabf..6c72d00 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -69,13 +69,36 @@ For pure-JS swap-ins still recommended where they exist: - **`better-sqlite3`** / `sqlite3` → use `@prisma/client` (works, see above), an HTTP-fronted SQLite (Turso, D1), or a pure-JS driver. -## HTTP/2 server / client +## HTTP/2 server -`require('node:http2')` loads, exposes `constants`, but `createServer` -and `connect` throw. Most deps probe via -`try { require('http2') } catch {}` and fall back to HTTP/1.1, so this -is usually transparent. gRPC clients that hard-require HTTP/2 will not -work. +`require('node:http2')` exposes a working **client** subset: +`http2.connect(authority)` opens a real h2 session via Rust ops, and +`session.request(headers)` returns a `Http2Stream` you can write/read +like a Duplex (enough for gRPC clients that go through `@grpc/grpc-js`'s +HTTP/2 channel, REST clients that opportunistically upgrade, etc.). + +The **server** side (`http2.createServer`, `createSecureServer`) is not +implemented — nexide's shield terminates HTTP/1.1 + h2 itself and hands +requests to JS as a Node-shaped `IncomingMessage`/`ServerResponse` +regardless of the wire protocol, so user code does not need a separate +h2 server. Most deps probe via `try { require('http2') } catch {}` and +fall back, so this is usually transparent. + +## Inspector / debugger protocol + +`require('node:inspector')` ships a small APM-probe shim: a +`Session` whose `post(method, params, cb)` answers a curated set of +calls that Datadog / Sentry / Elastic agents issue at startup — +`Runtime.evaluate` (via `vm.runInThisContext`), `Runtime.getHeapUsage`, +`Runtime.getHeapStatistics`, `HeapProfiler.collectGarbage`, plus +acknowledged-but-no-op `Profiler.enable`/`disable`. Every other method +rejects with the standard `-32601` "Method not found" error, mirroring +the Inspector wire format. + +The full DevTools protocol (live debugger, sampling CPU profiler, +incremental heap snapshots) is **not** implemented. Capture profiles +and heap snapshots externally — e.g. via `kill -USR1` on stock Node +during local repro, or via the host process / sidecar in production. ## Worker threads @@ -85,12 +108,6 @@ path is serialised onto the request loop. For most workloads this is invisible; if you have heavy CPU-bound revalidation, scale horizontally instead. -## Inspector / debugger protocol - -`require('node:inspector')` loads (so deps probing it don't crash) but -the DevTools wire protocol is not implemented. CPU profiles and heap -snapshots must be captured externally (e.g. via the host process). - ## `cluster`, `dgram`, `repl`, `domain`, `wasi`, `trace_events` Not shipped. These are server-side primitives nexide replaces with