cloud dev server: a pull reaches open pages as ONE reload, once the tree is complete
The pull loop (entry.sh) runs `git reset --hard` straight into the tree Vite watches. A landing rewrites hundreds of files one by one, and Vite answered the first of them while git was still writing the rest: an open page was reloaded onto a mix of old and new modules, and its tab clicks then fetched nothing until it was reloaded by hand. deployment/cloud/atomic-pull.mjs, added to the cloud overlay only (local dev, builds and entry.sh are unchanged; Vite restarts itself once when this config lands). The first file event opens a gate that closes once the tree has been quiet for ATOMIC_QUIET_MS (1200), never past ATOMIC_CEILING_MS (20000, under oauth2-proxy's 30 s upstream timeout). While it is open it holds the messages to the browsers (update, full-reload, prune, error: at ws.send, which Vite 5's server.hot broadcasts through and which IS environments.client.hot in Vite 6/7), every request except the backend routes (a middleware registered ahead of all others), and a config restart. When it closes: invalidate the module graph, release the requests, then exactly ONE full-reload (or the held restart). A held error is dropped, so none is buffered for the next browser. Past the ceiling it passes everything through until the tree is quiet. ATOMIC_QUIET_MS=0 or PULL_INTERVAL=0 turns it off. Measured on a scratch Vite 5.4.21 project (the version bun.lock pins) under a copy of this overlay, with a page of 50 modules in a browser: one `git reset --hard` of 312 files sent the page 27 full-reloads and 25 updates without the plugin, and exactly 1 full-reload with every module new with it. Bursts, a request mid-burst, a single edit, a broken-then-fixed module (page open and no page open), a config restart (and a failed one) and the ceiling: 27/27 checks pass.
This commit is contained in:
parent
98e8cace71
commit
11ba7bff46
3 changed files with 222 additions and 1 deletions
|
|
@ -29,6 +29,7 @@ private network only: it has none of the public mode's guards. On a public host,
|
|||
| `PORT` | no (`5173`) | the port the container serves on |
|
||||
| `DEV_SERVER` | no (`on`) | `off` runs a tenant without the Plugin Studio dev server: no Vite, no pulls, no Rudder; `/` redirects to `/cdn/`. Needs the sign-in (`OIDC_ISSUER`); refused with `ACCESS=public` |
|
||||
| `HMR_CLIENT_PORT`, `HMR_PROTOCOL` | behind HTTPS | `443` and `wss`, so browsers reach the hot-reload websocket |
|
||||
| `ATOMIC_QUIET_MS`, `ATOMIC_CEILING_MS` | no (`1200`, `20000`) | a pull reaches open pages as ONE reload once no file has changed for `ATOMIC_QUIET_MS`: until then hot-reload messages, page and module requests and a config restart wait (never past `ATOMIC_CEILING_MS`), so no page loads a half-written tree. `ATOMIC_QUIET_MS=0` turns it off, as does `PULL_INTERVAL=0` ([atomic-pull.mjs](./atomic-pull.mjs)) |
|
||||
|
||||
## Where it runs
|
||||
|
||||
|
|
|
|||
217
deployment/cloud/atomic-pull.mjs
Normal file
217
deployment/cloud/atomic-pull.mjs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
// atomic-pull.mjs: the cloud dev server shows a landing to the browsers ALL AT ONCE, never half-written.
|
||||
// Used by vite.cloud.config.mjs only: local `bun run dev` and every build are unchanged.
|
||||
//
|
||||
// WHY. entry.sh follows BRANCH with `git reset --hard` straight into the tree Vite serves and watches. A landing
|
||||
// rewrites hundreds of files one after another, and Vite answered the first of them while git was still writing
|
||||
// the rest: an open page was reloaded onto a mix of old and new modules, and its tab clicks then fetched nothing
|
||||
// until it was reloaded by hand (the cloud server's log, measured). Vite also keeps an error raised while no
|
||||
// browser is connected and shows it to the next browser that connects, so a module read half-written could put
|
||||
// an error overlay on a page opened long after the pull had finished.
|
||||
//
|
||||
// HOW. The first file event under the root opens a GATE. Each further event, and each raw notification from the
|
||||
// kernel that comes before one, moves its closing to QUIET ms later (ATOMIC_QUIET_MS, default 1200), never later
|
||||
// than CEILING ms after it opened (ATOMIC_CEILING_MS, default 20000: below oauth2-proxy's 30 s upstream timeout,
|
||||
// so a request held here is never cut off in front of it).
|
||||
// While the gate is open:
|
||||
// - what Vite sends the browsers about the tree (update, full-reload, prune, error) is held, not sent;
|
||||
// - HTTP requests wait (pages, modules, the reconnect ping); the backend routes (/api/, /cdn/, /api-prod/, /pb/,
|
||||
// /admin/) never do, they don't read the tree;
|
||||
// - a dev-server restart (a config file changed) waits too, so it loads the new config once, complete.
|
||||
// When the tree has been quiet for QUIET ms: every module is invalidated (a file read mid-write is never served
|
||||
// from the cache), the waiting requests go on, and if anything was held the browsers get exactly ONE full-reload,
|
||||
// or the waiting restart, which reloads them itself. The held messages are dropped, a stale error with them. An
|
||||
// error raised while the gate is closed waits 250 ms first: a gate opening meanwhile means it came from a file
|
||||
// git was still writing. One log line per gate. A writer that never stops: at the ceiling the gate lets
|
||||
// everything through and stays out of the way (plain Vite) until the tree has been quiet once.
|
||||
//
|
||||
// WHERE IT HOOKS (read from Vite's source, 5.4 as pinned in bun.lock, and 7.3):
|
||||
// - ws.send: in Vite 5 server.hot broadcasts through it (channel.send), and in Vite 6/7 server.ws IS the client
|
||||
// environment's hot channel (environments.client.hot) and server.hot. Every message to a browser passes here,
|
||||
// and an error that never reaches it is never buffered for the next browser either.
|
||||
// - the request gate is registered from a configureServer hook with order 'pre', before every other plugin's
|
||||
// middleware; Vite adds its own (transform, static files, the ping) after all of those.
|
||||
// - server.restart: what Vite calls when a config file or one of its imports changes.
|
||||
// - server.watcher (chokidar): add/change/unlink/addDir/unlinkDir open and extend the gate; raw only extends it.
|
||||
//
|
||||
// Off with ATOMIC_QUIET_MS=0, and with PULL_INTERVAL=0 (a checkout someone edits by hand keeps plain hot reload).
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
|
||||
const HELD = new Set(['update', 'full-reload', 'prune', 'error'])
|
||||
const BACKEND = /^\/(?:api|cdn|api-prod|pb|admin)(?:\/|$)/
|
||||
const EVENTS = ['add', 'change', 'unlink', 'addDir', 'unlinkDir']
|
||||
const RELOAD = { type: 'full-reload', path: '*' }
|
||||
const num = (v, d) => (v === undefined || v === null || v === '' || !Number.isFinite(Number(v)) ? d : Number(v))
|
||||
|
||||
export function atomicPull(opts = {}) {
|
||||
const env = opts.env || process.env
|
||||
const quiet = num(opts.quietMs ?? env.ATOMIC_QUIET_MS, 1200)
|
||||
const ceiling = Math.max(num(opts.ceilingMs ?? env.ATOMIC_CEILING_MS, 20000), quiet)
|
||||
const grace = num(opts.errorGraceMs, 250)
|
||||
const off = !(quiet > 0) || env.PULL_INTERVAL === '0'
|
||||
return {
|
||||
name: 'cloud-atomic-pull',
|
||||
enforce: 'pre',
|
||||
apply: 'serve',
|
||||
configureServer: {
|
||||
order: 'pre',
|
||||
handler(server) {
|
||||
if (!off) install(server, { quiet, ceiling, grace })
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function install(server, { quiet, ceiling, grace }) {
|
||||
const root = resolve(server.config.root)
|
||||
const logger = server.config.logger
|
||||
// Nothing to hold without a browser channel and a watcher (middleware mode, hmr or watch turned off).
|
||||
if (typeof server.ws?.send !== 'function' || !server.watcher) return
|
||||
const under = (file) => {
|
||||
const r = relative(root, resolve(root, String(file)))
|
||||
return r !== '..' && !r.startsWith('..' + sep) && !isAbsolute(r)
|
||||
}
|
||||
|
||||
let openedAt = 0 // when the gate opened; 0 = closed
|
||||
let deadline = 0 // when it closes if nothing else changes
|
||||
let timer = null
|
||||
let confirm = null // the setImmediate that double-checks a deadline that has passed
|
||||
let ceilingTimer = null
|
||||
let passing = false // after a ceiling: plain Vite until the tree is quiet
|
||||
let passDeadline = 0
|
||||
let passTimer = null
|
||||
let held = 0
|
||||
let waiting = []
|
||||
let restart = null // a restart asked for while the gate was open: { force, settle: [] }
|
||||
let closed = false // this server instance has closed (a restart replaced it, or the process exits)
|
||||
const graceErrors = new Set()
|
||||
|
||||
// Messages to the browsers.
|
||||
const ws = server.ws
|
||||
const send = ws.send
|
||||
ws.send = function atomicPullSend(...args) {
|
||||
const type = typeof args[0] === 'string' ? 'custom' : args[0]?.type
|
||||
if (closed || !HELD.has(type)) return send.apply(this, args)
|
||||
if (openedAt) { held++; return }
|
||||
if (type === 'error' && grace > 0 && !passing) {
|
||||
const t = setTimeout(() => { graceErrors.delete(t); if (!closed) send.apply(ws, args) }, grace)
|
||||
graceErrors.add(t)
|
||||
return
|
||||
}
|
||||
return send.apply(this, args)
|
||||
}
|
||||
|
||||
// Requests.
|
||||
server.middlewares.use(function atomicPullGate(req, res, next) {
|
||||
if (!openedAt || closed || BACKEND.test((req.url || '').split('?')[0])) return next()
|
||||
waiting.push({ req, res, next })
|
||||
})
|
||||
|
||||
// Restarts.
|
||||
const realRestart = server.restart
|
||||
server.restart = function atomicPullRestart(forceOptimize) {
|
||||
if (!openedAt || closed) return realRestart.call(server, forceOptimize)
|
||||
restart ||= { force: false, settle: [] }
|
||||
restart.force ||= !!forceOptimize
|
||||
return new Promise((ok, fail) => restart.settle.push((e) => (e ? fail(e) : ok())))
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
timer = null
|
||||
const left = deadline - Date.now()
|
||||
if (left > 0) { timer = setTimeout(tick, left); return }
|
||||
// A busy process can run this timer before the file events that queued up meanwhile: look again right after
|
||||
// the event loop's next I/O poll has read them.
|
||||
confirm = setImmediate(() => {
|
||||
confirm = null
|
||||
const again = deadline - Date.now()
|
||||
if (again > 0) timer = setTimeout(tick, again)
|
||||
else release(false)
|
||||
})
|
||||
}
|
||||
const passTick = () => {
|
||||
const left = passDeadline - Date.now()
|
||||
if (left > 0) { passTimer = setTimeout(passTick, left); return }
|
||||
passTimer = null
|
||||
passing = false
|
||||
}
|
||||
|
||||
const onEvent = (file) => {
|
||||
if (closed || !under(file)) return
|
||||
const now = Date.now()
|
||||
if (passing) { passDeadline = now + quiet; return }
|
||||
if (!openedAt) {
|
||||
openedAt = now
|
||||
timer = setTimeout(tick, quiet)
|
||||
ceilingTimer = setTimeout(() => release(true), ceiling)
|
||||
// An error sent just before the first event came from a file git had begun to write.
|
||||
for (const t of graceErrors) { clearTimeout(t); held++ }
|
||||
graceErrors.clear()
|
||||
}
|
||||
deadline = now + quiet
|
||||
}
|
||||
// chokidar's raw events come straight from the kernel's notification, before the file's stat that its add and
|
||||
// change events wait for (on a loaded machine that stat lagged the writes by over a second): they keep an open
|
||||
// gate open while git is still writing. They never open one: a read that updates a file's access time is one.
|
||||
const onRaw = (_event, _path, details) => {
|
||||
if (closed || !(openedAt || passing) || !under(details?.watchedPath ?? root)) return
|
||||
const until = Date.now() + quiet
|
||||
if (passing) passDeadline = Math.max(passDeadline, until)
|
||||
else deadline = Math.max(deadline, until)
|
||||
}
|
||||
// First in line, ahead of Vite's own handlers, so the gate is open before anything they send.
|
||||
const watcher = server.watcher
|
||||
for (const ev of EVENTS) {
|
||||
if (typeof watcher.prependListener === 'function') watcher.prependListener(ev, onEvent)
|
||||
else watcher.on(ev, onEvent)
|
||||
}
|
||||
watcher.on('raw', onRaw)
|
||||
|
||||
const invalidateAll = () => {
|
||||
if (server.environments) for (const e of Object.values(server.environments)) e.moduleGraph?.invalidateAll?.()
|
||||
else server.moduleGraph?.invalidateAll?.()
|
||||
}
|
||||
|
||||
function release(byCeiling) {
|
||||
if (!openedAt) return
|
||||
const took = Date.now() - openedAt
|
||||
clearTimeout(timer); clearImmediate(confirm); clearTimeout(ceilingTimer)
|
||||
timer = confirm = ceilingTimer = null
|
||||
openedAt = 0
|
||||
const messages = held
|
||||
const requests = waiting
|
||||
const r = restart
|
||||
held = 0; waiting = []; restart = null
|
||||
if (byCeiling) {
|
||||
passing = true
|
||||
passDeadline = Date.now() + quiet
|
||||
passTimer = setTimeout(passTick, quiet)
|
||||
}
|
||||
try { invalidateAll() } catch (e) { logger.warn(`[atomic-pull] could not invalidate the module graph: ${e?.message || e}`) }
|
||||
for (const w of requests) {
|
||||
if (!w.res.writableEnded && !w.res.destroyed && !w.req.socket?.destroyed) w.next()
|
||||
}
|
||||
if (messages && !r) send.call(ws, RELOAD)
|
||||
const outcome = r ? 'server restart' : messages ? '1 full-reload' : 'no reload'
|
||||
const why = byCeiling ? `; ceiling ${ceiling} ms reached, passing through until the tree is quiet` : ''
|
||||
logger.info(`[atomic-pull] held ${messages} messages, ${requests.length} requests for ${took} ms; ${outcome}${why}`, { timestamp: true })
|
||||
if (r) runRestart(r, messages)
|
||||
}
|
||||
|
||||
async function runRestart(r, messages) {
|
||||
let err = null
|
||||
try { await realRestart.call(server, r.force) } catch (e) { err = e }
|
||||
for (const settle of r.settle) settle(err)
|
||||
// A failed restart leaves this server serving (Vite logs "server restart failed"): reload the pages from here.
|
||||
if (messages && !closed && server.ws === ws) send.call(ws, RELOAD)
|
||||
}
|
||||
|
||||
server.httpServer?.once('close', () => {
|
||||
closed = true
|
||||
clearTimeout(timer); clearImmediate(confirm); clearTimeout(ceilingTimer); clearTimeout(passTimer)
|
||||
for (const t of graceErrors) clearTimeout(t)
|
||||
graceErrors.clear()
|
||||
waiting = [] // their sockets closed with the server
|
||||
for (const ev of EVENTS) watcher.off?.(ev, onEvent)
|
||||
watcher.off?.('raw', onRaw)
|
||||
})
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ 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
|
||||
|
|
@ -74,7 +75,9 @@ export default async (e) => {
|
|||
? { clientPort: Number(env.HMR_CLIENT_PORT), protocol: env.HMR_PROTOCOL || 'wss' }
|
||||
: undefined
|
||||
return mergeConfig(b, {
|
||||
plugins: [publicGuard, rudderStarting],
|
||||
// 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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue