doxa-maps/internal/backend-dev-plugin.mjs
Doxa Maps bac8a1048c Rudder with RUDDER_ENGINE=0 never starts the engine, and a page on another machine never offers to record the server's mic
A server where Rudder is laptop-only (RUDDER_ENGINE=0) no longer starts a
leftover engine binary. If an engine that records its own machine's microphone
is reached from another machine anyway, Rudder shows the laptop notice and
disables Record instead of recording the server.
2026-09-27 23:24:08 +00:00

135 lines
6.9 KiB
JavaScript

/**
* internal/backend-dev-plugin.mjs — `bun run dev` starts the local plugin backends.
*
* WHY: some plugins ship to DESKTOP with a local backend (rudder-voice → backend/bin/rudder-engine,
* run as a Tauri sidecar). In the Plugin Studio the same backend must be running, or the plugin can't
* be tested in the browser. This plugin starts each built backend with the dev server, forwards its
* /api/<name>/ routes, and stops it when the dev server stops. A backend that isn't built is skipped
* with a hint: plugins without a backend never notice this file.
*
* ★ ONE BACKEND PER `bun run dev`, SURVIVING VITE RESTARTS. Vite restarts itself whenever vite.config.js
* or anything it imports changes, and re-runs configureServer. Stopping the backend on every restart and
* starting a new one at once raced the old process for its port: the new one died with "address already
* in use" and Rudder showed "engine offline" until `bun run dev` was restarted by hand (measured
* 2026-09-27, twice in one session). Each backend now lives in a process-wide registry (globalThis — the
* config module itself is re-imported on a restart): a restart REUSES the running backend, it is stopped
* only when the dev process exits, and it is restarted (up to 5 times) if it crashes.
*
* DEV ONLY (`apply: 'serve'`). A production build never starts or bundles a backend.
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
// name → how to run it. `route` is proxied to `port` on 127.0.0.1.
export const DEV_BACKENDS = [
{
name: 'rudder-engine',
bin: 'backend/bin/rudder-engine',
build: 'cd backend/rudder-engine && go build -o ../bin/rudder-engine .',
route: '/api/rudder',
port: Number(process.env.RUDDER_PORT || 5193),
env: { RUDDER_PORT: String(process.env.RUDDER_PORT || 5193), RUDDER_NEMO_PORT: String(process.env.RUDDER_NEMO_PORT || 5194) },
// This server is SUPPOSED to run the engine (a server deployment with the engine switched on): while it
// is down, the plugin says "not running yet" instead of "runs on your own computer".
expected: () => process.env.RUDDER_ENGINE === '1' || process.env.RUDDER_AUDIO === 'browser',
// RUDDER_ENGINE=0 (a server where Rudder is laptop-only, the Driver 2026-09-27): never start it, even if a
// binary built by an earlier engine-on deploy is still there — it would offer to record a server's mic.
disabled: () => process.env.RUDDER_ENGINE === '0',
},
]
// The proxy table for vite's `server.proxy`. SSE (`/events`) streams through unbuffered.
export function devBackendProxy() {
// ws: a backend may take a WebSocket (rudder's /stream carries the browser's microphone on a server deployment).
return Object.fromEntries(DEV_BACKENDS.map((b) => [b.route, { target: `http://127.0.0.1:${b.port}`, changeOrigin: false, ws: true }]))
}
const REG = (globalThis.__dxDevBackends ||= { procs: new Map(), exitHooked: false, stopping: false })
const isAlive = (name) => { const e = REG.procs.get(name); return !!e && e.child.exitCode === null && e.child.signalCode === null }
function stopAll() {
REG.stopping = true
for (const { child } of REG.procs.values()) { try { child.kill('SIGTERM') } catch {} }
}
function start(root, b, logger, restarts = 0) {
const tag = `[${b.name}]`
const child = spawn(resolve(root, b.bin), [], { cwd: root, env: { ...process.env, ...b.env }, stdio: ['ignore', 'pipe', 'pipe'] })
const entry = { child, restarts, logger }
REG.procs.set(b.name, entry)
const out = (d) => String(d).trim().split('\n').forEach((l) => l && entry.logger.info(`${tag} ${l}`))
child.stdout.on('data', out)
child.stderr.on('data', out)
child.on('exit', (code) => {
if (REG.procs.get(b.name) !== entry) return
REG.procs.delete(b.name)
if (REG.stopping) return
entry.logger.warn(`${tag} exited (${code})`)
// Crashed on its own: restart it, after its port has had a moment to free up.
if (entry.restarts < 5) {
setTimeout(() => {
if (REG.stopping || REG.procs.has(b.name)) return
entry.logger.warn(`${tag} restarting (${entry.restarts + 1}/5)`)
start(root, b, entry.logger, entry.restarts + 1)
}, 2000)
}
})
logger.info(`${tag} started → ${b.route} on 127.0.0.1:${b.port}`)
}
export function backendDevPlugin(root) {
return {
name: 'dx-backend-dev',
apply: 'serve',
configureServer(server) {
// ENGINE NOT RUNNING, QUIETLY (dx-shepherd, R500): with no backend process the proxy answered
// 500 ECONNREFUSED, which every page showed as console errors. While a backend is down its routes get
// well-formed answers instead: /events streams ONE status event saying the engine is offline (the
// plugin renders its own "engine not running" state from it), anything else returns an empty JSON
// list. Registered here, so it runs before vite's proxy.
for (const b of DEV_BACKENDS) {
server.middlewares.use(b.route, (req, res, next) => {
if (isAlive(b.name)) return next()
const status = { engine: 'offline', expected: !!(b.expected && b.expected()), error: 'The desktop engine is not running.' }
if ((req.url || '').startsWith('/events')) {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
res.write(`data: ${JSON.stringify({ type: 'status', status })}\n\n`)
return // held open, so the EventSource does not reconnect in a loop
}
// /status answers the same, for a page whose event stream cannot get through (a proxy that buffers).
if ((req.url || '').startsWith('/status')) {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(status))
return
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end('[]')
})
}
const logger = server.config.logger
if (!REG.exitHooked) {
REG.exitHooked = true
process.once('exit', stopAll)
process.once('SIGINT', () => { stopAll(); process.exit(0) })
process.once('SIGTERM', () => { stopAll(); process.exit(0) })
}
for (const b of DEV_BACKENDS) {
if (isAlive(b.name)) {
REG.procs.get(b.name).logger = logger // log through the restarted server
logger.info(`[${b.name}] still running → ${b.route} (kept across the dev-server restart)`)
continue
}
if (b.disabled && b.disabled()) {
logger.info(`[backend] ${b.name} switched off here (${b.name === 'rudder-engine' ? 'RUDDER_ENGINE=0' : 'disabled'})`)
continue
}
if (!existsSync(resolve(root, b.bin))) {
logger.warn(`[backend] ${b.name} not built — its plugin will show "engine offline". Build: ${b.build}`)
continue
}
start(root, b, logger)
}
},
}
}