OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / index.js

326 lines · javascript ref:
import ws from 'bare-ws'
import http from 'bare-http1'
import fs from 'bare-fs'
import path from 'bare-path'
import env from 'bare-env'
// ESM-only modules the backend needs but can't dynamic-import from CJS:
// Bare/Pear's import() resolver has no referrer URL when called from a
// CommonJS file, so all forms of import() from backend/*.js fail with
// "Cannot find referrer". The fix: load them statically here in the
// ESM entry point and hand them to the backend through bootBackend().
import { ServiceRegistry, ServiceProtocol } from 'p2p-hiverelay/core/services/index.js'
import { bootBackend } from './backend/pear-adapter.cjs'
import { createLazyQvacService } from './backend/ai/qvac-host.mjs'
import { QVAC_MODEL_CATALOG } from './backend/ai/qvac-model-catalog.mjs'
import { discoverOllamaQwenModels } from './backend/ai/qvac-ollama-catalog.mjs'
import rpcWebSocketAuth from './backend/rpc-websocket-auth.cjs'

const { authorizeRpcWebSocket, DiagnosticRpcRouter } = rpcWebSocketAuth

// Renderer scans 9876-9880 in order. Backend binds the first one
// that's free. Handles the common case where a zombie pear-runtime
// from a crashed earlier session still holds 9876 — new launch
// grabs the next port instead of failing with EADDRINUSE.
const RPC_PORT_BASE = 9876
const RPC_PORT_COUNT = 5
const RENDERER_RECONNECT_GRACE_MS = 8000
// Pear v3 executes this file as a worker owned by `pear-runtime`. The Electron
// host supplies the two values below before loading us; unlike the retired
// `pear run` host, no ambient global `Pear` API is involved.
const runtimeContext = globalThis.PearBrowserRuntime || {}
const rpcSessionToken = String(runtimeContext.sessionToken || '')
const diagnosticToken = String(env.PEARBROWSER_RPC_DIAGNOSTIC_TOKEN || '')
if (!rpcSessionToken) throw new Error('PearBrowser v3 host did not provide a per-launch RPC session token')

// --- 1. Boot the backend in this Bare main process. ---
//
// If require('./backend/index.js') throws synchronously — typically
// because a transitive dep is missing from the staged drive, or the
// staged drive itself is incomplete — we DO NOT want to crash the main
// process silently. The renderer would then see "ws error" on every
// port (no listener) and the user has no idea what's wrong.
//
// Instead: catch the boot failure, still bind the WS server, and when
// the renderer connects send it a structured `backend-boot-failed`
// event so the UI can render a clear error. The dev logs also get a
// loud banner that makes the cause obvious in the native host logs.
const storagePath = String(runtimeContext.storagePath || '.') + '/pearbrowser-storage'
const ollamaModels = env.PEARBROWSER_QVAC_OLLAMA === '0'
  ? {}
  : discoverOllamaQwenModels({
      fs,
      path,
      homeDir: env.HOME,
      modelsRoot: env.OLLAMA_MODELS,
      device: env.PEARBROWSER_QVAC_DEVICE
    })
const qvacModels = Object.freeze({ ...QVAC_MODEL_CATALOG, ...ollamaModels })
if (Object.keys(ollamaModels).length) {
  console.log('[qvac] approved local models:', Object.keys(ollamaModels).join(', '))
}
// Release native model RAM after a quiet period; the next request reloads
// through the normal ensureModel path with progress events. 0 disables.
const idleUnloadRaw = Number(env.PEARBROWSER_QVAC_IDLE_UNLOAD_MS)
const qvacIdleUnloadMs = Number.isFinite(idleUnloadRaw) && idleUnloadRaw >= 0
  ? idleUnloadRaw
  : 15 * 60 * 1000
