OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / tab-runtime.js

210 lines · javascript ref:
/**
 * Tab Runtime — the "run in a tab" path.
 *
 * Lets a Pear app render HEADLESS inside a browser tab instead of spawning its
 * own window. The app is a `pear-request` worker (htmx frontend + a route
 * handler "server"): the tab's XMLHttpRequest is hooked to a streamx, so htmx
 * thinks it's talking to an HTTP server while the bytes actually flow over a
 * pipe to a headless worker. See examples/headless-tab for the standalone proof.
 *
 * CREDIT: this run-in-a-tab pattern is from Drache93's Pear Browser
 * (https://github.com/Drache93/pear-browser) and his `pear-request` library
 * (https://github.com/Drache93/pear-request, npm: pear-request) — Pear.worker
 * + htmx-over-a-pipe. Drache93 runs the worker + XHR hook directly in the
 * renderer and renders inline; we instead bridge it over bare-ws into an
 * ISOLATED iframe tab (per-app isolation), driven by our Bare backend.
 *
 * Two transports meet here:
 *   - a bare-http1 server serves the tiny wrapper page + htmx + the injected
 *     pear-request client (everything else streams from the worker)
 *   - a bare-ws server bridges each tab's WebSocket <-> the worker's duplex pipe
 *
 * The only worker is the in-process demo router. This runtime is a local UI
 * proof surface, never an application loader: native app code must arrive in a
 * verified package outside the browser process.
 */
const http = require('bare-http1')
const ws = require('bare-ws')
const hypercoreCrypto = require('hypercore-crypto')
const b4a = require('b4a')
const { PAGE_CONTEXT_SHIM, pageContextMeta } = require('./page-context-bridge.cjs')
const router = require('./tab-assets/router.cjs')   // { PearRequestRouter, registerRoutes }
const assets = require('./tab-assets/assets.js')     // { wrapper, htmx, client } inline strings

const WS_PORT_BASE = 9886
const WS_PORT_COUNT = 12

function listenWsServer (port, onSocket) {
  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')
  })
}

class TabRuntime {
  constructor (opts = {}) {
    this.tabs = new Map()               // tabId -> { source }
    this._seq = 0
    this.httpPort = 0
    this.wsPort = 0
  }

  async start () {
    this._wrapper = assets.wrapper
    this._assets = {
      '/htmx.min.js': { body: assets.htmx, type: 'text/javascript' },
      '/pear-request-client.bundle.js': { body: assets.client, type: 'text/javascript' }
    }

    this._http = http.createServer((req, res) => this._serve(req, res))
    this.httpPort = await new Promise((resolve, reject) => {
      this._http.on('error', reject)
      this._http.listen(0, '127.0.0.1', () => resolve(this._http.address().port))
    })

    for (let p = WS_PORT_BASE; p < WS_PORT_BASE + WS_PORT_COUNT; p++) {
      try {
        this._ws = await listenWsServer(p, (sock) => this._onSocket(sock))
        this.wsPort = p
        break
      } catch (err) {
        if (err && err.code === 'EADDRINUSE') continue
        throw err
      }
    }
    if (!this._ws) throw new Error('TabRuntime: no free WS port in range')
    // A stable demo tab so the headless run-in-tab path is reachable without UI
    // (GET /tab/demo) — the in-process router; the "Headless Demo" card opens it.
    this.tabs.set('demo', { source: 'demo' })
    console.log(`[tab-runtime] http :${this.httpPort}  ws :${this.wsPort}  (demo: /tab/demo)`)
    return { httpPort: this.httpPort, wsPort: this.wsPort }
  }

  // Register a tab and return the wrapper URL the UI should load in an iframe.
  open (source = 'demo') {
    if (source !== 'demo') throw new Error('Only the in-process demo is available in a browser tab')
    const tabId = 'tab' + (++this._seq)
    const tab = { source, contextToken: this._newContextToken() }
    this.tabs.set(tabId, tab)
    return {
      tabId,
      url: `http://127.0.0.1:${this.httpPort}/tab/${tabId}?ws=${this.wsPort}`,
      contextToken: tab.contextToken
    }
  }

  contextTokenForUrl (url) {
    try {
      const parsed = new URL(url)
      if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1') return null
      if (Number(parsed.port) !== this.httpPort) return null
      const match = parsed.pathname.match(/^\/tab\/([a-zA-Z0-9_-]+)$/)
      if (!match) return null
      const tab = this.tabs.get(match[1])
      if (!tab) return null
      if (!tab.contextToken) tab.contextToken = this._newContextToken()
      return tab.contextToken
    } catch {
      return null
    }
  }

  _newContextToken () {
    return b4a.toString(hypercoreCrypto.randomBytes(32), 'hex')
  }

  _serve (req, res) {
    const u = (req.url || '/').split('?')[0]
    res.setHeader('Access-Control-Allow-Origin', '*')
    if (u.startsWith('/tab/')) {
      const tabId = u.slice('/tab/'.length)
      const tab = this.tabs.get(tabId)
      if (!tab) { res.statusCode = 404; return res.end('unknown tab') }
      if (!tab.contextToken) tab.contextToken = this._newContextToken()
      res.setHeader('Content-Type', 'text/html; charset=utf-8')
      const contextHead = pageContextMeta(tab.contextToken) + PAGE_CONTEXT_SHIM
      return res.end(this._wrapper.replace('<head>', `<head>${contextHead}`))
    }
    const asset = this._assets[u]
    if (asset) { res.setHeader('Content-Type', asset.type); return res.end(asset.body) }
    res.statusCode = 404
    res.end('not found')
  }

  // Each tab opens one WebSocket. Its FIRST frame is the tabId (bare-ws gives no
  // request URL); every frame after that is pear-request wire bytes for the worker.
  _onSocket (sock) {
    let worker = null
    sock.on('data', (data) => {
      if (!worker) {
        const tabId = data.toString().trim()
        const tab = this.tabs.get(tabId)
        if (!tab) { try { sock.end() } catch {} ; return }
        try {
          worker = this._spawnInProc(sock)
        } catch (err) {
          console.error('[tab-runtime] spawn failed:', err && err.message)
          try { sock.end() } catch {}
        }
        return
      }
      worker.toWorker(data) // browser -> worker request bytes
    })
    sock.on('close', () => { if (worker) worker.close() })
    sock.on('error', () => { if (worker) worker.close() })
  }

  // Demo: host the pear-request router in-process. The worker pipe just writes
  // responses straight back out the same WebSocket.
  _spawnInProc (sock) {
    const workerPipe = {
      write: (buf) => { try { sock.write(buf) } catch {} ; return true },
      once: (ev, cb) => { if (ev === 'drain') queueMicrotask(cb) }
    }
    const r = new router.PearRequestRouter(workerPipe)
    router.registerRoutes(r, { label: 'in-proc worker (pearbrowser backend)' })
    return {
      toWorker: (data) => { try { r.processMessage(data) } catch (e) { console.error('[tab-runtime] router:', e && e.message) } },
      close: () => {}
    }
  }

  async stop () {
    try { this._ws && this._ws.close() } catch {}
    try { this._http && this._http.close() } catch {}
    this.tabs.clear()
  }
}

module.exports = { TabRuntime }