perf(vite-plugins): Optimize Vite build performance by improving caching in simCachePlugin and worker management in simWorker

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-31 22:47:31 -07:00
parent b7db69aa1f
commit 4d115e001b
2 changed files with 213 additions and 72 deletions

View file

@ -1,47 +1,166 @@
import type { Plugin, ViteDevServer } from 'vite'
import fs from 'fs'
import path from 'path'
import { spawn } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import Redis from 'ioredis'
/**
* Vite dev-only plugin that pre-computes climate simulation scenarios
* on the server and serves cached stats+events via HTTP.
* in child processes (non-blocking) and serves cached results via HTTP.
*
* Endpoint: GET /__sim-cache/:scenarioId?seed=42&turns=2000
* Response: { scenarioId, seed, turns, stats, events }
*
* Cache invalidation: clears when engine source or game data files change.
* Two-layer persistence:
* - In-memory Map: session-scoped, cleared on hot update
* - Redis (black:26402, /bigdisk/redis/magic-civ): 30-day TTL, survives restarts
*
* Pre-warms earth scenarios at server startup so page loads are instant.
* Cache invalidates when engine source or game data files change.
*/
export function simCachePlugin(): Plugin {
const cache = new Map<string, { stats: unknown[]; events: unknown[][] }>()
let engineModule: typeof import('@magic-civ/engine-ts') | null = null
let terrainData: Record<string, unknown> | null = null
let climateParams: Record<string, unknown> | null = null
function loadGameData(root: string): void {
const dataDir = path.resolve(root, '../../games/age-of-dwarves/data')
const WORKER_PATH = fileURLToPath(new URL('./simWorker.ts', import.meta.url))
const REDIS_URL = process.env.SIM_CACHE_REDIS ?? 'redis://10.0.0.11:26402'
const REDIS_TTL_SECONDS = 30 * 24 * 60 * 60
// Load terrain files
const terrainDir = path.join(dataDir, 'terrain')
terrainData = {}
for (const file of fs.readdirSync(terrainDir).filter(f => f.endsWith('.json'))) {
const raw = JSON.parse(fs.readFileSync(path.join(terrainDir, file), 'utf8')) as { terrains?: Array<{ id: string }> }
if (raw.terrains) {
for (const t of raw.terrains) {
(terrainData as Record<string, unknown>)[t.id] = t
}
const PREWARM_IDS = [
'hadean_earth',
'base_no_magic',
'ice_age',
'desertification',
'ecological_collapse',
'volcanic_winter',
]
interface SimResult {
stats: unknown[]
events: unknown[][]
}
type Logger = ViteDevServer['config']['logger']
function redisKey(scenarioId: string, seed: number, turns: number): string {
return `sim:${scenarioId}:${seed}:${turns}`
}
function runChildProcess(input: {
scenarioId: string
seed: number
turns: number
enginePath: string
terrainDir: string
paramsPath: string
}): Promise<SimResult> {
return new Promise((resolve, reject) => {
const child = spawn('node', ['--import', 'tsx/esm', WORKER_PATH], {
env: { ...process.env, WORKER_DATA: JSON.stringify(input) },
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
child.on('close', code => {
if (code !== 0) {
reject(new Error(`sim worker exited ${code}: ${stderr.trim()}`))
return
}
}
try {
resolve(JSON.parse(stdout.trim()) as SimResult)
} catch {
reject(new Error(`sim worker output parse failed: ${stdout.slice(0, 200)}`))
}
})
})
}
// Load climate params from world definition
const paramsPath = path.resolve(root, '../../../src/resources/worlds/earth/climate_params.json')
climateParams = JSON.parse(fs.readFileSync(paramsPath, 'utf8')) as Record<string, unknown>
function errMsg(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
export function simCachePlugin(): Plugin {
const cache = new Map<string, SimResult>()
const pending = new Map<string, Promise<SimResult>>()
let redis: Redis | null = null
let enginePath = ''
let terrainDir = ''
let paramsPath = ''
function initPaths(root: string): void {
enginePath = path.resolve(root, '../../../src/packages/engine-ts/src/index.ts')
terrainDir = path.resolve(root, '../../../src/resources/tiles')
paramsPath = path.resolve(root, '../../../src/resources/worlds/earth/climate_params.json')
}
function readRedis(key: string): Promise<SimResult | null> {
if (!redis) return Promise.resolve(null)
return redis.get(key)
.then(raw => raw ? JSON.parse(raw) as SimResult : null)
.catch(() => null)
}
function writeRedis(key: string, result: SimResult, logger: Logger): void {
if (!redis) return
redis.set(key, JSON.stringify(result), 'EX', REDIS_TTL_SECONDS)
.catch((err: unknown) => logger.warn(`[sim-cache] redis write failed for ${key}: ${errMsg(err)}`))
}
function compute(scenarioId: string, seed: number, turns: number, logger: Logger): Promise<SimResult> {
const memKey = `${scenarioId}:${seed}:${turns}`
const rKey = redisKey(scenarioId, seed, turns)
const mem = cache.get(memKey)
if (mem) return Promise.resolve(mem)
const inFlight = pending.get(memKey)
if (inFlight) return inFlight
const promise = readRedis(rKey).then(persisted => {
if (persisted) {
cache.set(memKey, persisted)
return persisted
}
return runChildProcess({ scenarioId, seed, turns, enginePath, terrainDir, paramsPath })
.then(result => {
cache.set(memKey, result)
writeRedis(rKey, result, logger)
return result
})
}).finally(() => {
pending.delete(memKey)
})
pending.set(memKey, promise)
return promise
}
function prewarm(logger: Logger): void {
for (const id of PREWARM_IDS) {
compute(id, 42, 2000, logger).then(
() => logger.info(`[sim-cache] warmed: ${id}`),
(err: unknown) => logger.warn(`[sim-cache] pre-warm failed for ${id}: ${errMsg(err)}`)
)
}
}
return {
name: 'magic-civ-sim-cache',
apply: 'serve', // Dev only
apply: 'serve',
configureServer(server: ViteDevServer) {
const { logger } = server.config
initPaths(server.config.root)
redis = new Redis(REDIS_URL, { lazyConnect: true, enableReadyCheck: false, maxRetriesPerRequest: 1 })
redis.connect()
.then(() => logger.info('[sim-cache] Redis connected'))
.catch((err: unknown) => logger.warn(`[sim-cache] Redis unavailable (cache will not persist): ${errMsg(err)}`))
prewarm(logger)
server.middlewares.use((req, res, next) => {
const url = req.url ?? ''
const match = url.match(/^\/__sim-cache\/([^?]+)/)
@ -51,64 +170,33 @@ export function simCachePlugin(): Plugin {
const params = new URL(url, 'http://localhost').searchParams
const seed = Number(params.get('seed') ?? '42')
const turns = Number(params.get('turns') ?? '2000')
const cacheKey = `${scenarioId}:${seed}:${turns}`
// Return cached result if available
const cached = cache.get(cacheKey)
if (cached) {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ scenarioId, seed, turns, stats: cached.stats, events: cached.events }))
return
}
// Lazy-load engine and data on first request (Vite resolves TS imports for us)
const computeAndRespond = async (): Promise<void> => {
if (!engineModule) {
const enginePath = path.resolve(server.config.root, '../../packages/engine-ts/src/index.ts')
engineModule = await server.ssrLoadModule(enginePath) as typeof import('@magic-civ/engine-ts')
compute(scenarioId, seed, turns, logger).then(
result => {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ scenarioId, seed, turns, ...result }))
},
(err: unknown) => {
res.statusCode = 500
res.end(JSON.stringify({ error: errMsg(err) }))
}
if (!terrainData || !climateParams) {
loadGameData(server.config.root)
}
const { SCENARIOS, buildTerrainCacheFromData, runScenarioSync } = engineModule
const config = SCENARIOS.find(s => s.id === scenarioId)
if (!config) {
res.statusCode = 404
res.end(JSON.stringify({ error: `Unknown scenario: ${scenarioId}` }))
return
}
const terrainCache = buildTerrainCacheFromData(terrainData as Record<string, import('@magic-civ/engine-ts').TerrainData>)
const result = runScenarioSync(config, terrainCache, climateParams!, turns, seed)
const stats = result.snapshots.map(s => s.stats)
const events = result.snapshots.map(s => s.events)
cache.set(cacheKey, { stats, events })
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ scenarioId, seed, turns, stats, events }))
}
computeAndRespond().catch((err: unknown) => {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
})
)
})
},
handleHotUpdate({ file }) {
// Invalidate cache when engine source or game data changes
if (
file.includes('/packages/engine-ts/src/') ||
file.includes('/src/packages/engine-ts/src/') ||
file.includes('/src/resources/') ||
file.includes('/games/age-of-dwarves/data/')
) {
cache.clear()
engineModule = null
terrainData = null
climateParams = null
pending.clear()
}
},
buildEnd() {
redis?.disconnect()
},
}
}

