Skip to content
Draft
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
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@
- [`fetchBunDeps`](./building-packages/fetchBunDeps.md)
- [`writeBunApplication`](./building-packages/writeBunApplication.md)
- [`writeBunScriptBin`](./building-packages/writeBunScriptBin.md)
- [Offline Peer Dependency Resolution](./peer-dependency-resolution.md)
- [V2 Update Guide](./v2-update-guide.md)
55 changes: 55 additions & 0 deletions docs/src/peer-dependency-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Offline Peer Dependency Resolution

Packages with peer dependencies — the Svelte ecosystem, React rendering libraries, and many others — require npm package _manifests_ at install time so bun can resolve those dependencies. Without manifests, `bun install` tries to contact the npm registry even when all tarballs are already cached, which fails inside the Nix sandbox and produces an error like:

```
error: ConnectionRefused downloading package manifest
```

Since v2.2.0, `bun2nix` synthesizes the manifest files bun needs, making peer-dependency-heavy packages build fully offline. This fixes [issue #71](https://github.com/nix-community/bun2nix/issues/71).

## At Generation Time

When you run `bun2nix` — for example via the `postinstall` hook right after `bun install` — it reconstructs each package's manifest metadata directly from `bun.lock` (no network access needed) and embeds it as a `manifest` attribute on each registry entry in the generated `bun.nix`:

```nix
"react-dom@19.2.7" = fetchurl {
url = "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz";
hash = "sha512-...";
} // {
manifest = {
tarballUrl = "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz";
dependencies = { "scheduler" = "^0.27.0"; };
peerDependencies = { "react" = "^19.2.7"; };
optionalDependencies = { };
optionalPeers = [ ];
bin = { };
os = [ ];
cpu = [ ];
hasInstallScript = false;
};
};
```

Packages resolved from a **non-default registry** — configured in a project-local `bunfig.toml` (`[install].registry`, `[install.scopes]`) or `.npmrc` (`registry=`, `@scope:registry=`) committed next to the lockfile — additionally carry a `registry` attribute inside the `manifest` block. `bun2nix` reads only those two project-local files (`.npmrc` overriding `bunfig.toml` per key): the offline `bun install` runs in the Nix sandbox where global config, environment variables, and CLI flags do not exist, so the manifest cache key must be derived from exactly the config bun will see there. A registry configured only in `~/.npmrc` or `BUN_CONFIG_REGISTRY` cannot work offline and is deliberately ignored.

## At Build Time

The Nix build is fully offline — no extra setup required.

[`fetchBunDeps`](./building-packages/fetchBunDeps.md) reads the `manifest` attributes from `bun.nix` and runs `cache-entry-creator manifest` to synthesize the binary `.npm` cache files bun looks for at resolve time. These files are merged into the dependency cache alongside the regular package tarballs.

The [install hook](./building-packages/hook.md) automatically exports `BUN_MANIFEST_CACHE=2`, which tells bun to use the on-disk manifest cache instead of hitting the network.

## Graceful Degradation

Manifests are reconstructed from `bun.lock`, not fetched, so npm-registry entries always carry a `manifest` attribute — there is no network step that can fail or get skipped. Only entries that aren't npm-registry packages (git, GitHub, tarball, workspace, and file dependencies) have no `manifest`.

Older `bun.nix` files generated before this feature, which have no `manifest` attributes at all, still build: `fetchBunDeps` synthesizes an empty manifest cache for them, matching the behavior of older releases.

Projects that do not have peer-dependency-heavy packages are unaffected.

## Limitations

- **Registry authentication is not covered.** The manifest cache key excludes credentials, so private registries key correctly, but fetching auth-gated tarballs still relies on `fetchBunDeps`'s `bunfigPath`/`npmrcPath` credential support.
- **Non-default registries must be configured in committed project-local config** (`bunfig.toml` or `.npmrc` next to `bun.lock`) — see above. Since `.npmrc` commonly holds tokens and is gitignored, prefer `bunfig.toml` for the registry URL itself.
3 changes: 3 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
imports = lib.pipe ./nix [
lib.filesystem.listFilesRecursive
(lib.filter (lib.hasSuffix ".nix"))
# Exclude fixture subdirectories — their bun.nix files are package
# expressions (called via pkgs.callPackage), not flake-parts modules.
(lib.filter (path: !lib.hasInfix "/fixture/" (toString path)))
];
}
);
Expand Down
12 changes: 11 additions & 1 deletion nix/bun2nix.nix
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,22 @@ in
pname = pkgInfo.name;
inherit (pkgInfo) version;

