/**
* Node materializer — pnpm-style install layout for Node/Bare apps.
*
* Layout produced:
*
* apps/<name>/
* <rootTree files> ← symlinks into dependencies/<rootHash>/*
* node_modules/
* .pgh/
* <depHash>/ ← real dir. files HARDLINKED from
* <dep bytes> dependencies/<depHash>/* (zero byte dup)
* node_modules/ ← constructed: symlinks to sibling .pgh entries
* <dep-of-dep> → ../../<otherHash>/
* <depName> → .pgh/<depHash>/ ← convenience for app's direct deps
* @scope/<name> → ../.pgh/<depHash>/
*
* Why hardlinks (and not just symlinks): Node's default resolution walks
* up the REAL path. A hardlinked file's real path IS its hardlink path
* (hardlinks don't have a "target"). So walking up from
* `.pgh/<h-ov>/lib/foo.js` lands in `.pgh/<h-ov>/node_modules/` — which
* we constructed with the right deps. Transitive requires resolve
* correctly with plain `node` / `bare`.
*
* Dedup happens at the bytes layer: `dependencies/<hash>/` holds one
* copy per unique tree hash. Hardlinks in `.pgh/<hash>/` share inodes
* with that copy. Two apps using the same dep share identical bytes.
*
* Constraint: dependencies/ and apps/ must be on the same filesystem
* (hardlinks are single-fs). We control the store root, so this is
* guaranteed in practice.
*/
import fs from '#node:fs'
import path from '#node:path'
/**
* Build an app install from a rootTree + a flat list of dep trees.
*
* The `deps` list MUST carry the full transitive closure (publish's
* top-level node_modules walk captures this for npm-hoisted installs).
* Each dep's package.json is read to resolve its own internal deps
* against the closure.
*/
export function materialize ({ store, appName, rootTreeHash, deps, host }) {
if (!store.hasTree(rootTreeHash)) {
throw new Error('rootTree not in store: ' + rootTreeHash)
}
const installPath = store.appPath(appName)
if (safeLstat(installPath)) {
throw new Error(installPath + ' already exists — remove before re-install')
}
fs.mkdirSync(installPath, { recursive: true })
const nodeModulesPath = path.join(installPath, 'node_modules')
const pghDir = path.join(nodeModulesPath, '.pgh')
fs.mkdirSync(pghDir, { recursive: true })
// Track every hash this app references so we can atomically replace
// the app's ref set at the end. Old behavior was per-dep addRef which
// accumulated stale entries across versions (a hash referenced by an
// old version stayed referenced after the new version replaced it),
// making gc unable to clean up. setAppRefs at the end of materialize
// is the canonical replacement.
const referenced = new Set()
// Version-aware closure index: dep name → [{version, tree}, …].
// CRITICAL: the closure can carry MULTIPLE versions of the same package
// (e.g. compact-encoding 2.19.2 AND 3.2.0 — the former for the older
// hypercore/dht line, the latter for hypercore@11.33.1 + autobase +
// protomux@3.11.0). A flat name→hash map collapses these to one
// (last-writer-wins), so every dependent gets the SAME ce regardless of
// what it requires — which is how protomux@3.11.0 ended up paired with
// ce@2.19.2 (no optionalBuffer → crash) and protomux@3.10.3 with ce@3.2.0.
// Keep every version and resolve each edge against the requirer's range.
const closureByName = new Map()
for (const dep of deps) {
if (!dep.tree) continue
if (!closureByName.has(dep.name)) closureByName.set(dep.name, [])
closureByName.get(dep.name).push({ version: dep.version || null, tree: dep.tree })
}
const pick = (name, range) => pickHash(closureByName, name, range)
// Two-pass materialization.
//
// Pass 1 — create every dep's scratch dir via hardlink. This MUST
// complete for ALL deps before Pass 2 runs, because Pass 2 (wiring
// each dep's internal node_modules/ to sibling scratch dirs) depends
// on sibling scratch dirs already existing. Before the split, Pass 2
// ran per-dep inside Pass 1, so any sibling processed AFTER the
// current dep had no scratch dir yet and the symlink was silently
// skipped (manifest symptom: `Cannot find package 'ready-resource'`
// from hyper-svc, whose scratch dir got symlinks only for the one
// dep that happened to come before it in the deps array, b4a).
for (const dep of deps) {
if (!dep.tree) continue
if (!store.hasTree(dep.tree)) {
throw new Error('dep tree missing from store: ' + dep.name + ' (' + dep.tree.slice(0, 12) + ')')
}
const depScratch = path.join(pghDir, dep.tree)
if (!fs.existsSync(depScratch)) {
hardlinkTree(store.treePath(dep.tree), depScratch)
}
// Per-arch prebuild overlay — co-located with the base tree's scratch
// dir, so it's part of Pass 1 too (no sibling dependency).
if (dep.prebuilds && host && dep.prebuilds[host]) {
const archHash = dep.prebuilds[host]
if (store.hasTree(archHash)) {
const archDst = path.join(depScratch, 'prebuilds', host)
if (!fs.existsSync(archDst)) {
hardlinkTree(store.treePath(archHash), archDst)
}
referenced.add(archHash)
}
}
referenced.add(dep.tree)
}
// Pass 2 — wire each dep's internal node_modules/ with symlinks to
// sibling scratch dirs. Safe now because every sibling dir exists.
for (const dep of deps) {
if (!dep.tree) continue
const depScratch = path.join(pghDir, dep.tree)
wireDepDeps(depScratch, pick, pghDir)
}
// Top-level symlinks: node_modules/<depName> → .pgh/<depHash>/ for
// EVERY dep in the closure (not just the app's direct deps).
//
// Rationale — npm's flat-hoisting model. Many published packages in
// the wild have undeclared runtime deps (imported in code but absent
// from package.json); they work under npm because the flat layout
// means Node's walk-up from `<pkg>/index.js` finds the undeclared
// dep hoisted at the top-level. Example: blind-pairing@2.3.1 requires
// 'protomux' and 'compact-encoding' but declares neither.
//
// Our pnpm-style per-dep node_modules only wires DECLARED deps, so
// these undeclared imports fail. Replicating npm's flat layout at the
// app's top-level restores Node's walk-up fallback: declared deps
// resolve via the .pgh/<hash>/node_modules/ layer first, undeclared
// ones fall through to the flat top-level. Same behavior as a normal
// `npm install`-produced node_modules tree.
//
// Cost: ~N symlink inodes (cheap). No content duplication — symlinks
// point at the shared scratch dirs.
//
// Version choice: for the ROOT app's DECLARED deps, hoist the version
// satisfying the root's own range (so the app's direct `require`s get the
// right major); for UNDECLARED deps (the flat-hoist fallback above) hoist
// the highest version present. Declared transitive edges already resolved
// correctly via the per-dep .pgh/<hash>/node_modules wiring (wireDepDeps),
// which Node consults before falling through to this top level.
const rootRanges = readDirectDeps(path.join(store.treePath(rootTreeHash), 'package.json'))
for (const depName of closureByName.keys()) {
const hash = Object.prototype.hasOwnProperty.call(rootRanges, depName)
? pick(depName, rootRanges[depName])
: pick(depName, '*')
if (!hash) continue
const depScratch = path.join(pghDir, hash)
if (!fs.existsSync(depScratch)) continue
const topLink = path.join(nodeModulesPath, depName)
if (depName.includes('/')) fs.mkdirSync(path.dirname(topLink), { recursive: true })
const relTarget = path.relative(path.dirname(topLink), depScratch)
try { fs.symlinkSync(relTarget, topLink) } catch (err) {
// Benign: existing link (e.g. from a previous stage of same install)
if (err.code !== 'EEXIST') throw err
}
}
// Finally, wire top-level app files from the rootTree into installPath
// as HARDLINKS (not symlinks). Hardlinks preserve Node's default
// real-path resolution: when the installed app's code does
// `require('b4a')`, Node walks UP from the file's real path. With
// hardlinks the real path IS the hardlink path (apps/<name>/...), so
// the walk finds `apps/<name>/node_modules/` — which we constructed
// above with the dep symlinks. Symlinks here would instead resolve
// through to `dependencies/<rootHash>/...` which has no adjacent
// node_modules, breaking require() entirely.
//
// Byte-dedup across installs is preserved because hardlinks share
// inodes with the store's rootTree dir — no content duplication.
const rootTarget = store.treePath(rootTreeHash)
for (const entry of fs.readdirSync(rootTarget, { withFileTypes: true })) {
if (entry.name === 'node_modules') continue // defensive; publish strips it
const src = path.join(rootTarget, entry.name)
const dst = path.join(installPath, entry.name)
if (entry.isDirectory()) {
hardlinkTree(src, dst)
} else if (entry.isSymbolicLink()) {
fs.symlinkSync(fs.readlinkSync(src), dst)
} else if (entry.isFile()) {
linkOrCopy(src, dst)
}
}
referenced.add(rootTreeHash)
// Atomic per-app ref swap. Replaces every prior reference held by
// appName with the exact set this materialize produced — defeats the
// legacy add-only leak across version upgrades.
store.setAppRefs(appName, referenced)
// `referenced` is returned so an atomic install swap (which materializes
// under a scratch app name) can re-key the refs to the real install name.
return { installPath, rootTarget, depCount: deps.filter(d => d.tree).length, referenced }
}
/**
* Tear down an app install. Decrements refcounts; GC reclaims unreferenced
* trees later.
*/
export function unmaterialize ({ store, appName }) {
const installPath = store.appPath(appName)
if (!fs.existsSync(installPath)) return { removed: false }
const referenced = new Set()
// rootTreeHash — read from refs.json. We no longer symlink top-level
// files (we hardlink them now, which is more correct for Node's
// resolver but means the rootHash can't be recovered by reading a
// symlink). Look it up from the refs registry instead.
const allRefs = store.listAllRefs()
for (const [hash, apps] of Object.entries(allRefs)) {
if (apps.includes(appName)) referenced.add(hash)
}
// node_modules/.pgh/<hash>/ — every hardlinked scratch dir is a referenced tree
const pghDir = path.join(installPath, 'node_modules', '.pgh')
try {
for (const entry of fs.readdirSync(pghDir)) {
if (entry.length === 64) referenced.add(entry) // hex hash
}
} catch {}
fs.rmSync(installPath, { recursive: true, force: true })
for (const hash of referenced) store.removeRef(hash, appName)
return { removed: true, referenced: [...referenced] }
}
// ── helpers ─────────────────────────────────────────────────────────
/**
* Reconstruct `src` at `dst` with HARDLINKS for files, regular dirs for
* dirs, symlinks passed through verbatim. Hardlinks require same fs.
*/
function hardlinkTree (src, dst) {
fs.mkdirSync(dst, { recursive: true })
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name)
const d = path.join(dst, entry.name)
if (entry.isDirectory()) {
hardlinkTree(s, d)
} else if (entry.isSymbolicLink()) {
fs.symlinkSync(fs.readlinkSync(s), d)
} else if (entry.isFile()) {
linkOrCopy(s, d)
}
}
}
/**
* Try hardlink first (same-inode byte dedup). Fall back to copy when:
* EXDEV — source + dest on different filesystems (e.g. user put
* ~/.local/share/store/ on a separate mount from the
* default app dir). Hardlinks can't span filesystems.
* EPERM — some sandboxed filesystems (Flatpak, certain CI runners)
* forbid hardlinks.
*
* On copy fallback, bytes are duplicated but the install still works.
* Byte-dedup within the same filesystem stays intact.
*/
function linkOrCopy (src, dst) {
// Hardlink for dedup, but fall back to copy whenever a hardlink is impossible
// OR unavailable. Critically, an OLD bundled bare-fs (< 4.7.0, e.g. a nested
// hyper-deploy/hyperident copy resolved in the closure) has NO linkSync at
// all, so `fs.linkSync` is undefined → a TypeError with no err.code. Without
// this guard that TypeError propagated mid-materialize and left a corrupt,
// half-written install. Treat "no linkSync" + the link-impossible error codes
// as copy-fallback; only genuinely unexpected errors propagate.
if (typeof fs.linkSync !== 'function') { fs.copyFileSync(src, dst); return }
try {
fs.linkSync(src, dst)
} catch (err) {
if (err.code === 'EXDEV' || err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ENOSYS' || err.code === 'ENOTSUP' || err.code === 'EMLINK') {
// EACCES — SELinux `link` denial on no-root Android nodes (celltron):
// sepolicy forbids link(2) in every unprivileged domain. Copy instead.
fs.copyFileSync(src, dst)
} else {
throw err
}
}
}
function wireDepDeps (depScratch, pick, pghDir) {
const pkgPath = path.join(depScratch, 'package.json')
if (!fs.existsSync(pkgPath)) return
const ranges = readDirectDeps(pkgPath) // { name: range }
const names = Object.keys(ranges)
if (names.length === 0) return
const nm = path.join(depScratch, 'node_modules')
fs.mkdirSync(nm, { recursive: true })
for (const depName of names) {
// Resolve THIS package's declared range to the matching closure version
// (not a globally-collapsed one). protomux@3.11.0 declaring ce ^3.0.0
// gets ce@3.2.0; a sibling declaring ^2.0.0 gets ce@2.19.2.
const hash = pick(depName, ranges[depName])
if (!hash) continue // external or not in closure — skip silently
const targetScratch = path.join(pghDir, hash)
if (!fs.existsSync(targetScratch)) continue
const linkPath = path.join(nm, depName)
if (depName.includes('/')) fs.mkdirSync(path.dirname(linkPath), { recursive: true })
// Relative link so the scratch dir stays portable within the install.
const relTarget = path.relative(path.dirname(linkPath), targetScratch)
try { fs.symlinkSync(relTarget, linkPath) } catch (err) {
if (err.code !== 'EEXIST') throw err
}
}
}
function readDirectDeps (pkgPath) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
return {
...(pkg.dependencies || {}),
...(pkg.peerDependencies || {})
}
} catch {
return {}
}
}
// ── version resolution ──────────────────────────────────────────────
//
// Resolve a (name, range) edge to a specific tree hash in the closure.
// Picks the HIGHEST closure version satisfying `range`; if none satisfies
// (or the range is non-semver: file:/git/workspace:/dist-tag), falls back to
// the highest version present. Single-version names short-circuit. This is
// what lets protomux@3.11.0 (ce ^3.0.0) → ce@3.2.0 while hypercore@11.28.1
// (ce ^2.11.0) → ce@2.19.2 inside the SAME closure — instead of every
// dependent collapsing onto one ce.
function pickHash (closureByName, name, range) {
const versions = closureByName.get(name)
if (!versions || versions.length === 0) return null
if (versions.length === 1) return versions[0].tree
const withParsed = versions
.map(v => ({ tree: v.tree, parsed: parseVersion(v.version) }))
.filter(v => v.parsed)
if (withParsed.length === 0) return versions[0].tree // unparseable — stable arbitrary
const matching = withParsed.filter(v => satisfies(v.parsed, range))
const pool = matching.length ? matching : withParsed
pool.sort((a, b) => compareParsed(b.parsed, a.parsed)) // highest first
return pool[0].tree
}
function parseVersion (v) {
if (!v || typeof v !== 'string') return null
const m = v.trim().replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/)
if (!m) return null
return [Number(m[1]), Number(m[2]), Number(m[3])]
}
function compareParsed (a, b) {
for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] - b[i] }
return 0
}
// Minimal semver-range satisfies for the operators that appear in this
// ecosystem: ^, ~, exact, *, x, >=, <=, >, <, =, and `||` unions. Non-semver
// ranges (file:, git, workspace:, npm:, dist-tags) return true so the caller
// falls back to the highest available version.
function satisfies (parsed, range) {
if (!range || typeof range !== 'string') return true
range = range.trim()
if (range === '' || range === '*' || range === 'x' || range === 'latest') return true
if (/^(file:|link:|git|github:|https?:|workspace:|npm:)/.test(range)) return true
if (range.includes('||')) return range.split('||').some(r => satisfies(parsed, r))
const m = range.match(/^(\^|~|>=|<=|>|<|=)?\s*v?(\d+)\.(\d+)\.(\d+)/)
if (!m) return true // unrecognised → don't exclude
const op = m[1] || '='
const want = [Number(m[2]), Number(m[3]), Number(m[4])]
const cmp = compareParsed(parsed, want)
switch (op) {
case '=': return cmp === 0
case '>=': return cmp >= 0
case '<=': return cmp <= 0
case '>': return cmp > 0
case '<': return cmp < 0
case '~': return cmp >= 0 && parsed[0] === want[0] && parsed[1] === want[1]
case '^':
if (cmp < 0) return false
if (want[0] > 0) return parsed[0] === want[0]
if (want[1] > 0) return parsed[0] === 0 && parsed[1] === want[1]
return parsed[0] === 0 && parsed[1] === 0 && parsed[2] === want[2]
default: return true
}
}
function hashFromTreePath (store, target) {
if (!target || !target.includes(store.dependenciesDir)) return null
return path.basename(target)
}
function safeLstat (p) {
try { fs.lstatSync(p); return true } catch { return false }
}
// Exported for unit tests (version-resolution is the load-bearing logic that
// keeps multi-version closures from collapsing onto a single dep version).
export { pickHash, satisfies, parseVersion, compareParsed }