View file

@ -0,0 +1,53 @@
/**
* Worker process for climate simulation computation.
* Spawned by simCachePlugin via child_process to run scenarios
* off the main event loop without blocking Vite's dev server.
*
* Input: WORKER_DATA env var (JSON)
* Output: single JSON line written to stdout
*/
import fs from 'node:fs'
import path from 'node:path'
interface WorkerInput {
scenarioId: string
seed: number
turns: number
enginePath: string
terrainDir: string
paramsPath: string
}
const input = JSON.parse(process.env.WORKER_DATA ?? '{}') as WorkerInput
const { scenarioId, seed, turns, enginePath, terrainDir, paramsPath } = input
const terrainData: Record<string, unknown> = {}
for (const file of fs.readdirSync(terrainDir).filter(f => f.endsWith('.json'))) {
const raw = JSON.parse(
fs.readFileSync(path.join(terrainDir, file), 'utf8')
) as { terrains?: Array<{ id: string }> }
if (raw.terrains) {
for (const t of raw.terrains) terrainData[t.id] = t
}
}
const climateParams = JSON.parse(fs.readFileSync(paramsPath, 'utf8')) as Record<string, unknown>
const engine = await import(enginePath) as typeof import('@magic-civ/engine-ts')
const { SCENARIOS, buildTerrainCacheFromData, runScenarioSync } = engine
const config = SCENARIOS.find(s => s.id === scenarioId)
if (!config) {
process.stderr.write(`Unknown scenario: ${scenarioId}\n`)
process.exit(1)
}
const terrainCache = buildTerrainCacheFromData(
terrainData as Record<string, import('@magic-civ/engine-ts').TerrainData>
)
const result = runScenarioSync(config, terrainCache, climateParams, turns, seed)
process.stdout.write(JSON.stringify({
stats: result.snapshots.map(s => s.stats),
events: result.snapshots.map(s => s.events),
}) + '\n')