src = ../programs/bun2nix;
src = ../programs;

cargoLock = {
lockFile = finalAttrs.src + "/Cargo.lock";
};

cargoBuildFlags = [
"-p"
"bun2nix"
];

cargoTestFlags = [
"-p"
"bun2nix"
];

passthru = with config; {
inherit (mkDerivation) hook;
inherit writeBunScriptBin writeBunApplication;
Expand Down
10 changes: 8 additions & 2 deletions nix/bun2nix/bun2nix-js.nix
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@
pname = "bun2nix-js";
inherit (config.cargoTOML.package) version;

src = ../../programs/bun2nix;
src = ../../programs;

cargoLock = {
lockFile = finalAttrs.src + "/Cargo.lock";
};

# The `bun2nix` crate path-depends on the sibling `bun2nix-core`
# crate, so the build source must be the workspace root. The actual
# wasm/js build still runs inside the `bun2nix` crate directory.
bunRoot = "bun2nix";

bunDeps = final.bun2nix.fetchBunDeps {
bunNix = finalAttrs.src + "/bun.nix";
bunNix = finalAttrs.src + "/bun2nix/bun.nix";
};

nativeBuildInputs = with final; [
Expand All @@ -34,6 +39,7 @@
buildPhase = ''
runHook preBuild

cd "$bunRoot"
bun run build

runHook postBuild
Expand Down
12 changes: 11 additions & 1 deletion nix/cargo-toml.nix
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,15 @@ in
type = types.raw;
};

config.cargoTOML = builtins.fromTOML (builtins.readFile "${self}/programs/bun2nix/Cargo.toml");
config.cargoTOML =
let
crate = builtins.fromTOML (builtins.readFile "${self}/programs/bun2nix/Cargo.toml");
ws = builtins.fromTOML (builtins.readFile "${self}/programs/Cargo.toml");
in
crate
// {
package = crate.package // {
inherit (ws.workspace.package) version;
};
};
}
161 changes: 161 additions & 0 deletions nix/checks/lockfile-drift-detection.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Guards the hook's offline-install prep script (resolve-catalog.ts):
#
# 1. A bun.lock whose trustedDependencies / patchedDependencies drifted from
# package.json must fail fast with a diagnostic naming the drifted
# entries. Without the check, bun silently re-resolves the affected
# dependencies at install time and the drift surfaces as a wall of
# ConnectionRefused errors in the sandbox with no hint of the cause.
# 2. Sections that merely list the same entries in a different order must
# NOT trip the check.
# 3. `catalog:` references resolving to registry versions are rewritten to
# the exact version, but references resolving to non-registry specs
# (github:, git+, tarball URLs, file:) must be left alone — rewriting
# them registers as a changed spec and forces the same re-resolve.
#
# The drifted fixture deliberately contains no `catalog:` refs, pinning that
# the drift check runs for plain projects too, not only catalog users.
_: {
perSystem =
{ pkgs, ... }:
{
checks.lockfileDriftDetection = pkgs.stdenv.mkDerivation {
name = "lockfile-drift-detection";

dontUnpack = true;

nativeBuildInputs = [ pkgs.bun ];

buildPhase = ''
export HOME="$TMPDIR"
script=${../mk-derivation/resolve-catalog.ts}

run() {
rc=0
bun --config=/dev/null --no-install "$script" "$1" >log.txt 2>&1 || rc=$?
}

expect() {
if ! grep -qF "$1" log.txt; then
echo "expected output to contain: $1"
cat log.txt
exit 1
fi
}

# --- drifted: both sections disagree with package.json ---
mkdir drifted
cat > drifted/package.json <<'EOF'
{
"name": "fixture",
"version": "1.0.0",
"trustedDependencies": ["node-pty"],
"patchedDependencies": { "left-pad@1.3.0": "patches/left-pad.patch" }
}
EOF
cat > drifted/bun.lock <<'EOF'
{
"lockfileVersion": 1,
"workspaces": { "": { "name": "fixture" } },
"trustedDependencies": ["esbuild"],
"packages": {}
}
EOF

run drifted
if [ "$rc" -eq 0 ]; then
echo "drifted fixture: expected failure, got exit 0"
cat log.txt
exit 1
fi
expect "bun.lock is out of sync with package.json"
expect "missing from bun.lock: node-pty"
expect "only in bun.lock: esbuild"
expect "patchedDependencies differ for: left-pad@1.3.0"
echo "drifted fixture: failed with diagnostic, as intended"

# --- synced: same entries, different order ---
mkdir synced
cat > synced/package.json <<'EOF'
{
"name": "fixture",
"version": "1.0.0",
"trustedDependencies": ["b-pkg", "a-pkg"],
"patchedDependencies": { "x@1.0.0": "patches/x.patch" }
}
EOF
cat > synced/bun.lock <<'EOF'
{
"lockfileVersion": 1,
"workspaces": { "": { "name": "fixture" } },
"trustedDependencies": ["a-pkg", "b-pkg"],
"patchedDependencies": { "x@1.0.0": "patches/x.patch" },
"packages": {}
}
EOF

run synced
if [ "$rc" -ne 0 ]; then
echo "synced fixture: expected success, got exit $rc"
cat log.txt
exit 1
fi
echo "synced fixture: passed, ordering ignored"

# --- catalog: registry ref rewritten, non-registry ref preserved ---
mkdir catalog
cat > catalog/package.json <<'EOF'
{
"name": "fixture",
"version": "1.0.0",
"dependencies": {
"bar": "catalog:",
"foo": "catalog:"
}
}
EOF
cat > catalog/bun.lock <<'EOF'
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "fixture",
"dependencies": { "bar": "catalog:", "foo": "catalog:" }
}
},
"catalog": {
"bar": "^1.0.0",
"foo": "github:user/repo#abcdef"
},
"packages": {
"bar": ["bar@1.2.3", "", {}, "sha512-aaa"],
"foo": ["foo@github:user/repo#abcdef", {}, "abcdef"]
}
}
EOF

