Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/turborepo-devtools/src/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const RELEVANT_FILES: &[&str] = &[
"pnpm-lock.yaml",
"nub.lock",
"lock.yaml",
"bun.lock",
"bun.lockb",
"Cargo.toml",
"Cargo.lock",
Expand Down Expand Up @@ -225,6 +226,8 @@ mod tests {
assert!(is_relevant_file(Path::new("pnpm-workspace.yaml")));
assert!(is_relevant_file(Path::new("nub.lock")));
assert!(is_relevant_file(Path::new("lock.yaml")));
assert!(is_relevant_file(Path::new("bun.lock")));
assert!(is_relevant_file(Path::new("bun.lockb")));
assert!(is_relevant_file(Path::new("crates/app/Cargo.toml")));
assert!(is_relevant_file(Path::new("Cargo.lock")));
assert!(!is_relevant_file(Path::new("index.ts")));
Expand Down
56 changes: 51 additions & 5 deletions crates/turborepo-lockfiles/src/bun/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,20 +142,25 @@ pub(crate) enum LockfileVersion {
// `resolve_package`'s workspace-direct optimization checks
// `lockfile_version >= 1`, which V2 satisfies.
V2 = 2,
// V3 is stamped only when the `overrides` section contains nested rules,
// i.e. object values (`"parent@range": { "child": "range", ".": "range" }`).
// Everything else is identical to V2.
V3 = 3,
}

impl LockfileVersion {
#[allow(dead_code)]
pub(super) const LATEST: Self = Self::V3;

pub(super) fn from_i32(value: i32) -> Option<Self> {
match value {
0 => Some(Self::V0),
1 => Some(Self::V1),
2 => Some(Self::V2),
3 => Some(Self::V3),
_ => None,
}
}

#[allow(dead_code)]
pub(super) fn as_i32(self) -> i32 {
self as i32
}
Expand All @@ -178,7 +183,7 @@ pub struct BunLockfileData {
#[serde(default)]
pub(super) trusted_dependencies: Vec<String>,
#[serde(default)]
pub(super) overrides: Map<String, String>,
pub(super) overrides: Map<String, OverrideValue>,
#[serde(default)]
pub(super) catalog: Map<String, String>,
#[serde(default)]
Expand All @@ -188,12 +193,48 @@ pub struct BunLockfileData {
pub(super) patched_dependencies: Map<String, String>,
}

/// A value in the top-level `overrides` section.
///
/// Flat rules are strings (`"lodash": "4.17.21"`). A rule can also be an
/// object scoping overrides to the dependencies of one parent package
/// (`"webpack@^4": { "terser": "4.8.1" }`); inside such an object the special
/// `"."` key is a flat rule for the parent itself. Bun stamps a lockfile
/// containing object rules as version 3 but accepts them at any version.
/// Nested rules are materialized by Bun as nested lockfile keys
/// (`webpack/terser`), which the regular resolution path already follows, so
/// turbo only needs to carry them through unchanged and apply the `"."` entry.
#[derive(Debug, Deserialize, PartialEq, Clone, Serialize)]
#[serde(untagged)]
pub(crate) enum OverrideValue {
Version(String),
Nested(Map<String, Value>),
}

impl OverrideValue {
/// The version to use for a dependency on the package named by this
/// rule's key, if the rule specifies one.
pub(super) fn version(&self) -> Option<&str> {
match self {
Self::Version(version) => Some(version),
Self::Nested(rules) => rules.get(".").and_then(Value::as_str),
}
}
}

#[derive(Debug, Deserialize, PartialEq, Default, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WorkspaceEntry {
// Bun omits the root workspace's name when the root package.json has none.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub(super) name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) version: Option<String>,
// `bin` is a string or an object; the installer links workspace bins from
// the lockfile, so these must survive a prune.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) bin: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) bin_dir: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) dependencies: Option<Map<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
Expand All @@ -212,8 +253,10 @@ pub(crate) struct PackageEntry {
pub(super) registry: Option<String>,
// Present for all package types except root deps
pub(super) info: Option<PackageInfo>,
// Present on registry
// Registry/tarball: integrity. Git/github: the `.bun-tag` string.
pub(super) checksum: Option<String>,
// Git/github only: the optional 4th element pinning the packed checkout.
pub(super) integrity: Option<String>,
pub(super) root: Option<RootInfo>,
}

Expand Down Expand Up @@ -244,7 +287,10 @@ pub(crate) struct PackageInfo {
#[derive(Debug, Deserialize, PartialEq, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RootInfo {
pub(super) bin: Option<String>,
// A string or an object, like `bin` in package.json.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) bin: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) bin_dir: Option<String>,
}
impl PackageEntry {
Expand Down
134 changes: 125 additions & 9 deletions crates/turborepo-lockfiles/src/bun/de.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ use crate::bun::RootInfo;
// symlink -> [ "name@link:path", INFO ]
// folder -> [ "name@file:path", INFO ]
// workspace -> [ "name@workspace:path", INFO ]
// tarball -> [ "name@tarball", INFO ]
// local tarball -> [ "name@./path.tgz", INFO, integrity? ]
// remote tarball -> [ "name@https://host/path.tgz", INFO, integrity? ]
// root -> [ "name@root:", { bin, binDir } ]
// git -> [ "name@git+repo", INFO, .bun-tag string (TODO: remove this) ]
// github -> [ "name@github:user/repo", INFO, .bun-tag string (TODO: remove
// this) ]
// git -> [ "name@git+repo", INFO, .bun-tag string, integrity? ]
// github -> [ "name@github:user/repo", INFO, .bun-tag string, integrity? ]
impl<'de> Deserialize<'de> for PackageEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
Expand Down Expand Up @@ -65,6 +65,7 @@ impl<'de> Deserialize<'de> for PackageEntry {
info,
registry,
checksum: None,
integrity: None,
root,
});
}
Expand All @@ -88,17 +89,26 @@ impl<'de> Deserialize<'de> for PackageEntry {
info = vals.pop_front().and_then(val_to_info);
}

// Checksum is last
let checksum = vals.pop_front().and_then(|val| match val {
Vals::Str(sha) => Some(sha),
Vals::Info(_) => None,
});
let mut next_string = || {
vals.pop_front().and_then(|val| match val {
Vals::Str(s) => Some(s),
Vals::Info(_) => None,
})
};
let checksum = next_string();
// Only git/github entries carry a further element after the checksum.
let integrity = if is_git_or_github_package(&key) {
next_string()
} else {
None
};

Ok(Self {
ident: key,
info,
registry,
checksum,
integrity,
root: None,
})
}
Expand Down Expand Up @@ -170,6 +180,7 @@ mod test {
..Default::default()
}),
checksum: Some("sha".into()),
integrity: None,
root: None,
}
);
Expand All @@ -187,6 +198,7 @@ mod test {
}),
registry: None,
checksum: None,
integrity: None,
root: None,
}
);
Expand All @@ -203,6 +215,7 @@ mod test {
info: None,
registry: None,
checksum: None,
integrity: None,
}
);

