/** * 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// 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() { // 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() 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) } }, } }