OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / scripts / build-bootstrap.js

355 lines · javascript ref:
#!/usr/bin/env node
/**
 * build-bootstrap — package pgh into a per-arch installable tarball.
 *
 * Produces `pgh-bootstrap-<os>-<arch>.tar.gz` from the pgh package,
 * including pgh's own code, its bin scripts, and its hoisted
 * node_modules (pgh-runtime + hyper-overlay + transport core).
 *
 * The target size is < 50 MB — pgh's dep closure is small (content-
 * addressable store, CLI, and the bits pgh-runtime + hyper-overlay
 * bring in transitively). A bundle ~2 GB means something's wrong
 * with dep resolution (e.g. you're building from a pear-git-v3 tree
 * that's pulled in all 10 sibling file: deps). Run this from inside
 * the pgh/ package root, not from a parent that has more deps.
 *
 * Usage:
 *   node scripts/build-bootstrap.js                 # infers os+arch
 *   node scripts/build-bootstrap.js --os linux --arch x64
 *   node scripts/build-bootstrap.js --out /tmp/out  # override output dir
 */

import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import os from 'os'
import { fileURLToPath } from 'url'

const HERE = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.resolve(HERE, '..') // pgh package root

// Bare-runtime shim modules — Node-builtin alternatives only loaded
// under Bare. Safe to strip from a Node-targeted bundle. Keeps utility
// packages like bare-addon-resolve that are needed under both runtimes.
const BARE_SHIMS = new Set([
  'bare-fs', 'bare-path', 'bare-os', 'bare-process',
  'bare-crypto', 'bare-http1', 'bare-https', 'bare-http-parser',
  'bare-tls', 'bare-zlib', 'bare-tty', 'bare-readline', 'bare-repl',
  'bare-signals', 'bare-subprocess', 'bare-dgram', 'bare-dns',
  'bare-net', 'bare-tcp', 'bare-pipe', 'bare-timers', 'bare-events',
  'bare-buffer', 'bare-assert', 'bare-url', 'bare-env', 'bare-stream',
  'bare-inspect', 'bare-inspector', 'bare-format', 'bare-console',
  'bare-hrtime', 'bare-atomics', 'bare-async-hooks',
  'bare-debug-log', 'bare-logger'
])

// ── Args ──────────────────────────────────────────────────────

function getFlag (name, def) {
  const i = process.argv.indexOf('--' + name)
  if (i === -1 || !process.argv[i + 1]) return def
  return process.argv[i + 1]
}

function archLabel (platform, arch) {
  const osMap = { linux: 'linux', darwin: 'darwin', win32: 'win32' }
  const archMap = { x64: 'x64', arm64: 'arm64' }
  return (osMap[platform] || platform) + '-' + (archMap[arch] || arch)
}

const OS = getFlag('os', os.platform())
const ARCH = getFlag('arch', os.arch())
const LABEL = archLabel(OS, ARCH)
const OUT_DIR = path.resolve(getFlag('out', path.join(ROOT, 'dist')))
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
const TARBALL_NAME = `pgh-bootstrap-${LABEL}.tar.gz`
const STAGE_NAME = 'pgh-bootstrap'

console.log('Building pgh bootstrap tarball')
console.log('  target:  ' + LABEL)
console.log('  version: ' + VERSION)
console.log('  out:     ' + path.join(OUT_DIR, TARBALL_NAME))

// ── Dep-closure sanity check ─────────────────────────────────

// Invariants that prevent yesterday's udx-skew disaster. Using npm ls
// gives us "how many copies of X exist in the tree" — should be 1 for
// each of these or the bundle isn't safe to ship.
const INVARIANT_SINGLETONS = ['udx-native', 'hypercore', 'hyperswarm', 'corestore']

function checkSingletons () {
  for (const pkg of INVARIANT_SINGLETONS) {
    let out
    try {
      out = execSync('npm ls ' + pkg + ' --all 2>/dev/null', { cwd: ROOT, encoding: 'utf8' })
    } catch (err) {
      out = err.stdout || ''
    }
    // Count distinct version lines: `pkg@x.y.z`
    const matches = out.match(new RegExp('\\s' + pkg.replace(/[-/]/g, '[-/]') + '@([\\d.]+[^\\s]*)', 'g')) || []
    const versions = new Set(matches.map(m => m.trim()))
    if (versions.size === 0) {
      console.log('  ' + pkg + ': NOT PRESENT (ok if optional)')
    } else if (versions.size > 1) {
      console.error('  ' + pkg + ': MULTIPLE VERSIONS — ' + [...versions].join(', '))
      console.error('    This bundle would reproduce the yesterday-class udx-skew bug.')
      console.error('    Run `npm dedupe` or reconcile the dep tree before building.')
      process.exit(1)
    } else {
      console.log('  ' + pkg + ': ' + [...versions][0] + ' (single version ✓)')
    }
  }
}

console.log('Dep-closure sanity check:')
checkSingletons()

// ── Stage ─────────────────────────────────────────────────────

fs.mkdirSync(OUT_DIR, { recursive: true })
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-bootstrap-'))
const stageRoot = path.join(stage, STAGE_NAME)
fs.mkdirSync(stageRoot)

const INCLUDE = ['bin', 'lib', 'index.js']

// Names that should never appear in the bootstrap tarball — dev cruft,
// nested node_modules (handled separately), and test/doc dirs that
// only apply at development time. Critical for file: deps that are
// checked-out git repos (their .git can be huge — hyper-overlay's is
// 119 MB alone).
const COPY_SKIP = new Set([
  'node_modules',  // handled at the top level + pruned
  '.git',
  '.pear-git',
  '.github',
  '.gitattributes',
  '.gitignore',
  '.npmrc',
  '.npmignore',
  'test',
  'tests',
  '__tests__',
  'docs',
  'doc',
  'examples',
  'example',
  'bench',
  'benchmarks',
  'coverage',
  '.nyc_output',
  '.eslintrc',
  '.prettierrc',
  '.prettierignore',
  '.vscode',
  '.idea',
  'CHANGELOG.md',
  'HISTORY.md'
])

function copyRecursive (src, dest) {
  const stat = fs.lstatSync(src)
  if (stat.isDirectory()) {
    fs.mkdirSync(dest, { recursive: true })
    for (const name of fs.readdirSync(src)) {
      if (COPY_SKIP.has(name)) continue
      copyRecursive(path.join(src, name), path.join(dest, name))
    }
  } else if (stat.isSymbolicLink()) {
    const real = fs.realpathSync(src)
    copyRecursive(real, dest)
  } else {
    fs.mkdirSync(path.dirname(dest), { recursive: true })
    fs.copyFileSync(src, dest)
    try { fs.chmodSync(dest, stat.mode & 0o777) } catch {}
  }
}

for (const rel of INCLUDE) {
  const src = path.join(ROOT, rel)
  if (!fs.existsSync(src)) continue
  copyRecursive(src, path.join(stageRoot, rel))
  console.log('  + ' + rel)
}

// node_modules — always copy full, then prune.
if (!fs.existsSync(path.join(ROOT, 'node_modules'))) {
  console.error('  node_modules missing — run `npm install` in pgh/ first')
  process.exit(1)
}

copyRecursive(path.join(ROOT, 'node_modules'), path.join(stageRoot, 'node_modules'))
const bareSize = du(path.join(stageRoot, 'node_modules'))
console.log('  + node_modules (raw: ' + fmtBytes(bareSize) + ')')

// Prune 1: nested node_modules inside file:-resolved deps. npm resolves
// file: deps by copying the target dir wholesale, which means their own
// node_modules come along — even though npm has already hoisted those
// deps up to the top level. Remove the nested dups.
pruneNestedNodeModules(path.join(stageRoot, 'node_modules'))
const afterNested = du(path.join(stageRoot, 'node_modules'))
console.log('    after nested-node_modules prune: ' + fmtBytes(afterNested))

// Prune 2: prebuilds for other architectures. Native modules ship
// prebuilds for every supported platform; we only need the target.
prunePrebuilds(path.join(stageRoot, 'node_modules'), LABEL)
const afterPrebuilds = du(path.join(stageRoot, 'node_modules'))
console.log('    after prebuilds prune:          ' + fmtBytes(afterPrebuilds))

// Prune 3 (selective): bare-* modules that are STRICTLY Node-shim
// alternatives — conditionally loaded only when running under Bare via
// imports maps. Carefully curated — some bare-* modules are runtime
// utilities (e.g. bare-addon-resolve is used by require-addon for
// native binding resolution on both Node and Bare).
pruneBareShims(path.join(stageRoot, 'node_modules'))
const afterBare = du(path.join(stageRoot, 'node_modules'))
console.log('    after bare-shim prune:          ' + fmtBytes(afterBare))

// Trimmed package.json
const srcPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
const bootPkg = {
  name: STAGE_NAME,
  version: srcPkg.version,
  type: srcPkg.type,
  main: srcPkg.main,
  bin: srcPkg.bin,
  exports: srcPkg.exports,
  dependencies: srcPkg.dependencies,
  pghBootstrap: {
    os: OS,
    arch: ARCH,
    label: LABEL,
    builtAt: new Date().toISOString(),
    sourceVersion: srcPkg.version
  }
}
fs.writeFileSync(path.join(stageRoot, 'package.json'), JSON.stringify(bootPkg, null, 2))
console.log('  + package.json (trimmed)')

