From ceacc170144ce554aa62722f8609ade50f16c632 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 11 Aug 2026 12:30:40 +0200 Subject: [PATCH] fix(raw-apps): respect the instance .npmrc in the raw app editor (#10629) * feat(raw-apps): route in-browser npm installs through the npm proxy * fix(npm-proxy): follow npm range semantics and cache packuments * fix(npm-proxy): bound the packument cache by bytes and stream tarballs * fix(npm-proxy): keep a v-prefixed pin exact and read the tarball once * chore(raw-apps): bump the ui_builder pin to the npm-proxy installer --- backend/Cargo.lock | 3 + backend/windmill-api-npm-proxy/Cargo.toml | 3 + backend/windmill-api-npm-proxy/src/lib.rs | 541 ++++++++++++------ backend/windmill-api/openapi.yaml | 50 ++ frontend/scripts/ui_builder_artifact.json | 4 +- .../chat/global/rawAppBundlerBridge.ts | 14 +- .../components/raw_apps/RawAppEditor.svelte | 11 +- 7 files changed, 457 insertions(+), 169 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 46ab639465..7c120a126e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15210,7 +15210,10 @@ version = "1.784.0" dependencies = [ "axum 0.8.9", "flate2", + "lazy_static", + "quick_cache", "reqwest 0.13.4", + "semver 1.0.28", "serde", "serde_json", "sqlx", diff --git a/backend/windmill-api-npm-proxy/Cargo.toml b/backend/windmill-api-npm-proxy/Cargo.toml index 001828cba1..205971c97d 100644 --- a/backend/windmill-api-npm-proxy/Cargo.toml +++ b/backend/windmill-api-npm-proxy/Cargo.toml @@ -13,7 +13,10 @@ windmill-api-auth.workspace = true windmill-common = { workspace = true, default-features = false } axum.workspace = true flate2.workspace = true +lazy_static.workspace = true +quick_cache.workspace = true reqwest.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true sqlx.workspace = true diff --git a/backend/windmill-api-npm-proxy/src/lib.rs b/backend/windmill-api-npm-proxy/src/lib.rs index 25a3dd22d8..d6f4af7f76 100644 --- a/backend/windmill-api-npm-proxy/src/lib.rs +++ b/backend/windmill-api-npm-proxy/src/lib.rs @@ -4,13 +4,19 @@ */ use axum::{ + body::Body, extract::{Path, Query}, + http::header, + response::{IntoResponse, Response}, routing::get, Extension, Json, Router, }; +use quick_cache::{sync::Cache, Weighter}; use serde::{Deserialize, Serialize}; use sqlx::types::JsonValue; use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; use tower_http::cors::{Any, CorsLayer}; use windmill_common::{ error::{Error, JsonResult, Result}, @@ -116,13 +122,20 @@ struct FileEntry { name: String, } +#[derive(Serialize)] +struct NpmProxyConfig { + registry_configured: bool, +} + pub fn workspaced_service() -> Router { Router::new() + .route("/config", get(get_config)) // Use wildcards for package names to support scoped packages like @scope/package .route("/metadata/{*package}", get(get_package_metadata)) .route("/resolve/{*package}", get(resolve_package_version)) .route("/filetree/{*package_version}", get(get_package_filetree)) .route("/file/{*package_version_filepath}", get(get_package_file)) + .route("/tarball/{*package_version}", get(get_package_tarball)) .layer( CorsLayer::new() .allow_origin(Any) @@ -156,21 +169,63 @@ fn build_registry_request( Ok(req) } -/// Get package metadata (versions and tags) from the private registry -async fn get_package_metadata( - _authed: ApiAuthed, - Path((_w_id, package_path)): Path<(String, StripPath)>, - Extension(db): Extension>, -) -> JsonResult { - let package = parse_package_name(package_path.to_path()); - let (registry_url, auth_token) = get_npm_registry(&db) +/// npm's abbreviated packument: same `dist-tags` and `versions` keys, same +/// `versions[v].dist`, without the per-version prose that makes a full document run to +/// tens of megabytes. Registries that do not implement it answer with the full document. +const ABBREVIATED_PACKUMENT: &str = "application/vnd.npm.install-v1+json"; + +/// An install resolves and then downloads every package, so without a cache each +/// dependency of the app being edited costs several packument round trips. +const PACKAGE_JSON_CACHE_TTL: Duration = Duration::from_secs(60); +/// Bounded by bytes rather than documents: packument size varies by orders of magnitude +/// between packages, so a count-based bound puts no ceiling on resident memory. +const PACKAGE_JSON_CACHE_BYTES: u64 = 64 * 1024 * 1024; + +#[derive(Clone)] +struct CachedPackageJson { + document: Arc, + /// Encoded length of the registry response, standing in for the parsed tree's size. + bytes: u64, + fetched_at: Instant, +} + +#[derive(Clone)] +struct PackageJsonWeighter; + +impl Weighter<(String, String), CachedPackageJson> for PackageJsonWeighter { + fn weight(&self, _key: &(String, String), cached: &CachedPackageJson) -> u64 { + cached.bytes.max(1) + } +} + +lazy_static::lazy_static! { + static ref PACKAGE_JSON_CACHE: Cache<(String, String), CachedPackageJson, PackageJsonWeighter> = + Cache::with_weighter(500, PACKAGE_JSON_CACHE_BYTES, PackageJsonWeighter); +} + +/// Fetch a package's registry document, together with the registry it came from so +/// callers can validate tarball URLs against it. +async fn fetch_package_json( + db: &sqlx::Pool, + package: &str, +) -> Result<(Arc, String, Option)> { + let (registry_url, auth_token) = get_npm_registry(db) .await? .ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?; - let package_url = format_registry_url(®istry_url, &package, None, None); + + let cache_key = (registry_url.clone(), package.to_string()); + if let Some(cached) = PACKAGE_JSON_CACHE.get(&cache_key) { + if cached.fetched_at.elapsed() < PACKAGE_JSON_CACHE_TTL { + return Ok((cached.document, registry_url, auth_token)); + } + } + + let package_url = format_registry_url(®istry_url, package, None, None); tracing::info!("Fetching package metadata from: {}", package_url); let response = build_registry_request(&package_url, &auth_token, ®istry_url)? + .header(header::ACCEPT, ABBREVIATED_PACKUMENT) .send() .await .map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?; @@ -182,10 +237,93 @@ async fn get_package_metadata( ))); } - let package_json: JsonValue = response - .json() + let body = response + .bytes() .await - .map_err(|e| Error::InternalErr(format!("Failed to parse package metadata: {}", e)))?; + .map_err(|e| Error::InternalErr(format!("Failed to read package metadata: {}", e)))?; + let package_json: Arc = Arc::new( + serde_json::from_slice(&body) + .map_err(|e| Error::InternalErr(format!("Failed to parse package metadata: {}", e)))?, + ); + + PACKAGE_JSON_CACHE.insert( + cache_key, + CachedPackageJson { + document: package_json.clone(), + bytes: body.len() as u64, + fetched_at: Instant::now(), + }, + ); + + Ok((package_json, registry_url, auth_token)) +} + +/// Open the tarball of a resolved package version on the private registry +async fn tarball_response( + package_json: &JsonValue, + package: &str, + version: &str, + registry_url: &str, + auth_token: &Option, +) -> Result { + let tarball_url = package_json + .get("versions") + .and_then(|v| v.get(version)) + .and_then(|v| v.get("dist")) + .and_then(|d| d.get("tarball")) + .and_then(|t| t.as_str()) + .ok_or_else(|| Error::NotFound(format!("Tarball not found for {}@{}", package, version)))?; + + let response = build_registry_request(tarball_url, auth_token, registry_url)? + .send() + .await + .map_err(|e| Error::InternalErr(format!("Failed to download tarball: {}", e)))?; + + if !response.status().is_success() { + return Err(Error::NotFound(format!( + "Failed to download tarball for {}@{}", + package, version + ))); + } + + Ok(response) +} + +/// Download the tarball of a resolved package version, for the handlers that unpack it here +async fn fetch_tarball( + package_json: &JsonValue, + package: &str, + version: &str, + registry_url: &str, + auth_token: &Option, +) -> Result { + tarball_response(package_json, package, version, registry_url, auth_token) + .await? + .bytes() + .await + .map_err(|e| Error::InternalErr(format!("Failed to read tarball: {}", e))) +} + +/// Report whether a private registry is configured, so clients that otherwise hit the +/// public npm CDNs (the raw app editor's in-browser installer) know to route through here. +async fn get_config( + _authed: ApiAuthed, + Path(_w_id): Path, + Extension(db): Extension>, +) -> JsonResult { + Ok(Json(NpmProxyConfig { + registry_configured: get_npm_registry(&db).await?.is_some(), + })) +} + +/// Get package metadata (versions and tags) from the private registry +async fn get_package_metadata( + _authed: ApiAuthed, + Path((_w_id, package_path)): Path<(String, StripPath)>, + Extension(db): Extension>, +) -> JsonResult { + let package = parse_package_name(package_path.to_path()); + let (package_json, _, _) = fetch_package_json(&db, &package).await?; let mut versions = Vec::new(); let mut tags = HashMap::new(); @@ -205,7 +343,7 @@ async fn get_package_metadata( Ok(Json(PackageVersions { tags, versions })) } -/// Resolve a package tag/version reference to a specific version +/// Resolve a package tag/version/range reference to a specific version async fn resolve_package_version( _authed: ApiAuthed, Path((_w_id, package_path)): Path<(String, StripPath)>, @@ -213,52 +351,126 @@ async fn resolve_package_version( Extension(db): Extension>, ) -> JsonResult { let package = parse_package_name(package_path.to_path()); - let (registry_url, auth_token) = get_npm_registry(&db) - .await? - .ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?; let reference = query.tag.unwrap_or_else(|| "latest".to_string()); - let package_url = format_registry_url(®istry_url, &package, None, None); + let (package_json, _, _) = fetch_package_json(&db, &package).await?; - tracing::info!("Resolving package version from: {}", package_url); + Ok(Json(PackageVersion { + version: resolve_version_spec(&package_json, &reference), + })) +} - let response = build_registry_request(&package_url, &auth_token, ®istry_url)? - .send() - .await - .map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?; +/// Resolve an npm version spec against a registry document: a dist-tag, an exact +/// version, or a semver range as it appears in a package.json dependency. +fn resolve_version_spec(package_json: &JsonValue, spec: &str) -> Option { + let spec = spec.trim(); - if !response.status().is_success() { - return Err(Error::NotFound(format!( - "Package {} not found in private registry", - package - ))); + if let Some(version) = package_json + .get("dist-tags") + .and_then(|v| v.get(spec)) + .and_then(|v| v.as_str()) + { + return Some(version.to_string()); } - let package_json: JsonValue = response - .json() - .await - .map_err(|e| Error::InternalErr(format!("Failed to parse package metadata: {}", e)))?; + let versions = package_json.get("versions").and_then(|v| v.as_object())?; + // A spec that pins one version, `v` prefix and all, is exact to npm. It has to be + // recognised here rather than left to the range path, where `1.2.3` reads as `^1.2.3`. + let pinned = strip_version_prefix(spec); + if versions.contains_key(pinned) { + return Some(pinned.to_string()); + } + if pinned.parse::().is_ok() { + return None; + } - // Try to resolve the reference as a tag first - let version = if let Some(tags) = package_json.get("dist-tags").and_then(|v| v.as_object()) { - if let Some(version) = tags.get(&reference).and_then(|v| v.as_str()) { - Some(version.to_string()) - } else { - // If not a tag, check if it's a valid version - if let Some(versions) = package_json.get("versions").and_then(|v| v.as_object()) { - if versions.contains_key(&reference) { - Some(reference) - } else { - None - } - } else { - None + let requirements = parse_npm_range(spec)?; + versions + .keys() + .filter_map(|raw| semver::Version::parse(raw).ok().map(|parsed| (parsed, raw))) + .filter(|(parsed, _)| requirements.iter().any(|req| req.matches(parsed))) + .max_by(|(a, _), (b, _)| a.cmp(b)) + .map(|(_, raw)| raw.clone()) +} + +/// npm ranges OR comparator sets together with `||`; the semver crate takes one set per +/// `VersionReq`. Sets it cannot parse are dropped, so an unsupported alternative narrows +/// the match rather than failing the whole range. +fn parse_npm_range(spec: &str) -> Option> { + let requirements = spec + .split("||") + .filter_map(|set| semver::VersionReq::parse(&normalize_comparator_set(set)).ok()) + .collect::>(); + + (!requirements.is_empty()).then_some(requirements) +} + +/// Rewrite one npm comparator set into the crate's syntax. npm additionally accepts an +/// operator detached from its version (`>= 1.0.0`), a `v` prefix, hyphen ranges, and +/// AND-ed comparators separated by spaces rather than commas; and it reads a bare partial +/// as an x-range (`1.2` is `1.2.x`) where the crate reads it as a caret. +fn normalize_comparator_set(set: &str) -> String { + let comparators = split_comparators(set); + match comparators.as_slice() { + [] => "*".to_string(), + [low, hyphen, high] if hyphen == "-" => format!(">={},<={}", low, high), + _ => comparators + .iter() + .map(|comparator| widen_bare_partial(comparator)) + .collect::>() + .join(","), + } +} + +/// Split on whitespace, keeping an operator attached to the version it applies to. +fn split_comparators(set: &str) -> Vec { + let mut comparators: Vec = Vec::new(); + for token in set.split_whitespace() { + match comparators.last_mut() { + Some(pending) if is_operator(pending) && token != "-" => { + pending.push_str(strip_version_prefix(token)) } + _ => comparators.push(if is_operator(token) { + token.to_string() + } else { + strip_operator_and_version_prefix(token) + }), } - } else { - None - }; + } + comparators +} - Ok(Json(PackageVersion { version })) +fn is_operator(token: &str) -> bool { + !token.is_empty() && token.chars().all(|c| "><=^~".contains(c)) +} + +/// npm accepts a `v` in front of a version (`v1.2.3`, `^v1.2.3`); the semver crate does not. +fn strip_version_prefix(version: &str) -> &str { + version + .strip_prefix('v') + .filter(|rest| rest.starts_with(|c: char| c.is_ascii_digit())) + .unwrap_or(version) +} + +fn strip_operator_and_version_prefix(comparator: &str) -> String { + let operator_len = comparator + .find(|c: char| !"><=^~".contains(c)) + .unwrap_or(comparator.len()); + let (operator, version) = comparator.split_at(operator_len); + format!("{}{}", operator, strip_version_prefix(version)) +} + +/// `1.2` bounds npm to the `1.2.x` line; the crate would read it as `^1.2`. +fn widen_bare_partial(comparator: &str) -> String { + let components = comparator.split('.').collect::>(); + if components.len() < 3 + && components + .iter() + .all(|c| !c.is_empty() && c.chars().all(|c| c.is_ascii_digit())) + { + format!("{}.*", comparator) + } else { + comparator.to_string() + } } /// Get the file tree for a specific package version @@ -268,66 +480,28 @@ async fn get_package_filetree( Extension(db): Extension>, ) -> JsonResult { let (package, version) = parse_package_and_version(package_version_path.to_path())?; - let (registry_url, auth_token) = get_npm_registry(&db) - .await? - .ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?; - let package_url = format_registry_url(®istry_url, &package, None, None); + let (package_json, registry_url, auth_token) = fetch_package_json(&db, &package).await?; + let tarball_bytes = fetch_tarball( + &package_json, + &package, + &version, + ®istry_url, + &auth_token, + ) + .await?; - tracing::info!("Fetching package filetree from: {}", package_url); - - let response = build_registry_request(&package_url, &auth_token, ®istry_url)? - .send() - .await - .map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?; - - if !response.status().is_success() { - return Err(Error::NotFound(format!( - "Package {} not found in private registry", - package - ))); - } - - let package_json: JsonValue = response - .json() - .await - .map_err(|e| Error::InternalErr(format!("Failed to parse package metadata: {}", e)))?; - - let tarball_url = package_json - .get("versions") - .and_then(|v| v.get(&version)) - .and_then(|v| v.get("dist")) - .and_then(|d| d.get("tarball")) - .and_then(|t| t.as_str()) - .ok_or_else(|| Error::NotFound(format!("Tarball not found for {}@{}", package, version)))?; - - let tarball_response = build_registry_request(tarball_url, &auth_token, ®istry_url)? - .send() - .await - .map_err(|e| Error::InternalErr(format!("Failed to download tarball: {}", e)))?; - - if !tarball_response.status().is_success() { - return Err(Error::NotFound(format!( - "Failed to download tarball for {}@{}", - package, version - ))); - } - - let tarball_bytes = tarball_response - .bytes() - .await - .map_err(|e| Error::InternalErr(format!("Failed to read tarball: {}", e)))?; - - // Extract file list from tarball - let files = extract_tarball_files(&tarball_bytes)?; - - // Find the main entry point - let main = package_json - .get("versions") - .and_then(|v| v.get(&version)) - .and_then(|v| v.get("main")) - .and_then(|m| m.as_str()) - .unwrap_or("index.js") - .to_string(); + // The entry point comes from the packaged manifest rather than the registry document, + // which carries no `main` in its abbreviated form. + let (files, manifest) = extract_tarball_files_and_manifest(&tarball_bytes)?; + let main = manifest + .and_then(|manifest| serde_json::from_str::(&manifest).ok()) + .and_then(|manifest| { + manifest + .get("main") + .and_then(|m| m.as_str()) + .map(String::from) + }) + .unwrap_or_else(|| "index.js".to_string()); Ok(Json(PackageFiletree { default: main, files })) } @@ -339,54 +513,15 @@ async fn get_package_file( Extension(db): Extension>, ) -> Result { let (package, version, filepath) = parse_package_version_and_file(full_path.to_path())?; - let (registry_url, auth_token) = get_npm_registry(&db) - .await? - .ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?; - let package_url = format_registry_url(®istry_url, &package, None, None); - - tracing::info!("Fetching package file from: {}", package_url); - - let response = build_registry_request(&package_url, &auth_token, ®istry_url)? - .send() - .await - .map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?; - - if !response.status().is_success() { - return Err(Error::NotFound(format!( - "Package {} not found in private registry", - package - ))); - } - - let package_json: JsonValue = response - .json() - .await - .map_err(|e| Error::InternalErr(format!("Failed to parse package metadata: {}", e)))?; - - let tarball_url = package_json - .get("versions") - .and_then(|v| v.get(&version)) - .and_then(|v| v.get("dist")) - .and_then(|d| d.get("tarball")) - .and_then(|t| t.as_str()) - .ok_or_else(|| Error::NotFound(format!("Tarball not found for {}@{}", package, version)))?; - - let tarball_response = build_registry_request(tarball_url, &auth_token, ®istry_url)? - .send() - .await - .map_err(|e| Error::InternalErr(format!("Failed to download tarball: {}", e)))?; - - if !tarball_response.status().is_success() { - return Err(Error::NotFound(format!( - "Failed to download tarball for {}@{}", - package, version - ))); - } - - let tarball_bytes = tarball_response - .bytes() - .await - .map_err(|e| Error::InternalErr(format!("Failed to read tarball: {}", e)))?; + let (package_json, registry_url, auth_token) = fetch_package_json(&db, &package).await?; + let tarball_bytes = fetch_tarball( + &package_json, + &package, + &version, + ®istry_url, + &auth_token, + ) + .await?; // Extract the specific file from the tarball let file_content = extract_file_from_tarball(&tarball_bytes, &filepath)?; @@ -394,6 +529,39 @@ async fn get_package_file( Ok(file_content) } +/// Stream a package version's tarball, so in-browser installers can unpack it +/// themselves instead of reaching the public registry directly +async fn get_package_tarball( + _authed: ApiAuthed, + Path((_w_id, package_version_path)): Path<(String, StripPath)>, + Extension(db): Extension>, +) -> Result { + let (package, version) = parse_package_and_version(package_version_path.to_path())?; + let (package_json, registry_url, auth_token) = fetch_package_json(&db, &package).await?; + let response = tarball_response( + &package_json, + &package, + &version, + ®istry_url, + &auth_token, + ) + .await?; + + Ok(( + [ + (header::CONTENT_TYPE, "application/octet-stream"), + // A published version's tarball never changes, and the response is + // registry-credentialed, so it is the viewer's cache to keep. + ( + header::CACHE_CONTROL, + "private, max-age=31536000, immutable", + ), + ], + Body::from_stream(response.bytes_stream()), + ) + .into_response()) +} + /// Get the npm registry URL and optional auth token from global settings. /// Checks the `npmrc` setting first, then falls back to `npm_config_registry`. async fn get_npm_registry( @@ -461,21 +629,25 @@ fn format_registry_url( } } -/// Extract file list from a tarball -fn extract_tarball_files(tarball_bytes: &[u8]) -> Result> { +/// Extract the file list and the manifest from a tarball, in one pass over the archive +fn extract_tarball_files_and_manifest( + tarball_bytes: &[u8], +) -> Result<(Vec, Option)> { use flate2::read::GzDecoder; + use std::io::Read; use tar::Archive; let gz = GzDecoder::new(tarball_bytes); let mut archive = Archive::new(gz); let mut files = Vec::new(); + let mut manifest = None; for entry in archive .entries() .map_err(|e| Error::InternalErr(format!("Failed to read tarball entries: {}", e)))? { - let entry = entry + let mut entry = entry .map_err(|e| Error::InternalErr(format!("Failed to read tarball entry: {}", e)))?; let path = entry .path() @@ -485,10 +657,16 @@ fn extract_tarball_files(tarball_bytes: &[u8]) -> Result> { let path_str = path.to_string_lossy().to_string(); if let Some(stripped) = path_str.strip_prefix("package/") { files.push(FileEntry { name: format!("/{}", stripped) }); + if stripped == "package.json" { + let mut content = String::new(); + if entry.read_to_string(&mut content).is_ok() { + manifest = Some(content); + } + } } } - Ok(files) + Ok((files, manifest)) } /// Extract a specific file from a tarball @@ -532,3 +710,46 @@ fn extract_file_from_tarball(tarball_bytes: &[u8], target_file: &str) -> Result< target_file ))) } + +#[cfg(test)] +mod tests { + use super::resolve_version_spec; + + fn packument() -> serde_json::Value { + serde_json::json!({ + "dist-tags": { "latest": "19.2.0", "next": "20.0.0-rc.1" }, + "versions": { + "18.3.1": {}, "19.0.0": {}, "19.1.5": {}, "19.2.0": {}, "20.0.0-rc.1": {} + } + }) + } + + #[test] + fn resolves_tags_exact_versions_and_ranges() { + let p = packument(); + let resolve = |spec: &str| resolve_version_spec(&p, spec); + + assert_eq!(resolve("latest").as_deref(), Some("19.2.0")); + assert_eq!(resolve("19.0.0").as_deref(), Some("19.0.0")); + // Ranges are what package.json dependencies actually hold, and each of these + // forms means something different to npm than to the semver crate's parser + assert_eq!(resolve("^19.0.0").as_deref(), Some("19.2.0")); + assert_eq!(resolve("~19.0.0").as_deref(), Some("19.0.0")); + assert_eq!(resolve(">=18 <19").as_deref(), Some("18.3.1")); + assert_eq!(resolve("^18.0.0 || ^19.0.0").as_deref(), Some("19.2.0")); + assert_eq!(resolve("*").as_deref(), Some("19.2.0")); + assert_eq!(resolve("19.x").as_deref(), Some("19.2.0")); + assert_eq!(resolve("18.3.1 - 19.1.5").as_deref(), Some("19.1.5")); + assert_eq!(resolve(">= 18 < 19").as_deref(), Some("18.3.1")); + assert_eq!(resolve("^v19.0.0").as_deref(), Some("19.2.0")); + // A `v` prefix does not turn a pinned version into a range + assert_eq!(resolve("v19.0.0").as_deref(), Some("19.0.0")); + assert_eq!(resolve("v19.0.1"), None); + // npm reads a bare partial as an x-range, the crate as a caret + assert_eq!(resolve("19.1").as_deref(), Some("19.1.5")); + assert_eq!(resolve("19").as_deref(), Some("19.2.0")); + // A pinned version the registry does not carry must not widen to a caret range + assert_eq!(resolve("19.0.1"), None); + assert_eq!(resolve("^21.0.0"), None); + } +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index a0efeaaa76..d783513bf8 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -8249,6 +8249,27 @@ paths: items: type: string + /w/{workspace}/npm_proxy/config: + get: + summary: get npm proxy configuration + operationId: getNpmProxyConfig + tags: + - npm_proxy + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: npm proxy configuration + content: + application/json: + schema: + type: object + properties: + registry_configured: + type: boolean + required: + - registry_configured + /w/{workspace}/npm_proxy/metadata/{package}: get: summary: get npm package metadata from private registry @@ -8384,6 +8405,35 @@ paths: schema: type: string + /w/{workspace}/npm_proxy/tarball/{package}/{version}: + get: + summary: get npm package tarball from private registry + operationId: getNpmPackageTarball + tags: + - npm_proxy + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: package + description: npm package name + in: path + required: true + schema: + type: string + - name: version + description: package version + in: path + required: true + schema: + type: string + responses: + "200": + description: package tarball + content: + application/octet-stream: + schema: + type: string + format: binary + /w/{workspace}/embeddings/query_resource_types: get: summary: query resource types by similarity diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index dcba3214de..87ce066c87 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "2471791", - "sha256": "04048791fb6b2ebca5cc30447e163826cdb143d65cce07a629252e131ac83618" + "version": "1522e43", + "sha256": "0c44db3a52e02e6530860d0e9e9031fcdad7a09a98897b6c97e6f522c1d4754c" } diff --git a/frontend/src/lib/components/copilot/chat/global/rawAppBundlerBridge.ts b/frontend/src/lib/components/copilot/chat/global/rawAppBundlerBridge.ts index 2e2a9c68b1..dd7c04c29a 100644 --- a/frontend/src/lib/components/copilot/chat/global/rawAppBundlerBridge.ts +++ b/frontend/src/lib/components/copilot/chat/global/rawAppBundlerBridge.ts @@ -12,6 +12,8 @@ type BundleRawAppFilesParams = { bundlerType?: 'esbuild' | 'rolldown' timeoutMs?: number onLog?: (delta: string) => void + /** Forwarded to the bundler so its npm installer can use the workspace npm proxy. */ + workspace?: string } type BundleRawAppDraftParams = BundleRawAppFilesParams & { @@ -49,7 +51,8 @@ export function bundleRawAppFiles({ sharedUiFiles = {}, bundlerType = 'esbuild', timeoutMs = DEFAULT_TIMEOUT_MS, - onLog + onLog, + workspace }: BundleRawAppFilesParams): Promise { if (typeof window === 'undefined' || typeof document === 'undefined') { return Promise.reject(new Error('Raw app bundling requires a browser environment.')) @@ -129,15 +132,18 @@ export function bundleRawAppFiles({ iframe.title = 'Raw app bundler' iframe.tabIndex = -1 - // Windmill pages use COEP=require-corp; the static UI builder iframe must be credentialless. - iframe.setAttribute('credentialless', '') + // No `credentialless`: /ui_builder/* is itself served with COEP=require-corp, so it + // embeds in isolated pages as is, while a credentialless frame gets an empty cookie + // jar, which would leave its npm installer unauthenticated against /api/w/*. iframe.style.position = 'fixed' iframe.style.width = '0' iframe.style.height = '0' iframe.style.border = '0' iframe.style.opacity = '0' iframe.style.pointerEvents = 'none' - iframe.src = '/ui_builder/index.html?mode=bundle' + const params = new URLSearchParams({ mode: 'bundle' }) + if (workspace) params.set('workspace', workspace) + iframe.src = `/ui_builder/index.html?${params}` window.addEventListener('message', onMessage) document.body.appendChild(iframe) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 9d49f7b7ea..ce881fb82d 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -1855,9 +1855,14 @@ // mount: a reactive src would reload the iframe on every theme toggle. Live // theme changes travel through postMessage instead (see the $effect below). function uiBuilderIframeSrc(): string { - const dark = document.documentElement.classList.contains('dark') - const variant = getAppliedDarkModeVariant() - return `/ui_builder/index.html?dark=${dark}&variant=${variant}` + const params = new URLSearchParams({ + dark: String(document.documentElement.classList.contains('dark')), + variant: getAppliedDarkModeVariant() + }) + // `workspace` lets the in-browser npm installer reach /api/w//npm_proxy so + // package installs honour the instance's .npmrc instead of the public registry. + if (opWorkspace) params.set('workspace', opWorkspace) + return `/ui_builder/index.html?${params}` } // Host's computed `text-xs` size in px. Windmill bumps :root to 18px at // ≥1760px viewports, so this re-evaluates on resize via the listener below.