From 7c2c9dc632c312344e335ff86b3c9e8b92bc88d7 Mon Sep 17 00:00:00 2001 From: Samuel Attard <6634592+MarshallOfSound@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:35:44 +0000 Subject: [PATCH 1/3] install: self-contained workspaces (installConfig.hoistingLimits) for the hoisted linker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools that walk, prune or repackage one workspace's node_modules (Electron packagers, serverless bundlers) need every dependency physically present under that workspace, which hoisting to the root breaks. A workspace can now be marked self-contained, either with Yarn's `"installConfig": { "hoistingLimits": "workspaces" }` in its own package.json or via `install.selfContainedWorkspaces = ["apps/desktop"]` in bunfig.toml (paths or names). For such a workspace the tree builder makes it a hoist root (the same hoist_root_id mechanism bundled dependencies already use), so nothing it transitively depends on — including through workspaces it depends on — is placed above /node_modules; and the hoisted installer materializes the packages under it with the copyfile backend (real files) instead of hardlinks/clonefile from the cache, so rewriting them cannot affect the cache. Other workspaces, and the isolated linker, are unchanged. Adds PackageManager::try_get() for the lockfile helper that consults the option outside an install. --- docs/pm/workspaces.mdx | 30 +++++ docs/runtime/bunfig.mdx | 9 ++ src/bunfig/bunfig.rs | 26 +++++ src/install/PackageInstaller.rs | 13 ++- src/install/PackageManager.rs | 14 +++ .../PackageManager/PackageManagerOptions.rs | 12 ++ src/install/hoisted_install.rs | 13 +++ src/install/lockfile.rs | 62 ++++++++++ src/install/lockfile/Tree.rs | 14 +++ src/options_types/schema.rs | 4 + .../bun-workspaces-self-contained.test.ts | 106 ++++++++++++++++++ 11 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 test/cli/install/bun-workspaces-self-contained.test.ts diff --git a/docs/pm/workspaces.mdx b/docs/pm/workspaces.mdx index 7673b6111780..224310866e69 100644 --- a/docs/pm/workspaces.mdx +++ b/docs/pm/workspaces.mdx @@ -96,6 +96,36 @@ Workspaces have a few major benefits. - **Bun can de-duplicate dependencies.** If `a` and `b` share a common dependency, Bun _hoists_ it to the root `node_modules` directory. This saves disk space and minimizes the "dependency hell" of multiple versions of a package installed at once. - **Run scripts in multiple packages.** Use the [`--filter` flag](/pm/filter) to run `package.json` scripts in several packages at once, or `--workspaces` to run scripts across all workspaces. +## Self-contained workspaces + +With the hoisted linker, dependencies shared by several workspaces are hoisted to the root +`node_modules`. Some tools cannot follow that: Electron packagers and serverless bundlers walk, +prune and repackage *one workspace's* `node_modules` and expect every dependency to be physically +present under it. Mark such a workspace as self-contained, either in its own `package.json` +(compatible with Yarn's setting): + +```json title="apps/desktop/package.json" icon="file-json" +{ + "name": "desktop", + "installConfig": { "hoistingLimits": "workspaces" } +} +``` + +or from the root: + +```toml title="bunfig.toml" icon="settings" +[install] +selfContainedWorkspaces = ["apps/desktop"] # workspace paths or names +``` + +For that workspace `bun install` then behaves as a hoisting barrier — nothing it depends on, +directly or transitively (including through other workspaces it depends on), is placed above +`apps/desktop/node_modules`, so that directory is a complete tree — and materializes those +packages as real copies instead of hardlinks / clones from the cache, so tools that rewrite them +cannot affect the cache or other projects. All other workspaces keep hoisting to the root as usual. +This setting has no effect with the isolated linker, where every package already resolves only its +own dependencies. + ## Share versions with Catalogs When many packages need the same dependency versions, define those versions once diff --git a/docs/runtime/bunfig.mdx b/docs/runtime/bunfig.mdx index 5927b01bf6d6..254e1082f0ff 100644 --- a/docs/runtime/bunfig.mdx +++ b/docs/runtime/bunfig.mdx @@ -514,6 +514,15 @@ Valid values are: | `"offline"` | Skip staleness checks and resolve packages from the local cache. Equivalent to `--prefer-offline`. | | `"latest"` | Always check npm for the latest matching versions. Equivalent to `--prefer-latest`. | +### `install.selfContainedWorkspaces` + +Workspaces (by path or name) that must have a complete, physical `node_modules` of their own when using the hoisted linker — see [Self-contained workspaces](/pm/workspaces#self-contained-workspaces). A workspace can also opt in itself with `"installConfig": { "hoistingLimits": "workspaces" }` in its `package.json`. Default `[]`. + +```toml title="bunfig.toml" icon="settings" +[install] +selfContainedWorkspaces = ["apps/desktop"] +``` + ### `install.frozenLockfile` When `true`, `bun install` does not update `bun.lock`. Default `false`. If `package.json` and the existing `bun.lock` disagree, the install errors. diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index 34044429cc60..4cc84fa20af3 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -1565,6 +1565,32 @@ impl<'a> Parser<'a> { install.hoist = Some(v); } + if let Some(list) = install_obj.get(b"selfContainedWorkspaces") { + match &list.data { + ExprData::EArray(arr) => { + let raw = arr.items.slice(); + let mut out: Vec> = Vec::with_capacity(raw.len()); + for p in raw { + self.expect_string(p)?; + out.push(estring_to_owned( + p.data + .e_string() + .expect("infallible: variant checked") + .get(), + self.bump, + )); + } + install.self_contained_workspaces = Some(out); + } + _ => { + self.add_error( + list.loc, + b"Expected an array of workspace paths or names for selfContainedWorkspaces", + )?; + } + } + } + Ok(()) } diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index a999c8d2a073..61c205442f8c 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -93,6 +93,10 @@ pub struct PackageInstaller<'a> { pub(crate) successfully_installed: Bitset, pub(crate) command_ctx: Command::Context<'a>, pub(crate) current_tree_id: lockfile::tree::Id, + /// Trees that live under a self-contained workspace: packages there are copied + /// (real files) rather than hardlinked/cloned/symlinked from the cache, so tools + /// that walk, prune or rewrite that node_modules cannot reach the shared cache. + pub(crate) copy_trees: Bitset, // fields used for running lifecycle scripts when it's safe // @@ -1869,10 +1873,17 @@ impl<'a> PackageInstaller<'a> { break 'result result; } + let method = if (self.current_tree_id as usize) < self.copy_trees.bit_length() + && self.copy_trees.is_set(self.current_tree_id as usize) + { + package_install::Method::Copyfile + } else { + installer.get_install_method() + }; break 'result installer.install( self.skip_delete, &destination_dir, - installer.get_install_method(), + method, resolution.tag, ); } diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index ed5bf8a3dd28..c2e7e8d01a22 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -730,6 +730,18 @@ impl PackageManager { unsafe { &*get() } } + /// `get()` for code that may also run before the manager exists (helpers that + /// only consult options when an install is in progress). + #[inline] + pub fn try_get() -> Option<&'static PackageManager> { + let p = get(); + if p.is_null() { + None + } else { + Some(unsafe { &*p }) + } + } + /// Associated-fn spelling that forwards to the free [`init`] so callers /// can write `PackageManager::init(ctx, cli, subcommand)`. #[inline] @@ -1408,6 +1420,7 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall public_hoist_pattern, hoist_pattern, hoist, + self_contained_workspaces, } = bunfig; if let Some(registry) = default_registry { @@ -1462,6 +1475,7 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall public_hoist_pattern, hoist_pattern, hoist, + self_contained_workspaces, ); } diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 10504ff5a1cd..689d48a27ef4 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -81,6 +81,11 @@ pub struct Options { /// fallback (pnpm's `hoist=false`); takes precedence over `hoist_pattern`. pub(crate) hoist: bool, + /// Workspaces (path or name) whose node_modules must be complete and physical: + /// a hoisting barrier + copy materialization. Also enabled per workspace by + /// `"installConfig": { "hoistingLimits": "workspaces" }` in its package.json. + pub self_contained_workspaces: &'static [&'static [u8]], + // Security scanner module path pub security_scanner: Option<&'static [u8]>, @@ -156,6 +161,7 @@ impl Default for Options { public_hoist_pattern: None, hoist_pattern: None, hoist: true, + self_contained_workspaces: &[], security_scanner: None, minimum_release_age_ms: None, minimum_release_age_excludes: None, @@ -475,6 +481,12 @@ impl Options { self.hoist = hoist; } + if let Some(list) = &config.self_contained_workspaces { + let leaked: Vec<&'static [u8]> = list.iter().map(|e| leak_static(e)).collect(); + self.self_contained_workspaces = + &*bun_core::heap::release(leaked.into_boxed_slice()); + } + if let Some(security_scanner) = config.security_scanner.as_deref() { self.security_scanner = Some(leak_static(security_scanner)); self.do_.set(Do::PREFETCH_RESOLVED_TARBALLS, false); diff --git a/src/install/hoisted_install.rs b/src/install/hoisted_install.rs index fb370c033827..f2fe1dd07161 100644 --- a/src/install/hoisted_install.rs +++ b/src/install/hoisted_install.rs @@ -407,6 +407,19 @@ pub(crate) fn install_hoisted_packages( folder_path_buf: bun_paths::PathBuffer::uninit(), current_tree_id: tree::INVALID_ID, pending_lifecycle_scripts: Vec::new(), + copy_trees: { + let self_contained = this.lockfile.self_contained_workspace_ids(); + let mut set = Bitset::init_empty(trees_count)?; + if !self_contained.is_empty() { + for tid in 0..trees_count { + let owner = this.lockfile.owning_workspace_of_tree(tid as tree::Id); + if owner != 0 && self_contained.contains(&owner) { + set.set(tid); + } + } + } + set + }, }; }; diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 2fa85d1f5d98..eb763bf5b611 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -814,6 +814,66 @@ impl Lockfile { /// Does this tree id belong to a workspace (including workspace root)? /// TODO(dylan-conway) fix! + /// Workspace packages whose node_modules must be self-contained: listed in + /// `install.selfContainedWorkspaces` (by path or name) or declaring yarn's + /// `"installConfig": { "hoistingLimits": "workspaces" | "dependencies" }`. + pub(crate) fn self_contained_workspace_ids(&self) -> Vec { + let mut out = Vec::new(); + let configured: &[&[u8]] = crate::PackageManager::try_get() + .map(|pm| pm.options.self_contained_workspaces) + .unwrap_or(&[]); + let buf = self.buffers.string_bytes.as_slice(); + let pkgs = self.packages.slice(); + for (i, res) in pkgs.items_resolution().iter().enumerate() { + if res.tag != crate::resolution::Tag::Workspace { + continue; + } + let path = res.workspace().slice(buf); + let name = pkgs.items_name()[i].slice(buf); + let mut yes = configured.iter().any(|c| { + let c = bun_core::strings::without_trailing_slash(c); + c == path || c == name + }); + if !yes { + // yarn compat: installConfig.hoistingLimits in the workspace's own manifest + let mut p = bun_paths::AutoAbsPath::init_top_level_dir(); + let _ = p.append(path); + let _ = p.append(b"package.json"); + if let Ok(bytes) = std::fs::read(std::path::Path::new(unsafe { + std::str::from_utf8_unchecked(p.slice()) + })) { + if let Some(i) = bun_core::strings::index_of(&bytes, b"\"hoistingLimits\"") { + let rest = &bytes[i + 16..bytes.len().min(i + 64)]; + yes = bun_core::strings::index_of(rest, b"\"workspaces\"").is_some() + || bun_core::strings::index_of(rest, b"\"dependencies\"").is_some(); + } + } + } + if yes { + out.push(i as PackageID); + } + } + out + } + + /// The workspace (or root, 0) package that owns tree `id`: walk up until a + /// workspace/root tree is reached and return the package it stands for. + pub(crate) fn owning_workspace_of_tree(&self, mut id: tree::Id) -> PackageID { + while id != 0 && (id as usize) < self.buffers.trees.len() { + let t = &self.buffers.trees[id as usize]; + let dep_id = t.dependency_id; + if (dep_id as usize) < self.buffers.dependencies.len() + && self.buffers.dependencies[dep_id as usize] + .behavior + .is_workspace() + { + return self.buffers.resolutions[dep_id as usize]; + } + id = t.parent; + } + 0 + } + pub(crate) fn is_workspace_tree_id(&self, id: tree::Id) -> bool { id == 0 || self.buffers.dependencies[self.buffers.trees[id as usize].dependency_id as usize] @@ -1322,8 +1382,10 @@ impl Lockfile { // `ParentRef::new` captures `SharedReadOnly` provenance from `&*self`, // which is exactly what `Builder` needs (it only ever `Deref`s); the // `Builder` does not outlive this `&mut self` borrow. + let self_contained = self.self_contained_workspace_ids(); let lockfile_ref = bun_ptr::ParentRef::::new(&*self); let mut builder = tree::Builder:: { + self_contained, queue: tree::TreeFiller::init(), resolution_lists: slice.items_resolutions(), resolutions: self.buffers.resolutions.as_mut_slice(), diff --git a/src/install/lockfile/Tree.rs b/src/install/lockfile/Tree.rs index 6ed128eaa9d3..c17ca42f3151 100644 --- a/src/install/lockfile/Tree.rs +++ b/src/install/lockfile/Tree.rs @@ -449,6 +449,8 @@ pub struct Builder<'a, const METHOD: BuilderMethod> { pub(crate) workspace_filters: &'a [WorkspaceFilter], pub(crate) install_root_dependencies: bool, pub(crate) packages_to_install: Option<&'a [PackageID]>, + /// Workspace package ids that are hoisting barriers (self-contained node_modules). + pub(crate) self_contained: Vec, } pub struct BuilderEntry { @@ -659,6 +661,18 @@ impl Tree { // reshaped for borrowck. let next_id = (builder.list.len() - 1) as Id; + // A self-contained workspace is a hoisting barrier: nothing below it may be + // placed above its own node_modules. + let hoist_root_id = if dependency_id != ROOT_DEP_ID + && builder.dependencies[dependency_id as usize] + .behavior + .is_workspace() + && builder.self_contained.contains(&parent_pkg_id) + { + next_id + } else { + hoist_root_id + }; // Copy the `ParentRef` out (it's `Copy`) so the resulting `&Lockfile` // is borrowed from a local, not `&builder` — subsequent `&mut builder` diff --git a/src/options_types/schema.rs b/src/options_types/schema.rs index ffde83bb9211..a98224648b2d 100644 --- a/src/options_types/schema.rs +++ b/src/options_types/schema.rs @@ -258,6 +258,10 @@ pub mod api { pub public_hoist_pattern: Option, pub hoist_pattern: Option, pub hoist: Option, + /// `selfContainedWorkspaces = ["apps/desktop"]` (workspace paths or names): with + /// the hoisted linker, nothing these workspaces depend on is hoisted above their + /// own node_modules, and their packages are copied rather than linked. + pub self_contained_workspaces: Option>>, } #[repr(u8)] diff --git a/test/cli/install/bun-workspaces-self-contained.test.ts b/test/cli/install/bun-workspaces-self-contained.test.ts new file mode 100644 index 000000000000..c19e358e7dcb --- /dev/null +++ b/test/cli/install/bun-workspaces-self-contained.test.ts @@ -0,0 +1,106 @@ +import { spawn } from "bun"; +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, setDefaultTimeout } from "bun:test"; +import { existsSync, readlinkSync, statSync } from "fs"; +import { mkdir, writeFile } from "fs/promises"; +import { bunExe, bunEnv as env, isWindows, readdirSorted } from "harness"; +import { join } from "path"; +import { + dummyAfterAll, + dummyAfterEach, + dummyBeforeAll, + dummyBeforeEach, + dummyRegistry, + package_dir, + root_url, + setHandler, +} from "./dummy.registry"; + +// A workspace that is packaged by tools which walk `node_modules` (Electron packagers, +// serverless bundlers) needs a *complete* and *physical* node_modules of its own: +// nothing it (transitively) depends on may be hoisted above it, and the files must be +// real copies rather than links into the cache. + +beforeAll(dummyBeforeAll); +afterAll(dummyAfterAll); +setDefaultTimeout(1000 * 60 * 5); + +beforeEach(async () => { + await dummyBeforeEach({ linker: "hoisted" }); + setHandler( + dummyRegistry([], { + "0.0.2": {}, + "0.0.3": {}, + "0.1.0": { dependencies: { bar: "0.0.2" } }, + latest: "0.0.3", + }), + ); +}); +afterEach(dummyAfterEach); + +async function install(cwd: string, args: string[] = []) { + await using proc = spawn({ cmd: [bunExe(), "install", ...args], cwd, env, stdout: "pipe", stderr: "pipe" }); + const [out, err, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { out, err, code }; +} + +async function writeProject(desktopExtra: object, bunfigExtra: object) { + await writeFile( + join(package_dir, "bunfig.toml"), + Bun.TOML.stringify({ install: { cache: false, registry: root_url + "/", saveTextLockfile: true, linker: "hoisted", ...bunfigExtra } }), + ); + await mkdir(join(package_dir, "apps", "desktop"), { recursive: true }); + await mkdir(join(package_dir, "apps", "web"), { recursive: true }); + await mkdir(join(package_dir, "packages", "shared"), { recursive: true }); + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ name: "root", private: true, workspaces: ["apps/*", "packages/*"], dependencies: { bar: "0.0.2" } }), + ); + await writeFile( + join(package_dir, "apps", "desktop", "package.json"), + JSON.stringify({ name: "desktop", version: "1.0.0", ...desktopExtra, dependencies: { "@barn/moo": "0.1.0", shared: "workspace:*" } }), + ); + await writeFile(join(package_dir, "apps", "web", "package.json"), JSON.stringify({ name: "web", version: "1.0.0", dependencies: { bar: "0.0.2", qux: "0.0.2" } })); + await writeFile(join(package_dir, "packages", "shared", "package.json"), JSON.stringify({ name: "shared", version: "1.0.0", dependencies: { baz: "0.0.3" } })); +} + +for (const [label, desktopExtra, bunfigExtra] of [ + ["installConfig.hoistingLimits in the workspace's package.json", { installConfig: { hoistingLimits: "workspaces" } }, {}], + ["install.selfContainedWorkspaces in bunfig.toml", {}, { selfContainedWorkspaces: ["apps/desktop"] }], +] as const) { + it(`self-contained workspace via ${label}`, async () => { + await writeProject(desktopExtra, bunfigExtra); + const r = await install(package_dir); + expect(r.err).not.toContain("error:"); + expect(r.code).toBe(0); + + const desktopNm = join(package_dir, "apps", "desktop", "node_modules"); + // desktop's direct dep, its transitive dep, the workspace it depends on and *that* + // workspace's dep are all under apps/desktop/node_modules … + expect(await readdirSorted(desktopNm)).toEqual(["@barn", "bar", "baz", "shared"]); + expect(existsSync(join(desktopNm, "@barn", "moo", "package.json"))).toBeTrue(); + expect(readlinkSync(join(desktopNm, "shared"))).toContain("shared"); + // … as real files, not hardlinks into the cache + if (!isWindows) { + expect(statSync(join(desktopNm, "bar", "package.json")).nlink).toBe(1); + } + // even though `bar` also exists at the root for the root package / other workspaces + expect(existsSync(join(package_dir, "node_modules", "bar", "package.json"))).toBeTrue(); + // the other workspace hoists as usual + expect(existsSync(join(package_dir, "node_modules", "qux", "package.json"))).toBeTrue(); + expect(existsSync(join(package_dir, "apps", "web", "node_modules", "qux"))).toBeFalse(); + + // stable across a repeat / frozen install + const again = await install(package_dir, ["--frozen-lockfile"]); + expect(again.err).not.toContain("error:"); + expect(again.code).toBe(0); + expect(await readdirSorted(desktopNm)).toEqual(["@barn", "bar", "baz", "shared"]); + }); +} + +it("without either setting the workspace is hoisted normally", async () => { + await writeProject({}, {}); + expect((await install(package_dir)).code).toBe(0); + expect(existsSync(join(package_dir, "apps", "desktop", "node_modules"))).toBeFalse(); + expect(existsSync(join(package_dir, "node_modules", "@barn", "moo", "package.json"))).toBeTrue(); + expect(existsSync(join(package_dir, "node_modules", "baz", "package.json"))).toBeTrue(); +}); From 6fa7ed009e68fc88790c110d78c6b5b5af1905ab Mon Sep 17 00:00:00 2001 From: Samuel Attard <6634592+MarshallOfSound@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:25:29 +0000 Subject: [PATCH 2/3] review: record installConfig.hoistingLimits while parsing workspaces instead of re-reading manifests; only "workspaces" is recognized; clippy; test tweaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WorkspaceMap::Entry carries hoisting_limits (read where the workspace package.json is already parsed); Lockfile.self_contained_workspaces (name hashes, not serialized) is filled from it and carried through the diff and clean passes, so hoisting no longer touches the filesystem. * Only hoistingLimits: "workspaces" enables it — "dependencies" has different semantics in Yarn and is not claimed here. * try_get(): SAFETY comment; no std::fs. * test: describe.each, assert stderr before exit code. --- docs/pm/workspaces.mdx | 2 +- src/install/PackageManager.rs | 2 + .../PackageManager/install_with_manager.rs | 7 +++ src/install/lockfile.rs | 50 ++++++++++--------- src/install/lockfile/Package.rs | 5 ++ src/install/lockfile/Package/WorkspaceMap.rs | 17 +++++++ .../bun-workspaces-self-contained.test.ts | 14 +++--- 7 files changed, 67 insertions(+), 30 deletions(-) diff --git a/docs/pm/workspaces.mdx b/docs/pm/workspaces.mdx index 224310866e69..8605f151ae59 100644 --- a/docs/pm/workspaces.mdx +++ b/docs/pm/workspaces.mdx @@ -102,7 +102,7 @@ With the hoisted linker, dependencies shared by several workspaces are hoisted t `node_modules`. Some tools cannot follow that: Electron packagers and serverless bundlers walk, prune and repackage *one workspace's* `node_modules` and expect every dependency to be physically present under it. Mark such a workspace as self-contained, either in its own `package.json` -(compatible with Yarn's setting): +(the same key Yarn uses; only the `"workspaces"` value is recognized): ```json title="apps/desktop/package.json" icon="file-json" { diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index c2e7e8d01a22..0e31ead0455a 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -738,6 +738,8 @@ impl PackageManager { if p.is_null() { None } else { + // SAFETY: same as `get()` — non-null means `allocate_package_manager()` + // ran and the singleton lives for the process. Some(unsafe { &*p }) } } diff --git a/src/install/PackageManager/install_with_manager.rs b/src/install/PackageManager/install_with_manager.rs index 8fb503659cec..d22492bc7a0a 100644 --- a/src/install/PackageManager/install_with_manager.rs +++ b/src/install/PackageManager/install_with_manager.rs @@ -417,6 +417,13 @@ pub fn install_with_manager( lf.workspace_versions.insert(*key, version); } } + // …and which workspaces asked for a self-contained node_modules + { + lf.self_contained_workspaces.clear_retaining_capacity(); + for key in lockfile.self_contained_workspaces.keys() { + lf.self_contained_workspaces.put(*key, ())?; + } + } // Update patched dependencies { diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index eb763bf5b611..4a030ba7fa72 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -180,6 +180,11 @@ pub struct Lockfile { pub(crate) scripts: Scripts, pub(crate) workspace_paths: NameHashMap, pub workspace_versions: VersionHashMap, + /// Workspaces (by name hash) whose package.json declares + /// `installConfig.hoistingLimits = "workspaces"`; unioned with + /// `install.selfContainedWorkspaces` at hoist time. Not serialized: it is + /// re-derived from the manifests on every install. + pub self_contained_workspaces: ArrayHashMap, /// Optional because `trustedDependencies` in package.json might be an /// empty list or it might not exist @@ -684,6 +689,7 @@ impl Lockfile { self.trusted_dependencies = None; self.workspace_paths = NameHashMap::default(); self.workspace_versions = VersionHashMap::default(); + self.self_contained_workspaces = ArrayHashMap::default(); self.overrides = OverrideMap::default(); self.catalogs = CatalogMap::default(); self.patched_dependencies = PatchedDependenciesMap::default(); @@ -815,13 +821,17 @@ impl Lockfile { /// Does this tree id belong to a workspace (including workspace root)? /// TODO(dylan-conway) fix! /// Workspace packages whose node_modules must be self-contained: listed in - /// `install.selfContainedWorkspaces` (by path or name) or declaring yarn's - /// `"installConfig": { "hoistingLimits": "workspaces" | "dependencies" }`. + /// `install.selfContainedWorkspaces` (by path or name) or declaring + /// `"installConfig": { "hoistingLimits": "workspaces" }` in their manifest + /// (recorded in `self_contained_workspaces` while the workspaces were parsed). pub(crate) fn self_contained_workspace_ids(&self) -> Vec { - let mut out = Vec::new(); let configured: &[&[u8]] = crate::PackageManager::try_get() .map(|pm| pm.options.self_contained_workspaces) .unwrap_or(&[]); + if configured.is_empty() && self.self_contained_workspaces.count() == 0 { + return Vec::new(); + } + let mut out = Vec::new(); let buf = self.buffers.string_bytes.as_slice(); let pkgs = self.packages.slice(); for (i, res) in pkgs.items_resolution().iter().enumerate() { @@ -830,26 +840,13 @@ impl Lockfile { } let path = res.workspace().slice(buf); let name = pkgs.items_name()[i].slice(buf); - let mut yes = configured.iter().any(|c| { - let c = bun_core::strings::without_trailing_slash(c); - c == path || c == name - }); - if !yes { - // yarn compat: installConfig.hoistingLimits in the workspace's own manifest - let mut p = bun_paths::AutoAbsPath::init_top_level_dir(); - let _ = p.append(path); - let _ = p.append(b"package.json"); - if let Ok(bytes) = std::fs::read(std::path::Path::new(unsafe { - std::str::from_utf8_unchecked(p.slice()) - })) { - if let Some(i) = bun_core::strings::index_of(&bytes, b"\"hoistingLimits\"") { - let rest = &bytes[i + 16..bytes.len().min(i + 64)]; - yes = bun_core::strings::index_of(rest, b"\"workspaces\"").is_some() - || bun_core::strings::index_of(rest, b"\"dependencies\"").is_some(); - } - } - } - if yes { + let name_hash = pkgs.items_name_hash()[i]; + if self.self_contained_workspaces.contains(&name_hash) + || configured.iter().any(|c| { + let c = bun_core::strings::without_trailing_slash(c); + c == path || c == name + }) + { out.push(i as PackageID); } } @@ -1150,6 +1147,9 @@ impl Lockfile { new.workspace_versions.re_index()?; new.workspace_paths.re_index()?; } + for key in old.self_contained_workspaces.keys() { + new.self_contained_workspaces.put(*key, ())?; + } } // When you run `"bun add react" @@ -2048,6 +2048,7 @@ impl Lockfile { trusted_dependencies: None, workspace_paths: NameHashMap::default(), workspace_versions: VersionHashMap::default(), + self_contained_workspaces: ArrayHashMap::default(), overrides: OverrideMap::default(), catalogs: CatalogMap::default(), meta_hash: ZERO_HASH, @@ -2404,6 +2405,7 @@ impl Lockfile { workspace_versions: &mut self.workspace_versions, patched_dependencies: &mut self.patched_dependencies, trusted_dependencies: &mut self.trusted_dependencies, + self_contained_workspaces: &mut self.self_contained_workspaces, }, ) } @@ -2457,6 +2459,8 @@ pub(crate) struct LockfileFields<'a> { pub(crate) workspace_versions: &'a mut VersionHashMap, pub(crate) patched_dependencies: &'a mut PatchedDependenciesMap, pub(crate) trusted_dependencies: &'a mut Option, + pub(crate) self_contained_workspaces: + &'a mut ArrayHashMap, } // ──────────────────────────────────────────────────────────────────────────── diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index 1645be78d998..da4572aef92f 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -2911,6 +2911,11 @@ impl Package { .workspace_versions .put(external_name.hash, version)?; } + if entry.hoisting_limits { + lockfile + .self_contained_workspaces + .put(external_name.hash, ())?; + } } } } else { diff --git a/src/install/lockfile/Package/WorkspaceMap.rs b/src/install/lockfile/Package/WorkspaceMap.rs index 2f2d415ccd4d..2925b0b0e850 100644 --- a/src/install/lockfile/Package/WorkspaceMap.rs +++ b/src/install/lockfile/Package/WorkspaceMap.rs @@ -26,6 +26,9 @@ pub struct Entry { pub(crate) name: Box<[u8]>, pub(crate) version: Option>, pub(crate) name_loc: bun_ast::Loc, + /// `"installConfig": { "hoistingLimits": "workspaces" }` — this workspace's + /// node_modules must be self-contained (see `Lockfile::self_contained_workspaces`). + pub(crate) hoisting_limits: bool, } impl WorkspaceMap { @@ -67,6 +70,7 @@ impl WorkspaceMap { name: value.name, version: value.version, name_loc: value.name_loc, + hoisting_limits: value.hoisting_limits, }; Ok(()) } @@ -155,6 +159,17 @@ fn process_workspace_name( let entry = Entry { name: Box::<[u8]>::from(name), name_loc: name_expr.loc, + hoisting_limits: workspace_json + .root + .get(b"installConfig") + .and_then(|c| c.get(b"hoistingLimits")) + .and_then(|h| { + h.as_string_cloned(&scratch) + .ok() + .flatten() + .map(|s| s == b"workspaces") + }) + .unwrap_or(false), version: 'brk: { if let Some(version_expr) = workspace_json.root.get(b"version") { if let Some(version) = version_expr.as_string_cloned(&scratch)? { @@ -354,6 +369,7 @@ impl WorkspaceMap { name: workspace_entry.name, name_loc: workspace_entry.name_loc, version: workspace_entry.version, + hoisting_limits: workspace_entry.hoisting_limits, }, )?; } @@ -548,6 +564,7 @@ impl WorkspaceMap { name: workspace_entry.name, version: workspace_entry.version, name_loc: workspace_entry.name_loc, + hoisting_limits: workspace_entry.hoisting_limits, }, )?; } diff --git a/test/cli/install/bun-workspaces-self-contained.test.ts b/test/cli/install/bun-workspaces-self-contained.test.ts index c19e358e7dcb..942a3e43ef6e 100644 --- a/test/cli/install/bun-workspaces-self-contained.test.ts +++ b/test/cli/install/bun-workspaces-self-contained.test.ts @@ -1,5 +1,5 @@ import { spawn } from "bun"; -import { afterAll, afterEach, beforeAll, beforeEach, expect, it, setDefaultTimeout } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test"; import { existsSync, readlinkSync, statSync } from "fs"; import { mkdir, writeFile } from "fs/promises"; import { bunExe, bunEnv as env, isWindows, readdirSorted } from "harness"; @@ -63,11 +63,11 @@ async function writeProject(desktopExtra: object, bunfigExtra: object) { await writeFile(join(package_dir, "packages", "shared", "package.json"), JSON.stringify({ name: "shared", version: "1.0.0", dependencies: { baz: "0.0.3" } })); } -for (const [label, desktopExtra, bunfigExtra] of [ +describe.each([ ["installConfig.hoistingLimits in the workspace's package.json", { installConfig: { hoistingLimits: "workspaces" } }, {}], ["install.selfContainedWorkspaces in bunfig.toml", {}, { selfContainedWorkspaces: ["apps/desktop"] }], -] as const) { - it(`self-contained workspace via ${label}`, async () => { +] as const)("self-contained workspace via %s", (_label, desktopExtra, bunfigExtra) => { + it("gets a complete, physical node_modules while other workspaces still hoist", async () => { await writeProject(desktopExtra, bunfigExtra); const r = await install(package_dir); expect(r.err).not.toContain("error:"); @@ -95,11 +95,13 @@ for (const [label, desktopExtra, bunfigExtra] of [ expect(again.code).toBe(0); expect(await readdirSorted(desktopNm)).toEqual(["@barn", "bar", "baz", "shared"]); }); -} +}); it("without either setting the workspace is hoisted normally", async () => { await writeProject({}, {}); - expect((await install(package_dir)).code).toBe(0); + const r = await install(package_dir); + expect(r.err).not.toContain("error:"); + expect(r.code).toBe(0); expect(existsSync(join(package_dir, "apps", "desktop", "node_modules"))).toBeFalse(); expect(existsSync(join(package_dir, "node_modules", "@barn", "moo", "package.json"))).toBeTrue(); expect(existsSync(join(package_dir, "node_modules", "baz", "package.json"))).toBeTrue(); From afb2eb1f61ffcf9cc5c88bb8e9192e4c9b353408 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:28:07 +0000 Subject: [PATCH 3/3] [autofix.ci] apply automated fixes --- docs/pm/workspaces.mdx | 2 +- .../bun-workspaces-self-contained.test.ts | 34 +++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/docs/pm/workspaces.mdx b/docs/pm/workspaces.mdx index 8605f151ae59..54cf0f49053a 100644 --- a/docs/pm/workspaces.mdx +++ b/docs/pm/workspaces.mdx @@ -100,7 +100,7 @@ Workspaces have a few major benefits. With the hoisted linker, dependencies shared by several workspaces are hoisted to the root `node_modules`. Some tools cannot follow that: Electron packagers and serverless bundlers walk, -prune and repackage *one workspace's* `node_modules` and expect every dependency to be physically +prune and repackage _one workspace's_ `node_modules` and expect every dependency to be physically present under it. Mark such a workspace as self-contained, either in its own `package.json` (the same key Yarn uses; only the `"workspaces"` value is recognized): diff --git a/test/cli/install/bun-workspaces-self-contained.test.ts b/test/cli/install/bun-workspaces-self-contained.test.ts index 942a3e43ef6e..aade28e7bc87 100644 --- a/test/cli/install/bun-workspaces-self-contained.test.ts +++ b/test/cli/install/bun-workspaces-self-contained.test.ts @@ -46,25 +46,47 @@ async function install(cwd: string, args: string[] = []) { async function writeProject(desktopExtra: object, bunfigExtra: object) { await writeFile( join(package_dir, "bunfig.toml"), - Bun.TOML.stringify({ install: { cache: false, registry: root_url + "/", saveTextLockfile: true, linker: "hoisted", ...bunfigExtra } }), + Bun.TOML.stringify({ + install: { cache: false, registry: root_url + "/", saveTextLockfile: true, linker: "hoisted", ...bunfigExtra }, + }), ); await mkdir(join(package_dir, "apps", "desktop"), { recursive: true }); await mkdir(join(package_dir, "apps", "web"), { recursive: true }); await mkdir(join(package_dir, "packages", "shared"), { recursive: true }); await writeFile( join(package_dir, "package.json"), - JSON.stringify({ name: "root", private: true, workspaces: ["apps/*", "packages/*"], dependencies: { bar: "0.0.2" } }), + JSON.stringify({ + name: "root", + private: true, + workspaces: ["apps/*", "packages/*"], + dependencies: { bar: "0.0.2" }, + }), ); await writeFile( join(package_dir, "apps", "desktop", "package.json"), - JSON.stringify({ name: "desktop", version: "1.0.0", ...desktopExtra, dependencies: { "@barn/moo": "0.1.0", shared: "workspace:*" } }), + JSON.stringify({ + name: "desktop", + version: "1.0.0", + ...desktopExtra, + dependencies: { "@barn/moo": "0.1.0", shared: "workspace:*" }, + }), + ); + await writeFile( + join(package_dir, "apps", "web", "package.json"), + JSON.stringify({ name: "web", version: "1.0.0", dependencies: { bar: "0.0.2", qux: "0.0.2" } }), + ); + await writeFile( + join(package_dir, "packages", "shared", "package.json"), + JSON.stringify({ name: "shared", version: "1.0.0", dependencies: { baz: "0.0.3" } }), ); - await writeFile(join(package_dir, "apps", "web", "package.json"), JSON.stringify({ name: "web", version: "1.0.0", dependencies: { bar: "0.0.2", qux: "0.0.2" } })); - await writeFile(join(package_dir, "packages", "shared", "package.json"), JSON.stringify({ name: "shared", version: "1.0.0", dependencies: { baz: "0.0.3" } })); } describe.each([ - ["installConfig.hoistingLimits in the workspace's package.json", { installConfig: { hoistingLimits: "workspaces" } }, {}], + [ + "installConfig.hoistingLimits in the workspace's package.json", + { installConfig: { hoistingLimits: "workspaces" } }, + {}, + ], ["install.selfContainedWorkspaces in bunfig.toml", {}, { selfContainedWorkspaces: ["apps/desktop"] }], ] as const)("self-contained workspace via %s", (_label, desktopExtra, bunfigExtra) => { it("gets a complete, physical node_modules while other workspaces still hoist", async () => {