254 lines
13 KiB
JavaScript
254 lines
13 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, mkdirSync, writeFileSync, rmSync, chmodSync, renameSync } from 'node:fs'
|
|
import { createHash } from 'node:crypto'
|
|
import { connect } from 'node:net'
|
|
import { inflateRawSync } from 'node:zlib'
|
|
import { resolve } from 'node:path'
|
|
|
|
// PocketBase, the backend for plugins that need one: the SAME pinned version as the cloud container
|
|
// (deployment/cloud/Dockerfile), SHA-256 from the release's checksums.txt. Downloaded on the first
|
|
// `bun run dev` into backend/bin (gitignored); its data lives in .pb/dev (gitignored).
|
|
const PB_VERSION = '0.40.4'
|
|
const PB_SHA256 = {
|
|
linux_amd64: '9042ec818570e79c3628dadcd0a756c1496d9e1173918ec409d133c02f82e5fa',
|
|
linux_arm64: '86095bf8ed9345954f0d2bf0a5fb9b57584ae60b77ebf3b6cd23a8003a3fd418',
|
|
darwin_amd64: '052906521f09f6f23405cd804c930d2b7a1f8eee06d39b85723b5054b2acb4e4',
|
|
darwin_arm64: 'eeb619ea4f8a06421daedb946d133bed269fea334a760941d147f76befc25ebc',
|
|
}
|
|
|
|
// name → how to run it. `route` is proxied to `port` on 127.0.0.1, or to `remote` when that is set.
|
|
// `env` is vite's loadEnv (so .env.local counts), falling back to process.env.
|
|
export function devBackends(env = process.env) {
|
|
const pbPort = Number(env.DX_PB_PORT || 8090)
|
|
return [
|
|
{
|
|
name: 'rudder-engine',
|
|
bin: 'backend/bin/rudder-engine',
|
|
build: 'cd backend/rudder-engine && go build -o ../bin/rudder-engine .',
|
|
// What a fresh clone is told at startup when this backend is not built: it is OPTIONAL (desktop voice).
|
|
optional: 'optional: Rudder (desktop voice-to-text) is not built, which is fine for the maps. It needs Go 1.22+; see documentation/HOWTO-build-the-desktop-app.md.',
|
|
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 agent 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',
|
|
},
|
|
{
|
|
// Hooks off (a hooks dir that never exists): only the migrations in git shape the schema, as in the cloud.
|
|
// DX_PB_URL points this laptop at a shared PocketBase instead (e.g. the cloud pb-dev); none starts here.
|
|
name: 'pocketbase',
|
|
bin: 'backend/bin/pocketbase',
|
|
args: ['serve', '--http', `127.0.0.1:${pbPort}`, '--dir', '.pb/dev', '--migrationsDir', 'backend/pocketbase/pb_migrations', '--hooksDir', '.pb/hooks-off'],
|
|
build: 'it downloads on the next `bun run dev` (or set DX_PB_URL)',
|
|
fetch: fetchPocketBase,
|
|
portEnv: 'DX_PB_PORT',
|
|
route: '/api/pb',
|
|
rewrite: (p) => p.replace(/^\/api\/pb/, '') || '/',
|
|
port: pbPort,
|
|
remote: env.DX_PB_URL || '',
|
|
offline: (res) => {
|
|
res.writeHead(503, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ status: 503, message: 'PocketBase is not running on this computer (yet). See the dev server log.' }))
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
async function fetchPocketBase(root, logger) {
|
|
const plat = { 'linux-x64': 'linux_amd64', 'linux-arm64': 'linux_arm64', 'darwin-x64': 'darwin_amd64', 'darwin-arm64': 'darwin_arm64' }[`${process.platform}-${process.arch}`]
|
|
if (!plat) {
|
|
logger.warn(`[pocketbase] no pinned build for ${process.platform}-${process.arch}: put PocketBase ${PB_VERSION} at backend/bin/pocketbase, or set DX_PB_URL`)
|
|
return false
|
|
}
|
|
const name = `pocketbase_${PB_VERSION}_${plat}.zip`
|
|
const dir = resolve(root, 'backend/bin')
|
|
const part = resolve(dir, `.pocketbase.part`)
|
|
logger.info(`[pocketbase] downloading ${name} (first run only)`)
|
|
try {
|
|
mkdirSync(dir, { recursive: true })
|
|
const r = await fetch(`https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/${name}`)
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
|
const buf = Buffer.from(await r.arrayBuffer())
|
|
const sha = createHash('sha256').update(buf).digest('hex')
|
|
if (sha !== PB_SHA256[plat]) throw new Error(`checksum mismatch (${sha}): refusing to use it`)
|
|
writeFileSync(part, unzipOne(buf, 'pocketbase')) // not every laptop has `unzip`
|
|
chmodSync(part, 0o755)
|
|
renameSync(part, resolve(dir, 'pocketbase'))
|
|
return true
|
|
} catch (e) {
|
|
logger.warn(`[pocketbase] download failed (${e.message}); /api/pb answers 503 until the next \`bun run dev\``)
|
|
return false
|
|
} finally {
|
|
rmSync(part, { force: true })
|
|
}
|
|
}
|
|
|
|
// One file out of a (non-zip64) zip, via its central directory; the zip's SHA-256 was checked before this.
|
|
function unzipOne(buf, want) {
|
|
const end = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
|
|
if (end < 0) throw new Error('not a zip')
|
|
for (let i = 0, p = buf.readUInt32LE(end + 16); i < buf.readUInt16LE(end + 10); i++) {
|
|
const method = buf.readUInt16LE(p + 10), size = buf.readUInt32LE(p + 20)
|
|
const nameLen = buf.readUInt16LE(p + 28), local = buf.readUInt32LE(p + 42)
|
|
if (buf.toString('utf8', p + 46, p + 46 + nameLen) === want) {
|
|
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
|
|
const data = buf.subarray(start, start + size)
|
|
if (method === 8) return inflateRawSync(data)
|
|
if (method === 0) return data
|
|
throw new Error(`unsupported zip method ${method}`)
|
|
}
|
|
p += 46 + nameLen + buf.readUInt16LE(p + 30) + buf.readUInt16LE(p + 32)
|
|
}
|
|
throw new Error(`${want} not in the zip`)
|
|
}
|
|
|
|
// Another `bun run dev` (another worktree) may already hold the port: say so once instead of crash-looping.
|
|
const portInUse = (port) => new Promise((ok) => {
|
|
const s = connect({ host: '127.0.0.1', port }, () => { s.destroy(); ok(true) })
|
|
s.on('error', () => ok(false))
|
|
})
|
|
|
|
// The proxy table for vite's `server.proxy`. SSE (`/events`) streams through unbuffered.
|
|
export function devBackendProxy(env = process.env) {
|
|
// ws: a backend may take a WebSocket (rudder's /stream carries the browser's microphone on a server deployment).
|
|
return Object.fromEntries(devBackends(env).map((b) => [b.route, {
|
|
target: b.remote || `http://127.0.0.1:${b.port}`, changeOrigin: !!b.remote, ws: true,
|
|
...(b.rewrite ? { rewrite: b.rewrite } : {}),
|
|
}]))
|
|
}
|
|
|
|
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), b.args || [], { 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, env = process.env) {
|
|
const backends = devBackends(env)
|
|
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 backends) {
|
|
server.middlewares.use(b.route, (req, res, next) => {
|
|
if (b.remote || isAlive(b.name)) return next()
|
|
if (b.offline) return b.offline(res)
|
|
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 backends) {
|
|
if (b.remote) {
|
|
logger.info(`[${b.name}] not started here: ${b.route} → ${b.remote}`)
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
const launch = async () => {
|
|
if (b.portEnv && await portInUse(b.port)) {
|
|
logger.warn(`[${b.name}] port ${b.port} is already in use (another \`bun run dev\`?): not started. Set ${b.portEnv} to use another port.`)
|
|
return
|
|
}
|
|
if (!REG.stopping && !isAlive(b.name)) start(root, b, logger)
|
|
}
|
|
if (!existsSync(resolve(root, b.bin))) {
|
|
if (b.fetch) {
|
|
REG.fetching ||= new Set()
|
|
if (REG.fetching.has(b.name)) continue // a vite restart mid-download: the first one finishes it
|
|
REG.fetching.add(b.name)
|
|
b.fetch(root, logger).then((ok) => { REG.fetching.delete(b.name); if (ok) launch() })
|
|
continue
|
|
}
|
|
// An OPTIONAL backend is a note, not a warning: a stranger's first `bun run dev` line said
|
|
// "rudder-engine not built … go build …" with no word that it is optional or needs Go (upg100's
|
|
// fresh-clone run). A backend without that note keeps the build instruction.
|
|
if (b.optional) logger.info(`[backend] ${b.optional}`)
|
|
else logger.warn(`[backend] ${b.name} not built — its plugin will show "engine offline". Build: ${b.build}`)
|
|
continue
|
|
}
|
|
launch()
|
|
}
|
|
},
|
|
}
|
|
}
|