Skip to content

Commit a4e9966

Browse files
authored
feat(install): create node_modules for workspace members (#34970)
When installing a workspace, `deno install` previously created only a single `node_modules` directory at the workspace root. Native Node.js tooling run from within a workspace member (for example `svelte-check`, `astro`, or `eslint` plugins) expects a local `node_modules` containing the member's dependencies, the way npm and pnpm lay workspaces out, and fails without it. Sharing only the root `node_modules` also hides missing dependencies, since a member can resolve a package that only a sibling declares. This makes both linker modes create a `node_modules` directory inside each workspace member and symlink that member's direct dependencies into it: npm dependencies point at their resolved location (the shared `node_modules/.deno` store for the isolated linker, or the package's top-level or nested directory for the hoisted linker) and sibling workspace members are linked to their directories. Each member's `node_modules/.bin` is also populated with its direct dependencies' executables, so a tool invoked as `node_modules/.bin/<tool>` from within a member (eslint, svelte-check, astro, ...) resolves the member's own copy. The workspace root is left untouched and members without dependencies get no directory. Stale links are pruned on reinstall the same way the root `node_modules` prunes them: when a member drops a dependency (or a sibling member is removed), its link is removed from the member's `node_modules` before re-linking so the dropped dependency stops being resolvable. The member's `.bin` is likewise rebuilt from its current dependencies so a dropped dependency's executable stops resolving. Only symlinks and junctions are touched, so real files a user placed in the member's `node_modules` are left alone. Known limitations (left for follow-ups): - Sibling workspace members are not npm packages, so they do not yet contribute executables to a member's `node_modules/.bin`. - In the hoisted linker, when two members depend on different versions of the same package the higher version is hoisted to the root and the lower one is not placed in the layout, so the member declaring the lower version does not get it linked into its own `node_modules` and resolves the hoisted version instead. The skip is logged at debug level. Closes #26743 Closes #27550
1 parent e855ab5 commit a4e9966

19 files changed

Lines changed: 735 additions & 1 deletion

File tree

libs/npm_installer/hoisted.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,119 @@ impl<
594594
}
595595
}
596596

