/**
* 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 }
}