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:
parent
b7db69aa1f
commit
4d115e001b
2 changed files with 213 additions and 72 deletions
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
games/age-of-dwarves/guide/src/vite-plugins/simWorker.ts
Normal file
53
games/age-of-dwarves/guide/src/vite-plugins/simWorker.ts
Normal 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')
|
||||
Loading…
Add table
Reference in a new issue