Expand All @@ -220,6 +233,7 @@ mod test {
..Default::default()
}),
checksum: Some("24a971c".into()),
integrity: None,
root: None,
}
);
Expand All @@ -241,6 +255,7 @@ mod test {
..Default::default()
}),
checksum: Some("24a971c".into()),
integrity: None,
root: None,
}
);
Expand All @@ -259,6 +274,7 @@ mod test {
..Default::default()
}),
checksum: Some("abc123".into()),
integrity: None,
root: None,
}
);
Expand All @@ -277,6 +293,7 @@ mod test {
..Default::default()
}),
checksum: None,
integrity: None,
root: None,
}
);
Expand All @@ -290,6 +307,98 @@ mod test {
registry: None,
info: Some(PackageInfo::default()),
checksum: None,
integrity: None,
root: None,
}
);

fixture!(
workspace_with_bin,
WorkspaceEntry,
WorkspaceEntry {
name: "cli".into(),
bin: Some(json!({"cli": "bin/cli.js"})),
..Default::default()
}
);

fixture!(
workspace_with_bin_dir,
WorkspaceEntry,
WorkspaceEntry {
name: "scripts".into(),
bin_dir: Some("bin".into()),
..Default::default()
}
);

fixture!(
root_workspace_without_name,
WorkspaceEntry,
WorkspaceEntry {
dependencies: Some(
Some(("is-odd".to_string(), "3.0.1".to_string()))
.into_iter()
.collect()
),
..Default::default()
}
);

fixture!(
root_pkg_with_object_bin,
PackageEntry,
PackageEntry {
ident: "some-package@root:".into(),
root: Some(RootInfo {
bin: Some(json!({"some-package": "cli.js"})),
bin_dir: None,
}),
info: None,
registry: None,
checksum: None,
integrity: None,
}
);

