OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / 9db0b4e

webui: createApp() library entry + embeddable page assets
So the Android app's worklet runs exactly what stortron runs: - lib/app.js createApp({ configDir, host, port, discovery, assets }) -- store + rig + server, listen()/close(). Imports NO discovery: the Linux one spawns avahi-browse via bare-subprocess, which a phone has neither of; callers inject theirs (avahi on desktops, NsdManager over IPC on the phone) or none (units added by address). - lib/asset-files.js: the one list of page files; scripts/gen-assets.js embeds them (lib/assets.generated.js, gitignored) for a packed bundle, which has no files on disk. createServer(rig, { assets }). - index.js is now a thin caller of createApp. Tests 64/64 incl. embedded assets served byte-identical to static/; smoke: index.js serves and discovers studiolight; generator runs on Bare. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9db0b4efce705c1ae11cb2b60fb74fd3978de488
by plankatron <fleeky@gmail.com> · 2026-10-02T15:48:58.000Z

Changed files (8) +188 -27

webui/.gitignore +1 -0
……@@ -1 +1,2 @@
11node_modules/
2lib/assets.generated.js
webui/index.js +7 -14
……@@ -3,7 +3,8 @@
33// Finds units over mDNS (_cthuloid._tcp, via the host's avahi), serves one page
44// on the LAN, and drives the units the user includes, the same way the Blender
55// addon does. Starts disconnected on every launch: nothing is sent to a light
6// until someone presses Connect.
6// until someone presses Connect. The controller itself is lib/app.js, shared
7// with the Android app.
78//
89// ⛔ LAN-only, no login, by decision (2026-10-02). The pgh defaults bind
910// 0.0.0.0:4050; the code default is 127.0.0.1 so a bare `bare index.js` on a
……@@ -12,34 +13,26 @@
1213import os from 'bare-os'
1314import path from 'bare-path'
1415import process from 'bare-process'
15import { Store } from './lib/store.js'
1616import { Discovery } from './lib/discovery.js'
17import { Rig } from './lib/rig.js'
18import { createServer } from './lib/server.js'
17import { createApp } from './lib/app.js'
1918
2019const port = Number(process.env.CTHULOID_WEB_PORT || 4050)
2120const host = process.env.CTHULOID_WEB_HOST || '127.0.0.1'
2221const configDir = process.env.CTHULOID_WEB_CONFIG || path.join(os.homedir(), '.config', 'cthuloid-web')
2322
24const store = new Store(configDir)
2523const discovery = new Discovery()
26const rig = new Rig({ store, discovery })
27
2824discovery.on('unavailable', (reason) => console.error(JSON.stringify({ msg: 'discovery unavailable', reason })))
2925discovery.on('up', (u) => console.log(JSON.stringify({ msg: 'unit up', name: u.name, addr: u.addr, proto: u.proto })))
3026discovery.on('down', (u) => console.log(JSON.stringify({ msg: 'unit down', name: u.name })))
3127
32rig.start()
33const server = createServer(rig)
34server.listen(port, host, () => {
35 console.log(JSON.stringify({ msg: 'cthuloid-web up', url: 'http://' + host + ':' + port, config: configDir }))
28const app = createApp({ configDir, host, port, discovery })
29app.listen().then(({ port: p }) => {
30 console.log(JSON.stringify({ msg: 'cthuloid-web up', url: 'http://' + host + ':' + p, config: configDir }))
3631})
3732
3833// Settings are written on a short debounce; flush them on the way out.
3934const shutdown = () => {
40 rig.stop()
41 server.close()
42 process.exit(0)
35 app.close().then(() => process.exit(0))
4336}
4437process.on('SIGTERM', shutdown)
4538process.on('SIGINT', shutdown)
webui/lib/app.js +54 -0
……@@ -0,0 +1,54 @@
1// The controller as a library: store + rig + HTTP server, started and stopped as
2// one. stortron's index.js and the Android app's worklet both start it here, so
3// the phone runs exactly the code stortron runs.
4//
5// Deliberately imports NO discovery: the Linux one spawns avahi-browse through
6// bare-subprocess, which a phone has neither of. The caller passes its own --
7// avahi on a desktop, Android's NsdManager over IPC in the app -- or none, and
8// units are added by address.
9
10import EventEmitter from 'bare-events'
11import { Store } from './store.js'
12import { Rig } from './rig.js'
13import { createServer } from './server.js'
14
15// No automatic discovery: present, quiet, never "broken".
16class NoDiscovery extends EventEmitter {
17 constructor () {
18 super()
19 this.available = true
20 this.reason = ''
21 }
22
23 start () {}
24 stop () {}
25}
26
27// assets: embedded page files (scripts/gen-assets.js) for a packed bundle; null
28// reads static/ from disk.
29export function createApp ({ configDir, host = '127.0.0.1', port = 4050, discovery = null, assets = null, rigOpts = {} }) {
30 const store = new Store(configDir)
31 const rig = new Rig({ store, discovery: discovery || new NoDiscovery(), ...rigOpts })
32 const server = createServer(rig, { assets })
33 let started = false
34
35 return {
36 rig,
37 server,
38 listen () {
39 if (!started) { rig.start(); started = true }
40 return new Promise((resolve, reject) => {
41 server.once('error', reject)
42 server.listen(port, host, () => {
43 server.off('error', reject)
44 resolve({ host, port: server.address().port })
45 })
46 })
47 },
48 close () {
49 if (started) rig.stop() // flushes the debounced config save
50 started = false
51 return new Promise((resolve) => server.close(() => resolve()))
52 }
53 }
54}
webui/lib/asset-files.js +11 -0
……@@ -0,0 +1,11 @@
1// The page's files: URL -> [file in static/, content type]. One list, read by the
2// server (from disk on a desktop) and by scripts/gen-assets.js (embedded for a
3// packed worklet, which has no files on disk).
4export const ASSET_FILES = {
5 '/': ['index.html', 'text/html; charset=utf-8'],
6 '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
7 '/app.css': ['app.css', 'text/css; charset=utf-8'],
8 '/color.js': ['color.js', 'text/javascript; charset=utf-8'],
9 '/picker.js': ['picker.js', 'text/javascript; charset=utf-8'],
10 '/swatches.js': ['swatches.js', 'text/javascript; charset=utf-8']
11}
webui/lib/server.js +15 -13
……@@ -9,16 +9,9 @@
99import http from 'bare-http1'
1010import fs from 'bare-fs'
1111import path from 'bare-path'
12import { ASSET_FILES } from './asset-files.js'
1213
1314const STATIC_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'static')
14const ASSETS = {
15 '/': ['index.html', 'text/html; charset=utf-8'],
16 '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
17 '/app.css': ['app.css', 'text/css; charset=utf-8'],
18 '/color.js': ['color.js', 'text/javascript; charset=utf-8'],
19 '/picker.js': ['picker.js', 'text/javascript; charset=utf-8'],
20 '/swatches.js': ['swatches.js', 'text/javascript; charset=utf-8']
21}
2215const MAX_BODY = 64 * 1024
2316const PING_MS = 15000
2417
……@@ -57,7 +50,9 @@ function readJSON (req) {
5750 })
5851}
5952
60export function createServer (rig) {
53// assets: { url: { type, body } } embedded page files (a packed worklet has no
54// files on disk); without it static/ is read per request, so edits show at once.
55export function createServer (rig, { assets = null } = {}) {
6156 const streams = new Set()
6257
6358 // One snapshot per change, to every open page.
……@@ -147,15 +142,22 @@ export function createServer (rig) {
147142 })
148143 return
149144 }
150 const asset = ASSETS[url]
145 const asset = ASSET_FILES[url]
151146 if (!asset || req.method !== 'GET') {
152147 res.writeHead(404, { 'content-type': 'text/plain' })
153148 return res.end('not found')
154149 }
155150 let body
156 try {
157 body = fs.readFileSync(path.join(STATIC_DIR, asset[0]))
158 } catch (err) {
151 if (assets) {
152 body = assets[url] ? Buffer.from(assets[url].body, 'utf8') : null
153 } else {
154 try {
155 body = fs.readFileSync(path.join(STATIC_DIR, asset[0]))
156 } catch {
157 body = null
158 }
159 }
160 if (!body) {
159161 res.writeHead(500, { 'content-type': 'text/plain' })
160162 return res.end('asset missing: ' + asset[0])
161163 }
webui/scripts/gen-assets-cli.js +9 -0
……@@ -0,0 +1,9 @@
1// bare scripts/gen-assets-cli.js [out] -- writes lib/assets.generated.js by default.
2import fs from 'bare-fs'
3import path from 'bare-path'
4import { buildAssets, assetsModule } from './gen-assets.js'
5
6const out = Bare.argv[2] || path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'lib', 'assets.generated.js')
7const assets = buildAssets()
8fs.writeFileSync(out, assetsModule(assets))
9console.log('wrote ' + out + ' (' + Object.keys(assets).length + ' files)')
webui/scripts/gen-assets.js +21 -0
……@@ -0,0 +1,21 @@
1// Embed the page's files into a JS module, for a packed worklet (the Android app),
2// where there are no files on disk to read. Used by scripts/gen-assets-cli.js and
3// tested in test/app.test.js against the files themselves.
4import fs from 'bare-fs'
5import path from 'bare-path'
6import { ASSET_FILES } from '../lib/asset-files.js'
7
8const STATIC_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'static')
9
10export function buildAssets (dir = STATIC_DIR) {
11 const out = {}
12 for (const [url, [file, type]] of Object.entries(ASSET_FILES)) {
13 out[url] = { type, body: fs.readFileSync(path.join(dir, file), 'utf8') }
14 }
15 return out
16}
17
18export function assetsModule (assets) {
19 return '// GENERATED by scripts/gen-assets-cli.js from static/ -- do not edit.\n' +
20 'export default ' + JSON.stringify(assets, null, 1) + '\n'
21}
webui/test/app.test.js +70 -0
……@@ -0,0 +1,70 @@
1// createApp: the one entry both stortron (index.js) and the phone's worklet use.
2import test from 'brittle'
3import http from 'bare-http1'
4import fs from 'bare-fs'
5import os from 'bare-os'
6import path from 'bare-path'
7import EventEmitter from 'bare-events'
8import { createApp } from '../lib/app.js'
9import { buildAssets } from '../scripts/gen-assets.js'
10import { ASSET_FILES } from '../lib/asset-files.js'
11
12class FakeDiscovery extends EventEmitter {
13 constructor () { super(); this.available = true; this.reason = '' }
14 start () {}
15 stop () {}
16}
17
18function get (port, p) {
19 return new Promise((resolve, reject) => {
20 http.request({ host: '127.0.0.1', port, path: p }, (res) => {
21 const chunks = []
22 res.on('data', (d) => chunks.push(d))
23 res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], body: Buffer.concat(chunks) }))
24 }).on('error', reject).end()
25 })
26}
27
28const tmp = () => path.join(os.tmpdir(), 'cthuloid-app-test-' + Date.now() + '-' + Math.random().toString(36).slice(2))
29
30test('createApp starts, serves state, stops', async (t) => {
31 const app = createApp({ configDir: tmp(), port: 0, discovery: new FakeDiscovery() })
32 const { port } = await app.listen()
33 const r = await get(port, '/api/state')
34 t.is(r.status, 200)
35 t.is(JSON.parse(r.body).connected, false)
36 await app.close()
37})
38
39test('createApp works with no discovery at all (units added by hand)', async (t) => {
40 const app = createApp({ configDir: tmp(), port: 0 })
41 const { port } = await app.listen()
42 const st = JSON.parse((await get(port, '/api/state')).body)
43 t.is(st.discovery.available, true, 'no "discovery broken" banner')
44 await app.close()
45})
46
47test('generated assets are exactly the files in static/', (t) => {
48 const assets = buildAssets()
49 const dir = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'static')
50 for (const [url, [file, type]] of Object.entries(ASSET_FILES)) {
51 t.is(assets[url].type, type, url + ' type')
52 t.is(assets[url].body, fs.readFileSync(path.join(dir, file), 'utf8'), url + ' body')
53 }
54})
55
56test('embedded assets are served byte-identical to the disk ones', async (t) => {
57 const disk = createApp({ configDir: tmp(), port: 0 })
58 const packed = createApp({ configDir: tmp(), port: 0, assets: buildAssets() })
59 const a = (await disk.listen()).port
60 const b = (await packed.listen()).port
61 for (const url of Object.keys(ASSET_FILES)) {
62 const x = await get(a, url)
63 const y = await get(b, url)
64 t.is(y.status, 200, url)
65 t.is(y.type, x.type, url + ' type')
66 t.ok(Buffer.compare(x.body, y.body) === 0, url + ' bytes')
67 }
68 await disk.close()
69 await packed.close()
70})