OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / materializers / node.js

420 lines · javascript ref:
/**
 * 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 }