OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / index.js

222 lines · javascript ref:
// Migrated 2026-05-11: @holepunchto/keet-core-hyperdb was retired upstream;
// the replacement is @holepunchto/keet-core. The new constructor requires
// opts.buildType (set in coreOpts below). API surface (KeetCore.from →
// { keet }, core.backendAPI, backend.api.core.*) is preserved. See
// hyper-fleeky/keet-audit-4.x/SEMANTIC-DIFF.md for the full mapping.
//
// All keet-stack dep versions are pinned EXACTLY to Keet AppImage 4.14.1's
// /resources/app/node_modules — see package.json `//pin-note`. Drift was
// the root cause of the 2026-05-12 fresh-storage boot hang (notably
// @hyperswarm/rpc 3.2.0 missing the Bare events import map that 3.4.1 has).
//
// Hypercore.destroyRequests polyfill — load-bearing on populated cores.
// Some path inside keet-core's close cascade for an identity that has real
// state (rooms, peers, active block requests) calls
// Hypercore.destroyRequests(activeRequests, err). Current hypercore 11.x
// exposes Hypercore.clearRequests but not destroyRequests under that name.
// Polyfill the static method here to back-port destroy-on-close semantics:
// walk activeRequests, call .destroy(err) on each, swallow per-request
// throws. Idempotent — won't overwrite if a future hypercore provides it.
//
// Was removed once on the theory the bundled hyperbee2 uses clearRequests,
// proven by tests on fresh /tmp identity-0 with no real state. Real users
// on populated identities hit a different code path that DOES call
// destroyRequests — the polyfill catches it. Keep it.
import Hypercore from 'hypercore'
if (!Hypercore.destroyRequests) {
  Hypercore.destroyRequests = function destroyRequests (requests, err) {
    if (!requests) return
    const iter = Array.isArray(requests) ? requests : Object.values(requests)
    for (const r of iter) {
      try { r && r.destroy && r.destroy(err) } catch {}
    }
  }
}

import KeetCoreModule from '@holepunchto/keet-core'
import crypto from 'hypercore-crypto'
import z32 from 'z32'
import path from 'path'
import os from 'os'
import fs from 'fs'
import fsx from 'fs-native-extensions'
import { Router } from './lib/router.js'
import { UdsTransport } from './lib/uds-transport.js'
import { IdentityRegistry } from './lib/identity-registry.js'
import { setDebug } from './lib/debug.js'

export { Router } from './lib/router.js'
export { UdsTransport } from './lib/uds-transport.js'
export { IdentityRegistry } from './lib/identity-registry.js'
export { SecureStorage } from './lib/secure-storage.js'
export { MetadataBase } from './lib/metadata-base.js'

export async function startDaemon (opts = {}) {
  // --debug flag enables verbose discovery logging
  if (opts.debug) setDebug(true)

  const baseDir = opts.storage || path.join(os.homedir(), '.config', 'keet-cli')
  const pidFile = opts.pidFile || '/tmp/keet-daemon.pid'

  // Identity registry — handles migration from default/ on first run
  const registry = new IdentityRegistry(baseDir)
  await registry.load()

  const active = registry.getActive()
  const storage = active ? active.dir : path.join(baseDir, 'identity-0')

  console.log('[keet-daemon] Starting... storage:', storage)
  console.log('[keet-daemon] Active identity:', active ? active.id : '(none)')

  // Single-daemon-per-storage guard. keet-core takes an EXCLUSIVE RocksDB lock
  // on `storage`; a SECOND daemon on the same storage does NOT fail cleanly — it
  // wedges keet-core so EVERY room join throws "Autobase is closing" (the
  // stortron --dedicated incident, 2026-06-24: a leftover --daemon-only instance
  // from the day before contended identity-0). Take an advisory flock on a
  // sidecar lock file BEFORE opening keet-core so a duplicate refuses to start
  // with a clear message instead of corrupting the live daemon. flock releases on
  // process death (incl. SIGKILL), so a crashed/killed daemon never leaves a
  // stale guard. NOTE: covers the primary identity; an identity swap (swapCore)
  // keeps this initial lock — the incident was concurrent daemons on one identity.
  fs.mkdirSync(storage, { recursive: true })
  const lockPath = path.join(storage, 'keet-daemon.lock')
  const lockFd = fs.openSync(lockPath, 'a+') // create if absent; no truncate
  if (!fsx.tryLock(lockFd)) {
    let holder = ''
    try { holder = fs.readFileSync(lockPath, 'utf8').trim() } catch {}
    try { fs.closeSync(lockFd) } catch {}
    console.error('[keet-daemon] storage already locked by another daemon%s — refusing to start. A second keet-core on the same storage wedges room joins ("Autobase is closing"). Stop the other daemon first. storage=%s',
      holder ? ` (PID ${holder})` : '', storage)
    throw new Error('KEET_DAEMON_ALREADY_RUNNING: ' + storage)
  }
  // Record our PID for diagnostics (a refused daemon reads it above). The flock —
  // not this file's content — is the guarantee. Keep lockFd open for the whole
  // process lifetime; do NOT close it (the OS releases the lock on exit).
  try { fs.writeFileSync(lockPath, String(Bare.pid)) } catch {}

  // Track broken rooms (KEET_CONTRACT_ABORT during open/sync)
  // Maps roomId (z-base-32 discovery key) → error message
  const brokenRooms = new Map()

  // Core options — reused for hot-swap
  const coreOpts = {
    experimental: false,
    devMirrors: false,
    devUserRegistry: false,
    swarming: true,
    production: true,
    // keet-core 4.14.4+ requires buildType; constructor throws
    // INVALID_BUILD_TYPE() if absent. Valid values: 'nightly' |
    // 'internal' | 'production'. Use 'production' for stable ABI.
    buildType: 'production',
    onerror: err => {
      const msg = err.message || String(err)
      if (err.details?.roomKey) {
        try {
          const dkey = crypto.discoveryKey(Buffer.from(err.details.roomKey))
          const roomId = z32.encode(dkey)
          if (!brokenRooms.has(roomId)) {
            brokenRooms.set(roomId, msg)
            console.error(`[keet-daemon] Broken room tracked: ${roomId.slice(0, 12)} — ${msg}`)
          }
        } catch {
          console.error('[keet-daemon] Core error:', msg)
        }
      } else {
        console.error('[keet-daemon] Core error:', msg)
        if (err.stack) console.error('[keet-daemon] stack:', err.stack)
        if (err.cause) console.error('[keet-daemon] cause:', err.cause)
      }
    }
  }

  // Init Keet core (holds exclusive RocksDB lock)
  const KeetCore = KeetCoreModule.default || KeetCoreModule
  let coreResult = KeetCore.from(storage, coreOpts)
  let core = coreResult.keet
  let backend = core.backendAPI
  await backend.ready()
  console.log('[keet-daemon] Backend ready')

  // Hot-swap function: close current core, open new storage
  async function swapCore (newStoragePath) {
    console.log('[keet-daemon] Swapping core to:', newStoragePath)

    // Tear down UDS subscriptions before closing core
    if (uds) uds.destroyAllSubscriptions()

    await core.close()

    coreResult = KeetCore.from(newStoragePath, coreOpts)
    core = coreResult.keet
    backend = core.backendAPI
    await backend.ready()

    // Update router references
    router.backend = backend
    router.core = core
    router._storage = newStoragePath
    router._vault = router._createVault(newStoragePath)

    // Swap MetadataBase to new identity storage
    await router.swapMetadata(newStoragePath)

    console.log('[keet-daemon] Core swapped, broadcasting event')

    // Notify all UDS clients
    if (uds) uds.broadcastEvent('core_swapped', { storage: newStoragePath })
  }

  // Router + UDS transport (no HTTP — security audit remediation)
  const router = new Router(backend, core, {
    storage, registry, swapCore, brokenRooms
  })
  const socketPath = opts.socketPath || '/tmp/keet-daemon.sock'
  // opts.tcp = { host, port, token }: loopback TCP instead of the Unix socket, for
  // Windows (see UdsTransport). The bound port is uds.port.
  var uds = new UdsTransport(router, { socketPath, tcp: opts.tcp || null })
  await uds.start()

  // Write PID file
  try {
    fs.writeFileSync(pidFile, String(Bare.pid))
  } catch (err) {
    console.error('[keet-daemon] Warning: could not write PID file:', err.message)
  }

  console.log(`[keet-daemon] Ready — ${opts.tcp ? 'TCP 127.0.0.1:' + uds.port : 'UDS ' + socketPath}, PID ${Bare.pid}`)

  // Initialize MetadataBase (Autobase smart contract + legacy migration)
  router.initMetadata().catch(err => {
    console.error('[keet-daemon] MetadataBase init failed:', err.message)
  })

  // Enrich migrated registry entries with live identity data. MOVED to
  // after uds.start() because `getIdentity()` blocks on keet-core's
  // onlineBoot.ready(), which never resolves on fresh storage (where
  // `_loadIdentity` errors with "Identity is required"). Running this
  // earlier prevented UDS from ever listening, making vault.setup
  // unreachable to bootstrap clients. Now non-fatal in both senses:
  // can't crash the daemon AND can't block its readiness.
  if (active && active.username === '') {
    Promise.resolve().then(async () => {
      try {
        const identity = await backend.api.core.getIdentity()
        if (identity) {
          const fields = {}
          if (identity.displayName) fields.name = identity.displayName
          if (identity.username) fields.username = identity.username
          if (fields.name || fields.username) {
            await registry.updateMeta(active.id, fields)
            console.log('[keet-daemon] Registry enriched from live identity:', fields)
          }
        }
      } catch (err) {
        console.error('[keet-daemon] Registry enrichment failed (non-fatal):', err.message)
      }
    }).catch(() => {})
  }

  return { router, uds, core, registry, pidFile }
}