597+
// 7. Create a `node_modules` directory inside each workspace member and
598+
// symlink that member's direct dependencies into it. This mirrors how npm
599+
// and pnpm lay out workspaces so that native Node.js tooling run from
600+
// within a member resolves the member's dependencies and sibling workspace
601+
// members. Each dependency is linked to its actual location in the hoisted
602+
// layout (a top-level package, or a nested one in case of conflicts).
603+
{
604+
let workspace_member_dirs: HashMap<&PackageNv, &Path> = self
605+
.npm_install_deps_provider
606+
.workspace_pkgs()
607+
.iter()
608+
.map(|pkg| (&pkg.nv, pkg.target_dir.as_path()))
609+
.collect();
610+
for workspace_pkg in self.npm_install_deps_provider.workspace_pkgs() {
611+
// The workspace root's `node_modules` is already fully set up above.
612+
// (Comparing paths here is unreliable: `root_node_modules_path` is
613+
// canonicalized while `target_dir` is not, so on Windows they can
614+
// differ by 8.3 short names or casing for the same directory.)
615+
if workspace_pkg.is_root {
616+
continue;
617+
}
618+
let member_node_modules = workspace_pkg.target_dir.join("node_modules");
619+
// Remove links to dependencies the member no longer declares (or to
620+
// sibling members that were removed) so they stop being resolvable,
621+
// mirroring how the root `node_modules` prunes stale links. This also
622+
// covers a member that dropped all of its dependencies, which is why it
623+
// runs before the `deps.is_empty()` short-circuit.
624+
let keep_aliases: HashSet<&str> = workspace_pkg
625+
.deps
626+
.iter()
627+
.map(|dep| dep.alias().as_str())
628+
.collect();
629+
crate::local::remove_stale_member_symlinks(
630+
sys.as_ref(),
631+
&member_node_modules,
632+
&keep_aliases,
633+
);
634+
// The member's direct npm dependencies that ship executables, gathered
635+
// while linking so their bins can be set up in the member's `.bin` once
636+
// every alias link exists.
637+
let mut bin_deps: Vec<crate::local::MemberBinDep> = Vec::new();
638+
if !workspace_pkg.deps.is_empty() {
639+
let mut created_dir = false;
640+
for dep in &workspace_pkg.deps {
641+
let (alias, target_path) = match dep {
642+
InstallWorkspacePkgDep::Remote { alias, req } => {
643+
let Some(id) = resolve_remote_pkg_id(snapshot, req) else {
644+
continue;
645+
};
646+
let Some(target_path) = hoisted_package_path(
647+
&layout,
648+
&self.root_node_modules_path,
649+
&id.nv,
650+
) else {
651+
// The resolved version isn't placed anywhere in the hoisted
652+
// layout (a version-conflict corner case where no package pins
653+
// this exact version), so there's nothing to link it to. Skip
654+
// it rather than fail, but log it: the member will resolve this
655+
// dependency via the hoisted top-level version instead, which
656+
// may differ from the version it declared. See
657+
// https://github.com/denoland/deno/pull/34970.
658+
log::debug!(
659+
"workspace member {} dependency {} ({}) is not present in the hoisted layout; skipping its node_modules link",
660+
workspace_pkg.nv,
661+
alias,
662+
id.nv,
663+
);
664+
continue;
665+
};
666+
if let Some(package) = snapshot.package_from_id(&id)
667+
&& package.has_bin
668+
{
669+
// The shim reads the package's `package.json` from its real
670+
// hoisted location but points at the member's own alias link.
671+
bin_deps.push(crate::local::MemberBinDep {
672+
package,
673+
read_path: target_path.clone(),
674+
link_path: member_node_modules.join(alias.as_str()),
675+
});
676+
}
677+
(alias, target_path)
678+
}
679+
InstallWorkspacePkgDep::Workspace { alias, nv } => {
680+
let Some(target_dir) = workspace_member_dirs.get(nv) else {
681+
continue;
682+
};
683+
(alias, target_dir.to_path_buf())
684+
}
685+
};
686+
if !created_dir {
687+
sys.fs_create_dir_all(&member_node_modules)?;
688+
created_dir = true;
689+
}
690+
crate::local::symlink_package_dir(
691+
sys.as_ref(),
692+
&target_path,
693+
&member_node_modules.join(alias.as_str()),
694+
)?;
695+
}
696+
}
697+
// Populate (and prune) the member's `node_modules/.bin`. Runs even when
698+
// the member has no dependencies so a dropped last bin is cleaned up.
699+
crate::local::setup_member_bin_entries(
700+
sys,
701+
snapshot,
702+
&extra_info_provider,
703+
&member_node_modules,
704+
&bin_deps,
705+
)
706+
.await?;
707+
}
708+
}
709+
597710
for package in &workspace_lifecycle_packages {
598711
lifecycle_scripts.borrow_mut().add(
599712
&package.package,
@@ -872,6 +985,38 @@ struct WorkspaceLifecyclePackage {
872985
scripts: HashMap<deno_semver::SmallStackString, String>,
873986
}
874987

988+
/// Resolves the on-disk location of a package version in the hoisted layout,
989+
/// either as a top-level package or a nested one. Returns `None` if the version
990+
/// isn't placed anywhere (for example a conflicting version that no package
991+
/// depends on).
992+
fn hoisted_package_path(
993+
layout: &HoistedLayout,
994+
root_node_modules_path: &Path,
995+
nv: &PackageNv,
996+
) -> Option<PathBuf> {
997+
if let Some(top) = layout.top_level.get(&nv.name)
998+
&& top.id.nv == *nv
999+
{
1000+
return Some(join_package_name(
1001+
Cow::Borrowed(root_node_modules_path),
1002+
&nv.name,
1003+
));
1004+
}
1005+
for nested in &layout.nested {
1006+
if nested.dep.id.nv == *nv {
1007+
return Some(join_package_name(
1008+
Cow::Owned(
1009+
root_node_modules_path
1010+
.join(&nested.parent_path)
1011+
.join("node_modules"),
1012+
),
1013+
&nv.name,
1014+
));
1015+
}
1016+
}
1017+
None
1018+
}
1019+
8751020
fn resolve_remote_pkg_id(
8761021
snapshot: &NpmResolutionSnapshot,
8771022
req: &deno_semver::package::PackageReq,

0 commit comments

Comments
 (0)