Closes the review of11ba7bff4(deployment/cloud/atomic-pull.mjs, vite.cloud.config.mjs; Vite 5.4.21). - The gate now opens the moment git takes <git dir>/index.lock (fs.watch on the git dir, a 50 ms poll if that watch cannot be set up), before git's first write, and stays open while the lock exists: `git reset --hard` writes every file, public/ included (which Vite does not watch), inside that window. File events (and raw kernel notifications) still extend it, and still open it for any other writer. A lock that comes and goes with no file event (`git status`, as the dev plugin's /__dev-sha runs) closes it at once. - What the container writes for itself under /app/repo leaves the watcher (server.watch.ignored in the overlay, appended to the repo's public/ by mergeConfig): .pb/ (backend logs and data), backend/bin/ (the engine binary), backend/pocketbase/pb_migrations/ (what PocketBase dev writes from its dashboard). They held pages before; a landing that changes them is still gated by the lock. - At closing only the touched files' modules are invalidated (from the file events, plus `git diff` between HEAD before the lock and HEAD after when git moved it), so unchanged modules keep their transform and answer 304. The gate's log line names the git range and file count. - With a restart waiting, the held requests (and any that arrive until this server closes) are answered 503 + Retry-After: 1, not passed into the closing server (504). A page gets a small page that reloads itself once the server answers again. - One startup line: "[atomic-pull] armed: quiet 1200 ms, cap 10000 ms, git-lock watch on". - The cap (ATOMIC_CEILING_MS) defaults to 10000: no browser is held longer, and it still gets its one reload. Measured on the scratch Vite 5.4.21 project under the worktree's own overlay (paths changed), with a page of 50 modules and 80 public files in a browser: 52/52 checks pass. The earlier cases (bursts, a request mid-burst, a single edit, a broken-then-fixed module with and without a page, a config restart and a failed one, the ceiling) plus: a git landing of 392 files (80 under public/) where requests sent at the lock are held until git lets go and answered new, one reload, every module and public file new after it; a .pb/ + backend/bin/ + pb_migrations/ writer every 250 ms holds nothing (slowest request 17 ms); `git status` every 300 ms holds nothing; the 10 s cap with a never-ending writer and with a stale index.lock; the startup line once per server start; a restart mid-burst answering 503 + Retry-After: 1 and one reload on the new server. Against11ba7bff4the same landing served a public bundle and a module OLD while git wrote, and the same server writer held a request 19.8 s.
101 lines
5.5 KiB
JavaScript
101 lines
5.5 KiB
JavaScript
// Overlay for the cloud dev server: the repo's own vite.config.js plus what a shared, public
|
|
// host needs. Every value comes from the environment (see deployment/cloud/README.md).
|
|
import base from '/app/repo/vite.config.js'
|
|
import { mergeConfig } from 'vite'
|
|
import { readFileSync } from 'node:fs'
|
|
import { GATED_PROFILES } from '/app/repo/internal/_tree-name.mjs'
|
|
import { atomicPull } from './atomic-pull.mjs'
|
|
|
|
const env = process.env
|
|
const SETUP_STATE = '/tmp/rudder-setup.state' // written by entry.sh: running -> done | failed
|
|
const setupState = () => { try { return readFileSync(SETUP_STATE, 'utf8').trim() } catch { return '' } }
|
|
|
|
// Rudder's setup runs in the background (entry.sh), so the site serves before the engine exists. While it
|
|
// runs, /api/rudder answers "starting" (the widget shows "Loading speech model…"; /events is held open so it
|
|
// does not retry in a loop). When setup ends, the dev server restarts ONCE: the repo's backend plugin only
|
|
// looks for the engine binary at start, and the restart is what starts it. 'pre' puts this ahead of that
|
|
// plugin's own "engine offline" answer.
|
|
const rudderStarting = {
|
|
name: 'cloud-rudder-starting',
|
|
enforce: 'pre',
|
|
apply: 'serve',
|
|
configureServer(server) {
|
|
if (setupState() !== 'running') return
|
|
server.middlewares.use('/api/rudder', (req, res, next) => {
|
|
if (setupState() !== 'running') return next()
|
|
const status = { engine: 'starting', error: '', audio: 'browser' }
|
|
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
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify(status))
|
|
})
|
|
const poll = setInterval(() => {
|
|
if (setupState() === 'running') return
|
|
clearInterval(poll)
|
|
server.config.logger.info(`[rudder] setup ${setupState() || 'ended'}: restarting the dev server to start the engine`)
|
|
server.restart()
|
|
}, 2000)
|
|
server.httpServer?.once('close', () => clearInterval(poll))
|
|
},
|
|
}
|
|
|
|
// ACCESS=public (entry.sh sets DX_PUBLIC_STUDIO=1): Vite owns the public port with no sign-in in front of it.
|
|
// - The backend's paths go to admin.mjs, whose gates decide: /cdn/ and /api-prod/api/ public, /pb/ records
|
|
// by PocketBase's own rules, and /pb/_/, _superusers and /admin/ for the admin password.
|
|
// - GATED bundles (gatedProfiles, e.g. key-cities) must never reach a public surface. The Studio already leaves
|
|
// them out of its list; here their source and dev pages are refused too, so a typed URL can't load them. So is
|
|
// /__plugin-gallery/registry.json, the raw discovery list for smoke tools: it still names the gated bundles and
|
|
// their profiles, and the Studio itself reads only manifest.json.
|
|
// - /__open-in-editor (Vite's own) would start an editor process on this server for a visitor: refused.
|
|
const PUBLIC = env.DX_PUBLIC_STUDIO === '1'
|
|
const publicGuard = {
|
|
name: 'cloud-public-guard',
|
|
enforce: 'pre',
|
|
apply: 'serve',
|
|
configureServer(server) {
|
|
if (!PUBLIC) return
|
|
server.middlewares.use((req, res, next) => {
|
|
let p = (req.url || '').split('?')[0]
|
|
try { p = decodeURIComponent(p) } catch { /* keep it raw */ }
|
|
const gated = GATED_PROFILES.some((g) => p.includes(`/plugin-designs/${g}/`) || p === `/__leaf/${g}` || p.startsWith(`/__leaf/${g}/`))
|
|
if (!gated && !p.startsWith('/__open-in-editor') && p !== '/__plugin-gallery/registry.json') return next()
|
|
res.statusCode = 404
|
|
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
res.end('not found\n')
|
|
})
|
|
},
|
|
}
|
|
|
|
export default async (e) => {
|
|
const b = typeof base === 'function' ? await base(e) : base
|
|
const hmr = env.HMR_CLIENT_PORT
|
|
? { clientPort: Number(env.HMR_CLIENT_PORT), protocol: env.HMR_PROTOCOL || 'wss' }
|
|
: undefined
|
|
return mergeConfig(b, {
|
|
// atomicPull: the pull loop's `git reset --hard` reaches the browsers as ONE reload once the tree is complete,
|
|
// never half-written (atomic-pull.mjs). Its request gate goes ahead of every other middleware.
|
|
plugins: [atomicPull(), publicGuard, rudderStarting],
|
|
cacheDir: '/tmp/vite-cache',
|
|
server: {
|
|
host: env.VITE_HOST || '0.0.0.0',
|
|
port: Number(env.VITE_PORT || 5173),
|
|
strictPort: true,
|
|
allowedHosts: true,
|
|
...(hmr ? { hmr } : {}),
|
|
// What this container writes for ITSELF under /app/repo is not the tree a landing changes, and every event
|
|
// the watcher reports opens atomic-pull's gate (pages and modules wait QUIET ms): the backend's logs and
|
|
// data (.pb/), the engine binary entry.sh builds (backend/bin/), and the migrations PocketBase dev writes
|
|
// there when its dashboard changes a collection. Vite serves none of them. mergeConfig APPENDS this list to
|
|
// the repo's own (public/), it does not replace it; a landing that changes any of them is still gated, by
|
|
// git's index lock.
|
|
watch: { ignored: ['**/.pb/**', '**/backend/bin/**', '**/backend/pocketbase/pb_migrations/**'] },
|
|
...(PUBLIC ? { proxy: { '^/(cdn|api-prod|pb|admin)/': { target: 'http://127.0.0.1:5175', ws: true } } } : {}),
|
|
// Public: the checkout's git internals are not served either (the private history, .git/config). Vite's
|
|
// defaults are restated because setting deny replaces them.
|
|
...(PUBLIC ? { fs: { strict: true, deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**'] } } : {}),
|
|
},
|
|
})
|
|
}
|