run catalog
if [ "$rc" -ne 0 ]; then
echo "catalog fixture: expected success, got exit $rc"
cat log.txt
exit 1
fi
if ! grep -qF '"bar": "1.2.3"' catalog/package.json; then
echo "catalog fixture: registry ref not rewritten to exact version"
cat catalog/package.json
exit 1
fi
if ! grep -qF '"foo": "catalog:"' catalog/package.json; then
echo "catalog fixture: non-registry ref was rewritten; must stay catalog:"
cat catalog/package.json
exit 1
fi
echo "catalog fixture: rewrite behavior correct"
'';

installPhase = ''
mkdir -p "$out"
echo "lockfileDriftDetection: PASS" > "$out/result"
'';
};
};
}
53 changes: 53 additions & 0 deletions nix/checks/non-default-registry-offline-install.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Regression guard for issue #71 (non-default registries): offline
# peer-dependency resolution for packages resolved from a non-default npm
# registry (https://registry.npmmirror.com, pinned via the fixture's committed
# bunfig.toml).
#
# bun.lock is EXCLUDED from the source, so bun must resolve peer deps at
# install time from the synthesized manifest cache. For a non-default
# registry bun looks up `<wyhash(name)>-<wyhash(registry_href)>.npm`, deriving
# the registry href from ./bunfig.toml inside the sandbox — so a green build
# proves the generation-time key (computed from the same committed config)
# matches byte-for-byte. If the registry-keyed manifests are absent or
# mis-keyed, bun falls back to the network, which the sandbox blocks → the
# build fails.
_: {
perSystem =
{ config, ... }:
{
checks.nonDefaultRegistryOfflineInstall = config.mkDerivation.function {
packageJson = ./non-default-registry-offline-install/fixture/package.json;

src = builtins.path {
path = ./non-default-registry-offline-install/fixture;
name = "non-default-registry-offline-install-fixture-src";
# Exclude node_modules (working-tree artefact) and bun.lock (forces
# manifest-cache resolution). bunfig.toml MUST stay included — bun
# derives the manifest-cache registry key from it in the sandbox.
filter =
path: _type:
let
base = builtins.baseNameOf path;
in
base != "node_modules" && base != "bun.lock";
};

bunDeps = config.fetchBunDeps.function {
bunNix = ./non-default-registry-offline-install/fixture/bun.nix;
};

# Skip lifecycle scripts and bun build — we only care that
# `bun install` resolves deps offline via the registry-keyed cache.
dontRunLifecycleScripts = true;

buildPhase = ''
echo "bun install resolved non-default-registry deps offline — manifest cache working"
'';

installPhase = ''
mkdir -p "$out"
echo "nonDefaultRegistryOfflineInstall: PASS" > "$out/result"
'';
};
};
}
20 changes: 20 additions & 0 deletions nix/checks/non-default-registry-offline-install/fixture/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading