OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-runtime / index.js

305 lines · javascript ref:
/**
 * pgh-runtime — per-app OTA updater.
 *
 * An installed app imports this module and instantiates a PghRuntime
 * with its own upgrade source, a store handle, and a fetcher. The
 * runtime periodically checks for a newer release and, when found,
 * materializes the new tree into the store and emits events. The app
 * decides when to actually apply the update (typically on next restart).
 *
 * Mirrors pear-runtime-updater's shape (~127 LOC) but uses hyper-overlay
 * tree content-addressing instead of Hyperdrive.
 *
 * Usage:
 *
 *   import { PghRuntime } from 'pgh-runtime'
 *   const runtime = new PghRuntime({
 *     name: 'my-app',
 *     currentTreeHash: process.env.PGH_CURRENT_TREE_HASH,
 *     fetcher,
 *     store,
 *     interval: 60 * 60 * 1000
 *   })
 *   runtime.on('updating', () => console.log('downloading...'))
 *   runtime.on('updated', (info) => console.log('ready:', info.newTreeHash))
 *   runtime.on('error', (err) => console.error(err))
 *   await runtime.start()
 *
 * Expected interfaces:
 *
 *   fetcher:
 *     async getLatestRelease(name) -> { treeHash, version, ... } | null
 *     async getTree(hash)          -> { entries: [...], size }
 *     async getContent(contentHash)-> Buffer
 *
 *   store (TreeStore-compatible):
 *     init()
 *     hasTree(hash) -> boolean
 *     materialize(hash, entries, { fetchContent, fetchTree, source? })
 *
 * The runtime is transport-agnostic — any fetcher conforming to that
 * shape works. See pgh's NetworkFetcher for the Hyperswarm implementation.
 */

import EventEmitter from '#node:events'

import { computeTreeHash } from 'hyper-overlay/tree'

const DEFAULT_INTERVAL = 30 * 60 * 1000 // 30 min

export class PghRuntime extends EventEmitter {
  /**
   * @param {object} opts
   * @param {string} opts.name                 app name
   * @param {string} [opts.currentTreeHash]    hex hash of the tree this app currently runs at
   * @param {object} opts.fetcher              { getTree, getContent, getLatestRelease(name) }
   * @param {object} opts.store                TreeStore-compatible (hasTree / materialize / init)
   * @param {number} [opts.interval]           ms between polls (default 30m; 0 = no polling)
   * @param {boolean} [opts.startImmediately]  do an initial check on start (default true)
   * @param {string} [opts.host]               host label for per-arch prebuild trees (e.g. "linux-x64")
   * @param {function} [opts.materialize]      injected materializer ({ store, appName, rootTreeHash, deps, host })
   *                                           — pgh's node materializer or any compatible fn. Omit for store-only.
   */
  constructor (opts = {}) {
    super()
    if (!opts.name) throw new Error('PghRuntime requires opts.name')
    if (!opts.fetcher) throw new Error('PghRuntime requires opts.fetcher')
    if (!opts.store) throw new Error('PghRuntime requires opts.store')

    this.name = opts.name
    this.currentTreeHash = opts.currentTreeHash || null
    this.fetcher = opts.fetcher
    this.store = opts.store
    this.interval = opts.interval != null ? opts.interval : DEFAULT_INTERVAL
    this.startImmediately = opts.startImmediately !== false
    this.host = opts.host || null
    this.materialize = typeof opts.materialize === 'function' ? opts.materialize : null

    this.latest = null
    this.updating = false
    this.updated = false
    this.newTreeHash = null

    this._timer = null
    this._started = false
  }

  async start () {
    if (this._started) return
    this._started = true
    if (this.startImmediately) await this._check()
    if (this.interval > 0) {
      this._timer = setInterval(() => { this._check().catch(e => this.emit('error', e)) }, this.interval)
      if (this._timer.unref) this._timer.unref()
    }
  }

  async stop () {
    if (!this._started) return
    this._started = false
    if (this._timer) { clearInterval(this._timer); this._timer = null }
  }

  /**
   * Force an immediate check. Returns the release entry if a new tree
   * was materialized, null otherwise.
   */
  async checkNow () {
    return this._check()
  }

  async _check () {
    let release
    try {
      release = await this.fetcher.getLatestRelease(this.name)
    } catch (err) {
      this.emit('error', err)
      return null
    }

    if (!release) return null

    // Nothing to do if we already run this tree.
    if (this.currentTreeHash && release.treeHash === this.currentTreeHash) {
      this.latest = release
      return null
    }
    // We've already staged this tree; don't re-emit.
    if (this.updated && release.treeHash === this.newTreeHash) {
      return null
    }

    this.latest = release
    this.updating = true
    this.emit('updating', release)

    try {
      // Full RELEASE install — root tree PLUS its dependency closure (and,
      // when a materializer is injected, the wired install layout). Using
      // installTree alone here fetched only the root tree → a broken install
      // for any app with deps (see installRelease).
      const result = await installRelease(this.store, release, this.fetcher, {
        host: this.host,
        materialize: this.materialize,
        appName: this.name,
        source: 'pgh-runtime:' + this.name
      })
      this.newTreeHash = release.treeHash
      this.updating = false
      this.updated = true
      this.emit('updated', {
        name: this.name,
        newTreeHash: release.treeHash,
        release,
        materialized: result.materialized,
        skipped: result.skipped
      })
      return release
    } catch (err) {
      this.updating = false
      this.emit('error', err)
      return null
    }
  }

  /**
   * Descriptor for the staged update. Apply via the app's own mechanism
   * (pgh app update <name> --tree <hash> is the usual CLI path).
   */
  readyToApply () {
    if (!this.updated) return null
    return {
      name: this.name,
      previousTreeHash: this.currentTreeHash,
      newTreeHash: this.newTreeHash,
      release: this.latest
    }
  }
}

/**
 * Walk a tree from the root, fetching each subtree and materializing
 * content into the store. Inlined here so pgh-runtime has no dependency
 * on the pgh CLI package — any store + fetcher that conform to the
 * expected shapes work.
 *
 * @param {object} store   — TreeStore-compatible
 * @param {string} rootHash
 * @param {object} fetcher — { getTree, getContent }
 * @param {object} [opts]
 * @returns {Promise<{ materialized: number, skipped: number }>}
 */
export async function installTree (store, rootHash, fetcher, opts = {}) {
  store.init()

  if (store.hasTree(rootHash)) {
    return { materialized: 0, skipped: 1 }
  }

  const tree = await fetcher.getTree(rootHash)
  if (!tree || !Array.isArray(tree.entries)) {
    throw new Error('fetcher returned invalid tree for ' + rootHash)
  }

  // Content-address verification: entries MUST hash to rootHash. A P2P peer
  // can answer with empty/wrong entries to signal "I don't have this tree";
  // without this check that silently materializes an empty/partial dir. Let
  // the caller retry on another peer (EHASHMISMATCH). Matches pgh's installTree.
  const actualRootHash = computeTreeHash(tree.entries)
  if (actualRootHash !== rootHash) {
    throw Object.assign(
      new Error('root tree-hash mismatch: expected ' + rootHash.slice(0, 12) + '…, got ' + actualRootHash.slice(0, 12) + '… (peer may not have this tree)'),
      { code: 'EHASHMISMATCH', expected: rootHash, actual: actualRootHash }
    )
  }

  // Single self-contained materialize — store.materialize recursively inlines
  // subtree content via fetchTree/fetchContent. Subtree hashes do NOT get their
  // own dependencies/<subhash>/ dir (that would duplicate every file N times
  // across levels); dedup happens at the top-level tree layer. `skipPath` skips
  // foreign subtrees (e.g. by-arch/<other-host>/). Mirrors pgh's tree-install.js
  // — the manual subtree recursion this replaced created redundant store dirs
  // and could not honor skipPath.
  await store.materialize(rootHash, tree.entries, {
    fetchContent: fetcher.getContent.bind(fetcher),
    fetchTree: async (h) => {
      const t = await fetcher.getTree(h)
      return t.entries
    },
    source: opts.source,
    skipPath: opts.skipPath
  })

  return { materialized: 1, skipped: 0 }
}

/**
 * Install a full RELEASE — the root tree PLUS its dependency closure.
 *
 * `installTree` fetches a single tree and its nested subdirectories, but an
 * app's node_modules dependencies are SEPARATE content-addressed trees listed
 * in the release's `deps` array (each with its own tree hash and a per-host
 * prebuild tree). Fetching only the root tree therefore yields a BROKEN
 * install — the deps are never fetched and node_modules is never wired. (This
 * is exactly why pear-git-v3 constructs a PghRuntime but refuses to call
 * checkNow(): "pgh-runtime only fetches root trees, not dep closures, so using
 * it here would produce a broken install.")
 *
 * installRelease closes that gap:
 *   1. fetch the FULL closure (root + every dep.tree + prebuilds[host]) into
 *      the store via installTree, then
 *   2. hand the closure to a materializer that wires the install layout
 *      (hardlink deps + symlink node_modules).
 *
 * The materializer is INJECTED (`opts.materialize`) — same dependency-injection
 * philosophy as fetcher/store — so pgh-runtime stays free of any dependency on
 * the pgh CLI's materializer. pgh's node materializer
 * (`materialize({ store, appName, rootTreeHash, deps, host })`) is the canonical
 * one; any function with that shape works. Without a materializer the closure
 * is fetched into the store but the install layout is not wired (store-only).
 *
 * @param {object} store     TreeStore-compatible (init / hasTree / materialize)
 * @param {object} release   { treeHash, deps?: [{ name, version, tree, prebuilds }], name? }
 * @param {object} fetcher   { getTree, getContent }
 * @param {object} [opts]    { host?, appName?, materialize?, source? }
 * @returns {Promise<{ closure: number, materialized: number, skipped: number, install: any }>}
 */
export async function installRelease (store, release, fetcher, opts = {}) {
  if (!release || !release.treeHash) {
    throw new Error('installRelease requires release.treeHash')
  }
  store.init()

  const host = opts.host || null
  const deps = Array.isArray(release.deps) ? release.deps : []

  // Build the full closure: root tree + each dep's tree + its per-host prebuild.
  const closureHashes = [release.treeHash]
  for (const dep of deps) {
    if (dep.tree) closureHashes.push(dep.tree)
    if (host && dep.prebuilds && dep.prebuilds[host]) closureHashes.push(dep.prebuilds[host])
  }

  const source = opts.source || ('pgh-runtime:' + (release.name || 'app'))
  let materialized = 0
  let skipped = 0
  for (const hash of closureHashes) {
    const r = await installTree(store, hash, fetcher, { source, skipPath: opts.skipPath })
    materialized += r.materialized
    skipped += r.skipped
  }

  let install = null
  if (typeof opts.materialize === 'function') {
    install = await opts.materialize({
      store,
      appName: opts.appName || release.name,
      rootTreeHash: release.treeHash,
      deps,
      host
    })
  }

  return { closure: closureHashes.length, materialized, skipped, install }
}