fixture!(
root_pkg_without_bins,
PackageEntry,
PackageEntry {
ident: "some-package@root:".into(),
root: Some(RootInfo {
bin: None,
bin_dir: None,
}),
info: None,
registry: None,
checksum: None,
integrity: None,
}
);

fixture!(
git_pkg_with_integrity,
PackageEntry,
PackageEntry {
ident: "my-package@git+https://github.com/user/repo#abc123".into(),
registry: None,
info: Some(PackageInfo::default()),
checksum: Some("abc123".into()),
integrity: Some("sha512-integrity".into()),
root: None,
}
);

fixture!(
local_tarball_pkg,
PackageEntry,
PackageEntry {
ident: "bar@./vendor/bar-0.0.2.tgz".into(),
registry: None,
info: Some(PackageInfo::default()),
checksum: Some("sha512-bar".into()),
integrity: None,
root: None,
}
);
Expand All @@ -304,6 +413,13 @@ mod test {
#[test_case(json!(["@tanstack/react-store@github:TanStack/store#24a971c", "", {"dependencies": {"@tanstack/store": "0.7.0"}}, "24a971c"]), github_pkg_corrupted_input() ; "github package with corrupted 4-element input")]
#[test_case(json!(["@api/sdk@file:apps/api/.api/apis/sdk", {"dependencies": {"is-odd": "^3.0.1"}}]), file_pkg() ; "file package")]
#[test_case(json!(["my-pkg@link:../../local-pkg", {}]), link_pkg() ; "link package")]
#[test_case(json!({"name": "cli", "bin": {"cli": "bin/cli.js"}}), workspace_with_bin() ; "workspace entry with bin")]
#[test_case(json!({"name": "scripts", "binDir": "bin"}), workspace_with_bin_dir() ; "workspace entry with binDir")]
#[test_case(json!({"dependencies": {"is-odd": "3.0.1"}}), root_workspace_without_name() ; "root workspace entry without name")]
#[test_case(json!(["some-package@root:", {"bin": {"some-package": "cli.js"}}]), root_pkg_with_object_bin() ; "root package with object bin")]
#[test_case(json!(["some-package@root:", {}]), root_pkg_without_bins() ; "root package without bins")]
#[test_case(json!(["my-package@git+https://github.com/user/repo#abc123", {}, "abc123", "sha512-integrity"]), git_pkg_with_integrity() ; "git package with integrity")]
#[test_case(json!(["bar@./vendor/bar-0.0.2.tgz", {}, "sha512-bar"]), local_tarball_pkg() ; "local tarball package")]
fn test_deserialization<T: for<'a> Deserialize<'a> + PartialEq + std::fmt::Debug>(
input: serde_json::Value,
expected: &T,
Expand Down
29 changes: 25 additions & 4 deletions crates/turborepo-lockfiles/src/bun/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,18 @@ impl BunLockfile {
} else {
output.push_str(&format!(" \"{key}\": [{ident_json}],"));
}
} else if ident.is_root() {
// Root entries: [ident, { bin, binDir }]. Bun expects an object
// as the second element even when there are no bins.
let ident_json = serde_json::to_string(&entry.ident)?;
let root_json = match &entry.root {
Some(root) => serde_json::to_string(root)?,
None => "{}".to_string(),
};
let root_json_spaced = self.format_info_json(&root_json);
output.push_str(&format!(
" \"{key}\": [{ident_json}, {root_json_spaced}],"
));
} else if ident.is_local_package() || is_tarball_or_url_package(&entry.ident) {
let ident_json = serde_json::to_string(&entry.ident)?;
let info_json =
Expand Down Expand Up @@ -192,10 +204,19 @@ impl BunLockfile {
// GitHub and git packages have 3 elements (no registry)
// npm packages have 4 elements (with registry)
if is_git_or_github_package(&entry.ident) {
// GitHub/git packages: [ident, info, checksum] - 3 elements
output.push_str(&format!(
" \"{key}\": [{ident_json}, {info_json_spaced}, {checksum_json}],",
));
// GitHub/git packages: [ident, info, bun-tag, integrity?]
match &entry.integrity {
Some(integrity) => {
let integrity_json = serde_json::to_string(integrity)?;
output.push_str(&format!(
" \"{key}\": [{ident_json}, {info_json_spaced}, \
{checksum_json}, {integrity_json}],",
));
}
None => output.push_str(&format!(
" \"{key}\": [{ident_json}, {info_json_spaced}, {checksum_json}],",
)),
}
} else {
// npm packages: [ident, registry, info, checksum] - 4 elements
let registry_json =
Expand Down
Loading
Loading