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
97 changes: 97 additions & 0 deletions .nix/scripts/canonicalize-node-modules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Canonicalize bun's internal node_modules symlinks for reproducible FOD hashes.
*
* Isolated-install layout produced by `bun install --linker=isolated`:
*
* node_modules/
* ├── react → .bun/react@18.3.1/node_modules/react (symlink)
* ├── minimatch → .bun/minimatch@3.1.2/node_modules/minimatch (symlink)
* └── .bun/
* ├── react@18.3.1/
* │ └── node_modules/
* │ └── react/ ← real package content
* ├── minimatch@3.1.2/
* │ └── node_modules/
* │ └── minimatch/ ← real package content
* └── node_modules/ ← target of this script
* ├── react → ../react@18.3.1/node_modules/react
* ├── minimatch → ../minimatch@3.1.2/node_modules/minimatch
* └── @babel/
* └── core → ../../@babel+core@7.28.5+…/node_modules/@babel/core
*
* Real package content lives in .bun/<pkg>@<ver>/node_modules/<pkg>/.
* The .bun/node_modules/ directory (linkRoot) holds only symlinks — it acts
* as a fallback upward-resolution path for packages inside .bun/.
*
* Bun's creation order for those symlinks is not guaranteed to be stable
* across hosts or filesystems, which can break fixed-output derivation hashes.
* This script reads the existing symlinks, removes them, and recreates them
* in lexicographic order while preserving the exact targets bun picked.
*/

import { lstat, mkdir, readdir, readlink, rm, symlink } from "fs/promises";
import { join } from "path";

type LinkEntry = {
slug: string;
target: string;
};

async function isDirectory(path: string) {
try {
const info = await lstat(path);
return info.isDirectory();
} catch {
return false;
}
}

async function collectLinks(dir: string, prefix: string): Promise<LinkEntry[]> {
const result: LinkEntry[] = [];
const names = await readdir(dir);
for (const name of names) {
const full = join(dir, name);
const info = await lstat(full);
if (info.isSymbolicLink()) {
const target = await readlink(full);
const slug = prefix ? `${prefix}/${name}` : name;
result.push({ slug, target });
} else if (info.isDirectory() && !prefix && name.startsWith("@")) {
result.push(...(await collectLinks(full, name)));
}
}
return result;
}

export async function canonicalizeNodeModules(): Promise<void> {
const root = process.cwd();
const linkRoot = join(root, "node_modules/.bun/node_modules");

if (!(await isDirectory(linkRoot))) {
console.log(
"[canonicalize-node-modules] no .bun/node_modules directory, skipping",
);
return;
}

const entries = await collectLinks(linkRoot, "");
entries.sort((a, b) => a.slug.localeCompare(b.slug));

await rm(linkRoot, { recursive: true, force: true });
await mkdir(linkRoot, { recursive: true });

for (const { slug, target } of entries) {
const parts = slug.split("/");
const leaf = parts.pop();
if (!leaf) continue;
const parent = join(linkRoot, ...parts);
await mkdir(parent, { recursive: true });
await symlink(target, join(parent, leaf));
}

console.log(`[canonicalize-node-modules] rebuilt ${entries.length} links`);
}

if (import.meta.main) {
await canonicalizeNodeModules();
}
229 changes: 229 additions & 0 deletions .nix/scripts/heal-peer-dep-bins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/**
* Heal missing `.bin/<peer>` symlinks produced by bun's isolated installer.
*
* ## The bug
*
* Bun's `--linker=isolated` installer creates `.bin/<name>` symlinks inside
* each package's private node_modules/.bin/ for every dependency that has a
* `bin` field in its manifest — regular, optional, AND peer dependencies all
* go through the same code path (`Installer.zig::linkDependencyBins`), and
* the decision to link is made purely on whether the source file exists on
* disk at the moment the linker looks (`bin.zig`:
*
* if (!bun.sys.exists(abs_target)) {
* this.skipped_due_to_missing_bin = true;
* return;
* }
*
* ). For most dependencies the installer blocks the consuming package on the
* provider via `isTaskBlocked`, so by the time `linkDependencyBins` runs for
* package A the provider's file is guaranteed to be in place. But for
* circular peer dependency pairs — A declares B as a peer, B (transitively)
* depends on A — that blocking would deadlock, so bun's `Store.isCycle`
* detector explicitly bypasses it and lets both sides run in parallel.
*
* The consequence is a plain timing race between two worker threads. Which
* side wins depends on anything that shifts the relative scheduling of the
* two workers — CPU load, thread-pool size, filesystem write latency and
* caching, the kernel scheduler, NICE / cgroup limits — so the same bun
* version with the same bun.lock and the same install flags can produce
* different `.bin/` sets not just between different hosts but in principle
* between two consecutive runs on the same host. In practice we have
* observed divergence between a local NixOS sandbox, a GitHub Actions
* ubuntu-latest runner, and a GitHub Actions macos-latest runner, which is
* enough to break any single-hash FOD.
*
* Concretely, the Handy install hits this with
* - update-browserslist-db/.bin/browserslist (update-browserslist-db
* declares browserslist as a peer, browserslist has
* update-browserslist-db in its regular dependencies → cycle), and
* - @eslint-community/eslint-utils/.bin/eslint (eslint has eslint-utils
* as a regular dep, eslint-utils declares eslint as a peer → cycle).
*
* There is no bun configuration flag or env var that makes the outcome
* deterministic, and no upstream issue yet tracks this specific symptom
* (oven-sh/bun#28147 is the closest family match, different project). See
* the header of `canonicalize-node-modules.ts` for our sibling normalization
* pass that rebuilds `.bun/node_modules/` in sorted order.
*
* ## The fix this script applies
*
* For every package under `node_modules/.bun/` we walk its declared
* `peerDependencies`, find each resolved peer inside the package's private
* `node_modules/`, and create any `.bin/<name> → ../<peerName>/<path>`
* symlinks that bun's installer "intended" to create but may have skipped.
* Entries that already exist are left alone (the script is idempotent).
*
* This is the "fix by adding" approach — we produce the complete `.bin/`
* set that bun would have produced without the race, rather than stripping
* the inconsistent subset. Advantages:
*
* - Matches bun's intended behavior; if bun ever fixes the race upstream,
* this script becomes a no-op (every entry it would add already exists)
* and the FOD hash is unchanged.
* - Preserves `.bin/` entries that real code might depend on. We don't
* rely on the (true but brittle) argument that peer-dep `.bin/` entries
* are dead code in Tauri apps.
* - Easy to explain in review: we're patching a known upstream race bug
* with the exact output the upstream code is trying to produce.
*/

