diff --git a/api/src/api/package.rs b/api/src/api/package.rs index 008b5069..8fc5fb39 100644 --- a/api/src/api/package.rs +++ b/api/src/api/package.rs @@ -1,14 +1,20 @@ // Copyright 2024 the JSR authors. All rights reserved. MIT license. -use std::borrow::Cow; -use std::io; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; -use std::sync::Arc; -use std::sync::Mutex; - use anyhow::Context; use chrono::Utc; use comrak::adapters::SyntaxHighlighterAdapter; +use deno_ast::MediaType; +use deno_ast::ModuleSpecifier; +use deno_ast::ParseDiagnostic; +use deno_graph::source::JsrUrlProvider; +use deno_graph::source::LoadOptions; +use deno_graph::source::NullFileSystem; +use deno_graph::BuildOptions; +use deno_graph::CapturingModuleAnalyzer; +use deno_graph::GraphKind; +use deno_graph::Module; +use deno_graph::ModuleInfo; +use deno_graph::Resolution; +use deno_graph::WorkspaceMember; use futures::future::Either; use futures::StreamExt; use hyper::body::HttpBody; @@ -16,10 +22,20 @@ use hyper::Body; use hyper::Request; use hyper::Response; use hyper::StatusCode; +use indexmap::IndexMap; +use regex::Regex; use routerify::prelude::RequestExt; use routerify::Router; use routerify_query::RequestQueryExt; +use serde::Deserialize; +use serde::Serialize; use sha2::Digest; +use std::borrow::Cow; +use std::io; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::sync::Mutex; use tracing::error; use tracing::field; use tracing::instrument; @@ -27,6 +43,8 @@ use tracing::Instrument; use tracing::Span; use url::Url; +use crate::analysis::JsrResolver; +use crate::analysis::ModuleParser; use crate::auth::access_token; use crate::auth::GithubOauth2Client; use crate::buckets::Buckets; @@ -50,6 +68,7 @@ use crate::ids::PackageName; use crate::ids::PackagePath; use crate::ids::ScopeName; use crate::metadata::PackageMetadata; +use crate::metadata::VersionMetadata; use crate::npm::generate_npm_version_manifest; use crate::orama::OramaClient; use crate::provenance; @@ -68,6 +87,7 @@ use crate::RegistryUrl; use super::ApiCreatePackageRequest; use super::ApiDependency; +use super::ApiDependencyGraphItem; use super::ApiDependent; use super::ApiDownloadDataPoint; use super::ApiError; @@ -150,6 +170,13 @@ pub fn package_router() -> Router { "/:package/versions/:version/dependencies", util::json(list_dependencies_handler), ) + .get( + "/:package/versions/:version/dependencies/graph", + util::cache( + CacheDuration::ONE_DAY, + util::json(get_dependencies_graph_handler), + ), + ) .get( "/:package/publishing_tasks", util::json(list_publishing_tasks_handler), @@ -1520,6 +1547,558 @@ pub async fn list_dependencies_handler( Ok(deps) } +struct DepTreeLoader { + scope: ScopeName, + package: PackageName, + version: crate::ids::Version, + bucket: crate::buckets::BucketWithQueue, + exports: Arc>>>, +} + +impl DepTreeLoader { + fn load_inner( + &self, + specifier: &ModuleSpecifier, + ) -> deno_graph::source::LoadFuture { + use futures::FutureExt; + let specifier = specifier.clone(); + + match specifier.scheme() { + "file" => { + let Ok(path) = PackagePath::new(specifier.path().to_string()) else { + return async move { Ok(None) }.boxed(); + }; + + let scope = self.scope.clone(); + let package = self.package.clone(); + let version = self.version.clone(); + let bucket = self.bucket.clone(); + + async move { + let Some(bytes) = bucket + .download( + crate::gcs_paths::file_path(&scope, &package, &version, &path) + .into(), + ) + .await? + else { + return Ok(None); + }; + + Ok(Some(deno_graph::source::LoadResponse::Module { + content: bytes.to_vec().into(), + specifier: specifier.clone(), + maybe_headers: None, + })) + } + .boxed() + } + "http" | "https" => { + let bucket = self.bucket.clone(); + let exports = self.exports.clone(); + + async move { + let jsr_matches = JSR_DEP_PATH_RE.captures(specifier.path()).unwrap(); + + let scope = jsr_matches.name("scope").unwrap(); + let package = jsr_matches.name("package").unwrap(); + let version = jsr_matches.name("version"); + let path = jsr_matches.name("path").unwrap(); + + let full_path: Arc = format!( + "@{}/{}/{}{}", + scope.as_str(), + package.as_str(), + version + .as_ref() + .map(|version| version.as_str()) + .unwrap_or_default(), + if path.as_str().starts_with('/') && version.is_none() { + &path.as_str()[1..] + } else { + path.as_str() + } + ) + .into(); + + let Some(bytes) = bucket.download(full_path.clone()).await? else { + return Ok(None); + }; + + if version.is_none() { + if let Some(captures) = JSR_DEP_META_RE.captures(path.as_str()) { + let version = captures.name("version").unwrap(); + let meta = + serde_json::from_slice::(&bytes).unwrap(); + + let mut lock = exports.lock().await; + lock.insert( + format!( + "@{}/{}@{}", + scope.as_str(), + package.as_str(), + version.as_str() + ), + meta.exports, + ); + } + } + + Ok(Some(deno_graph::source::LoadResponse::Module { + content: bytes.to_vec().into(), + specifier: specifier.clone(), + maybe_headers: None, + })) + } + .boxed() + } + "jsr" => unreachable!("{specifier}"), + // TODO(@crowlKats): handle npm specifiers + "npm" | "node" | "bun" => async move { + Ok(Some(deno_graph::source::LoadResponse::External { + specifier: specifier.clone(), + })) + } + .boxed(), + _ => async move { Ok(None) }.boxed(), + } + } +} + +impl deno_graph::source::Loader for DepTreeLoader { + fn load( + &self, + specifier: &ModuleSpecifier, + _options: LoadOptions, + ) -> deno_graph::source::LoadFuture { + self.load_inner(specifier) + } +} + +struct DepTreeJsrUrlProvider(Url); + +impl JsrUrlProvider for DepTreeJsrUrlProvider { + fn url(&self) -> &Url { + &self.0 + } +} + +struct DepTreeAnalyzer { + pub analyzer: CapturingModuleAnalyzer, + pub module_info: + std::cell::RefCell>>, +} + +impl Default for DepTreeAnalyzer { + fn default() -> Self { + Self { + analyzer: CapturingModuleAnalyzer::new( + Some(Box::new(ModuleParser::default())), + None, + ), + module_info: Default::default(), + } + } +} + +#[async_trait::async_trait(?Send)] +impl deno_graph::ModuleAnalyzer for DepTreeAnalyzer { + async fn analyze( + &self, + specifier: &ModuleSpecifier, + source: Arc, + media_type: MediaType, + ) -> Result { + let module_info = + self.analyzer.analyze(specifier, source, media_type).await?; + + let deps = module_info + .dependencies + .iter() + .filter_map(|dep| { + dep.as_static().and_then(|dep| { + if dep.specifier.starts_with("jsr:") { + Some(dep.specifier.clone()) + } else { + None + } + }) + }) + .collect::>(); + + if !deps.is_empty() { + self + .module_info + .borrow_mut() + .insert(specifier.clone(), deps.clone()); + } + + Ok(module_info) + } +} + +lazy_static::lazy_static! { + static ref JSR_DEP_PATH_RE: Regex = Regex::new(r"/@(?.+?)/(?.+?)(?:/(?.+?))?(?/.+)").unwrap(); + static ref JSR_DEP_META_RE: Regex = Regex::new(r"/(?.+?)_meta.json").unwrap(); +} + +// We have to spawn another tokio runtime, because +// `deno_graph::ModuleGraph::build` is not thread-safe. +#[tokio::main(flavor = "current_thread")] +async fn analyze_deps_tree( + registry_url: Url, + scope: ScopeName, + package: PackageName, + version: crate::ids::Version, + bucket: crate::buckets::BucketWithQueue, + exports: IndexMap, +) -> Result< + IndexMap, + deno_graph::ModuleGraphError, +> { + let roots = exports + .values() + .map(|path| Url::parse(&format!("file://{}", path)).unwrap()) + .collect::>(); + + let member = WorkspaceMember { + base: Url::parse("file:///").unwrap(), + name: format!("@{}/{}", scope, package), + version: Some(version.0.clone()), + exports: exports.clone(), + }; + + let module_analyzer = DepTreeAnalyzer::default(); + let mut graph = deno_graph::ModuleGraph::new(GraphKind::All); + let loader = DepTreeLoader { + scope, + package, + version, + bucket, + exports: Default::default(), + }; + graph + .build( + roots.clone(), + &loader, + BuildOptions { + is_dynamic: false, + module_analyzer: &module_analyzer, + imports: Default::default(), + // todo: use the data in the package for the file system + file_system: &NullFileSystem, + jsr_url_provider: &DepTreeJsrUrlProvider(registry_url), + passthrough_jsr_specifiers: false, + resolver: Some(&JsrResolver { member }), + npm_resolver: None, + reporter: None, + executor: Default::default(), + locker: None, + }, + ) + .await; + graph.valid()?; + + let mut index = 0; + let mut dependencies = Default::default(); + + let exports_by_identifier = Arc::into_inner(loader.exports) + .unwrap() + .into_inner() + .into_iter() + .map(|(p, exports)| { + // flips export keys->filepaths mapping, and removes leading . in filepaths + // and leading ./ in keys if the key is not the main entrypoint + let reversed_exports = exports + .into_iter() + .map(|(k, v)| { + ( + v[1..].to_string(), + if k == "." { k } else { k[2..].to_string() }, + ) + }) + .collect::>(); + + (p, reversed_exports) + }) + .collect(); + + for root in roots { + GraphDependencyCollector::collect( + &graph, + &root, + &exports_by_identifier, + &mut index, + &mut dependencies, + ); + } + + Ok(dependencies) +} + +struct GraphDependencyCollector<'a> { + graph: &'a deno_graph::ModuleGraph, + dependencies: &'a mut IndexMap, + exports: &'a IndexMap>, + id_index: &'a mut usize, +} + +impl<'a> GraphDependencyCollector<'a> { + pub fn collect( + graph: &'a deno_graph::ModuleGraph, + root: &'a ModuleSpecifier, + exports: &'a IndexMap>, + id_index: &'a mut usize, + dependencies: &'a mut IndexMap, + ) { + let root_module = graph.try_get(root).unwrap().unwrap(); + + Self { + graph, + dependencies, + exports, + id_index, + } + .build_module_info(root_module) + .unwrap(); + } + + fn build_module_info(&mut self, module: &Module) -> Option { + let specifier = module.specifier(); + + let dependency = match module { + Module::Js(_) | Module::Json(_) => { + if let Some(jsr_matches) = JSR_DEP_PATH_RE.captures(specifier.as_str()) + { + let scope = jsr_matches.name("scope").unwrap(); + let package = jsr_matches.name("package").unwrap(); + let version = jsr_matches.name("version").unwrap(); + let path = jsr_matches.name("path").unwrap(); + + let identifier = format!( + "@{}/{}@{}", + scope.as_str(), + package.as_str(), + version.as_str() + ); + + let entrypoint = if let Some(entrypoint) = self + .exports + .get(&identifier) + .and_then(|exports| exports.get(path.as_str())) + { + JsrEntrypoint::Entrypoint(entrypoint.to_string()) + } else { + JsrEntrypoint::Path(path.as_str().to_string()) + }; + + DependencyKind::Jsr { + scope: scope.as_str().to_string(), + package: package.as_str().to_string(), + version: version.as_str().to_string(), + entrypoint, + } + } else { + DependencyKind::Root { + path: specifier.path().to_string(), + } + } + } + Module::Wasm(_) + | Module::Npm(_) + | Module::Node(_) + | Module::External(_) => { + return None; + } + }; + + if let Some(info) = self.dependencies.get(&dependency) { + Some(info.id) + } else { + let maybe_size = match module { + Module::Js(js) => Some(js.size() as u64), + Module::Json(json) => Some(json.size() as u64), + Module::Wasm(_) + | Module::Node(_) + | Module::Npm(_) + | Module::External(_) => None, + }; + + let media_type = match module { + Module::Js(js) => Some(js.media_type), + Module::Json(json) => Some(json.media_type), + Module::Wasm(_) + | Module::Npm(_) + | Module::Node(_) + | Module::External(_) => None, + }; + + let mut children = vec![]; + match module { + Module::Js(module) => { + if let Some(types_dep) = &module.maybe_types_dependency { + if let Some(child) = self.build_resolved_info(&types_dep.dependency) + { + children.push(child); + } + } + for dep in module.dependencies.values() { + if !dep.maybe_code.is_none() { + if let Some(child) = self.build_resolved_info(&dep.maybe_code) { + children.push(child); + } + } + if !dep.maybe_type.is_none() { + if let Some(child) = self.build_resolved_info(&dep.maybe_type) { + children.push(child); + } + } + } + } + Module::Json(_) + | Module::Wasm(_) + | Module::Npm(_) + | Module::Node(_) + | Module::External(_) => {} + } + + let id = *self.id_index; + + self.dependencies.insert( + dependency, + DependencyInfo { + id, + children, + size: maybe_size, + media_type, + }, + ); + + *self.id_index += 1; + + Some(id) + } + } + + fn build_resolved_info(&mut self, resolution: &Resolution) -> Option { + match resolution { + Resolution::Ok(resolved) => { + let specifier = &resolved.specifier; + let resolved_specifier = self.graph.resolve(specifier); + match self.graph.try_get(resolved_specifier) { + Ok(Some(module)) => self.build_module_info(module), + Err(err) => { + let id = *self.id_index; + + self.dependencies.insert( + DependencyKind::Error { + error: err.to_string(), + }, + DependencyInfo { + id, + children: vec![], + size: None, + media_type: None, + }, + ); + + *self.id_index += 1; + + Some(id) + } + Ok(None) => None, + } + } + _ => None, + } + } +} + +#[derive(Serialize, Deserialize, Hash, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "camelCase", tag = "type", content = "value")] +pub enum JsrEntrypoint { + Entrypoint(String), + Path(String), +} + +#[derive(Serialize, Deserialize, Hash, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum DependencyKind { + Jsr { + scope: String, + package: String, + version: String, + entrypoint: JsrEntrypoint, + }, + Npm { + package: String, + }, + Root { + path: String, + }, + Error { + error: String, + }, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct DependencyInfo { + pub id: usize, + pub children: Vec, + pub size: Option, + pub media_type: Option, +} + +#[instrument( + name = "GET /api/scopes/:scope/packages/:package/versions/:version/dependencies/graph", + skip(req), + err, + fields(scope, package, version) +)] +pub async fn get_dependencies_graph_handler( + req: Request, +) -> ApiResult> { + let scope = req.param_scope()?; + let package = req.param_package()?; + let version = req.param_version()?; + Span::current().record("scope", field::display(&scope)); + Span::current().record("package", field::display(&package)); + Span::current().record("version", field::display(&version)); + + let buckets = req.data::().unwrap().clone(); + let gcs_path = + crate::gcs_paths::version_metadata(&scope, &package, &version).into(); + let version_meta = buckets + .modules_bucket + .download(gcs_path) + .await? + .ok_or(ApiError::PackageVersionNotFound)?; + let version_meta = serde_json::from_slice::(&version_meta)?; + + let registry_url = req.data::().unwrap().0.clone(); + + let deps = tokio::task::spawn_blocking(|| { + analyze_deps_tree( + registry_url, + scope, + package, + version, + buckets.modules_bucket, + version_meta.exports, + ) + }) + .await + .unwrap() + .unwrap(); + + let api_deps = deps + .into_iter() + .map(ApiDependencyGraphItem::from) + .collect::>(); + + Ok(api_deps) +} + #[instrument( name = "GET /api/scopes/:scope/packages/:package/publishing_tasks", skip(req), @@ -1581,6 +2160,7 @@ mod test { use serde_json::json; use crate::api::ApiDependency; + use crate::api::ApiDependencyGraphItem; use crate::api::ApiDependencyKind; use crate::api::ApiDependent; use crate::api::ApiList; @@ -3014,6 +3594,90 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== assert_eq!(dependents.total, 2); } + #[tokio::test] + async fn test_package_dependencies_graph() { + let mut t = TestSetup::new().await; + + // unpublished package + let mut resp = t + .http() + .get("/api/scopes/scope/packages/foo/versions/0.0.1/dependencies/graph") + .call() + .await + .unwrap(); + resp + .expect_err_code(StatusCode::NOT_FOUND, "packageVersionNotFound") + .await; + + let task = process_tarball_setup(&t, create_mock_tarball("ok")).await; + assert_eq!(task.status, PublishingTaskStatus::Success, "{:?}", task); + + // Empty deps + let mut resp = t + .http() + .get("/api/scopes/scope/packages/foo/versions/1.2.3/dependencies/graph") + .call() + .await + .unwrap(); + let deps: Vec = resp.expect_ok().await; + assert_eq!( + deps, + vec![ApiDependencyGraphItem { + dependency: super::DependencyKind::Root { + path: "/mod.ts".to_string(), + }, + children: vec![], + size: Some(155), + media_type: Some("TypeScript".to_string()), + }] + ); + + // Now publish a package that has a few deps + let package_name = PackageName::try_from("bar").unwrap(); + let version = Version::try_from("1.2.3").unwrap(); + let task = crate::publish::tests::process_tarball_setup2( + &t, + create_mock_tarball("depends_on_ok"), + &package_name, + &version, + false, + ) + .await; + assert_eq!(task.status, PublishingTaskStatus::Success, "{:?}", task); + + let mut resp = t + .http() + .get("/api/scopes/scope/packages/bar/versions/1.2.3/dependencies/graph") + .call() + .await + .unwrap(); + let deps: Vec = resp.expect_ok().await; + assert_eq!( + deps, + vec![ + ApiDependencyGraphItem { + dependency: super::DependencyKind::Jsr { + scope: "scope".to_string(), + package: "foo".to_string(), + version: "1.2.3".to_string(), + entrypoint: super::JsrEntrypoint::Entrypoint(".".to_string()) + }, + children: vec![], + size: Some(155), + media_type: Some("TypeScript".to_string()) + }, + ApiDependencyGraphItem { + dependency: super::DependencyKind::Root { + path: "/mod.ts".to_string() + }, + children: vec![0], + size: Some(117), + media_type: Some("TypeScript".to_string()) + } + ] + ); + } + #[tokio::test] async fn package_delete() { let mut t: TestSetup = TestSetup::new().await; diff --git a/api/src/api/types.rs b/api/src/api/types.rs index f55d0e01..50886e07 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -81,6 +81,36 @@ impl From for ApiPublishingTask { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ApiDependencyGraphItem { + pub dependency: super::package::DependencyKind, + pub children: Vec, + pub size: Option, + pub media_type: Option, +} + +impl + From<( + super::package::DependencyKind, + super::package::DependencyInfo, + )> for ApiDependencyGraphItem +{ + fn from( + (kind, info): ( + super::package::DependencyKind, + super::package::DependencyInfo, + ), + ) -> Self { + Self { + dependency: kind, + children: info.children, + size: info.size, + media_type: info.media_type.map(|media_type| media_type.to_string()), + } + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApiUser { diff --git a/api/src/util.rs b/api/src/util.rs index 228b7675..1c56088f 100644 --- a/api/src/util.rs +++ b/api/src/util.rs @@ -114,6 +114,7 @@ where pub struct CacheDuration(pub usize); impl CacheDuration { pub const ONE_MINUTE: CacheDuration = CacheDuration(60); + pub const ONE_DAY: CacheDuration = CacheDuration(60 * 60 * 24); } pub fn cache( diff --git a/frontend/components/icons/ChevronUp.tsx b/frontend/components/icons/ChevronUp.tsx new file mode 100644 index 00000000..88358586 --- /dev/null +++ b/frontend/components/icons/ChevronUp.tsx @@ -0,0 +1,19 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +export function ChevronUp(props: { class?: string }) { + return ( + + + + ); +} diff --git a/frontend/components/icons/Minus.tsx b/frontend/components/icons/Minus.tsx new file mode 100644 index 00000000..6ef9f650 --- /dev/null +++ b/frontend/components/icons/Minus.tsx @@ -0,0 +1,18 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +export function Minus(props: { class?: string }) { + return ( + + ); +} diff --git a/frontend/components/icons/Reset.tsx b/frontend/components/icons/Reset.tsx new file mode 100644 index 00000000..3f035cfd --- /dev/null +++ b/frontend/components/icons/Reset.tsx @@ -0,0 +1,18 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +export function Reset(props: { class?: string }) { + return ( + + ); +} diff --git a/frontend/deno.json b/frontend/deno.json index 2c4a5cbc..39f125b2 100644 --- a/frontend/deno.json +++ b/frontend/deno.json @@ -38,7 +38,9 @@ "@oramacloud/client": "npm:@oramacloud/client@^1", "tailwindcss": "npm:tailwindcss@3.4", - "postcss": "npm:postcss@8.4" + "postcss": "npm:postcss@8.4", + + "@viz-js/viz": "npm:@viz-js/viz@^3.11.0" }, "compilerOptions": { "lib": [ diff --git a/frontend/deno.lock b/frontend/deno.lock index 12cb3c09..4c6cf361 100644 --- a/frontend/deno.lock +++ b/frontend/deno.lock @@ -34,6 +34,7 @@ "npm:@preact/signals@1.2.1": "1.2.1_preact@10.24.3", "npm:@preact/signals@^1.2.3": "1.3.0_preact@10.24.3", "npm:@types/node@*": "22.5.4", + "npm:@viz-js/viz@^3.11.0": "3.11.0", "npm:autoprefixer@10.4.17": "10.4.17_postcss@8.4.35", "npm:cssnano@6.0.3": "6.0.3_postcss@8.4.35", "npm:esbuild-wasm@0.23.1": "0.23.1", @@ -400,6 +401,9 @@ "undici-types@6.19.8" ] }, + "@viz-js/viz@3.11.0": { + "integrity": "sha512-3zoKLQUqShIhTPvBAIIgJUf5wO9aY0q+Ftzw1u26KkJX1OJjT7Z5VUqgML2GIzXJYFgjqS6a2VREMwrgChuubA==" + }, "@vue/compiler-core@3.5.12": { "integrity": "sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==", "dependencies": [ @@ -1823,6 +1827,7 @@ "npm:@orama/orama@2", "npm:@oramacloud/client@1", "npm:@preact/signals@1.2.1", + "npm:@viz-js/viz@^3.11.0", "npm:marked-smartypants@1.1.6", "npm:postcss@8.4", "npm:preact-render-to-string@6.3.1", diff --git a/frontend/routes/package/(_islands)/DependencyGraph.tsx b/frontend/routes/package/(_islands)/DependencyGraph.tsx new file mode 100644 index 00000000..a3d2127f --- /dev/null +++ b/frontend/routes/package/(_islands)/DependencyGraph.tsx @@ -0,0 +1,478 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +import type { ComponentChildren } from "preact"; +import { useCallback, useEffect, useRef } from "preact/hooks"; +import { useSignal } from "@preact/signals"; +import { instance, type Viz } from "@viz-js/viz"; +import { ChevronDown } from "../../../components/icons/ChevronDown.tsx"; +import { ChevronLeft } from "../../../components/icons/ChevronLeft.tsx"; +import { ChevronRight } from "../../../components/icons/ChevronRight.tsx"; +import { ChevronUp } from "../../../components/icons/ChevronUp.tsx"; +import { Minus } from "../../../components/icons/Minus.tsx"; +import { Plus } from "../../../components/icons/Plus.tsx"; +import { Reset } from "../../../components/icons/Reset.tsx"; +import type { + DependencyGraphItem, + DependencyGraphKindError, + DependencyGraphKindNpm, + DependencyGraphKindRoot, +} from "../../../utils/api_types.ts"; + +export interface DependencyGraphProps { + dependencies: DependencyGraphItem[]; +} + +interface DependencyGraphKindGroupedJsr { + type: "jsr"; + scope: string; + package: string; + version: string; + entrypoints: string[]; +} + +type GroupedDependencyGraphKind = + | DependencyGraphKindGroupedJsr + | DependencyGraphKindNpm + | DependencyGraphKindRoot + | DependencyGraphKindError; + +export interface GroupedDependencyGraphItem { + dependency: GroupedDependencyGraphKind; + children: number[]; + size: number | undefined; + mediaType: string | undefined; +} + +interface JsrPackage { + scope: string; + package: string; + version: string; +} + +export function groupDependencies( + items: DependencyGraphItem[], +): GroupedDependencyGraphItem[] { + const referencedBy = new Map>(); + for (let i = 0; i < items.length; i++) { + for (const child of items[i].children) { + if (!referencedBy.has(child)) { + referencedBy.set(child, new Set()); + } + referencedBy.get(child)!.add(i); + } + } + + const jsrGroups = new Map(); + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.dependency.type === "jsr") { + const groupKey = + `${item.dependency.scope}/${item.dependency.package}@${item.dependency.version}`; + const group = jsrGroups.get(groupKey) ?? { + key: { + scope: item.dependency.scope, + package: item.dependency.package, + version: item.dependency.version, + }, + entrypoints: [], + children: [], + size: undefined, + mediaType: undefined, + oldIndices: [], + }; + group.entrypoints.push({ + entrypoint: item.dependency.entrypoint.value, + isEntrypoint: item.dependency.entrypoint.type == "entrypoint", + oldIndex: i, + }); + group.children.push(...item.children); + if (item.size !== undefined) { + group.size ??= 0; + group.size += item.size; + } + group.oldIndices.push(i); + jsrGroups.set(groupKey, group); + } + } + + const oldIndexToNewIndex = new Map(); + const placedJsrGroups = new Set(); + const out: GroupedDependencyGraphItem[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.dependency.type === "jsr") { + const groupKey = + `${item.dependency.scope}/${item.dependency.package}@${item.dependency.version}`; + const group = jsrGroups.get(groupKey)!; + + if (!placedJsrGroups.has(groupKey)) { + placedJsrGroups.add(groupKey); + + const groupIndicesSet = new Set(group.oldIndices); + const filteredEntrypoints = group.entrypoints.filter(({ oldIndex }) => { + const refs = referencedBy.get(oldIndex)!; + + for (const ref of refs) { + if (!groupIndicesSet.has(ref)) { + return true; + } + } + + return false; // all references are from within the same jsr package + }).map((p) => { + if (!p.isEntrypoint) { + throw new Error("unreachable"); + } + return p.entrypoint; + }); + + const uniqueChildren = Array.from(new Set(group.children)); + const newIndex = out.length; + out.push({ + dependency: { + type: "jsr", + scope: group.key.scope, + package: group.key.package, + version: group.key.version, + entrypoints: Array.from(new Set(filteredEntrypoints)), + }, + children: uniqueChildren, + size: group.size, + mediaType: group.mediaType, + }); + + for (const oldIdx of group.oldIndices) { + oldIndexToNewIndex.set(oldIdx, newIndex); + } + } else { + oldIndexToNewIndex.set( + i, + oldIndexToNewIndex.get(jsrGroups.get(groupKey)!.oldIndices[0])!, + ); + } + } else { + out.push({ + dependency: item.dependency, + children: item.children, + size: item.size, + mediaType: item.mediaType, + }); + oldIndexToNewIndex.set(i, out.length - 1); + } + } + + for (let index = 0; index < out.length; index++) { + const newItem = out[index]; + const remappedChildren = newItem.children + .map((childIdx) => oldIndexToNewIndex.get(childIdx)!) + .filter((childNewIdx) => childNewIdx !== index); + newItem.children = Array.from(new Set(remappedChildren)); + } + + return out; +} + +function createDigraph(dependencies: DependencyGraphItem[]) { + const groupedDependencies = groupDependencies(dependencies); + + const nodesWithNoParent = new Set( + Object.keys(groupedDependencies).map(Number), + ); + + const depsGraph = groupedDependencies.map( + ({ children, dependency, size }, index) => { + return [ + ` ${index} ${renderDependency(dependency, size)}`, + ...children.map((child) => { + nodesWithNoParent.delete(child); + return ` ${index} -> ${child}`; + }), + ].filter(Boolean).join("\n"); + }, + ).join("\n"); + + return `digraph "dependencies" { + graph [rankdir="LR", concentrate=true] + node [fontname="Courier", shape="box", style="filled,rounded"] + + { + rank=same + ${Array.from(nodesWithNoParent).join("; ")} + } + + ${depsGraph} +}`; +} + +function bytesToSize(bytes: number) { + const sizes = ["B", "KB", "MB", "GB", "TB"]; + if (bytes == 0) return "0 B"; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(0) + " " + sizes[i]; +} + +function renderDependency( + dependency: GroupedDependencyGraphKind, + size?: number, +) { + let href; + let content; + let tooltip; + let color; + switch (dependency.type) { + case "jsr": { + tooltip = + `@${dependency.scope}/${dependency.package}@${dependency.version}`; + href = `/${tooltip}`; + content = `${tooltip}\n${ + dependency.entrypoints.map((entrypoint) => { + if (entrypoint == ".") { + return "default entrypoint"; + } else { + return entrypoint; + } + }).join("\n") + }\n${bytesToSize(size ?? 0)}`; + color = "#faee4a"; + break; + } + case "npm": { + content = tooltip = `${dependency.package}@${dependency.version}`; + href = `https://www.npmjs.com/package/${dependency.package}`; + color = "#cb3837"; + break; + } + case "root": { + content = tooltip = dependency.path; + color = "#67bef9"; + break; + } + case "error": + default: + content = tooltip = dependency.error; + break; + } + + return `[${ + Object + .entries({ href, tooltip, label: content, color }) + .filter(([_, v]) => v) + .map(([k, v]) => `${k}="${v}"`) + .join(", ") + }]`; +} + +function useDigraph(dependencies: DependencyGraphItem[]) { + const controls = useSignal({ pan: { x: 0, y: 0 }, zoom: 1 }); + const defaults = useSignal({ pan: { x: 0, y: 0 }, zoom: 1 }); + const ref = useRef(null); + const svg = useRef(null); + const viz = useSignal(undefined); + + const center = useCallback(() => { + if (svg.current && ref.current) { + const { width: sWidth, height: sHeight } = svg.current + .getBoundingClientRect(); + const { width: rWidth, height: rHeight } = ref.current + .getBoundingClientRect(); + + defaults.value.pan.x = (rWidth - sWidth) / 2; + defaults.value.pan.y = (rHeight - sHeight) / 2; + defaults.value.zoom = Math.min(rWidth / sWidth, rHeight / sHeight); + controls.value = structuredClone(defaults.value); + svg.current.style.transform = + `translate(${controls.value.pan.x}px, ${controls.value.pan.y}px) scale(${controls.value.zoom})`; + } + }, []); + + const pan = useCallback((x: number, y: number) => { + controls.value.pan.x += x; + controls.value.pan.y += y; + if (svg.current) { + svg.current.style.transform = + `translate(${controls.value.pan.x}px, ${controls.value.pan.y}px) scale(${controls.value.zoom})`; + } + }, []); + + const zoom = useCallback((zoom: number) => { + controls.value.zoom = Math.max( + 0.1, + Math.min(controls.value.zoom + zoom, 2.5), + ); + + if (svg.current) { + svg.current.style.transform = + `translate(${controls.value.pan.x}px, ${controls.value.pan.y}px) scale(${controls.value.zoom})`; + } + }, []); + + const reset = useCallback(() => { + controls.value = structuredClone(defaults.value); + if (svg.current) { + svg.current.style.transform = + `translate(${controls.value.pan.x}px, ${controls.value.pan.y}px) scale(${controls.value.zoom})`; + } + }, []); + + useEffect(() => { + (async () => { + viz.value = await instance(); + + if (ref.current && viz.value) { + const digraph = createDigraph(dependencies); + + svg.current = viz.value.renderSVGElement(digraph, { + engine: "dot", + }); + ref.current.prepend(svg.current); + + center(); + } + })(); + }, [dependencies]); + + return { pan, zoom, reset, svg, ref }; +} + +interface GraphControlButtonProps { + children: ComponentChildren; + class: string; + onClick: () => void; + title: string; +} + +function GraphControlButton(props: GraphControlButtonProps) { + return ( + + ); +} + +const DRAG_THRESHOLD = 5; + +export function DependencyGraph(props: DependencyGraphProps) { + const { pan, zoom, reset, svg, ref } = useDigraph(props.dependencies); + const dragActive = useSignal(false); + const dragStart = useSignal({ x: 0, y: 0 }); + + function enableDrag(event: MouseEvent) { + dragStart.value = { x: event.clientX, y: event.clientY }; + } + + function disableDrag() { + dragActive.value = false; + dragStart.value = { x: 0, y: 0 }; + svg.current?.querySelectorAll("a").forEach((link) => { + link.style.pointerEvents = "auto"; + }); + } + + function onMouseMove(event: MouseEvent) { + if (!dragActive.value && (dragStart.value.x || dragStart.value.y)) { + const dx = Math.abs(event.clientX - dragStart.value.x); + const dy = Math.abs(event.clientY - dragStart.value.y); + if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) { + dragActive.value = true; + svg.current?.querySelectorAll("a").forEach((link) => { + link.style.pointerEvents = "none"; + }); + } + } + if (dragActive.value) { + pan(event.movementX, event.movementY); + } + } + + function wheelZoom(event: WheelEvent) { + event.preventDefault(); + // TODO: zoom on pointer + zoom(event.deltaY / 250); + } + + return ( +
+
+ {/* zoom */} + zoom(0.1)} + title="Zoom in" + > + + + zoom(-0.1)} + title="Zoom out" + > + + + + {/* pan */} + pan(0, 100)} + title="Pan up" + > + + + pan(100, 0)} + title="Pan left" + > + + + pan(-100, 0)} + title="Pan right" + > + + + pan(0, -100)} + title="Pan down" + > + + + + {/* reset */} + + + +
+
+ ); +} diff --git a/frontend/routes/package/dependencies/graph.tsx b/frontend/routes/package/dependencies/graph.tsx new file mode 100644 index 00000000..5732b263 --- /dev/null +++ b/frontend/routes/package/dependencies/graph.tsx @@ -0,0 +1,95 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +import { HttpError, type RouteConfig } from "fresh"; +import { path } from "../../../utils/api.ts"; +import { scopeIAM } from "../../../utils/iam.ts"; +import { define } from "../../../util.ts"; +import { DependencyGraph } from "../(_islands)/DependencyGraph.tsx"; +import { packageDataWithVersion } from "../../../utils/data.ts"; +import { PackageHeader } from "../(_components)/PackageHeader.tsx"; +import { PackageNav, type Params } from "../(_components)/PackageNav.tsx"; +import type { DependencyGraphItem } from "../../../utils/api_types.ts"; + +export default define.page( + function DepsGraph({ data, params, state }) { + const iam = scopeIAM(state, data.member); + + return ( +
+ + + + +
+ +
+
+ ); + }, +); + +export const handler = define.handlers({ + async GET(ctx) { + const res = await packageDataWithVersion( + ctx.state, + ctx.params.scope, + ctx.params.package, + ctx.params.version, + ); + if (res === null) { + throw new HttpError( + 404, + "This package or this package version was not found.", + ); + } + + const { + pkg, + scopeMember, + selectedVersion, + } = res; + + if (selectedVersion === null) { + return new Response(null, { + status: 302, + headers: { + Location: `/@${ctx.params.scope}/${ctx.params.package}`, + }, + }); + } + + const depsResp = await ctx.state.api.get( + path`/scopes/${pkg.scope}/packages/${pkg.name}/versions/${selectedVersion.version}/dependencies/graph`, + ); + if (!depsResp.ok) throw depsResp; + + ctx.state.meta = { + title: `Dependencies Graph - @${pkg.scope}/${pkg.name} - JSR`, + description: `@${pkg.scope}/${pkg.name} on JSR${ + pkg.description ? `: ${pkg.description}` : "" + }`, + }; + + return { + data: { + package: pkg, + deps: depsResp.data, + selectedVersion, + member: scopeMember, + }, + headers: { "X-Robots-Tag": "noindex" }, + }; + }, +}); + +export const config: RouteConfig = { + routeOverride: "/@:scope/:package{@:version}?/dependencies/graph", +}; diff --git a/frontend/routes/package/dependencies.tsx b/frontend/routes/package/dependencies/index.tsx similarity index 74% rename from frontend/routes/package/dependencies.tsx rename to frontend/routes/package/dependencies/index.tsx index a8f7ea8d..e45e3458 100644 --- a/frontend/routes/package/dependencies.tsx +++ b/frontend/routes/package/dependencies/index.tsx @@ -1,13 +1,13 @@ // Copyright 2024 the JSR authors. All rights reserved. MIT license. -import { HttpError, RouteConfig } from "fresh"; -import type { Dependency } from "../../utils/api_types.ts"; -import { path } from "../../utils/api.ts"; -import { define } from "../../util.ts"; -import { packageDataWithVersion } from "../../utils/data.ts"; -import { PackageHeader } from "./(_components)/PackageHeader.tsx"; -import { PackageNav, Params } from "./(_components)/PackageNav.tsx"; -import { Table, TableData, TableRow } from "../../components/Table.tsx"; -import { scopeIAM } from "../../utils/iam.ts"; +import { HttpError, type RouteConfig } from "fresh"; +import type { Dependency } from "../../../utils/api_types.ts"; +import { path } from "../../../utils/api.ts"; +import { define } from "../../../util.ts"; +import { packageDataWithVersion } from "../../../utils/data.ts"; +import { PackageHeader } from "../(_components)/PackageHeader.tsx"; +import { PackageNav, type Params } from "../(_components)/PackageNav.tsx"; +import { Table, TableData, TableRow } from "../../../components/Table.tsx"; +import { scopeIAM } from "../../../utils/iam.ts"; function getDependencyLink(dep: Dependency) { if (dep.kind === "jsr") { @@ -82,24 +82,33 @@ export default define.page(function Deps( ) : ( - - {list.map(([name, info]) => ( - - ))} -
+ <> + + {list.map(([name, info]) => ( + + ))} +
+

+ You can find a visualization of the dependencies by clicking the + button below. +

+ + Dependency Graph + + )} diff --git a/frontend/utils/api_types.ts b/frontend/utils/api_types.ts index 7108a66d..2adf36fa 100644 --- a/frontend/utils/api_types.ts +++ b/frontend/utils/api_types.ts @@ -266,3 +266,44 @@ export interface CreatedToken { token: Token; secret: string; } + +export interface DependencyGraphJsrEntrypoint { + type: "entrypoint" | "path"; + value: string; +} + +export interface DependencyGraphKindJsr { + type: "jsr"; + scope: string; + package: string; + version: string; + entrypoint: DependencyGraphJsrEntrypoint; +} + +export interface DependencyGraphKindNpm { + type: "npm"; + package: string; + version: string; +} +export interface DependencyGraphKindRoot { + type: "root"; + path: string; +} + +export interface DependencyGraphKindError { + type: "error"; + error: string; +} + +export type DependencyGraphKind = + | DependencyGraphKindJsr + | DependencyGraphKindNpm + | DependencyGraphKindRoot + | DependencyGraphKindError; + +export interface DependencyGraphItem { + dependency: DependencyGraphKind; + children: number[]; + size: number | undefined; + mediaType: string | undefined; +}