doxa-maps/internal/backend-dev-plugin.mjs
Doxa Maps 89ff223846 Rudder with no engine running is quiet in dev: the dev backend answers offline instead of 500
dx-shepherd (R500): with no rudder-engine process the dev proxy returned 500 ECONNREFUSED for
/api/rudder/{devices,events}, which showed as console errors. While a backend is down, backendDevPlugin now
answers its routes itself: /events streams one status event (engine offline, 'The desktop engine is not
running.'), which Rudder renders as its own state, and other routes return an empty JSON list. Running
engines are proxied as before.
2026-09-27 10:05:45 +00:00

80 lines
4 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.
*
* 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 }]))
}
export function backendDevPlugin(root) {
const children = []
const alive = new Set() // backends whose process is running right now
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 (alive.has(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('[]')
})
}
for (const b of DEV_BACKENDS) {
const bin = resolve(root, b.bin)
if (!existsSync(bin)) {
server.config.logger.warn(`[backend] ${b.name} not built — its plugin will show "engine offline". Build: ${b.build}`)
continue
}
const child = spawn(bin, [], { cwd: root, env: { ...process.env, ...b.env }, stdio: ['ignore', 'pipe', 'pipe'] })
const tag = `[${b.name}]`
const out = (d) => String(d).trim().split('\n').forEach((l) => l && server.config.logger.info(`${tag} ${l}`))
child.stdout.on('data', out)
child.stderr.on('data', out)
child.on('exit', (code) => { alive.delete(b.name); server.config.logger.warn(`${tag} exited (${code})`) })
children.push(child)
alive.add(b.name)
server.config.logger.info(`${tag} started → ${b.route} on 127.0.0.1:${b.port}`)
}
const stop = () => children.forEach((c) => { try { c.kill('SIGTERM') } catch {} })
server.httpServer?.on('close', stop)
process.once('exit', stop)
process.once('SIGINT', () => { stop(); process.exit(0) })
process.once('SIGTERM', () => { stop(); process.exit(0) })
},
}
}