Two gaps found while checking the corner widget (1775f5ac: "Needs eyes: open, HMR, collapse" — all three verified in Firefox against the running Plugin Studio: opens, keeps the SAME element across a Studio hot update, collapses). - LIVE MIC WHEN COLLAPSED: rudder-voice emits `recording` (true/false); the Studio's corner button turns red with "● " while it listens, so closing the panel never hides that the mic is on. - DEV BACKENDS SURVIVE VITE RESTARTS: Vite restarts itself when vite.config.js or anything it imports changes. The backend was stopped on every restart and a new one started at once; the old process still held the port, the new one died "address already in use", and Rudder showed "engine offline" until `bun run dev` was restarted by hand (measured twice). Backends now live in a process-wide registry: reused across restarts, stopped only when the dev process exits, restarted if they crash. The "engine not running, quietly" answers (89ff2238) are unchanged and read the same registry.
117 lines
5.6 KiB
JavaScript
117 lines
5.6 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) },
|
|
},
|
|
]
|
|
|
|
// The proxy table for vite's `server.proxy`. SSE (`/events`) streams through unbuffered.
|
|
export function devBackendProxy() {
|
|
return Object.fromEntries(DEV_BACKENDS.map((b) => [b.route, { target: `http://127.0.0.1:${b.port}`, changeOrigin: false }]))
|
|
}
|
|
|
|
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()
|
|
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: { engine: 'offline', error: 'The desktop engine is not running.' } })}\n\n`)
|
|
return // held open, so the EventSource does not reconnect in a loop
|
|
}
|
|
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 (!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)
|
|
}
|
|
},
|
|
}
|
|
}
|