const aiService = createLazyQvacService({
  homeDir: storagePath,
  models: qvacModels,
  idleUnloadMs: qvacIdleUnloadMs
})
let backendPipe = null
let bootError = null
try {
  backendPipe = bootBackend({
    storagePath,
    // Hand statically-imported ESM modules down to the CJS backend so
    // it can build a ServiceProtocol stack for anonGPT's seller dial
    // without paying Bare's CJS-→-ESM dynamic-import penalty.
    esmModules: { ServiceRegistry, ServiceProtocol, aiService }
  })
} catch (err) {
  bootError = err
  console.error('')
  console.error('========================================================')
  console.error('  BACKEND FAILED TO BOOT — main process is in error mode')
  console.error('========================================================')
  console.error('  reason : ' + (err?.message || String(err)))
  if (err?.code) console.error('  code   : ' + err.code)
  console.error('')
  console.error(err?.stack || '(no stack)')
  console.error('')
  console.error('The WS server will still bind so the renderer can')
  console.error('display this error instead of "could not reach backend".')
  console.error('')
  console.error('Common causes:')
  console.error('  • The local PearBrowser installation is incomplete')
  console.error('     Reinstall the verified native package, then relaunch it.')
  console.error('  • The embedded Pear OTA runtime is incompatible with this OS/arch')
  console.error('  • Missing native addon for this OS/arch')
  console.error('========================================================')
  console.error('')
}

// --- 2. Start the renderer RPC bridge (WebSocket). ---
// Single-client for M1.5: the React renderer is the only caller.
// Events emitted by the backend before the renderer connects are
// buffered here so nothing boot-time is missed.
let client = null
let rendererDisconnectTimer = null
const diagnostics = new Set()
const eventBuffer = []
const diagnosticRouter = new DiagnosticRpcRouter({
  forward: (frame) => backendPipe?.write(frame)
})

if (backendPipe) {
  backendPipe.on('data', (chunk) => {
    if (client) client.write(chunk)
    else eventBuffer.push(chunk)
    try { diagnosticRouter.routeBackend(chunk) } catch (err) {
      console.error('[rpc] diagnostic response routing failed:', err.message)
    }
  })
}

// Build a single length-prefixed JSON frame matching the wire format
// backend/rpc.js emits (8-hex-char ASCII length prefix + JSON body) so
// the renderer's RpcClient parses it the same way regardless of
// whether the backend or this error-mode synthesizer wrote it.
function frameEvent (event, data) {
  const json = JSON.stringify({ event, data })
  return json.length.toString(16).padStart(8, '0') + json
}

const onDiagnosticSocket = (socket, access) => {
  console.log(`[rpc] ${access.full ? 'operator' : 'read-only'} diagnostic connected`)
  diagnostics.add(socket)
  diagnosticRouter.add(socket, { full: access.full })
  socket.on('close', () => {
    console.log('[rpc] diagnostic disconnected')
    diagnostics.delete(socket)
    diagnosticRouter.remove(socket)
  })
  socket.on('error', (err) => {
    console.error('[rpc] diagnostic error:', err.message)
    diagnostics.delete(socket)
    diagnosticRouter.remove(socket)
  })

  if (bootError) {
    console.log('[rpc] sending backend-boot-failed event to diagnostic')
    socket.write(frameEvent('backend-boot-failed', {
      message: bootError.message || String(bootError),
      code: bootError.code || null,
      stack: bootError.stack || null
    }))
    setTimeout(() => { try { socket.end() } catch {} }, 200)
    return
  }

  socket.on('data', (data) => {
    try {
      diagnosticRouter.receive(socket, data)
    } catch (err) {
      console.error('[rpc] rejecting malformed diagnostic frame:', err.message)
      try { socket.end() } catch {}
    }
  })
}

const onSocket = (socket, req) => {
  const access = authorizeRpcWebSocket({ url: req?.url, headers: req?.headers }, {
    sessionToken: rpcSessionToken,
    diagnosticToken
  })
  if (!access.allowed) {
    console.error('[rpc] rejected unauthenticated socket:', access.reason)
    return socket.end()
  }
  if (access.kind === 'diagnostic') {
    return onDiagnosticSocket(socket, access)
  }

  if (client) {
    console.log('[rpc] rejecting extra WS connection')
    return socket.end()
  }
  if (rendererDisconnectTimer) {
    clearTimeout(rendererDisconnectTimer)
    rendererDisconnectTimer = null
    console.log('[rpc] renderer reconnected within grace period')
  }
  console.log('[rpc] renderer connected')
  client = socket

  // Backend-boot-failed path: send the structured error and close.
  // The renderer sees `event: 'backend-boot-failed'` and renders a
  // splash with the real message instead of a port-scan failure.
  if (bootError) {
    console.log('[rpc] sending backend-boot-failed event to renderer')
    socket.write(frameEvent('backend-boot-failed', {
      message: bootError.message || String(bootError),
      code: bootError.code || null,
      stack: bootError.stack || null
    }))
    // Brief grace period for the frame to flush, then close the socket
    // (renderer won't reconnect in error mode).
    setTimeout(() => { try { socket.end() } catch {} }, 200)
    return
  }

  for (const chunk of eventBuffer) socket.write(chunk)
  eventBuffer.length = 0

  socket.on('data', (data) => backendPipe.write(data))
  socket.on('close', () => {
    console.log('[rpc] renderer disconnected')
    detachRenderer(socket)
  })
  socket.on('error', (err) => {
    console.error('[rpc] socket error:', err.message)
    detachRenderer(socket)
  })
}

