// 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
}