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 (
+
+ {props.children}
+
+ );
+}
+
+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;
+}