diff --git a/.env.example b/.env.example index 323fe31cb..4beb20906 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,7 @@ VITE_API_BASE_URL=https://pray.doxa.life # DX_SYNC_BRANCH=main # DX_SYNC_INTERVAL=15 +# PocketBase for plugins (internal/backend-dev-plugin.mjs): `bun run dev` downloads the pinned binary once +# and serves it at /api/pb, data in .pb/dev. DX_PB_URL uses a shared PocketBase instead of a local one. +# DX_PB_PORT=8090 +# DX_PB_URL= diff --git a/.gitignore b/.gitignore index de4567753..81008ec50 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,5 @@ data-private/ # Local plugin backends (backend//) — the compiled binaries are rebuilt from source, never committed. /backend/bin/ +# The local PocketBase's data (`bun run dev`, internal/backend-dev-plugin.mjs): per laptop, never committed. +/.pb/ diff --git a/internal/backend-dev-plugin.mjs b/internal/backend-dev-plugin.mjs index 0f15dfb76..c2e76bbbf 100644 --- a/internal/backend-dev-plugin.mjs +++ b/internal/backend-dev-plugin.mjs @@ -18,11 +18,28 @@ * 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 { 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' -// name → how to run it. `route` is proxied to `port` on 127.0.0.1. -export const DEV_BACKENDS = [ +// 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', @@ -37,12 +54,88 @@ export const DEV_BACKENDS = [ // 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() { +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(DEV_BACKENDS.map((b) => [b.route, { target: `http://127.0.0.1:${b.port}`, changeOrigin: false, ws: true }])) + 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 }) @@ -55,7 +148,7 @@ function stopAll() { 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 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}`)) @@ -78,7 +171,8 @@ function start(root, b, logger, restarts = 0) { logger.info(`${tag} started → ${b.route} on 127.0.0.1:${b.port}`) } -export function backendDevPlugin(root) { +export function backendDevPlugin(root, env = process.env) { + const backends = devBackends(env) return { name: 'dx-backend-dev', apply: 'serve', @@ -88,9 +182,10 @@ export function backendDevPlugin(root) { // 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) { + for (const b of backends) { server.middlewares.use(b.route, (req, res, next) => { - if (isAlive(b.name)) return 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' }) @@ -114,7 +209,11 @@ export function backendDevPlugin(root) { process.once('SIGINT', () => { stopAll(); process.exit(0) }) process.once('SIGTERM', () => { stopAll(); process.exit(0) }) } - for (const b of DEV_BACKENDS) { + 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)`) @@ -124,11 +223,25 @@ export function backendDevPlugin(root) { 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 + } logger.warn(`[backend] ${b.name} not built — its plugin will show "engine offline". Build: ${b.build}`) continue } - start(root, b, logger) + launch() } }, } diff --git a/vite.config.js b/vite.config.js index 4b6a5438f..f511047aa 100644 --- a/vite.config.js +++ b/vite.config.js @@ -122,7 +122,7 @@ export default defineConfig(({ command, mode }) => { // port. `--host` on the command line still overrides this for a deliberate LAN session. // Local plugin backends (backend/, e.g. rudder-engine) are started by backendDevPlugin and reached // through these same-origin routes, so a plugin calls /api//… in dev exactly as on desktop. - server: { host: '127.0.0.1', proxy: devBackendProxy() }, + server: { host: '127.0.0.1', proxy: devBackendProxy(env) }, define: { // ★ NO 'unsafe-eval' (CRITIC-4, 2026-09-26). vue-i18n's default message compiler builds each message // with `new Function`, so a partner with a strict Content-Security-Policy saw every translated string @@ -163,7 +163,7 @@ export default defineConfig(({ command, mode }) => { vue({ customElement: true }), cssInjectedByJsPlugin(), autoSpaDevPlugin(env.VITE_MAPBOX_TOKEN || process.env.VITE_MAPBOX_TOKEN || ''), - backendDevPlugin(__dirname), + backendDevPlugin(__dirname, env), devSyncPlugin(__dirname, env), ], resolve: {