// ── Tar it up ────────────────────────────────────────────────

const outFile = path.join(OUT_DIR, TARBALL_NAME)
try {
  execSync(`tar -czf "${outFile}" -C "${stage}" ${STAGE_NAME}`, { stdio: 'inherit' })
} catch (err) {
  console.error('tar failed: ' + err.message)
  process.exit(1)
}

const size = fs.statSync(outFile).size
const sizeStr = fmtBytes(size)
console.log('\nDone: ' + outFile + ' (' + sizeStr + ')')

if (size > 50 * 1024 * 1024) {
  console.warn('\nWARNING: bundle is ' + sizeStr + ' — target is < 50 MB.')
  console.warn('Check npm ls for unexpected deps.')
}

// Clean up stage
fs.rmSync(stage, { recursive: true, force: true })

// Checksum
try {
  const sum = execSync(`sha256sum "${outFile}" 2>/dev/null || shasum -a 256 "${outFile}"`, { encoding: 'utf8' }).trim()
  fs.writeFileSync(outFile + '.sha256', sum + '\n')
  console.log('      ' + outFile + '.sha256')
} catch {
  // no sha256sum / shasum available
}

// ── Helpers ──────────────────────────────────────────────────

/**
 * Remove nested node_modules directories for deps resolved via file:
 * paths. When npm resolves "file:../foo", it copies foo/ wholesale —
 * including foo/node_modules. But npm also hoists those transitive
 * deps to the top-level node_modules, so the nested copies are pure
 * duplication. Safe to remove.
 */
function pruneNestedNodeModules (topNodeModules) {
  // Only prune nested node_modules whose parent is directly under the
  // top-level node_modules. Transitive nested node_modules inside
  // non-file: deps (rare, but happens for version conflicts) we leave
  // alone — npm had a reason to keep them distinct.
  const topEntries = fs.readdirSync(topNodeModules)
  for (const name of topEntries) {
    if (name.startsWith('.')) continue
    const pkgDir = path.join(topNodeModules, name)
    let stat
    try { stat = fs.statSync(pkgDir) } catch { continue }
    if (!stat.isDirectory()) continue

    const nested = path.join(pkgDir, 'node_modules')
    if (fs.existsSync(nested)) {
      fs.rmSync(nested, { recursive: true, force: true })
    }

    // Scoped packages (@scope/name): one more level deep
    if (name.startsWith('@')) {
      for (const sub of fs.readdirSync(pkgDir)) {
        const subNested = path.join(pkgDir, sub, 'node_modules')
        if (fs.existsSync(subNested)) {
          fs.rmSync(subNested, { recursive: true, force: true })
        }
      }
    }
  }
}

/**
 * Remove prebuilt binaries for non-target architectures. Native npm
 * modules ship prebuilds under prebuilds/<label>/ — dropping 12 of 13
 * arches shrinks the bundle dramatically.
 */
function prunePrebuilds (rootDir, targetLabel) {
  function visit (dir) {
    let entries
    try { entries = fs.readdirSync(dir) } catch { return }
    for (const name of entries) {
      const p = path.join(dir, name)
      let stat
      try { stat = fs.lstatSync(p) } catch { continue }
      if (!stat.isDirectory() || stat.isSymbolicLink()) continue

      if (name === 'prebuilds') {
        for (const arch of fs.readdirSync(p)) {
          if (arch !== targetLabel) {
            fs.rmSync(path.join(p, arch), { recursive: true, force: true })
          }
        }
      } else {
        visit(p)
      }
    }
  }
  visit(rootDir)
}

/**
 * Remove Bare-only shim modules (see BARE_SHIMS set at top of file).
 * Utility bare-* packages (bare-addon-resolve, etc.) are preserved
 * because require-addon uses them on Node too.
 */
function pruneBareShims (topNodeModules) {
  for (const name of fs.readdirSync(topNodeModules)) {
    if (BARE_SHIMS.has(name)) {
      fs.rmSync(path.join(topNodeModules, name), { recursive: true, force: true })
    }
  }
}

function du (dir) {
  try {
    return parseInt(execSync('du -sb ' + JSON.stringify(dir), { encoding: 'utf8' }).split(/\s+/)[0], 10) || 0
  } catch {
    return 0
  }
}

function fmtBytes (n) {
  if (n < 1024) return n + ' B'
  if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
  if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
  return (n / 1073741824).toFixed(2) + ' GB'
}