chore: add dev server supervisor to cut idle vite dev memory (#10672)

* chore: add dev server supervisor and dev-only polling dormancy

* fix: address review findings in dev supervisor

* fix: support https mode and bound the idle reaper in dev supervisor

* fix: persist dormancy install guard and hold the reaper during startup

* chore: run worktree frontends under the dev supervisor

* fix: keep app websockets working and reap children on sighup
This commit is contained in:
Ruben Fiszel
2026-08-13 06:51:38 +02:00
committed by GitHub
parent 2fcce4526a
commit 2ff8681715
6 changed files with 671 additions and 2 deletions
+14 -2
View File
@@ -58,11 +58,19 @@ profiles:
split: right
workingDir: backend
command: PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"
# dev-supervisor runs vite only while someone is looking at the preview, which keeps
# the worktrees nobody has open from each costing 1.1-1.7 GB. The guard keeps panes
# working on branches cut before the script landed.
- id: frontend
kind: command
split: bottom
workingDir: frontend
command: npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
command: >-
npm run generate-backend-client && bash -c 'export
REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}}; if [ -f
scripts/dev-supervisor.mjs ]; then exec node scripts/dev-supervisor.mjs -t
${FRONTEND_PORT:-3000} --bind 0.0.0.0 --idle ${DEV_SUPERVISOR_IDLE:-15m}; else
exec npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0; fi'
frontendOnly:
runtime: host
@@ -87,7 +95,11 @@ profiles:
kind: command
split: right
workingDir: frontend
command: npm run generate-backend-client && npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
command: >-
npm run generate-backend-client && bash -c 'if [ -f scripts/dev-supervisor.mjs
]; then exec node scripts/dev-supervisor.mjs -t ${FRONTEND_PORT:-3000} --bind
0.0.0.0 --idle ${DEV_SUPERVISOR_IDLE:-15m}; else exec npm run dev -- --port
${FRONTEND_PORT:-3000} --host 0.0.0.0; fi'
agentOnly:
runtime: host
+18
View File
@@ -90,6 +90,24 @@ You can configure another proxy to use like so:
REMOTE=http://127.0.0.1:8000 REMOTE_LSP=http://127.0.0.1:3001 npm run dev
```
### Run dev servers on demand
A dev server costs 1.1-1.7 GB resident once a page has been browsed, which adds up when
several worktrees are open at once. `scripts/dev-supervisor.mjs` owns the port instead and
runs `vite dev` only while it is being used, spawning it on the first connection (~1s to a
served response) and stopping it once traffic stops:
```bash
node scripts/dev-supervisor.mjs # this worktree, $FRONTEND_PORT
node scripts/dev-supervisor.mjs -t 3340:/path/wt-a -t 3350:/path/wt-b --idle 15m
```
`--bind 0.0.0.0` to reach it off-host, `--stats <file>` to record RSS samples. In dev the
app also suspends its background polling after 5 minutes of an inactive tab
(`VITE_DEV_DORMANT_MS`), so a tab left open does not keep a server resident. That last
part holds over plaintext only: with `HTTPS=true` the HMR socket is indistinguishable from
real traffic, so an open tab keeps its server alive.
### Use a Local backend
#### 1. Backend is run by docker
+426
View File
@@ -0,0 +1,426 @@
#!/usr/bin/env node
// Keeps a worktree's `vite dev` off the machine until someone actually looks at it.
//
// The supervisor owns the public port, spawns the dev server on first connection,
// proxies to it, and kills it once traffic stops. A dev server for this frontend
// costs 1.1-1.7 GB resident once browsed, so with several worktrees in flight the
// ones nobody has open are the bulk of the cost.
//
// node scripts/dev-supervisor.mjs # this worktree, $FRONTEND_PORT
// node scripts/dev-supervisor.mjs -t 3340:/path/to/wt -t 3350:/other
// node scripts/dev-supervisor.mjs -t 3340:/path --idle 10m --stats rss.jsonl
// node scripts/dev-supervisor.mjs -t 3340:/path --bind 0.0.0.0 # reachable off-host
//
// Proxying is at the TCP layer so HTTP, the HMR websocket, and the /api proxy all
// pass through untouched. Under HTTPS=true the request head is encrypted, so an HMR
// socket cannot be told apart from real traffic: a target with no tab open is still
// reclaimed, but a tab left open keeps its server alive rather than going dormant.
import net from 'node:net'
import { spawn } from 'node:child_process'
import { appendFileSync, existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
const START_TIMEOUT_MS = 180_000
const POLL_INTERVAL_MS = 250
const TICK_MS = 15_000
const KILL_GRACE_MS = 5_000
const MAX_HEAD_BYTES = 8_192
const HEAD_TIMEOUT_MS = 30_000
const TLS_HANDSHAKE_BYTE = 0x16
// Every spawned dev server, so no exit path can orphan one.
const liveChildren = new Set()
// Children are spawned detached, so signalling the negated pid reaches vite's own workers
// too. Without that, a wedged vite leaves the esbuild/rollup processes this tool exists to
// reclaim. Falls back to the direct child if the group is already gone.
function killTree(child, signal) {
try {
process.kill(-child.pid, signal)
} catch {
try {
child.kill(signal)
} catch {
// already exited
}
}
}
function parseDuration(text) {
const m = /^(\d+)(ms|s|m|h)?$/.exec(text)
if (!m) throw new Error(`invalid duration: ${text}`)
const scale = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[m[2] ?? 'm']
return Number(m[1]) * scale
}
function parseArgs(argv) {
// Loopback by default: the supervised port fronts the /api proxy to a local backend
// and serves source over /@fs, and `server.allowedHosts` does not stop a non-browser
// client from sending whatever Host header it likes. Widening is opt-in.
const opts = { targets: [], idleMs: parseDuration('15m'), bind: '127.0.0.1', stats: null }
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
const next = () => {
const value = argv[++i]
if (value === undefined) throw new Error(`${arg} needs a value`)
return value
}
if (arg === '-t' || arg === '--target') {
// First colon only: a path may contain one, and an unvalidated port would reach
// `listen(NaN)`, which quietly binds a random port instead of failing.
const value = next()
const split = value.indexOf(':')
const port = Number(split === -1 ? value : value.slice(0, split))
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`--target needs <port>[:<cwd>], got: ${value}`)
}
const cwd = split === -1 ? process.cwd() : value.slice(split + 1)
opts.targets.push({ port, cwd: path.resolve(cwd) })
} else if (arg === '--idle') opts.idleMs = parseDuration(next())
else if (arg === '--bind') opts.bind = next()
else if (arg === '--stats') opts.stats = path.resolve(next())
else throw new Error(`unknown argument: ${arg}`)
}
if (opts.targets.length === 0) {
opts.targets.push({
port: Number(process.env.FRONTEND_PORT ?? 3000),
cwd: path.resolve(process.cwd())
})
}
return opts
}
function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer()
probe.on('error', reject)
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address()
probe.close(() => resolve(port))
})
})
}
function canConnect(port) {
return new Promise((resolve) => {
const socket = net.connect(port, '127.0.0.1')
const settle = (ok) => {
socket.destroy()
resolve(ok)
}
socket.on('connect', () => settle(true))
socket.on('error', () => settle(false))
})
}
// RSS of the child's whole process tree: vite spawns workers, and the number worth
// reporting is what the machine gives back when the tree is killed.
function treeRssMb(rootPid) {
const children = new Map()
let entries
try {
entries = readdirSync('/proc')
} catch {
return null
}
for (const entry of entries) {
if (!/^\d+$/.test(entry)) continue
// Processes come and go mid-scan, so one unreadable pid must not lose the whole
// tree: a null here would silently report a running server as 0 MB.
let stat
try {
stat = readFileSync(`/proc/${entry}/stat`, 'utf8')
} catch {
continue
}
const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1])
if (!children.has(ppid)) children.set(ppid, [])
children.get(ppid).push(Number(entry))
}
let total = 0
const stack = [rootPid]
while (stack.length) {
const pid = stack.pop()
try {
const status = readFileSync(`/proc/${pid}/status`, 'utf8')
total += Number(/VmRSS:\s+(\d+)/.exec(status)?.[1] ?? 0)
} catch {
continue
}
stack.push(...(children.get(pid) ?? []))
}
return Math.round(total / 1024)
}
class Target {
constructor({ port, cwd }, opts) {
this.port = port
this.cwd = cwd
this.opts = opts
this.name = path.basename(path.dirname(cwd)) + '/' + path.basename(cwd)
this.child = null
this.internalPort = null
this.starting = null
this.ready = false
this.lastActivity = Date.now()
this.liveSockets = new Set()
this.stopping = false
}
log(message) {
console.log(`[${this.port} ${this.name}] ${message}`)
}
async ensureStarted() {
// `ready`, not `child`: the port is only connectable once vite has bound it, and a
// second request arriving mid-startup would otherwise be handed a dead port.
if (this.child && this.ready) return this.internalPort
if (this.starting) return this.starting
this.starting = this.#start().finally(() => {
this.starting = null
})
return this.starting
}
async #start() {
const bin = path.join(this.cwd, 'node_modules/.bin/vite')
if (!existsSync(bin)) throw new Error(`no vite binary at ${bin}`)
const internalPort = await freePort()
// --strictPort so a port race fails loudly instead of vite silently binding
// elsewhere and every proxied request hanging. --host pins the child to v4
// loopback: vite's default `localhost` can resolve to ::1 only, which the
// readiness probe and the proxy would never reach.
const child = spawn(
bin,
['dev', '--port', String(internalPort), '--strictPort', '--host', '127.0.0.1'],
{
cwd: this.cwd,
env: { ...process.env, FRONTEND_PORT: String(internalPort) },
stdio: ['ignore', 'pipe', 'pipe'],
detached: true
}
)
this.child = child
this.internalPort = internalPort
this.ready = false
this.stopping = false
liveChildren.add(child)
const relay = (stream) => {
let buffered = ''
stream.setEncoding('utf8')
stream.on('data', (chunk) => {
buffered += chunk
const lines = buffered.split('\n')
buffered = lines.pop() ?? ''
for (const line of lines) if (line.trim()) this.log(` ${line}`)
})
}
relay(child.stdout)
relay(child.stderr)
child.on('exit', (code, signal) => {
liveChildren.delete(child)
if (!this.stopping) this.log(`dev server exited unexpectedly (${signal ?? code})`)
if (this.child === child) {
this.child = null
this.internalPort = null
this.ready = false
}
})
this.log(`starting dev server on :${internalPort}`)
const started = Date.now()
while (Date.now() - started < START_TIMEOUT_MS) {
if (!this.child) throw new Error('dev server exited during startup')
if (await canConnect(internalPort)) {
this.log(`ready in ${((Date.now() - started) / 1000).toFixed(1)}s`)
this.ready = true
this.lastActivity = Date.now()
return internalPort
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS))
}
this.stop()
throw new Error('dev server did not become reachable')
}
stop() {
if (!this.child) return
this.stopping = true
const rss = treeRssMb(this.child.pid)
this.log(`stopping dev server${rss ? ` (reclaiming ${rss} MB)` : ''}`)
const child = this.child
killTree(child, 'SIGTERM')
// Only if it is still ours to kill: the pid may have been recycled by then, and the
// group signal would land on an unrelated process group.
setTimeout(() => {
if (liveChildren.has(child)) killTree(child, 'SIGKILL')
}, KILL_GRACE_MS).unref()
this.child = null
this.internalPort = null
this.ready = false
for (const socket of this.liveSockets) socket.destroy()
this.liveSockets.clear()
}
handle(client) {
client.on('error', () => client.destroy())
// TCP preserves no message boundaries, so the request head can arrive split across
// segments: classify only once the header terminator is in hand, otherwise an upgrade
// split mid-header reads as ordinary traffic and the HMR socket keeps a server alive
// that nobody is watching. A TLS record (HTTPS=true) never contains that terminator,
// so it is dispatched opaquely rather than waited on forever.
const chunks = []
let buffered = 0
let dispatched = false
const dispatch = (head) => {
dispatched = true
clearTimeout(headTimer)
client.off('data', onData)
client.pause()
this.#dispatch(client, head)
}
const onData = (chunk) => {
chunks.push(chunk)
buffered += chunk.length
const head = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)
if (head.length > 0 && head[0] === TLS_HANDSHAKE_BYTE) return dispatch(head)
if (head.indexOf('\r\n\r\n') === -1 && buffered < MAX_HEAD_BYTES) return
dispatch(head)
}
// Nothing may sit here forever: a speculative preconnect that never sends a request
// would otherwise leak a socket per attempt.
const headTimer = setTimeout(() => {
if (!dispatched) client.destroy()
}, HEAD_TIMEOUT_MS)
headTimer.unref()
client.on('data', onData)
}
#dispatch(client, first) {
// Three kinds of connection, told apart by the subprotocol vite gives its own
// sockets. Vite's must not restart a reclaimed server, because its reconnect probe
// (`vite-ping`) would resurrect every one we stop. The app's `/ws/*` language-server
// and multiplayer sockets must, or a parked script editor loses its smart assistant
// until reload. Neither counts as activity: a tab nobody is looking at still
// heartbeats, and treating that as use is what pinned servers forever.
const head = first.toString('latin1', 0, Math.min(first.length, MAX_HEAD_BYTES))
const isWebsocket = /\r\nupgrade:\s*websocket/i.test(head)
const isViteSocket =
isWebsocket && /\r\nsec-websocket-protocol:[^\r\n]*vite-(hmr|ping)/i.test(head)
if (isViteSocket && !this.child) {
client.destroy()
return
}
if (!isWebsocket) this.lastActivity = Date.now()
if (!isViteSocket) {
this.liveSockets.add(client)
// Registered at insertion, not after the upstream connects: a client that gives
// up during a cold start would otherwise stay in the set forever and silently
// wedge the idle reaper for the life of the process.
client.once('close', () => this.liveSockets.delete(client))
}
this.ensureStarted().then(
(port) => {
const upstream = net.connect(port, '127.0.0.1', () => {
upstream.write(first)
client.pipe(upstream)
upstream.pipe(client)
client.resume()
})
const bump = isWebsocket ? () => {} : () => (this.lastActivity = Date.now())
client.on('data', bump)
upstream.on('data', bump)
const teardown = () => {
this.liveSockets.delete(client)
client.destroy()
upstream.destroy()
}
upstream.on('error', teardown)
upstream.on('close', teardown)
client.on('close', teardown)
},
(err) => {
this.log(`failed to start: ${err.message}`)
this.liveSockets.delete(client)
client.destroy()
}
)
}
tick() {
if (!this.child) return
// No bytes flow while vite boots, so an `--idle` shorter than a cold start would
// otherwise reap the server the waiting client is still queued behind.
if (this.starting) return
// Staleness alone, deliberately: an open socket is not proof of use, and gating on
// one being present lets a half-open connection (VPN drop, suspend), an idle SSE
// stream, or an opaque TLS socket pin the server forever. Every byte in either
// direction bumps lastActivity, so anything genuinely in flight keeps this fresh.
if (Date.now() - this.lastActivity < this.opts.idleMs) return
this.log(`idle for ${Math.round(this.opts.idleMs / 60_000)}m`)
this.stop()
}
sample() {
if (!this.child) return { port: this.port, cwd: this.cwd, running: false, rssMb: 0 }
return {
port: this.port,
cwd: this.cwd,
running: true,
rssMb: treeRssMb(this.child.pid) ?? 0
}
}
}
const opts = parseArgs(process.argv.slice(2))
const targets = opts.targets.map((t) => new Target(t, opts))
for (const target of targets) {
const server = net.createServer((client) => target.handle(client))
server.on('error', (err) => {
console.error(`[${target.port}] listen failed: ${err.message}`)
process.exit(1)
})
server.listen(target.port, opts.bind, () => target.log(`supervising ${target.cwd}`))
}
setInterval(() => {
for (const target of targets) target.tick()
if (!opts.stats) return
const now = new Date().toISOString()
try {
for (const target of targets) {
appendFileSync(opts.stats, JSON.stringify({ at: now, ...target.sample() }) + '\n')
}
} catch (err) {
// An unwritable stats path must not throw out of the tick: that would leave the
// dev servers running with nothing left to reap them.
console.error(`stats write failed, continuing: ${err.message}`)
}
}, TICK_MS).unref()
// Whatever route we leave by, the dev servers are the thing this tool exists to
// reclaim, so nothing may outlive the supervisor.
process.on('exit', () => {
for (const child of liveChildren) killTree(child, 'SIGKILL')
})
// SIGHUP included: as a tmux pane the supervisor is hung up when the pane closes, and
// without a listener node dies on the default disposition without running `exit` — which
// would strand the detached child, since it has its own session and misses the hangup.
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.on(signal, async () => {
for (const target of targets) target.stop()
const deadline = Date.now() + KILL_GRACE_MS
while (liveChildren.size > 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 50))
}
process.exit(0)
})
}
console.log(
`supervising ${targets.length} target(s), idle timeout ${Math.round(opts.idleMs / 60_000)}m`
)
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
// Freshly imported per case: install is idempotent by design, so a shared module instance
// would make the second case a no-op against a restored `window.setInterval`.
async function install() {
vi.resetModules()
const module = await import('./devPollingDormancy')
module.installDevPollingDormancy()
}
// The patch is invisible in production and has no UI, so the freeze/re-arm pair is the
// only thing standing between a forgotten tab and a dev server that never gets reclaimed.
describe('devPollingDormancy', () => {
const original = { setInterval: window.setInterval, clearInterval: window.clearInterval }
afterEach(() => {
window.setInterval = original.setInterval
window.clearInterval = original.clearInterval
// The install guard lives on `window`, so it is part of the global state to restore.
delete (window as unknown as Record<string, boolean>).__wmDevPollingDormancyInstalled
vi.useRealTimers()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
function setHidden(hidden: boolean) {
Object.defineProperty(document, 'hidden', { value: hidden, configurable: true })
document.dispatchEvent(new Event('visibilitychange'))
}
it('freezes intervals once the tab goes inactive and re-arms them on return', async () => {
vi.useFakeTimers()
vi.stubEnv('VITE_DEV_DORMANT_MS', '1000')
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
setHidden(false)
await install()
const tick = vi.fn()
const handle = window.setInterval(tick, 100)
vi.advanceTimersByTime(300)
expect(tick).toHaveBeenCalledTimes(3)
setHidden(true)
vi.advanceTimersByTime(1000)
tick.mockClear()
vi.advanceTimersByTime(500)
expect(tick).not.toHaveBeenCalled()
setHidden(false)
vi.advanceTimersByTime(300)
expect(tick).toHaveBeenCalledTimes(3)
// Clearing through the patched handle must still stop the underlying timer.
window.clearInterval(handle)
tick.mockClear()
vi.advanceTimersByTime(300)
expect(tick).not.toHaveBeenCalled()
})
it('does not re-patch when the module itself is hot-replaced', async () => {
vi.useFakeTimers()
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
setHidden(false)
await install()
const patched = window.setInterval
// A fresh module instance is what HMR hands the layout on the next edit.
await install()
expect(window.setInterval).toBe(patched)
})
it('arms intervals registered while dormant only once the tab is back', async () => {
vi.useFakeTimers()
vi.stubEnv('VITE_DEV_DORMANT_MS', '1000')
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
setHidden(false)
await install()
setHidden(true)
vi.advanceTimersByTime(1000)
const tick = vi.fn()
window.setInterval(tick, 100)
vi.advanceTimersByTime(500)
expect(tick).not.toHaveBeenCalled()
setHidden(false)
vi.advanceTimersByTime(200)
expect(tick).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,115 @@
// Dev-only: freeze recurring polling while nobody is looking at the tab.
//
// `scripts/dev-supervisor.mjs` reclaims a worktree's dev server once traffic stops, but a tab left
// open keeps polling /api forever and would pin it resident. Intervals are cleared while dormant
// and re-armed on the next focus, so returning to the tab costs one extra tick of staleness.
const DEFAULT_DORMANT_AFTER_MS = 5 * 60_000
// Handles are offset so anything we don't recognise in clearInterval can be delegated to the
// native one: clearing an id that never existed is a no-op, mistaking one for ours is not.
const HANDLE_OFFSET = 1_000_000_000
type Registration = {
handler: TimerHandler
ms: number
args: unknown[]
native: number | undefined
}
const INSTALLED_FLAG = '__wmDevPollingDormancyInstalled'
export function installDevPollingDormancy(): void {
if (!import.meta.env.DEV || typeof window === 'undefined') return
// The root layout's body re-runs on HMR, and a second install would bind its "native"
// setInterval to the already-patched one and stack another set of window listeners. The
// guard lives on `window` rather than in module scope because hot-replacing this very
// module resets module state while leaving the previous patch in place.
const flags = window as unknown as Record<string, boolean>
if (flags[INSTALLED_FLAG]) return
flags[INSTALLED_FLAG] = true
const configured = import.meta.env.VITE_DEV_DORMANT_MS
const dormantAfterMs = configured ? Number(configured) : DEFAULT_DORMANT_AFTER_MS
if (!Number.isFinite(dormantAfterMs) || dormantAfterMs <= 0) return
const nativeSetInterval = window.setInterval.bind(window)
const nativeClearInterval = window.clearInterval.bind(window)
const nativeSetTimeout = window.setTimeout.bind(window)
const nativeClearTimeout = window.clearTimeout.bind(window)
const registrations = new Map<number, Registration>()
let nextHandle = HANDLE_OFFSET
let dormant = false
let countdown: number | undefined
window.setInterval = ((handler: TimerHandler, ms?: number, ...args: unknown[]): number => {
const handle = ++nextHandle
const registration: Registration = { handler, ms: ms ?? 0, args, native: undefined }
if (!dormant) {
registration.native = nativeSetInterval(handler, registration.ms, ...args)
}
registrations.set(handle, registration)
return handle
}) as typeof window.setInterval
window.clearInterval = ((handle?: number): void => {
const registration = handle === undefined ? undefined : registrations.get(handle)
if (!registration) {
nativeClearInterval(handle)
return
}
if (registration.native !== undefined) nativeClearInterval(registration.native)
registrations.delete(handle as number)
}) as typeof window.clearInterval
function inactive(): boolean {
return document.hidden || !document.hasFocus()
}
function scheduleDormancy(): void {
if (dormant || countdown !== undefined) return
countdown = nativeSetTimeout(() => {
countdown = undefined
if (!inactive()) return
dormant = true
for (const registration of registrations.values()) {
if (registration.native === undefined) continue
nativeClearInterval(registration.native)
registration.native = undefined
}
console.debug(
`[dev] polling suspended after ${Math.round(dormantAfterMs / 1000)}s inactive ` +
`(${registrations.size} intervals)`
)
}, dormantAfterMs)
}
function wake(): void {
if (countdown !== undefined) {
nativeClearTimeout(countdown)
countdown = undefined
}
if (!dormant) return
dormant = false
for (const registration of registrations.values()) {
registration.native = nativeSetInterval(
registration.handler,
registration.ms,
...registration.args
)
}
console.debug('[dev] polling resumed')
}
window.addEventListener('blur', scheduleDormancy)
window.addEventListener('focus', wake)
window.addEventListener('pointerdown', wake)
window.addEventListener('keydown', wake)
document.addEventListener('visibilitychange', () => {
if (document.hidden) scheduleDormancy()
else wake()
})
if (inactive()) scheduleDormancy()
}
+3
View File
@@ -3,6 +3,7 @@
import { SvelteToast } from '@zerodevx/svelte-toast'
import '$lib/assets/app.css'
import { installDevPollingDormancy } from '$lib/utils/devPollingDormancy'
interface Props {
children?: import('svelte').Snippet
}
@@ -23,6 +24,8 @@
document.getElementById('svelte-global-loader')?.remove()
installDevPollingDormancy()
// Prevent scrolling over number inputs from changing their value
function handleWheel(e: WheelEvent) {
const target = e.target as HTMLElement