function detachRenderer (socket) {
  if (client !== socket) return
  client = null
  if (rendererDisconnectTimer) clearTimeout(rendererDisconnectTimer)
  // A renderer reload or brief localhost socket interruption can reconnect
  // with the same per-launch token. Preserve the backend and buffered events
  // briefly; a genuine window close still tears down before it can become a
  // zombie that holds the Corestore lock or RPC port.
  rendererDisconnectTimer = setTimeout(() => {
    rendererDisconnectTimer = null
    if (!client) teardown('renderer-ws-close')
  }, RENDERER_RECONNECT_GRACE_MS)
}

let rpcServer = null
let rpcPort = null
function listenRpcServer (port) {
  return new Promise((resolve, reject) => {
    const httpServer = http.createServer((req, res) => {
      const body = 'WebSocket required'
      res.writeHead(426, {
        'Content-Type': 'text/plain',
        'Content-Length': body.length
      })
      res.end(body)
    })
    const server = new ws.Server({ server: httpServer }, onSocket)
    let settled = false

    const cleanup = () => {
      httpServer.removeListener('error', onError)
      httpServer.removeListener('listening', onListening)
    }
    const onError = (err) => {
      if (settled) return
      settled = true
      cleanup()
      try { server.close() } catch {}
      reject(err)
    }
    const onListening = () => {
      if (settled) return
      settled = true
      cleanup()
      resolve(server)
    }

    httpServer.once('error', onError)
    httpServer.once('listening', onListening)
    httpServer.listen(port, '127.0.0.1')
  })
}

for (let p = RPC_PORT_BASE; p < RPC_PORT_BASE + RPC_PORT_COUNT; p++) {
  try {
    rpcServer = await listenRpcServer(p)
    rpcPort = p
    console.log(`[rpc] WS listening on :${rpcPort}`)
    break
  } catch (err) {
    if (err?.code === 'EADDRINUSE') {
      console.log(`[rpc] :${p} in use, trying next`)
      continue
    }
    throw err
  }
}
if (!rpcServer) {
  throw new Error(`No free WS RPC port in ${RPC_PORT_BASE}-${RPC_PORT_BASE + RPC_PORT_COUNT - 1}`)
}

// --- 3. Worker lifecycle. ---
// The Electron host owns windows and terminates this Pear OTA worker when the
// last window exits. Keep the backend cleanup local to the worker rather than
// relying on Pear v2's renderer pipe or global teardown hooks.
let tornDown = false
function teardown (reason) {
  if (tornDown) return
  tornDown = true
  console.log('[teardown] triggered by', reason)
  if (rendererDisconnectTimer) clearTimeout(rendererDisconnectTimer)
  rendererDisconnectTimer = null
  try { rpcServer?.close() } catch {}
  try { client?.end?.() } catch {}
  try { backendPipe.end?.() } catch {}
  // Hard-exit fast: hypercore/corestore can hold the event loop
  // open for tens of seconds on graceful close, and that's what
  // was letting zombies survive. 300ms is plenty for our WS
  // client to flush.
  setTimeout(() => {
    console.log('[teardown] hard-exit')
    try { Bare.exit?.(0) } catch {}
    try { process?.exit?.(0) } catch {}
  }, 300)
}
try { Bare.on?.('beforeExit', () => teardown('beforeExit')) } catch {}
try { Bare.on?.('exit', () => teardown('exit')) } catch {}
try { process?.on?.('SIGTERM', () => teardown('SIGTERM')) } catch {}
try { process?.on?.('SIGINT', () => teardown('SIGINT')) } catch {}