import { lstat, mkdir, readdir, readlink, symlink } from "fs/promises";
import { join } from "path";

type Manifest = {
name?: string;
bin?: string | Record<string, string>;
peerDependencies?: Record<string, string>;
};

async function isDirectory(path: string) {
try {
const info = await lstat(path);
return info.isDirectory();
} catch {
return false;
}
}

async function readManifest(path: string): Promise<Manifest | null> {
const file = Bun.file(path);
if (!(await file.exists())) return null;
return (await file.json()) as Manifest;
}

// Parse a .bun entry directory name (e.g. "@babel+core@7.28.5+a1c3dd1b9adf390b")
// back into an npm package name ("@babel/core"). Returns null for entries that
// do not look like <name>@<version>[+<peer-hash>].
function parsePkgName(bunEntry: string): string | null {
const at = bunEntry.startsWith("@")
? bunEntry.indexOf("@", 1)
: bunEntry.indexOf("@");
if (at <= 0) return null;
return bunEntry.slice(0, at).replace(/\+/g, "/");
}

// Unscoped name used as the default bin name when `bin` is a bare string.
// For "@scope/pkg" returns "pkg"; for "pkg" returns "pkg".
function defaultBinName(pkgName: string): string {
const slash = pkgName.lastIndexOf("/");
return slash >= 0 ? pkgName.slice(slash + 1) : pkgName;
}

type BinSpec = { name: string; path: string };

function parseBinField(pkgName: string, binField: Manifest["bin"]): BinSpec[] {
if (!binField) return [];
if (typeof binField === "string") {
return [{ name: defaultBinName(pkgName), path: binField }];
}
return Object.entries(binField).map(([name, path]) => ({
name: defaultBinName(name),
path,
}));
}

// Produce the relative symlink target that bun itself uses for a .bin entry
// sitting inside `.bun/<containing>/node_modules/.bin/`, pointing to a file
// under `.bun/<containing>/node_modules/<peerName>/...`.
function binTarget(peerName: string, binPath: string): string {
const clean = binPath.replace(/^\.\//, "");
return `../${peerName}/${clean}`;
}

type HealedEntry = {
containingEntry: string;
containingPkg: string;
peerName: string;
binName: string;
target: string;
};

export async function healPeerDepBins(): Promise<void> {
const root = process.cwd();
const bunRoot = join(root, "node_modules/.bun");

if (!(await isDirectory(bunRoot))) {
console.log("[heal-peer-dep-bins] no .bun directory, skipping");
return;
}

const bunEntries = (await readdir(bunRoot)).sort();
const healed: HealedEntry[] = [];

for (const entry of bunEntries) {
const pkgName = parsePkgName(entry);
if (!pkgName) continue;
const containingNodeModules = join(bunRoot, entry, "node_modules");
if (!(await isDirectory(containingNodeModules))) continue;

const manifest = await readManifest(
join(containingNodeModules, pkgName, "package.json"),
);
if (!manifest) continue;

const peers = Object.keys(manifest.peerDependencies ?? {});
if (peers.length === 0) continue;

const binRoot = join(containingNodeModules, ".bin");

for (const peerName of peers) {
// Peer may be optional and unresolved, or may not even be a real package
// directory in this install layout (e.g. bundled peer). Skip anything we
// cannot verify as "the peer's package.json is reachable from here".
const peerManifest = await readManifest(
join(containingNodeModules, peerName, "package.json"),
);
if (!peerManifest) continue;

const bins = parseBinField(peerName, peerManifest.bin);
if (bins.length === 0) continue;

// Ensure the bin directory exists (it may be absent entirely if bun's
// race skipped every link it would have created for this package).
await mkdir(binRoot, { recursive: true });

for (const bin of bins) {
const linkPath = join(binRoot, bin.name);
const target = binTarget(peerName, bin.path);

// Idempotent: skip if anything already occupies this path. We do not
// overwrite entries bun created; if bun already wrote a .bin/<name>,
// that is either the correct target (race won) or a close variant we
// should not second-guess.
try {
await lstat(linkPath);
continue;
} catch {
// Does not exist — fall through to create.
}

await symlink(target, linkPath);
healed.push({
containingEntry: entry,
containingPkg: pkgName,
peerName,
binName: bin.name,
target,
});
}
}
}

if (healed.length > 0) {
console.log(
`[heal-peer-dep-bins] healed ${healed.length} missing peer .bin entries:`,
);
for (const h of healed) {
console.log(
` ${h.containingEntry}/node_modules/.bin/${h.binName} → ${h.target} (peer ${h.peerName} of ${h.containingPkg})`,
);
}
} else {
console.log("[heal-peer-dep-bins] nothing to heal");
}
}

if (import.meta.main) {
await healPeerDepBins();
}
Loading
Loading