OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / webui / lib / server.js

175 lines · javascript ref:
// HTTP surface: the page, a JSON API, and a Server-Sent Events stream that
// pushes the rig's state to every open page whenever it changes.
//
// ⛔ No login: LAN-only by decision (2026-10-02). What stops a random web page
// in a LAN browser from driving the lights is that every POST must carry
// `x-cthuloid: 1` with a JSON body -- a cross-site request cannot add a custom
// header without a CORS preflight, and this server never answers one.

import http from 'bare-http1'
import fs from 'bare-fs'
import path from 'bare-path'
import { ASSET_FILES } from './asset-files.js'

const STATIC_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'static')
const MAX_BODY = 64 * 1024
const PING_MS = 15000

class HttpError extends Error {
  constructor (status, message) {
    super(message)
    this.status = status
  }
}

function sendJSON (res, status, obj) {
  const body = JSON.stringify(obj)
  res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'cache-control': 'no-store' })
  res.end(body)
}

function readJSON (req) {
  return new Promise((resolve, reject) => {
    let size = 0
    const chunks = []
    req.on('data', (d) => {
      size += d.length
      if (size > MAX_BODY) return reject(new HttpError(413, 'body too large'))
      chunks.push(d)
    })
    req.on('end', () => {
      const text = Buffer.concat(chunks).toString()
      if (!text) return resolve({})
      try {
        resolve(JSON.parse(text))
      } catch {
        reject(new HttpError(400, 'body is not JSON'))
      }
    })
    req.on('error', reject)
  })
}

// assets: { url: { type, body } } embedded page files (a packed worklet has no
// files on disk); without it static/ is read per request, so edits show at once.
export function createServer (rig, { assets = null } = {}) {
  const streams = new Set()

  // One snapshot per change, to every open page.
  rig.on('change', () => {
    const frame = 'event: state\ndata: ' + JSON.stringify(rig.snapshot()) + '\n\n'
    for (const res of streams) res.write(frame)
  })
  const ping = setInterval(() => {
    for (const res of streams) res.write(': ping\n\n')
  }, PING_MS)

  function events (req, res) {
    res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' })
    res.write('event: state\ndata: ' + JSON.stringify(rig.snapshot()) + '\n\n')
    streams.add(res)
    rig.setViewers(streams.size)
    const gone = () => {
      if (!streams.delete(res)) return
      rig.setViewers(streams.size)
    }
    // Only the RESPONSE closing means the page went away. In bare-http1 the
    // request emits 'close' as soon as its (empty) body is read -- measured at
    // ~9 ms -- and listening for that dropped every stream after one frame.
    res.on('close', gone)
    res.on('error', gone)
  }

  async function api (req, res, url) {
    if (req.method === 'GET' && url === '/api/state') return sendJSON(res, 200, rig.snapshot())
    if (req.method === 'GET' && url === '/api/events') return events(req, res)
    if (req.method !== 'POST') throw new HttpError(405, 'method not allowed')
    if (req.headers['x-cthuloid'] !== '1') throw new HttpError(403, 'missing x-cthuloid header')

    const body = await readJSON(req)
    const parts = url.split('/').slice(2).map(decodeURIComponent) // ['units', id, action]
    const [kind, id, action] = parts

    if (kind === 'connect' && parts.length === 1) {
      rig.setConnected(!!body.on)
    } else if (kind === 'blackout' && parts.length === 1) {
      rig.blackout()
    } else if (kind === 'units' && parts.length === 1) {
      const name = await rig.addManual(String(body.host || '').trim(), Number(body.port) || 1337)
      return sendJSON(res, 200, { ok: true, id: name })
    } else if (kind === 'units' && parts.length === 2) {
      rig.updateUnit(id, body)
    } else if (kind === 'units' && action === 'slot') {
      try {
        rig.setSlot(id, Number(body.index), body, String(body.session || ''))
      } catch (err) {
        if (/not holding/.test(err.message)) throw new HttpError(409, err.message)
        throw err
      }
    } else if (kind === 'units' && action === 'grab') {
      // Proto 3: a browser session asks for a slot before dragging it.
      const r = await rig.grab(id, Number(body.index), String(body.session || ''), body.name)
      return sendJSON(res, r.granted ? 200 : 409, r)
    } else if (kind === 'units' && action === 'release') {
      rig.release(id, Number(body.index), String(body.session || ''))
    } else if (kind === 'units' && action === 'blackout') {
      rig.blackout(id)
    } else if (kind === 'units' && action === 'settings') {
      if (!rig.pushSettings(id)) throw new HttpError(409, 'unit is not connected')
    } else if (kind === 'units' && action === 'adopt') {
      if (!rig.adoptActual(id)) throw new HttpError(409, 'no readback from this unit yet')
    } else if (kind === 'looks' && parts.length === 1) {
      rig.saveLook(body.name)
    } else if (kind === 'looks' && action === 'recall') {
      const r = await rig.recallLook(id, Number(body.fadeMs) || 0)
      return sendJSON(res, 200, { ok: true, skipped: r.skipped })
    } else if (kind === 'looks' && action === 'delete') {
      rig.deleteLook(id)
    } else {
      throw new HttpError(404, 'not found')
    }
    sendJSON(res, 200, { ok: true })
  }

  const server = http.createServer((req, res) => {
    const url = req.url.split('?')[0]
    if (url.startsWith('/api/')) {
      api(req, res, url).catch((err) => {
        // A thrown model error (unknown unit, bad slot) is the caller's fault.
        const status = err.status || 400
        if (status >= 500) console.error('api failed: ' + (err.stack || err))
        sendJSON(res, status, { error: err.message })
      })
      return
    }
    const asset = ASSET_FILES[url]
    if (!asset || req.method !== 'GET') {
      res.writeHead(404, { 'content-type': 'text/plain' })
      return res.end('not found')
    }
    let body
    if (assets) {
      body = assets[url] ? Buffer.from(assets[url].body, 'utf8') : null
    } else {
      try {
        body = fs.readFileSync(path.join(STATIC_DIR, asset[0]))
      } catch {
        body = null
      }
    }
    if (!body) {
      res.writeHead(500, { 'content-type': 'text/plain' })
      return res.end('asset missing: ' + asset[0])
    }
    res.writeHead(200, { 'content-type': asset[1], 'content-length': body.length, 'cache-control': 'no-cache' })
    res.end(body)
  })

  server.on('close', () => {
    clearInterval(ping)
    for (const res of streams) res.end()
    streams.clear()
  })
  return server
}