OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / scripts / pin-app-on-hiverelay.js

182 lines · javascript ref:
/**
 * Durably pin an EXISTING app bundle (or any Hyperdrive) on HiveRelay,
 * WITHOUT owning its signing key.
 *
 * A content bundle is addressed by a Hyperdrive key. Pinning is read-only
 * replication: HiveRelay's seed-request is signed by
 * THIS client's swarm keypair (see HiveRelayClient.seed → request.publisher*
 * is OUR keypair, not the drive author's). So any peer that can fetch the
 * content may ask the relay fleet to hold it. The author does NOT have to be
 * the one to seed it — replication does not make a binary trustworthy or
 * provide a native application installer.
 *
 * IMPORTANT sizing note: a drive's blob *core* byteLength can be far larger
 * than its CURRENT checkout when the key was re-staged many times during dev
 * (dead history accumulates in the append-only core). We therefore size
 * maxStorage from the CURRENT file set + metadata core, NOT the blob core —
 * otherwise we'd ask relays to reserve tens of GB and they reject it.
 *
 * What this does:
 *   1. Opens the drive by key (seedAsReader, so we serve onward too).
 *   2. Lists the CURRENT checkout (sum of live blob bytes) and best-effort
 *      pre-fetches it, so we are a second complete source.
 *   3. Broadcasts an archive-tier seed-request sized to current+metadata.
 *   4. Stays online (--hold), logging every relay acceptance as it lands
 *      (acceptances often arrive after the initial 15s broadcast window).
 *
 * If the current checkout resolves to zero file entries, this refuses to seed.
 * A zero-entry checkout usually means the bundle is unavailable or unresolved,
 * and asking relays to hold it can create misleading "reachable but empty"
 * evidence instead of repairing availability.
 *
 * Confirm durability after stopping the local seed source:
 *   node scripts/verify-app-full.js --key <64-hex>
 *
 * Usage:
 *   node scripts/pin-app-on-hiverelay.js <hyper://key | z32 | 64-hex> \
 *     [--name x] [--hold 300] [--replicas 5] [--ttlDays 365] \
 *     [--maxStorage <MB>] [--standard] [--dlTimeout 150]
 */

import { HiveRelayClient } from 'p2p-hiverelay-client'
import z32 from 'z32'
import b4a from 'b4a'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

function parseArgs (argv) {
  const positional = []
  const flags = {}
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i]
    if (a.startsWith('--')) {
      const k = a.slice(2)
      const v = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true
      flags[k] = v
    } else positional.push(a)
  }
  return { positional, flags }
}

function toHexKey (raw) {
  if (!raw) return null
  let s = String(raw).trim()
  s = s.replace(/^hyper:\/\//i, '').replace(/\/.*$/, '')
  if (/^[0-9a-f]{64}$/i.test(s)) return s.toLowerCase()
  try {
    const buf = z32.decode(s)
    if (buf.length === 32) return b4a.toString(buf, 'hex')
  } catch { /* not z32 */ }
  return null
}

const { positional, flags } = parseArgs(process.argv.slice(2))
const keyHex = toHexKey(positional[0])
if (!keyHex) {
  console.error('usage: node scripts/pin-app-on-hiverelay.js <hyper://key | z32 | 64-hex> [--name x] [--hold 300] [--maxStorage <MB>] [--replicas 5] [--ttlDays 365] [--standard]')
  process.exit(2)
}

const name = flags.name || 'app'
const holdSecs = Number(flags.hold) || 300
const replicas = Number(flags.replicas) || 5
const ttlDays = Number(flags.ttlDays) || 365
const durability = flags.standard ? 0 : 'archive'
const dlTimeoutS = Number(flags.dlTimeout) || 150
const explicitMaxMB = Number(flags.maxStorage) || 0

const MB = 1024 * 1024
const storage = mkdtempSync(join(tmpdir(), 'pin-app-'))
console.log('🍐 Pinning app bundle on HiveRelay (read-only foreign-key seed)')
console.log('   key     :', keyHex)
console.log('   name    :', name, '| hold:', holdSecs + 's | replicas:', replicas, '| ttlDays:', ttlDays, '| durability:', durability)
console.log()

const client = new HiveRelayClient(storage)
let acceptCount = 0
client.on('seed-accepted', (info) => {
  acceptCount++
  const who = info && (info.relay || info.pubkey || info.relayPubkey)
  console.log('  ✔ ACCEPTED #' + acceptCount, who ? ('by ' + String(who).slice(0, 12) + '…') : '')
})
client.on('seeded', ({ key, acceptances }) => console.log('  · seeded event —', acceptances, 'acceptances at broadcast'))
client.on('seed-error', ({ key, error }) => console.error('  ✗ seed-error', '—', error && error.message))
client.on('seed-cap-warning', (w) => console.warn('  ! cap warning:', w.hint))

await client.start()
console.log('  · client started, discovering relays…')
await new Promise((res) => {
  let n = 0
  client.on('relay-connected', () => { if (++n >= 1) setTimeout(res, 3000) })
  setTimeout(() => { if (n === 0) { console.error('  ✗ no relays within 15s — DHT issue?'); process.exit(2) } res() }, 15000)
})
console.log('  · relays connected:', client.relays?.size ?? '?')

// 1) Open the drive by key. seedAsReader:true → we serve content onward.
const drive = await client.open(keyHex, { seedAsReader: true, wait: true, timeout: 30000 })
await drive.ready()
console.log('  · drive opened — version', drive.version)
if (!drive.version || drive.version === 0) {
  console.error('  ✗ drive is empty (version 0) — no content to pin. Aborting.')
  try { await client.destroy() } catch {}
  process.exit(3)
}

// 2) Measure the CURRENT checkout (live blobs only) and pre-fetch it.
console.log('  · measuring current checkout…')
let currentBytes = 0
let fileCount = 0
try {
  await Promise.race([
    (async () => {
      for await (const e of drive.list('/', { recursive: true })) {
        fileCount++
        currentBytes += (e?.value?.blob?.byteLength || 0)
      }
    })(),
    new Promise((_, rej) => setTimeout(() => rej(new Error('list-cap')), 90_000))
  ])
} catch (e) { console.log('  · list incomplete (' + e.message + '), counted', fileCount, 'so far') }
const metaBytes = (drive.db?.core?.byteLength) || (drive.core?.byteLength) || 0
console.log('  · current checkout:', fileCount, 'files,', (currentBytes / MB).toFixed(2), 'MB live blobs · metadata core', (metaBytes / MB).toFixed(1), 'MB')
if (fileCount === 0) {
  console.error('  ✗ current checkout has 0 file entries — refusing to seed an empty/unresolved drive.')
  console.error('    Find a full-content source, run relay/operator cleanup if needed, then rerun and verify with scripts/verify-app-full.js.')
  try { await client.destroy() } catch {}
  process.exit(4)
}

console.log('  · pre-fetching current content (best-effort, ' + dlTimeoutS + 's cap)…')
try {
  await Promise.race([
    drive.download('/'),
    new Promise((_, rej) => setTimeout(() => rej(new Error('download-cap')), dlTimeoutS * 1000))
  ])
  console.log('  · current content fetched into our store')
} catch (e) { console.log('  · pre-fetch incomplete (' + e.message + ') — relays pull from the live seeder during hold') }

// 3) Size maxStorage from CURRENT content + metadata core (NOT the bloated
//    blob core). Explicit --maxStorage <MB> overrides.
const sized = Math.max(256 * MB, Math.ceil((currentBytes + metaBytes) * 3))
const maxStorage = explicitMaxMB > 0 ? explicitMaxMB * MB : sized
console.log('  · maxStorage:', (maxStorage / MB).toFixed(0), 'MB', explicitMaxMB > 0 ? '(explicit)' : '(sized from current+meta)')

const acceptances = await client.seed(drive.key, {
  replicas, ttlDays, durability, maxStorage, discoveryKey: drive.discoveryKey
})
const n0 = Array.isArray(acceptances) ? acceptances.length : (acceptances || 0)
console.log()
console.log('✅ seed-request broadcast — acceptances within initial window:', n0)
console.log('   hex   ' + keyHex)
console.log()

// 4) Hold so late acceptances land + relays replicate the content from us.
console.log('   holding online ' + holdSecs + 's (logging acceptances as they arrive)…')
await new Promise((r) => setTimeout(r, holdSecs * 1000))
console.log()
console.log('   TOTAL acceptances observed during run:', acceptCount)
try { await client.destroy() } catch {}
console.log('  · done. Verify (with local seed daemon STOPPED): node scripts/verify-app-full.js --key ' + keyHex)
process.exit(0)