OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / examples / headless-tab / run-demo.mjs

105 lines · javascript ref:
/**
 * Stage 1 proof: an htmx app served by a worker over a stream, rendered HEADLESS
 * in a browser tab. No HTTP app-server — the tab's XMLHttpRequest is hooked to a
 * WebSocket that bridges to a pear-request router (the "worker"). This is the
 * exact mechanism the pearbrowser "run in tab" path will use; here the worker is
 * in-process, in Stage 2 it's a real `pear-run` headless worker.
 *
 *   node run-demo.mjs            # assert headless render + htmx-over-stream
 *   node run-demo.mjs --keep     # leave the server up to open in a real browser
 */
import http from 'node:http'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { WebSocketServer } from 'ws'
import * as esbuild from 'esbuild'
import { PearRequestRouter } from 'pear-request/server'
import { registerRoutes } from './counter-routes.mjs'

const dir = path.dirname(fileURLToPath(import.meta.url))
const keep = process.argv.includes('--keep')

// 1. bundle the browser client (createPearRequest + Buffer) -> IIFE
const bundle = (await esbuild.build({
  entryPoints: [path.join(dir, 'client-entry.mjs')],
  bundle: true, format: 'iife', write: false, platform: 'browser',
  define: { 'process.env.NODE_ENV': '"production"', global: 'globalThis' }
})).outputFiles[0].text
console.log('client bundle: ' + bundle.length + ' bytes')

// 2. static http server (wrapper page + htmx + the client bundle)
const types = { '.html': 'text/html', '.js': 'text/javascript' }
const files = {
  '/': fs.readFileSync(path.join(dir, 'wrapper.html')),
  '/htmx.min.js': fs.readFileSync(path.join(dir, 'htmx.min.js')),
  '/pear-request-client.bundle.js': Buffer.from(bundle)
}
const server = http.createServer((req, res) => {
  const p = req.url.split('?')[0]
  const body = files[p === '/' ? '/' : p]
  if (!body) { res.writeHead(404); return res.end('nf') }
  res.writeHead(200, { 'content-type': types[path.extname(p) || '.html'] || 'text/html' })
  res.end(body)
})

// 3. WS <-> worker-pipe bridge. Each tab connection gets its own headless worker.
const wss = new WebSocketServer({ server, path: '/__pipe' })
let workers = 0
wss.on('connection', (ws) => {
  workers++
  const pipe = { write: (buf) => { ws.send(buf); return true }, once: (ev, cb) => { if (ev === 'drain') queueMicrotask(cb) } }
  const router = new PearRequestRouter(pipe)
  registerRoutes(router, { label: 'in-proc worker #' + workers })
  ws.on('message', (data) => { try { router.processMessage(data) } catch (e) { console.error('router:', e.message) } })
})

await new Promise((r) => server.listen(0, '127.0.0.1', r))
const url = `http://127.0.0.1:${server.address().port}/`
console.log('serving ' + url)

if (keep) { console.log('--keep: open the URL above in a browser. Ctrl-C to stop.'); }
else {
  const { chromium } = await import('playwright')
  let failed = false
  const check = (c, m) => { if (!c) { failed = true; console.log('  ✗ ' + m) } else console.log('  ✓ ' + m) }
  const browser = await chromium.launch({ headless: true })
  const page = await browser.newPage()
  const errs = []
  page.on('pageerror', (e) => errs.push(e.message))
  page.on('console', (m) => { if (m.type() === 'error') errs.push('console: ' + m.text()) })

  console.log('\n# Stage 1 — htmx app served by a worker-over-stream, rendered headless')
  await page.goto(url)
  await page.waitForFunction(() => window.__PEAR_TAB_READY === true, null, { timeout: 10000 })
  // the worker's GET / fragment must have rendered into the tab
  await page.waitForFunction(() => document.body.innerText.includes('Headless htmx app'), null, { timeout: 10000 })
  check(true, 'worker served the app UI into the tab (GET / over the stream)')

  const count0 = await page.textContent('#count')
  check(count0.trim() === '0', 'initial count is 0 (from the worker)')

  // click the htmx button 3x — each is an XHR-over-stream POST /inc
  for (let i = 1; i <= 3; i++) {
    await page.click('button:has-text("Count +1")')
    await page.waitForFunction((n) => document.querySelector('#count')?.textContent?.trim() === String(n), i, { timeout: 5000 })
  }
  check((await page.textContent('#count')).trim() === '3', 'htmx POST /inc over the stream incremented count to 3')

  await page.click('button:has-text("reset")')
  await page.waitForFunction(() => document.querySelector('#count')?.textContent?.trim() === '0', null, { timeout: 5000 })
  check(true, 'htmx POST /reset over the stream reset count to 0')

  const who = (await page.textContent('#who')) || ''
  check(who.includes('served headless'), 'lazy hx-get /whoami fragment loaded over the stream')

  if (errs.length) { failed = true; console.log('  page errors: ' + errs.join(' | ')) }
  const shot = path.join(dir, 'headless-tab.png')
  await page.screenshot({ path: shot })
  console.log('  screenshot: ' + shot)

  await browser.close(); server.close()
  console.log('\n' + (failed ? 'STAGE 1 FAILED' : 'STAGE 1 PASSED — htmx app runs headless in a tab, UI streamed from a worker over XHR-as-streamx'))
  process.exit(failed ? 1 : 0)
}