From c1d27970f0d9ec01ad1a43de5d987bf12101f171 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 21:11:12 +0100 Subject: [PATCH 01/25] refactor server networking: extract metrics and http, add metrics endpoint - add ServerMetrics collector with typed snapshot and TickTracker - keep rolling history in BandwidthTracker and CpuTracker - move express setup into Http/HttpServer with GET /api/game/metrics (token or localhost only, off in production without a token) - make socket/http ports and metrics configurable via ServerOptions - fix crash when a delayed command's player disconnected - fix onCommand delta never resetting lastTime --- packages/core/src/BaseGame.ts | 2 + .../src/Networking/Server/Http/HttpServer.ts | 109 +++++++++ .../src/Networking/Server/LatencySimulator.ts | 4 + packages/core/src/Networking/Server/Server.ts | 162 ++++++++------ .../Server/Stats/BandwidthTracker.ts | 45 ++++ .../src/Networking/Server/Stats/CpuTracker.ts | 52 ++++- .../Networking/Server/Stats/ServerMetrics.ts | 209 ++++++++++++++++++ .../Networking/Server/Stats/TickTracker.ts | 79 +++++++ packages/core/tests/Networking/Server.test.ts | 75 +++++++ .../Networking/Server/Http/HttpServer.test.ts | 81 +++++++ .../Server/Stats/BandwidthTracker.test.ts | 37 ++++ .../Server/Stats/CpuTracker.test.ts | 26 ++- .../Server/Stats/ServerMetrics.test.ts | 103 +++++++++ .../Server/Stats/TickTracker.test.ts | 44 ++++ 14 files changed, 954 insertions(+), 74 deletions(-) create mode 100644 packages/core/src/Networking/Server/Http/HttpServer.ts create mode 100644 packages/core/src/Networking/Server/Stats/ServerMetrics.ts create mode 100644 packages/core/src/Networking/Server/Stats/TickTracker.ts create mode 100644 packages/core/tests/Networking/Server/Http/HttpServer.test.ts create mode 100644 packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts create mode 100644 packages/core/tests/Networking/Server/Stats/TickTracker.test.ts diff --git a/packages/core/src/BaseGame.ts b/packages/core/src/BaseGame.ts index 2576bf2..72b9166 100644 --- a/packages/core/src/BaseGame.ts +++ b/packages/core/src/BaseGame.ts @@ -102,6 +102,8 @@ export default class BaseGame extends EventEmitter implements GameObjectInterfac const tickNow = performance.now() this.update((tickNow - this.lastTickTime) / 1000) this.lastTickTime = tickNow + + this.trigger('tickComplete', performance.now() - tickNow) } onUpdate(callback: (delta: number) => void): void { diff --git a/packages/core/src/Networking/Server/Http/HttpServer.ts b/packages/core/src/Networking/Server/Http/HttpServer.ts new file mode 100644 index 0000000..c3aa592 --- /dev/null +++ b/packages/core/src/Networking/Server/Http/HttpServer.ts @@ -0,0 +1,109 @@ +import type { Express, NextFunction, Request, Response } from 'express' +import type winston from 'winston' +import type ServerMetrics from '../Stats/ServerMetrics' +import { Buffer } from 'node:buffer' +import { timingSafeEqual } from 'node:crypto' +import process from 'node:process' +import express from 'express' + +export interface MetricsEndpointOptions { + /** + * Expose GET /api/game/metrics. + * Defaults to true, but in production it stays off unless a token is set. + */ + enabled?: boolean + + /** + * Required as `Authorization: Bearer ` or `?token=`. + * Without a token the endpoint only answers requests from loopback addresses. + */ + token?: string +} + +export interface HttpServerOptions { + port?: number + metrics?: MetricsEndpointOptions +} + +const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']) + +export default class HttpServer { + readonly app: Express + + private port: number + private metricsOptions: MetricsEndpointOptions + + constructor( + private logger: winston.Logger, + private metrics: ServerMetrics, + options: HttpServerOptions = {}, + app: Express = express(), + ) { + this.app = app + this.port = options.port ?? 8050 + this.metricsOptions = options.metrics ?? {} + + this.registerHealthEndpoint() + this.registerMetricsEndpoint() + } + + start() { + this.logger.info('Starting @mavon/engine - HTTP Server') + this.app.listen(this.port) + + this.logger.info(`Visit game server status: http://localhost:${this.port}/api/game/health`) + } + + get metricsEnabled() { + const { enabled = true, token } = this.metricsOptions + + if (!enabled) + return false + + return process.env.NODE_ENV !== 'production' || !!token + } + + private registerHealthEndpoint() { + this.app.get('/api/game/health', (_req, res) => { + res.json(this.metrics.health()) + }) + } + + private registerMetricsEndpoint() { + this.app.get('/api/game/metrics', (req, res, next) => this.authorizeMetrics(req, res, next), (_req, res) => { + res.json(this.metrics.snapshot()) + }) + } + + private authorizeMetrics(req: Request, res: Response, next: NextFunction) { + if (!this.metricsEnabled) { + res.status(404).end() + return + } + + const { token } = this.metricsOptions + + if (token) { + const header = req.headers.authorization + const provided = header?.startsWith('Bearer ') ? header.slice(7) : req.query.token + + if (typeof provided !== 'string' || !safeEqual(provided, token)) { + res.status(401).json({ error: 'Invalid metrics token' }) + return + } + } + else if (!LOOPBACK_ADDRESSES.has(req.socket.remoteAddress ?? '')) { + res.status(403).json({ error: 'Metrics are only available from localhost unless a token is configured' }) + return + } + + next() + } +} + +function safeEqual(a: string, b: string) { + const bufA = Buffer.from(a) + const bufB = Buffer.from(b) + + return bufA.length === bufB.length && timingSafeEqual(bufA, bufB) +} diff --git a/packages/core/src/Networking/Server/LatencySimulator.ts b/packages/core/src/Networking/Server/LatencySimulator.ts index f30dab7..eb8bcab 100644 --- a/packages/core/src/Networking/Server/LatencySimulator.ts +++ b/packages/core/src/Networking/Server/LatencySimulator.ts @@ -29,6 +29,10 @@ export default class LatencySimulator { logger.warn(`RUNNING WITH LATENCY SIMULATION: latency ${this.options.fixedLatency}ms, jitter: ${this.options.jitter}ms, packet loss: ${this.options.packetLoss}%`) } + get config(): Readonly> { + return this.options + } + /** * Calculate our latency items and then * run the callback as appropriate diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index cec17ba..2955914 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -6,39 +6,54 @@ import type GameObject from '../../World/GameObject' import type Player from '../Entities/Player' import type NetworkedActor from '../NetworkedActor' import type { ClientCommand, CommandPacket, IncomingClientCommandPacket } from './Commands' +import type { MetricsEndpointOptions } from './Http/HttpServer' import type { LatencySimulatorOptions } from './LatencySimulator' +import type { ServerMetricsOptions, ServerMetricsSource } from './Stats/ServerMetrics' import { Buffer } from 'node:buffer' import geckos from '@geckos.io/server' -import express from 'express' import BaseGame from '../../BaseGame' import { ServerCommand } from './Commands' +import HttpServer from './Http/HttpServer' import LatencySimulator from './LatencySimulator' -import BandwidthTracker from './Stats/BandwidthTracker' -import CpuTracker from './Stats/CpuTracker' +import ServerMetrics, { SERVER_BANDWIDTH_ID } from './Stats/ServerMetrics' import ServerWorld from './World' let instance: Server export interface ServerOptions { latencySimulation?: LatencySimulatorOptions + + /** Port the geckos.io game socket listens on. Default 8081 */ + socketPort?: number + + /** Port the HTTP server listens on. Default 8050 */ + httpPort?: number + + metrics?: MetricsEndpointOptions & ServerMetricsOptions & { + /** How often a CPU / bandwidth summary is logged, in ms. Default 5000 */ + logIntervalMs?: number + } } -export default abstract class Server { +export default abstract class Server implements ServerMetricsSource { game: BaseGame private commandBuffer: IncomingClientCommandPacket[] = [] gameSocket: GeckosServer - httpServer: Express = express() protected logger: winston.Logger + readonly metrics: ServerMetrics + readonly http: HttpServer + private onHttpCallback?: (httpServer: Express) => void + private options: ServerOptions lastTime = performance.now() - bandwidthTracker = new BandwidthTracker() - private cpuTracker: CpuTracker private latencySimulator?: LatencySimulator + private commandsProcessedThisTick = 0 + private timers: ReturnType[] = [] protected abstract onConnection(channel: ServerChannel): TClient protected abstract onCommand(command: any, delta: number): void @@ -51,12 +66,18 @@ export default abstract class Server { this.gameSocket = geckos() this.logger = logger - this.cpuTracker = new CpuTracker(this.logger) + this.options = options ?? {} - if (options?.latencySimulation) { - this.latencySimulator = new LatencySimulator(options.latencySimulation, this.logger) + if (this.options.latencySimulation) { + this.latencySimulator = new LatencySimulator(this.options.latencySimulation, this.logger) } + this.metrics = new ServerMetrics(this, this.logger, this.options.metrics) + this.http = new HttpServer(this.logger, this.metrics, { + port: this.options.httpPort, + metrics: this.options.metrics, + }) + this.gameSocket.onConnection((channel) => { this.logger.info(`Connected: ${channel.id}`) @@ -108,22 +129,29 @@ export default abstract class Server { channel.on('command', (data) => { const bytes = Buffer.byteLength(JSON.stringify(data)) - this.bandwidthTracker.recordReceived('server', bytes) + this.bandwidthTracker.recordReceived(SERVER_BANDWIDTH_ID, bytes) this.bufferIncomingCommand(channel, data) }) }) this.game.onUpdate(delta => this.tick(delta)) + this.game.on('tickComplete', (durationMs: number) => { + this.metrics.tick.record(durationMs, this.commandsProcessedThisTick) + this.commandsProcessedThisTick = 0 + }) + } - setInterval(() => { - const { sent, received } = this.bandwidthTracker.getBandwidthUsage('server') - this.logger.debug(`Client - Sent: ${sent} B, Received: ${received} B`) + get httpServer(): Express { + return this.http.app + } - this.cpuTracker.log() + get bandwidthTracker() { + return this.metrics.bandwidth + } - this.bandwidthTracker.reset() - }, 5000) + get latencySimulation() { + return this.latencySimulator?.config } private bufferIncomingCommand(channel: ServerChannel, command: Data) { @@ -134,7 +162,7 @@ export default abstract class Server { } private tick(delta: number) { - this.runThroughBuffer() + this.commandsProcessedThisTick += this.runThroughBuffer() this.onTick(delta) this.stateSync() @@ -193,7 +221,7 @@ export default abstract class Server { type: ServerCommand.SV_STATE, } - this.bandwidthTracker.recordSent('server', Buffer.byteLength(JSON.stringify(state))) + this.bandwidthTracker.recordSent(SERVER_BANDWIDTH_ID, Buffer.byteLength(JSON.stringify(state))) const SendState = () => con.channel.emit(ServerCommand.SV_STATE, state) @@ -215,7 +243,10 @@ export default abstract class Server { */ abstract getStateSyncDistance(): number - private runThroughBuffer() { + /** + * Process every buffered command and return how many were processed. + */ + private runThroughBuffer(): number { /** * Due to network latency packets could come in different orders. First step is sort by sequence Id * @@ -229,10 +260,27 @@ export default abstract class Server { */ this.commandBuffer.sort((a, b) => a.sequenceId! - b.sequenceId!) - while (this.commandBuffer.length > 0) { - const currentCommand = this.commandBuffer[0] + const commands = this.commandBuffer + this.commandBuffer = [] + + const delta = this.calculateDelta() + + for (const currentCommand of commands) { + const process = () => { + // The player may have disconnected while the latency simulator held the command back + const player = this.game.world.entities.items.get(currentCommand.playerId!) as Player | undefined + if (!player) + return - const process = () => this.onCommand(currentCommand, this.calculateDelta()) + this.onCommand(currentCommand, delta) + + /** + * Update the players lastProcessedSequenceId after the command has executed. + * Simulated jitter can complete commands out of order so never move it backwards. + */ + if (currentCommand.sequenceId! > player.lastProcessedSequenceId) + player.updateLastProcessedSequenceId(currentCommand.sequenceId!) + } if (this.latencySimulator) { this.latencySimulator.handle(process) @@ -240,22 +288,18 @@ export default abstract class Server { else { process() } + } - /** - * Update the players lastProcessedSequenceId after the command has executed - */ - const player = this.game.world.entities.items.get(currentCommand.playerId!) as Player - player.updateLastProcessedSequenceId(currentCommand.sequenceId!) + this.lastTime = performance.now() - this.commandBuffer.shift() - } + return commands.length } + /** + * Ms since the command buffer was last processed + */ private calculateDelta() { - const currentTime = performance.now() - const deltaTime = (currentTime - this.lastTime) - - return deltaTime + return performance.now() - this.lastTime } protected onTick(_delta: number): void { } @@ -266,9 +310,10 @@ export default abstract class Server { start() { this.logger.info('Starting @mavon/engine - Socket Server') - this.gameSocket.listen(8081) + this.gameSocket.listen(this.options.socketPort ?? 8081) - this.startHttpServer() + this.http.start() + this.startMetrics() /** * Register any http functionality in your own server @@ -278,35 +323,24 @@ export default abstract class Server { } } - private startHttpServer() { - this.logger.info('Starting @mavon/engine - HTTP Server') - this.httpServer.listen(8050) - this.registerHealthEndpoint() - - this.logger.info('Visit game server status: http://localhost:8050/api/game/health') + /** + * Stop the metric sampling and logging timers + */ + stop() { + this.timers.forEach(timer => clearInterval(timer)) + this.timers = [] } - private registerHealthEndpoint() { - this.httpServer.get('/api/game/health', (_req, res) => { - // eslint-disable-next-line ts/no-this-alias - const server = this - - res.format({ - json() { - res.json({ - serverTime: new Date(), - uptime: Math.round(performance.now() / 1000), - players: (server.game.world as ServerWorld).players.length, - world: { - entities: server.game.world.entities.length, - }, - physics: { - bodies: server.game.physicsWorld?.bodies.len(), - colliders: server.game.physicsWorld?.colliders.len(), - }, - }) - }, - }) - }) + private startMetrics() { + const logIntervalMs = this.options.metrics?.logIntervalMs ?? 5000 + + this.timers.push(setInterval(() => this.metrics.sample(), this.metrics.sampleIntervalMs)) + + this.timers.push(setInterval(() => { + const { sent, received } = this.bandwidthTracker.getTotalSince(logIntervalMs) + this.logger.debug(`Client - Sent: ${sent} B, Received: ${received} B`) + + this.metrics.cpu.log() + }, logIntervalMs)) } } diff --git a/packages/core/src/Networking/Server/Stats/BandwidthTracker.ts b/packages/core/src/Networking/Server/Stats/BandwidthTracker.ts index 139e4d4..0c1dca5 100644 --- a/packages/core/src/Networking/Server/Stats/BandwidthTracker.ts +++ b/packages/core/src/Networking/Server/Stats/BandwidthTracker.ts @@ -1,7 +1,23 @@ +export interface BandwidthSample { + timestamp: number + /** Bytes sent since the previous sample */ + sent: number + /** Bytes received since the previous sample */ + received: number +} + export default class BandwidthTracker { private sent = new Map() private received = new Map() + /** + * Rolling history of samples, oldest first. + * Each sample holds the bytes counted between two calls to sample(). + */ + readonly history: BandwidthSample[] = [] + + constructor(private historyLength = 60) { } + recordSent(id: string, bytes: number) { this.sent.set(id, (this.sent.get(id) || 0) + bytes) } @@ -17,6 +33,35 @@ export default class BandwidthTracker { } } + /** + * Push the counters for `id` into the history and reset all counters. + */ + sample(id: string, now = Date.now()): BandwidthSample { + const sample = { timestamp: now, ...this.getBandwidthUsage(id) } + + this.history.push(sample) + if (this.history.length > this.historyLength) + this.history.splice(0, this.history.length - this.historyLength) + + this.reset() + + return sample + } + + /** + * Total bytes over the samples taken within the last `duration` ms. + */ + getTotalSince(duration: number, now = Date.now()) { + const fromTime = now - duration + + return this.history + .filter(sample => sample.timestamp > fromTime) + .reduce((acc, sample) => ({ + sent: acc.sent + sample.sent, + received: acc.received + sample.received, + }), { sent: 0, received: 0 }) + } + reset() { this.sent.clear() this.received.clear() diff --git a/packages/core/src/Networking/Server/Stats/CpuTracker.ts b/packages/core/src/Networking/Server/Stats/CpuTracker.ts index af697a3..2bcf4d0 100644 --- a/packages/core/src/Networking/Server/Stats/CpuTracker.ts +++ b/packages/core/src/Networking/Server/Stats/CpuTracker.ts @@ -2,15 +2,30 @@ import type winston from 'winston' import os from 'node:os' import process from 'node:process' -interface CpuStats { +export interface CpuHistoryEntry { timestamp: number cpuPercentage: number } +export interface CpuStats { + timestamp: number + /** ms of user CPU time since the previous sample */ + userCPU: number + /** ms of system CPU time since the previous sample */ + systemCPU: number + totalUsedCPU: number + /** Percentage of a single core used since the previous sample */ + cpuPercentage: number + avg1s: number + avg10s: number + avg60s: number +} + export default class CpuTracker { private lastCpuUsage = process.cpuUsage() private lastSystemCpuInfo = this.getSystemCpuInfo() - private history: CpuStats[] = [] + private _history: CpuHistoryEntry[] = [] + private _latest?: CpuStats private logger: winston.Logger @@ -18,13 +33,28 @@ export default class CpuTracker { this.logger = logger } + get history(): readonly CpuHistoryEntry[] { + return this._history + } + + get latest(): CpuStats | undefined { + return this._latest + } + + /** + * Logs the most recent sample, taking one first if none exists yet. + * Does not take a new sample so it can't skew a sampling interval running alongside it. + */ log() { - const cpuStats = this.getCpuStats() + const cpuStats = this._latest ?? this.sample() this.logger.info(`CPU Usage - User: ${cpuStats.userCPU.toFixed(2)} ms, System: ${cpuStats.systemCPU.toFixed(2)} ms, Total 1s: ${cpuStats.avg1s}%, Total 10s: ${cpuStats.avg10s}%, Total 1m: ${cpuStats.avg60s}%`) } - private getCpuStats() { + /** + * Measure CPU usage since the previous sample and add it to the history. + */ + sample(): CpuStats { const now = Date.now() const currentCpuUsage = process.cpuUsage(this.lastCpuUsage) @@ -41,14 +71,16 @@ export default class CpuTracker { const cpuCount = os.cpus().length const maxPossibleCpuTime = totalSystemDelta / cpuCount - const cpuPercentage = (totalUsedCPU / maxPossibleCpuTime) * 100 + // os.cpus() times only tick every few ms, so two samples close together can have no delta + const cpuPercentage = maxPossibleCpuTime > 0 ? (totalUsedCPU / maxPossibleCpuTime) * 100 : 0 const roundedCpuPercentage = Number.parseFloat(cpuPercentage.toFixed(2)) // Update history and clean up old entries - this.history.push({ timestamp: now, cpuPercentage: roundedCpuPercentage }) + this._history.push({ timestamp: now, cpuPercentage: roundedCpuPercentage }) this.cleanupHistory(now) - return { + this._latest = { + timestamp: now, userCPU, systemCPU, totalUsedCPU, @@ -57,6 +89,8 @@ export default class CpuTracker { avg10s: this.getAverage(now, 10000), avg60s: this.getAverage(now, 60000), } + + return this._latest } private getSystemCpuInfo() { @@ -81,12 +115,12 @@ export default class CpuTracker { private cleanupHistory(now: number) { const oneMinuteAgo = now - 60000 - this.history = this.history.filter(stat => stat.timestamp >= oneMinuteAgo) + this._history = this._history.filter(stat => stat.timestamp >= oneMinuteAgo) } private getAverage(now: number, duration: number): number { const fromTime = now - duration - const relevantStats = this.history.filter(stat => stat.timestamp >= fromTime) + const relevantStats = this._history.filter(stat => stat.timestamp >= fromTime) if (relevantStats.length === 0) return 0 diff --git a/packages/core/src/Networking/Server/Stats/ServerMetrics.ts b/packages/core/src/Networking/Server/Stats/ServerMetrics.ts new file mode 100644 index 0000000..acb2143 --- /dev/null +++ b/packages/core/src/Networking/Server/Stats/ServerMetrics.ts @@ -0,0 +1,209 @@ +import type winston from 'winston' +import type BaseGame from '../../../BaseGame' +import type { LatencySimulatorOptions } from '../LatencySimulator' +import type ServerWorld from '../World' +import type { BandwidthSample } from './BandwidthTracker' +import type { CpuHistoryEntry } from './CpuTracker' +import type { TickSample } from './TickTracker' +import process from 'node:process' +import { version as ENGINE_VERSION } from '../../../version' +import BandwidthTracker from './BandwidthTracker' +import CpuTracker from './CpuTracker' +import TickTracker from './TickTracker' + +/** + * Bumped whenever a field is removed or changes meaning. + * Consumers (e.g. the mavonengine CLI dashboard) should tolerate unknown fields. + */ +export const METRICS_SCHEMA_VERSION = 1 + +export interface PlayerMetrics { + id: string + position: { x: number, y: number, z: number } + lastProcessedSequenceId: number + trackedEntities: number +} + +export interface ServerHealth { + serverTime: string + uptime: number + players: number + world: { + entities: number + } + physics: { + bodies?: number + colliders?: number + } +} + +export interface ServerMetricsSnapshot { + schemaVersion: typeof METRICS_SCHEMA_VERSION + engineVersion: string + serverTime: string + /** Seconds since the process started */ + uptime: number + /** Ms between metric samples, i.e. the width of each history bucket */ + sampleIntervalMs: number + tickRate: number + players: PlayerMetrics[] + world: { + entities: number + } + physics: { + bodies: number + colliders: number + } | null + cpu: { + current: number + avg1s: number + avg10s: number + avg60s: number + history: CpuHistoryEntry[] + } + bandwidth: { + sentPerSecond: number + receivedPerSecond: number + history: BandwidthSample[] + } + tick: { + budgetMs: number + latest: TickSample | null + history: TickSample[] + } + memory: { + rss: number + heapUsed: number + heapTotal: number + external: number + } + latencySimulation: Required | null +} + +export interface ServerMetricsOptions { + /** How often trackers are sampled, in ms. Default 1000 */ + sampleIntervalMs?: number + /** How many samples each tracker keeps. Default 60 */ + historyLength?: number +} + +export interface ServerMetricsSource { + game: BaseGame + latencySimulation?: Readonly> +} + +/** + * Bandwidth is tracked under a single id for the whole server. + */ +export const SERVER_BANDWIDTH_ID = 'server' + +/** + * Collects server statistics on a fixed interval and builds snapshots of them. + */ +export default class ServerMetrics { + readonly cpu: CpuTracker + readonly bandwidth: BandwidthTracker + readonly tick: TickTracker + + readonly sampleIntervalMs: number + + constructor( + private source: ServerMetricsSource, + logger: winston.Logger, + { sampleIntervalMs = 1000, historyLength = 60 }: ServerMetricsOptions = {}, + ) { + this.sampleIntervalMs = sampleIntervalMs + this.cpu = new CpuTracker(logger) + this.bandwidth = new BandwidthTracker(historyLength) + this.tick = new TickTracker(historyLength) + } + + /** + * Close the current sampling window on every tracker. + */ + sample(now = Date.now()) { + this.cpu.sample() + this.bandwidth.sample(SERVER_BANDWIDTH_ID, now) + this.tick.sample(now) + } + + health(): ServerHealth { + const { game } = this.source + + return { + serverTime: new Date().toISOString(), + uptime: Math.round(performance.now() / 1000), + players: this.players().length, + world: { + entities: game.world.entities.length, + }, + physics: { + bodies: game.physicsWorld?.bodies.len(), + colliders: game.physicsWorld?.colliders.len(), + }, + } + } + + snapshot(): ServerMetricsSnapshot { + const { game, latencySimulation } = this.source + const cpu = this.cpu.latest + const lastBandwidth = this.bandwidth.history.at(-1) + const perSecond = 1000 / this.sampleIntervalMs + const memory = process.memoryUsage() + + return { + schemaVersion: METRICS_SCHEMA_VERSION, + engineVersion: ENGINE_VERSION, + serverTime: new Date().toISOString(), + uptime: Math.round(performance.now() / 1000), + sampleIntervalMs: this.sampleIntervalMs, + tickRate: game.tickRate, + players: this.players().map(player => ({ + id: player.id, + position: { x: player.position.x, y: player.position.y, z: player.position.z }, + lastProcessedSequenceId: player.lastProcessedSequenceId, + trackedEntities: player.trackedEntities.size, + })), + world: { + entities: game.world.entities.length, + }, + physics: game.physicsWorld + ? { + bodies: game.physicsWorld.bodies.len(), + colliders: game.physicsWorld.colliders.len(), + } + : null, + cpu: { + current: cpu?.cpuPercentage ?? 0, + avg1s: cpu?.avg1s ?? 0, + avg10s: cpu?.avg10s ?? 0, + avg60s: cpu?.avg60s ?? 0, + history: [...this.cpu.history], + }, + bandwidth: { + sentPerSecond: Math.round((lastBandwidth?.sent ?? 0) * perSecond), + receivedPerSecond: Math.round((lastBandwidth?.received ?? 0) * perSecond), + history: [...this.bandwidth.history], + }, + tick: { + budgetMs: Math.round((1000 / game.tickRate) * 100) / 100, + latest: this.tick.latest ?? null, + history: [...this.tick.history], + }, + memory: { + rss: memory.rss, + heapUsed: memory.heapUsed, + heapTotal: memory.heapTotal, + external: memory.external, + }, + latencySimulation: latencySimulation ? { ...latencySimulation } : null, + } + } + + /** + * Players that haven't been marked for removal yet. + */ + private players() { + return (this.source.game.world as ServerWorld).players.filter(player => !player.garbageCollect) + } +} diff --git a/packages/core/src/Networking/Server/Stats/TickTracker.ts b/packages/core/src/Networking/Server/Stats/TickTracker.ts new file mode 100644 index 0000000..53c162d --- /dev/null +++ b/packages/core/src/Networking/Server/Stats/TickTracker.ts @@ -0,0 +1,79 @@ +export interface TickSample { + timestamp: number + /** Number of ticks run since the previous sample */ + ticks: number + /** Ticks per second over the sample window */ + ticksPerSecond: number + /** Average time spent inside a tick in ms */ + avgMs: number + /** Slowest tick in ms */ + maxMs: number + /** Client commands processed per second */ + commandsPerSecond: number + /** Most commands processed in a single tick */ + maxCommandsPerTick: number +} + +export default class TickTracker { + private ticks = 0 + private totalMs = 0 + private maxMs = 0 + private commands = 0 + private maxCommandsPerTick = 0 + private lastSampleTime: number + + /** + * Rolling history of samples, oldest first. + */ + readonly history: TickSample[] = [] + + constructor(private historyLength = 60, now = Date.now()) { + this.lastSampleTime = now + } + + record(durationMs: number, commandsProcessed = 0) { + this.ticks++ + this.totalMs += durationMs + this.maxMs = Math.max(this.maxMs, durationMs) + this.commands += commandsProcessed + this.maxCommandsPerTick = Math.max(this.maxCommandsPerTick, commandsProcessed) + } + + /** + * Summarise the ticks recorded since the previous sample, add it to the history and reset the counters. + */ + sample(now = Date.now()): TickSample { + const elapsedSeconds = Math.max(now - this.lastSampleTime, 1) / 1000 + + const sample: TickSample = { + timestamp: now, + ticks: this.ticks, + ticksPerSecond: round(this.ticks / elapsedSeconds), + avgMs: this.ticks > 0 ? round(this.totalMs / this.ticks) : 0, + maxMs: round(this.maxMs), + commandsPerSecond: round(this.commands / elapsedSeconds), + maxCommandsPerTick: this.maxCommandsPerTick, + } + + this.history.push(sample) + if (this.history.length > this.historyLength) + this.history.splice(0, this.history.length - this.historyLength) + + this.ticks = 0 + this.totalMs = 0 + this.maxMs = 0 + this.commands = 0 + this.maxCommandsPerTick = 0 + this.lastSampleTime = now + + return sample + } + + get latest(): TickSample | undefined { + return this.history.at(-1) + } +} + +function round(value: number) { + return Math.round(value * 100) / 100 +} diff --git a/packages/core/tests/Networking/Server.test.ts b/packages/core/tests/Networking/Server.test.ts index 739714a..b56ad36 100644 --- a/packages/core/tests/Networking/Server.test.ts +++ b/packages/core/tests/Networking/Server.test.ts @@ -181,6 +181,81 @@ describe('server', () => { expect(player.lastProcessedSequenceId).toBe(4) }) + it('skips commands from players that disconnected before they were processed', () => { + const server = new TestServer(logger) + + const onCommand = vi.fn() + ;(server as any).onCommand = onCommand + ;(server as any).commandBuffer = [{ sequenceId: 1, playerId: 'gone' }] + + expect(() => (server as any).runThroughBuffer()).not.toThrow() + expect(onCommand).not.toHaveBeenCalled() + }) + + it('never moves lastProcessedSequenceId backwards', () => { + const server = new TestServer(logger) + + const player = new TestClient('p1') + player.updateLastProcessedSequenceId(10) + server.game.world.entities.items.set('p1', player) + + ;(server as any).commandBuffer = [{ sequenceId: 4, playerId: 'p1' }] + ;(server as any).runThroughBuffer() + + expect(player.lastProcessedSequenceId).toBe(10) + }) + + it('passes the same delta to every command in a tick and resets it afterwards', () => { + const server = new TestServer(logger) + server.game.world.entities.items.set('p1', new TestClient('p1')) + + const deltas: number[] = [] + ;(server as any).onCommand = (_cmd: unknown, delta: number) => deltas.push(delta) + + server.lastTime = performance.now() - 1000 + ;(server as any).commandBuffer = [ + { sequenceId: 1, playerId: 'p1' }, + { sequenceId: 2, playerId: 'p1' }, + ] + + expect((server as any).runThroughBuffer()).toBe(2) + expect(deltas[0]).toBeGreaterThanOrEqual(1000) + expect(deltas[1]).toBe(deltas[0]) + expect((server as any).calculateDelta()).toBeLessThan(1000) + }) + + it('records tick duration and processed commands when a tick completes', () => { + const server = new TestServer(logger) + server.game.world.entities.items.set('p1', new TestClient('p1')) + server.getStateSyncDistance = () => 100 + + ;(server as any).commandBuffer = [{ sequenceId: 1, playerId: 'p1' }] + server.game.tick() + + expect(server.metrics.tick.sample()).toMatchObject({ ticks: 1, maxCommandsPerTick: 1 }) + }) + + it('exposes the latency simulation config to metrics', () => { + const server = new TestServer(logger, undefined, { latencySimulation: { fixedLatency: 20 } }) + + expect(server.metrics.snapshot().latencySimulation).toEqual({ fixedLatency: 20, jitter: 0, packetLoss: 0 }) + }) + + it('listens on the configured ports', () => { + const server = new TestServer(logger, undefined, { socketPort: 9001, httpPort: 9002 }) + + const listen = vi.fn() + ;(server.gameSocket as any).listen = listen + const httpStart = vi.spyOn(server.http, 'start').mockImplementation(() => {}) + + server.start() + server.stop() + + expect(listen).toHaveBeenCalledWith(9001) + expect(httpStart).toHaveBeenCalled() + expect((server.http as any).port).toBe(9002) + }) + describe('stateSync', () => { function makeConnection(id: string) { const emit = vi.fn() diff --git a/packages/core/tests/Networking/Server/Http/HttpServer.test.ts b/packages/core/tests/Networking/Server/Http/HttpServer.test.ts new file mode 100644 index 0000000..bc86d80 --- /dev/null +++ b/packages/core/tests/Networking/Server/Http/HttpServer.test.ts @@ -0,0 +1,81 @@ +import type { Server as NodeServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { HttpServerOptions } from '../../../../src/Networking/Server/Http/HttpServer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import HttpServer from '../../../../src/Networking/Server/Http/HttpServer' + +const health = { serverTime: 'now', uptime: 1, players: 0, world: { entities: 0 }, physics: {} } +const snapshot = { schemaVersion: 1, players: [] } + +let listener: NodeServer | undefined + +async function serve(options: HttpServerOptions = {}) { + const logger = { info: vi.fn() } as any + const metrics = { health: () => health, snapshot: () => snapshot } as any + + const http = new HttpServer(logger, metrics, options) + + listener = await new Promise((resolve) => { + const server = http.app.listen(0, '127.0.0.1', () => resolve(server)) + }) + + const { port } = listener.address() as AddressInfo + + return (path: string, init?: RequestInit) => fetch(`http://127.0.0.1:${port}${path}`, init) +} + +describe('httpServer', () => { + afterEach(() => { + listener?.close() + listener = undefined + vi.unstubAllEnvs() + }) + + it('serves health', async () => { + const request = await serve() + + const res = await request('/api/game/health') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual(health) + }) + + it('serves metrics to localhost without a token', async () => { + const request = await serve() + + const res = await request('/api/game/metrics') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual(snapshot) + }) + + it('requires the token when one is configured', async () => { + const request = await serve({ metrics: { token: 'secret' } }) + + expect((await request('/api/game/metrics')).status).toBe(401) + expect((await request('/api/game/metrics?token=wrong')).status).toBe(401) + expect((await request('/api/game/metrics?token=secret')).status).toBe(200) + + const res = await request('/api/game/metrics', { headers: { Authorization: 'Bearer secret' } }) + expect(res.status).toBe(200) + }) + + it('can be disabled', async () => { + const request = await serve({ metrics: { enabled: false } }) + + expect((await request('/api/game/metrics')).status).toBe(404) + expect((await request('/api/game/health')).status).toBe(200) + }) + + it('is disabled in production unless a token is set', async () => { + vi.stubEnv('NODE_ENV', 'production') + + const request = await serve() + expect((await request('/api/game/metrics')).status).toBe(404) + + listener?.close() + + const withToken = await serve({ metrics: { token: 'secret' } }) + expect((await withToken('/api/game/metrics?token=secret')).status).toBe(200) + }) +}) diff --git a/packages/core/tests/Networking/Server/Stats/BandwidthTracker.test.ts b/packages/core/tests/Networking/Server/Stats/BandwidthTracker.test.ts index e45bef9..071ab26 100644 --- a/packages/core/tests/Networking/Server/Stats/BandwidthTracker.test.ts +++ b/packages/core/tests/Networking/Server/Stats/BandwidthTracker.test.ts @@ -13,4 +13,41 @@ describe('bandwidthTracker', () => { tracker.reset() expect(tracker.getBandwidthUsage('a')).toEqual({ sent: 0, received: 0 }) }) + + it('moves counters into the history when sampled', () => { + const tracker = new BandwidthTracker() + tracker.recordSent('a', 10) + tracker.recordReceived('a', 4) + + const sample = tracker.sample('a', 1000) + + expect(sample).toEqual({ timestamp: 1000, sent: 10, received: 4 }) + expect(tracker.history).toEqual([sample]) + expect(tracker.getBandwidthUsage('a')).toEqual({ sent: 0, received: 0 }) + }) + + it('caps the history length', () => { + const tracker = new BandwidthTracker(3) + + for (let i = 0; i < 5; i++) { + tracker.recordSent('a', i) + tracker.sample('a', i) + } + + expect(tracker.history.map(s => s.sent)).toEqual([2, 3, 4]) + }) + + it('totals the samples within a time window', () => { + const tracker = new BandwidthTracker() + + tracker.recordSent('a', 1) + tracker.sample('a', 1000) + tracker.recordSent('a', 2) + tracker.recordReceived('a', 5) + tracker.sample('a', 5000) + tracker.recordSent('a', 3) + tracker.sample('a', 6000) + + expect(tracker.getTotalSince(2000, 6000)).toEqual({ sent: 5, received: 5 }) + }) }) diff --git a/packages/core/tests/Networking/Server/Stats/CpuTracker.test.ts b/packages/core/tests/Networking/Server/Stats/CpuTracker.test.ts index 9c3d27d..d401fe8 100644 --- a/packages/core/tests/Networking/Server/Stats/CpuTracker.test.ts +++ b/packages/core/tests/Networking/Server/Stats/CpuTracker.test.ts @@ -6,7 +6,8 @@ describe('cpuTracker', () => { const logger = { info: vi.fn() } as any const tracker = new CpuTracker(logger) - vi.spyOn(tracker as any, 'getCpuStats').mockReturnValue({ + vi.spyOn(tracker, 'sample').mockReturnValue({ + timestamp: 0, userCPU: 1, systemCPU: 2, totalUsedCPU: 3, @@ -21,4 +22,27 @@ describe('cpuTracker', () => { 'CPU Usage - User: 1.00 ms, System: 2.00 ms, Total 1s: 10%, Total 10s: 20%, Total 1m: 30%', ) }) + + it('logs the latest sample instead of taking a new one', () => { + const logger = { info: vi.fn() } as any + const tracker = new CpuTracker(logger) + + const latest = tracker.sample() + const sample = vi.spyOn(tracker, 'sample') + + tracker.log() + + expect(sample).not.toHaveBeenCalled() + expect(tracker.latest).toBe(latest) + }) + + it('records each sample in the history', () => { + const tracker = new CpuTracker({ info: vi.fn() } as any) + + tracker.sample() + tracker.sample() + + expect(tracker.history).toHaveLength(2) + expect(Number.isFinite(tracker.history[1].cpuPercentage)).toBe(true) + }) }) diff --git a/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts b/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts new file mode 100644 index 0000000..0b4eae1 --- /dev/null +++ b/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest' +import BaseGame from '../../../../src/BaseGame' +import Player from '../../../../src/Networking/Entities/Player' +import ServerMetrics, { METRICS_SCHEMA_VERSION, SERVER_BANDWIDTH_ID } from '../../../../src/Networking/Server/Stats/ServerMetrics' +import ServerWorld from '../../../../src/Networking/Server/World' + +class TestPlayer extends Player { + health = 100 + maxHealth = 100 + takeDamage(_amount: number): void { } + heal(_amount: number): void { } + update(_delta: number): void { } +} + +function makeMetrics(latencySimulation?: any) { + const logger = { info: vi.fn(), debug: vi.fn(), warn: vi.fn() } as any + const game = new BaseGame(null) + game.world = new ServerWorld() + + const metrics = new ServerMetrics({ game, latencySimulation }, logger, { sampleIntervalMs: 500 }) + + return { game, metrics } +} + +function addPlayer(game: BaseGame, id: string) { + const player = new TestPlayer(id) + game.world.entities.items.set(id, player) + return player +} + +describe('serverMetrics', () => { + it('builds a snapshot of the server', () => { + const { game, metrics } = makeMetrics({ fixedLatency: 50, jitter: 10, packetLoss: 0 }) + + const player = addPlayer(game, 'p1') + player.position.set(1, 2, 3) + player.updateLastProcessedSequenceId(7) + player.trackedEntities.add('p1') + + const snapshot = metrics.snapshot() + + expect(snapshot.schemaVersion).toBe(METRICS_SCHEMA_VERSION) + expect(snapshot.sampleIntervalMs).toBe(500) + expect(snapshot.tickRate).toBe(game.tickRate) + expect(snapshot.players).toEqual([{ + id: 'p1', + position: { x: 1, y: 2, z: 3 }, + lastProcessedSequenceId: 7, + trackedEntities: 1, + }]) + expect(snapshot.world.entities).toBe(1) + expect(snapshot.physics).toBeNull() + expect(snapshot.latencySimulation).toEqual({ fixedLatency: 50, jitter: 10, packetLoss: 0 }) + expect(snapshot.memory.rss).toBeGreaterThan(0) + }) + + it('leaves out players that are marked for removal', () => { + const { game, metrics } = makeMetrics() + + addPlayer(game, 'p1') + addPlayer(game, 'p2').destroy() + + expect(metrics.snapshot().players.map(p => p.id)).toEqual(['p1']) + expect(metrics.health().players).toBe(1) + }) + + it('reports bandwidth per second from the latest sample', () => { + const { metrics } = makeMetrics() + + metrics.bandwidth.recordSent(SERVER_BANDWIDTH_ID, 100) + metrics.bandwidth.recordReceived(SERVER_BANDWIDTH_ID, 40) + metrics.sample() + + const { bandwidth } = metrics.snapshot() + + // sampled every 500ms so per second is double + expect(bandwidth.sentPerSecond).toBe(200) + expect(bandwidth.receivedPerSecond).toBe(80) + expect(bandwidth.history).toHaveLength(1) + }) + + it('samples every tracker', () => { + const { metrics } = makeMetrics() + + metrics.tick.record(5, 2) + metrics.sample() + + const snapshot = metrics.snapshot() + + expect(snapshot.cpu.history).toHaveLength(1) + expect(snapshot.tick.latest).toMatchObject({ ticks: 1, avgMs: 5, maxCommandsPerTick: 2 }) + expect(snapshot.tick.budgetMs).toBeCloseTo(1000 / 30, 1) + }) + + it('serializes to JSON without losing data', () => { + const { metrics } = makeMetrics() + metrics.sample() + + const snapshot = metrics.snapshot() + + expect(JSON.parse(JSON.stringify(snapshot))).toEqual(snapshot) + }) +}) diff --git a/packages/core/tests/Networking/Server/Stats/TickTracker.test.ts b/packages/core/tests/Networking/Server/Stats/TickTracker.test.ts new file mode 100644 index 0000000..ad7b574 --- /dev/null +++ b/packages/core/tests/Networking/Server/Stats/TickTracker.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import TickTracker from '../../../../src/Networking/Server/Stats/TickTracker' + +describe('tickTracker', () => { + it('summarises the ticks recorded since the last sample', () => { + const tracker = new TickTracker(60, 0) + + tracker.record(2, 1) + tracker.record(4, 3) + tracker.record(6, 0) + + const sample = tracker.sample(500) + + expect(sample).toEqual({ + timestamp: 500, + ticks: 3, + ticksPerSecond: 6, + avgMs: 4, + maxMs: 6, + commandsPerSecond: 8, + maxCommandsPerTick: 3, + }) + expect(tracker.latest).toBe(sample) + }) + + it('resets counters after sampling', () => { + const tracker = new TickTracker(60, 0) + + tracker.record(10, 5) + tracker.sample(1000) + + expect(tracker.sample(2000)).toMatchObject({ ticks: 0, avgMs: 0, maxMs: 0, commandsPerSecond: 0 }) + }) + + it('caps the history length', () => { + const tracker = new TickTracker(2, 0) + + tracker.sample(1000) + tracker.sample(2000) + tracker.sample(3000) + + expect(tracker.history.map(s => s.timestamp)).toEqual([2000, 3000]) + }) +}) From 19567d2b4fc32f348d47182a5cf68bd283332815 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 21:29:14 +0100 Subject: [PATCH 02/25] add @mavonengine/cli with 'mavonengine dashboard' - live terminal dashboard for the game server metrics endpoint, built on hqtui - MetricsClient polls /api/game/metrics with backoff and keeps last values on failure - tolerant snapshot normalisation so cli and server versions can drift - CPU, network, tick budget, world/memory and players panels with a narrow layout - transparent background and readable muted text across themes - --sim mode with deterministic simulated data --- package-lock.json | 56 ++++- package.json | 3 +- packages/cli/README.md | 56 +++++ packages/cli/package.json | 44 ++++ packages/cli/src/commands/dashboard.ts | 109 +++++++++ packages/cli/src/dashboard/MetricsClient.ts | 174 +++++++++++++ packages/cli/src/dashboard/format.ts | 39 +++ packages/cli/src/dashboard/simulator.ts | 129 ++++++++++ packages/cli/src/dashboard/snapshot.ts | 110 +++++++++ packages/cli/src/dashboard/theme.ts | 23 ++ packages/cli/src/dashboard/view.ts | 258 ++++++++++++++++++++ packages/cli/src/index.ts | 57 +++++ packages/cli/tests/MetricsClient.test.ts | 137 +++++++++++ packages/cli/tests/dashboardArgs.test.ts | 57 +++++ packages/cli/tests/format.test.ts | 25 ++ packages/cli/tests/snapshot.test.ts | 46 ++++ packages/cli/tests/theme.test.ts | 33 +++ packages/cli/tests/view.test.ts | 85 +++++++ packages/cli/tsconfig.check.json | 9 + packages/cli/tsconfig.json | 16 ++ packages/cli/tsup.config.ts | 12 + packages/cli/vitest.config.ts | 7 + 22 files changed, 1483 insertions(+), 2 deletions(-) create mode 100644 packages/cli/README.md create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/commands/dashboard.ts create mode 100644 packages/cli/src/dashboard/MetricsClient.ts create mode 100644 packages/cli/src/dashboard/format.ts create mode 100644 packages/cli/src/dashboard/simulator.ts create mode 100644 packages/cli/src/dashboard/snapshot.ts create mode 100644 packages/cli/src/dashboard/theme.ts create mode 100644 packages/cli/src/dashboard/view.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/tests/MetricsClient.test.ts create mode 100644 packages/cli/tests/dashboardArgs.test.ts create mode 100644 packages/cli/tests/format.test.ts create mode 100644 packages/cli/tests/snapshot.test.ts create mode 100644 packages/cli/tests/theme.test.ts create mode 100644 packages/cli/tests/view.test.ts create mode 100644 packages/cli/tsconfig.check.json create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/tsup.config.ts create mode 100644 packages/cli/vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 6a3a82f..5d416a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "workspaces": [ "packages/bootstrap", "packages/core", - "packages/editor" + "packages/editor", + "packages/cli" ], "devDependencies": { "@antfu/eslint-config": "8.0.0", @@ -1630,6 +1631,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mavonengine/cli": { + "resolved": "packages/cli", + "link": true + }, "node_modules/@mavonengine/core": { "resolved": "packages/core", "link": true @@ -1686,6 +1691,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@profullstack/hqtui": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@profullstack/hqtui/-/hqtui-0.11.1.tgz", + "integrity": "sha512-c/UzTmiRDjdLZQEO/gdVinUKtx6Q0WBPdixIm90MBTvC3DqFPqpersRs0BIDPS7XVokCSr1DtELI91kj5J1kCg==", + "license": "MIT", + "bin": { + "hqtui": "bin/hqtui.mjs" + }, + "engines": { + "bun": ">=1.1", + "node": ">=22.6" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -10895,6 +10913,42 @@ "node": ">=14.17" } }, + "packages/cli": { + "name": "@mavonengine/cli", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@profullstack/hqtui": "0.11.1" + }, + "bin": { + "mavonengine": "dist/index.js" + }, + "devDependencies": { + "@mavonengine/core": "*", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.2.4", + "tsup": "^8.5.1", + "typescript": "5.9.2", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22.6" + } + }, + "packages/cli/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/core": { "name": "@mavonengine/core", "version": "0.0.20", diff --git a/package.json b/package.json index 64dea82..898eca0 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "workspaces": [ "packages/bootstrap", "packages/core", - "packages/editor" + "packages/editor", + "packages/cli" ], "devDependencies": { "@antfu/eslint-config": "8.0.0", diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..33d3b5c --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,56 @@ +# @mavonengine/cli + +Command line tools for [MavonEngine](https://mavonengine.com). + +Requires Node 22.6 or newer. + +```sh +npm install -D @mavonengine/cli +npx mavonengine --help +``` + +## `mavonengine dashboard` + +A live terminal dashboard for a running game server, built with [hqtui](https://github.com/profullstack/hqtui). + +It shows CPU, network bandwidth, tick timings against the tick budget, world/physics/memory stats and the connected players. It reads them from the server's `GET /api/game/metrics` endpoint, which needs a `@mavonengine/core` release that includes it. + +```sh +# Local server on the default HTTP port (8050) +npx mavonengine dashboard + +# Remote server with a metrics token +MAVONENGINE_METRICS_TOKEN=secret npx mavonengine dashboard --host game.example.com --port 8050 + +# No server needed: simulated data +npx mavonengine dashboard --sim +``` + +| Option | Default | | +| --- | --- | --- | +| `--host ` | `localhost` | Server host | +| `--port ` | `8050` | Server HTTP port (`ServerOptions.httpPort`) | +| `--url ` | | Full metrics URL, overrides `--host` and `--port` | +| `--token ` | `$MAVONENGINE_METRICS_TOKEN` | Metrics token (`ServerOptions.metrics.token`) | +| `--interval ` | `1000` | Poll interval | +| `--theme ` | `dark` | `dark`, `dracula`, `nord`, `tokyoNight`, `gruvbox`, `matrix`, `monochrome`, `highContrast`, `light` | +| `--sim` | | Simulated data instead of a server | + +Keys: `q` quits, `r` refreshes now, and the mouse wheel scrolls the players table. + +### Server access + +The metrics endpoint is configured on the game server: + +```ts +const server = new MyServer(logger, physicsWorld, { + httpPort: 8050, + metrics: { + token: process.env.METRICS_TOKEN, + }, +}) +``` + +- With no token, the endpoint only answers requests from localhost. +- With `NODE_ENV=production` and no token, the endpoint is turned off. +- Set `metrics.enabled: false` to turn it off everywhere. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..0a2b364 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,44 @@ +{ + "name": "@mavonengine/cli", + "type": "module", + "version": "0.0.1", + "description": "MavonEngine command line tools", + "license": "MIT", + "keywords": [ + "mavonengine", + "cli", + "dashboard", + "game-server", + "monitoring" + ], + "bin": { + "mavonengine": "./dist/index.js" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=22.6" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc -p tsconfig.check.json", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "dependencies": { + "@profullstack/hqtui": "0.11.1" + }, + "devDependencies": { + "@mavonengine/core": "*", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.2.4", + "tsup": "^8.5.1", + "typescript": "5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts new file mode 100644 index 0000000..1b53142 --- /dev/null +++ b/packages/cli/src/commands/dashboard.ts @@ -0,0 +1,109 @@ +import type { MetricsSource } from '../dashboard/MetricsClient' +import type { ViewState } from '../dashboard/view' +import process from 'node:process' +import { parseArgs } from 'node:util' +import MetricsClient, { httpSource } from '../dashboard/MetricsClient' +import { simulatedSource } from '../dashboard/simulator' +import { readableTheme } from '../dashboard/theme' +import { renderDashboard } from '../dashboard/view' + +export const DASHBOARD_HELP = `Usage: mavonengine dashboard [options] + +Live terminal dashboard for a running MavonEngine game server. + +Options: + --host Server host (default: localhost) + --port Server HTTP port (default: 8050) + --url Full metrics URL, overrides --host and --port + --token Metrics token (or set MAVONENGINE_METRICS_TOKEN) + --interval Poll interval in ms (default: 1000) + --theme dark, dracula, nord, tokyoNight, gruvbox, matrix, light, … + --sim Show simulated data instead of connecting to a server + -h, --help Show this help +` + +export interface DashboardOptions { + source: MetricsSource + intervalMs: number + theme?: string +} + +export class UsageError extends Error { } + +/** + * Turn CLI arguments into dashboard options. Throws UsageError on bad input. + */ +export function parseDashboardArgs(args: string[], env: Record = process.env): DashboardOptions | 'help' { + const { values } = parseArgs({ + args, + options: { + host: { type: 'string', default: 'localhost' }, + port: { type: 'string', default: '8050' }, + url: { type: 'string' }, + token: { type: 'string' }, + interval: { type: 'string', default: '1000' }, + theme: { type: 'string' }, + sim: { type: 'boolean', default: false }, + help: { type: 'boolean', short: 'h', default: false }, + }, + strict: true, + allowPositionals: false, + }) + + if (values.help) + return 'help' + + const intervalMs = Number(values.interval) + if (!Number.isInteger(intervalMs) || intervalMs < 100) + throw new UsageError('--interval must be a whole number of ms, at least 100') + + if (values.sim) + return { source: simulatedSource(), intervalMs, theme: values.theme } + + let url = values.url + if (!url) { + const port = Number(values.port) + if (!Number.isInteger(port) || port < 1 || port > 65535) + throw new UsageError('--port must be between 1 and 65535') + + const host = values.host!.includes(':') && !values.host!.startsWith('[') ? `[${values.host}]` : values.host + url = `http://${host}:${port}/api/game/metrics` + } + + try { + void new URL(url) + } + catch { + throw new UsageError(`Invalid --url: ${url}`) + } + + const token = values.token ?? env.MAVONENGINE_METRICS_TOKEN + + return { source: httpSource({ url, token }), intervalMs, theme: values.theme } +} + +export async function runDashboard(options: DashboardOptions) { + // Loaded lazily so `mavonengine --help` doesn't pay for the TUI + const { createApp } = await import('@profullstack/hqtui') + + const client = new MetricsClient(options.source, { intervalMs: options.intervalMs }) + const view: ViewState = { playerOffset: 0 } + + // Leave cells on the terminal's default background so transparent / blurred terminals show through + const app = await createApp({ fps: 15, theme: readableTheme(options.theme), paintBackground: false }) + + app.render(({ ui, theme }) => renderDashboard(ui, theme, client.state, view)) + + client.onChange(() => app.invalidate()) + + app.on('key', (event) => { + if (event.key === 'r') + void client.refresh() + }) + + app.on('exit', () => client.stop()) + + client.start() + await app.start() + client.stop() +} diff --git a/packages/cli/src/dashboard/MetricsClient.ts b/packages/cli/src/dashboard/MetricsClient.ts new file mode 100644 index 0000000..91d18d6 --- /dev/null +++ b/packages/cli/src/dashboard/MetricsClient.ts @@ -0,0 +1,174 @@ +import type { ServerMetricsSnapshot } from './snapshot' +import { normalizeSnapshot } from './snapshot' + +export type ConnectionStatus = 'connecting' | 'live' | 'unreachable' | 'unauthorized' | 'forbidden' | 'disabled' + +export interface DashboardState { + status: ConnectionStatus + /** Where metrics come from, shown in the header */ + source: string + snapshot: ServerMetricsSnapshot | null + /** Date.now() of the last successful poll */ + lastUpdate: number | null + error: string | null + /** Consecutive failed polls */ + failures: number +} + +export class MetricsSourceError extends Error { + constructor(message: string, readonly status: ConnectionStatus) { + super(message) + } +} + +/** + * Something that produces one raw metrics snapshot per call. + */ +export interface MetricsSource { + label: string + fetch(): Promise +} + +export interface HttpSourceOptions { + url: string + token?: string + timeoutMs?: number + fetch?: typeof globalThis.fetch +} + +export function httpSource({ url, token, timeoutMs = 3000, fetch = globalThis.fetch }: HttpSourceOptions): MetricsSource { + return { + label: url, + async fetch() { + let res: Response + + try { + res = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + signal: AbortSignal.timeout(timeoutMs), + }) + } + catch (err) { + throw new MetricsSourceError(`Can't reach ${url}: ${(err as Error).message}`, 'unreachable') + } + + if (res.status === 401) + throw new MetricsSourceError('Metrics token missing or wrong (use --token)', 'unauthorized') + if (res.status === 403) + throw new MetricsSourceError('Server only allows metrics from localhost without a token (use --token)', 'forbidden') + if (res.status === 404) + throw new MetricsSourceError('Metrics endpoint not found: it is disabled, or the server runs a @mavonengine/core release without it', 'disabled') + if (!res.ok) + throw new MetricsSourceError(`Server responded ${res.status} ${res.statusText}`, 'unreachable') + + return res.json() + }, + } +} + +export interface MetricsClientOptions { + intervalMs?: number + /** Longest wait between retries while the server is failing */ + maxBackoffMs?: number +} + +/** + * Polls a metrics source and keeps the latest dashboard state. + * Knows nothing about rendering so it can be tested without a terminal. + */ +export default class MetricsClient { + readonly state: DashboardState + private intervalMs: number + private maxBackoffMs: number + private timer: ReturnType | null = null + private running = false + private listeners = new Set<(state: DashboardState) => void>() + + constructor(private source: MetricsSource, { intervalMs = 1000, maxBackoffMs = 10000 }: MetricsClientOptions = {}) { + this.intervalMs = intervalMs + this.maxBackoffMs = maxBackoffMs + this.state = { + status: 'connecting', + source: source.label, + snapshot: null, + lastUpdate: null, + error: null, + failures: 0, + } + } + + onChange(listener: (state: DashboardState) => void) { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + start() { + if (this.running) + return + + this.running = true + void this.loop() + } + + stop() { + this.running = false + + if (this.timer) + clearTimeout(this.timer) + this.timer = null + } + + /** + * Poll once now, skipping the wait. + */ + async refresh() { + if (this.timer) + clearTimeout(this.timer) + this.timer = null + + await this.loop() + } + + /** + * Fetch one snapshot and update the state. + */ + async poll() { + try { + const raw = await this.source.fetch() + + this.state.snapshot = normalizeSnapshot(raw) + this.state.status = 'live' + this.state.lastUpdate = Date.now() + this.state.error = null + this.state.failures = 0 + } + catch (err) { + this.state.status = err instanceof MetricsSourceError ? err.status : 'unreachable' + this.state.error = (err as Error).message + this.state.failures++ + } + + this.listeners.forEach(listener => listener(this.state)) + } + + /** + * Delay before the next poll: the normal interval, doubling per failure up to maxBackoffMs. + */ + nextDelay() { + if (this.state.failures === 0) + return this.intervalMs + + return Math.min(this.intervalMs * 2 ** this.state.failures, Math.max(this.maxBackoffMs, this.intervalMs)) + } + + private async loop() { + await this.poll() + + if (this.running && !this.timer) { + this.timer = setTimeout(() => { + this.timer = null + void this.loop() + }, this.nextDelay()) + } + } +} diff --git a/packages/cli/src/dashboard/format.ts b/packages/cli/src/dashboard/format.ts new file mode 100644 index 0000000..a51c8c1 --- /dev/null +++ b/packages/cli/src/dashboard/format.ts @@ -0,0 +1,39 @@ +const BYTE_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] + +export function formatBytes(bytes: number, decimals = 1): string { + if (!Number.isFinite(bytes) || bytes <= 0) + return '0 B' + + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1) + const value = bytes / 1024 ** exponent + + return `${exponent === 0 ? value : value.toFixed(decimals)} ${BYTE_UNITS[exponent]}` +} + +export function formatRate(bytesPerSecond: number): string { + return `${formatBytes(bytesPerSecond)}/s` +} + +export function formatDuration(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)) + const d = Math.floor(seconds / 86400) + const h = Math.floor((seconds % 86400) / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = seconds % 60 + + if (d > 0) + return `${d}d ${h}h ${m}m` + if (h > 0) + return `${h}h ${m}m ${s}s` + if (m > 0) + return `${m}m ${s}s` + return `${s}s` +} + +export function formatMs(ms: number): string { + return `${ms.toFixed(ms < 10 ? 2 : 1)} ms` +} + +export function formatPercent(value: number): string { + return `${value.toFixed(1)}%` +} diff --git a/packages/cli/src/dashboard/simulator.ts b/packages/cli/src/dashboard/simulator.ts new file mode 100644 index 0000000..bfce5fb --- /dev/null +++ b/packages/cli/src/dashboard/simulator.ts @@ -0,0 +1,129 @@ +import type { MetricsSource } from './MetricsClient' +import type { ServerMetricsSnapshot } from './snapshot' + +const HISTORY_LENGTH = 60 + +/** + * Small deterministic PRNG so --sim output is the same every run. + */ +function mulberry32(seed: number) { + return () => { + seed |= 0 + seed = (seed + 0x6D2B79F5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function push(history: T[], item: T) { + history.push(item) + if (history.length > HISTORY_LENGTH) + history.shift() +} + +/** + * A fake game server, for working on the dashboard without running one. + * Each fetch advances the simulation by one sample. + */ +export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSource { + const random = mulberry32(seed) + const tickRate = 30 + const budgetMs = Math.round((1000 / tickRate) * 100) / 100 + + let step = 0 + let now = Date.UTC(2026, 0, 1) + let sequence = 0 + + const cpuHistory: ServerMetricsSnapshot['cpu']['history'] = [] + const bandwidthHistory: ServerMetricsSnapshot['bandwidth']['history'] = [] + const tickHistory: ServerMetricsSnapshot['tick']['history'] = [] + + return { + label: 'simulation', + async fetch() { + step++ + now += sampleIntervalMs + sequence += tickRate + + // Players drift in and out on a slow wave + const playerCount = Math.max(1, Math.round(6 + 5 * Math.sin(step / 20) + random() * 2)) + const players = Array.from({ length: playerCount }, (_, i) => ({ + id: `sim-${(i + 1).toString().padStart(2, '0')}`, + position: { + x: Math.round(Math.cos(step / 10 + i) * (10 + i * 3) * 100) / 100, + y: 0, + z: Math.round(Math.sin(step / 10 + i) * (10 + i * 3) * 100) / 100, + }, + lastProcessedSequenceId: sequence - Math.floor(random() * 3), + trackedEntities: playerCount + 20 + Math.floor(random() * 5), + })) + + const cpu = Math.max(0, 12 + playerCount * 2.5 + Math.sin(step / 7) * 6 + random() * 4) + push(cpuHistory, { timestamp: now, cpuPercentage: Math.round(cpu * 100) / 100 }) + + const sent = Math.round(playerCount * (900 + random() * 300) * tickRate * sampleIntervalMs / 1000) + const received = Math.round(playerCount * (120 + random() * 60) * tickRate * sampleIntervalMs / 1000) + push(bandwidthHistory, { timestamp: now, sent, received }) + + // Occasional spike so the max line has something to show + const avgMs = 2 + playerCount * 0.6 + random() * 1.5 + const maxMs = avgMs + random() * 3 + (step % 17 === 0 ? 25 : 0) + const commandsPerSecond = playerCount * tickRate + push(tickHistory, { + timestamp: now, + ticks: tickRate, + ticksPerSecond: tickRate - (maxMs > budgetMs ? 1 : 0), + avgMs: Math.round(avgMs * 100) / 100, + maxMs: Math.round(maxMs * 100) / 100, + commandsPerSecond, + maxCommandsPerTick: playerCount + Math.floor(random() * 3), + }) + + const average = (ms: number) => { + const recent = cpuHistory.slice(-Math.max(1, Math.round(ms / sampleIntervalMs))) + return Math.round(recent.reduce((acc, h) => acc + h.cpuPercentage, 0) / recent.length * 100) / 100 + } + + const heapUsed = 60e6 + playerCount * 2e6 + (step % 30) * 1e6 + + const snapshot: ServerMetricsSnapshot = { + schemaVersion: 1, + engineVersion: 'simulated', + serverTime: new Date(now).toISOString(), + uptime: Math.round(step * sampleIntervalMs / 1000), + sampleIntervalMs, + tickRate, + players, + world: { entities: playerCount + 140 }, + physics: { bodies: playerCount + 120, colliders: playerCount + 180 }, + cpu: { + current: cpuHistory.at(-1)!.cpuPercentage, + avg1s: average(1000), + avg10s: average(10000), + avg60s: average(60000), + history: [...cpuHistory], + }, + bandwidth: { + sentPerSecond: Math.round(sent * 1000 / sampleIntervalMs), + receivedPerSecond: Math.round(received * 1000 / sampleIntervalMs), + history: [...bandwidthHistory], + }, + tick: { + budgetMs, + latest: tickHistory.at(-1)!, + history: [...tickHistory], + }, + memory: { + rss: heapUsed + 80e6, + heapUsed, + heapTotal: heapUsed + 30e6, + external: 5e6, + }, + latencySimulation: null, + } + + return snapshot + }, + } +} diff --git a/packages/cli/src/dashboard/snapshot.ts b/packages/cli/src/dashboard/snapshot.ts new file mode 100644 index 0000000..3ad0b15 --- /dev/null +++ b/packages/cli/src/dashboard/snapshot.ts @@ -0,0 +1,110 @@ +import type { ServerMetricsSnapshot } from '@mavonengine/core/Networking/Server/Stats/ServerMetrics' + +export type { ServerMetricsSnapshot } + +/** + * The newest metrics schema this CLI understands. + */ +export const SUPPORTED_SCHEMA_VERSION = 1 + +type AnyRecord = Record + +function isRecord(value: unknown): value is AnyRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function num(value: unknown, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function str(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +function arr(value: unknown, map: (item: AnyRecord) => T): T[] { + return Array.isArray(value) ? value.filter(isRecord).map(map) : [] +} + +function section(value: unknown): AnyRecord { + return isRecord(value) ? value : {} +} + +function tickSample(t: AnyRecord) { + return { + timestamp: num(t.timestamp), + ticks: num(t.ticks), + ticksPerSecond: num(t.ticksPerSecond), + avgMs: num(t.avgMs), + maxMs: num(t.maxMs), + commandsPerSecond: num(t.commandsPerSecond), + maxCommandsPerTick: num(t.maxCommandsPerTick), + } +} + +/** + * Turn whatever a server returned into a complete snapshot. + * + * The CLI and the server are released separately, so a field can be missing + * (older server) or unknown (newer server). Missing fields fall back to empty + * values so the dashboard keeps rendering instead of crashing. + */ +export function normalizeSnapshot(raw: unknown): ServerMetricsSnapshot { + const s = section(raw) + const world = section(s.world) + const cpu = section(s.cpu) + const bandwidth = section(s.bandwidth) + const tick = section(s.tick) + const memory = section(s.memory) + const physics = isRecord(s.physics) ? s.physics : null + const latency = isRecord(s.latencySimulation) ? s.latencySimulation : null + + return { + schemaVersion: num(s.schemaVersion, SUPPORTED_SCHEMA_VERSION) as typeof SUPPORTED_SCHEMA_VERSION, + engineVersion: str(s.engineVersion, 'unknown'), + serverTime: str(s.serverTime), + uptime: num(s.uptime), + sampleIntervalMs: num(s.sampleIntervalMs, 1000) || 1000, + tickRate: num(s.tickRate), + players: arr(s.players, (p) => { + const position = section(p.position) + return { + id: str(p.id, '?'), + position: { x: num(position.x), y: num(position.y), z: num(position.z) }, + lastProcessedSequenceId: num(p.lastProcessedSequenceId), + trackedEntities: num(p.trackedEntities), + } + }), + world: { + entities: num(world.entities), + }, + physics: physics + ? { bodies: num(physics.bodies), colliders: num(physics.colliders) } + : null, + cpu: { + current: num(cpu.current), + avg1s: num(cpu.avg1s), + avg10s: num(cpu.avg10s), + avg60s: num(cpu.avg60s), + history: arr(cpu.history, h => ({ timestamp: num(h.timestamp), cpuPercentage: num(h.cpuPercentage) })), + }, + bandwidth: { + sentPerSecond: num(bandwidth.sentPerSecond), + receivedPerSecond: num(bandwidth.receivedPerSecond), + history: arr(bandwidth.history, b => ({ timestamp: num(b.timestamp), sent: num(b.sent), received: num(b.received) })), + }, + tick: { + budgetMs: num(tick.budgetMs), + latest: isRecord(tick.latest) ? tickSample(tick.latest) : null, + history: arr(tick.history, tickSample), + }, + memory: { + rss: num(memory.rss), + heapUsed: num(memory.heapUsed), + heapTotal: num(memory.heapTotal), + external: num(memory.external), + }, + latencySimulation: latency + ? { fixedLatency: num(latency.fixedLatency), jitter: num(latency.jitter), packetLoss: num(latency.packetLoss) } + : null, + } +} diff --git a/packages/cli/src/dashboard/theme.ts b/packages/cli/src/dashboard/theme.ts new file mode 100644 index 0000000..c74c23b --- /dev/null +++ b/packages/cli/src/dashboard/theme.ts @@ -0,0 +1,23 @@ +import type { Theme } from '@profullstack/hqtui' +import { contrast, defineTheme, mix, resolveTheme } from '@profullstack/hqtui' + +/** + * Contrast the dashboard wants for secondary text (labels, headers, axes). + * Above WCAG AA (4.5) because with a transparent background the real + * background is usually lighter than the theme's own. + */ +export const MIN_MUTED_CONTRAST = 6 + +/** + * The named theme, with `muted` moved towards the foreground until it is readable. + * Most built-in themes ship a muted color around 3-4:1, which is too dim for labels. + */ +export function readableTheme(name?: string): Theme { + const base = resolveTheme(name) + + let muted = base.muted + for (let t = 0.05; contrast(muted, base.background) < MIN_MUTED_CONTRAST && t <= 1; t += 0.05) + muted = mix(base.muted, base.foreground, t) + + return defineTheme({ name: base.name, muted }, base) +} diff --git a/packages/cli/src/dashboard/view.ts b/packages/cli/src/dashboard/view.ts new file mode 100644 index 0000000..c0118cf --- /dev/null +++ b/packages/cli/src/dashboard/view.ts @@ -0,0 +1,258 @@ +import type { Color, Container, Span, Theme } from '@profullstack/hqtui' +import type { ConnectionStatus, DashboardState } from './MetricsClient' +import type { ServerMetricsSnapshot } from './snapshot' +import { DEFAULT_COLOR } from '@profullstack/hqtui' +import { formatBytes, formatDuration, formatMs, formatPercent, formatRate } from './format' +import { SUPPORTED_SCHEMA_VERSION } from './snapshot' + +/** + * UI state that lives across frames but isn't part of the metrics. + */ +export interface ViewState { + playerOffset: number +} + +type PlayerRow = ServerMetricsSnapshot['players'][number] + +const WIDE_LAYOUT = 100 + +const STATUS_LABELS: Record = { + connecting: 'CONNECTING', + live: 'LIVE', + unreachable: 'UNREACHABLE', + unauthorized: 'UNAUTHORIZED', + forbidden: 'FORBIDDEN', + disabled: 'METRICS DISABLED', +} + +function statusColor(status: ConnectionStatus, theme: Theme): Color { + if (status === 'live') + return theme.success + if (status === 'connecting') + return theme.warning + return theme.danger +} + +/** + * Draw the whole dashboard. Pure apart from `view`, so it can be rendered in tests. + */ +export function renderDashboard(ui: Container, theme: Theme, state: DashboardState, view: ViewState) { + ui.column({}, (root) => { + header(root, theme, state) + + const { snapshot } = state + + if (snapshot?.latencySimulation) + latencyBanner(root, theme, snapshot.latencySimulation) + + if (state.error && snapshot) + root.text([{ text: ` ${state.error} — showing last known values`, fg: theme.danger }], { size: 1 }) + + if (!snapshot) { + waiting(root, theme, state) + } + else { + root.responsive({ + [WIDE_LAYOUT]: c => wideLayout(c, theme, snapshot, view), + 0: c => narrowLayout(c, theme, snapshot, view), + }) + } + + root.statusBar({ + size: 1, + background: DEFAULT_COLOR, + items: [ + { key: 'q', label: 'Quit' }, + { key: 'r', label: 'Refresh' }, + ], + right: [{ label: STATUS_LABELS[state.status], color: statusColor(state.status, theme), active: state.status === 'live' }], + }) + }) +} + +function header(ui: Container, theme: Theme, state: DashboardState) { + const spans: Span[] = [ + { text: ' MavonEngine ', fg: theme.primary, bold: true }, + { text: state.source, fg: theme.muted }, + ] + + if (state.snapshot) { + spans.push( + { text: ' uptime ', fg: theme.muted }, + { text: formatDuration(state.snapshot.uptime) }, + { text: ' engine ', fg: theme.muted }, + { text: state.snapshot.engineVersion }, + ) + + if (state.snapshot.schemaVersion > SUPPORTED_SCHEMA_VERSION) + spans.push({ text: ' newer metrics schema, update @mavonengine/cli', fg: theme.warning }) + } + + ui.text(spans, { size: 1 }) +} + +function latencyBanner(ui: Container, theme: Theme, latency: NonNullable) { + ui.text([{ + text: ` LATENCY SIMULATION ON ${latency.fixedLatency}ms ± ${latency.jitter / 2}ms, ${formatPercent(latency.packetLoss * 100)} packet loss `, + fg: theme.background, + bg: theme.warning, + bold: true, + }], { size: 1 }) +} + +function waiting(ui: Container, theme: Theme, state: DashboardState) { + ui.panel({ title: 'Game server', size: '1fr' }, (panel) => { + if (state.status === 'connecting') { + panel.spinner({ label: `Connecting to ${state.source}` }) + return + } + + panel.text([{ text: STATUS_LABELS[state.status], fg: statusColor(state.status, theme), bold: true }]) + panel.text(state.error ?? '', { wrap: true }) + panel.label(`Retrying (attempt ${state.failures})…`) + }) +} + +function wideLayout(ui: Container, theme: Theme, snapshot: ServerMetricsSnapshot, view: ViewState) { + ui.grid({ columns: ['1fr', '1fr'], rows: [11, 11], size: 22 }, (grid) => { + grid.panel({ title: 'CPU' }, p => cpuPanel(p, theme, snapshot)) + grid.panel({ title: 'Network' }, p => networkPanel(p, theme, snapshot)) + grid.panel({ title: 'Tick' }, p => tickPanel(p, theme, snapshot)) + grid.panel({ title: 'World' }, p => worldPanel(p, theme, snapshot)) + }) + + playersPanel(ui, theme, snapshot, view) +} + +function narrowLayout(ui: Container, theme: Theme, snapshot: ServerMetricsSnapshot, view: ViewState) { + ui.panel({ title: 'CPU', size: 9 }, p => cpuPanel(p, theme, snapshot)) + ui.panel({ title: 'Network', size: 9 }, p => networkPanel(p, theme, snapshot)) + ui.panel({ title: 'Tick', size: 9 }, p => tickPanel(p, theme, snapshot)) + ui.panel({ title: 'World', size: 11 }, p => worldPanel(p, theme, snapshot)) + playersPanel(ui, theme, snapshot, view) +} + +function cpuPanel(ui: Container, theme: Theme, { cpu }: ServerMetricsSnapshot) { + const values = cpu.history.map(h => h.cpuPercentage) + + ui.graph({ + values, + min: 0, + max: Math.max(100, ...values), + fill: true, + colors: theme.heat, + axis: true, + axisFormat: v => `${Math.round(v)}%`, + size: '1fr', + }) + + ui.meters([ + { label: '1s', value: cpu.avg1s, max: 100, text: formatPercent(cpu.avg1s) }, + { label: '10s', value: cpu.avg10s, max: 100, text: formatPercent(cpu.avg10s) }, + { label: '1m', value: cpu.avg60s, max: 100, text: formatPercent(cpu.avg60s) }, + ], { columns: 3 }) +} + +function networkPanel(ui: Container, theme: Theme, { bandwidth, sampleIntervalMs }: ServerMetricsSnapshot) { + const perSecond = 1000 / sampleIntervalMs + + ui.multiGraph([ + { label: 'sent', values: bandwidth.history.map(b => b.sent * perSecond), color: theme.graph[0] }, + { label: 'received', values: bandwidth.history.map(b => b.received * perSecond), color: theme.graph[1] }, + ], { + min: 0, + axis: true, + axisFormat: formatRate, + legend: true, + legendAlign: 'right', + size: '1fr', + }) + + ui.keyValues([ + { label: 'Sent', value: formatRate(bandwidth.sentPerSecond), color: theme.graph[0] }, + { label: 'Received', value: formatRate(bandwidth.receivedPerSecond), color: theme.graph[1] }, + ], { spread: true }) +} + +function tickPanel(ui: Container, theme: Theme, { tick, tickRate }: ServerMetricsSnapshot) { + const avg = tick.history.map(t => t.avgMs) + const max = tick.history.map(t => t.maxMs) + const latest = tick.latest + + ui.multiGraph([ + { label: 'avg', values: avg, color: theme.graph[0] }, + { label: 'max', values: max, color: theme.graph[2] ?? theme.warning }, + ], { + min: 0, + max: Math.max(tick.budgetMs * 1.25, ...max, 1), + axis: true, + axisFormat: v => `${v.toFixed(0)}ms`, + legend: true, + legendAlign: 'right', + size: '1fr', + }) + + ui.meter({ + label: 'Budget', + value: latest?.avgMs ?? 0, + max: tick.budgetMs || 1, + text: `${formatMs(latest?.avgMs ?? 0)} / ${formatMs(tick.budgetMs)}`, + }) + + ui.keyValues([ + { label: 'Ticks/s', value: `${latest?.ticksPerSecond ?? 0} / ${tickRate}`, color: (latest?.ticksPerSecond ?? 0) < tickRate * 0.9 ? theme.danger : undefined }, + { label: 'Commands/s', value: `${latest?.commandsPerSecond ?? 0} (max ${latest?.maxCommandsPerTick ?? 0}/tick)` }, + ], { spread: true }) +} + +function worldPanel(ui: Container, theme: Theme, { players, world, physics, memory }: ServerMetricsSnapshot) { + ui.keyValues([ + { label: 'Players', value: String(players.length), color: theme.primary }, + { label: 'Entities', value: String(world.entities) }, + { label: 'Physics bodies', value: physics ? String(physics.bodies) : '—' }, + { label: 'Colliders', value: physics ? String(physics.colliders) : '—' }, + { label: 'RSS', value: formatBytes(memory.rss) }, + { label: 'External', value: formatBytes(memory.external) }, + ], { spread: true }) + + ui.spacer('1fr') + + ui.meter({ + label: 'Heap', + value: memory.heapUsed, + max: memory.heapTotal || 1, + text: `${formatBytes(memory.heapUsed)} / ${formatBytes(memory.heapTotal)}`, + }) +} + +function playersPanel(ui: Container, theme: Theme, { players }: ServerMetricsSnapshot, view: ViewState) { + const rows = [...players].sort((a, b) => a.id.localeCompare(b.id)) + const maxOffset = Math.max(0, rows.length - 1) + view.playerOffset = Math.min(view.playerOffset, maxOffset) + + ui.panel({ title: `Players (${rows.length})`, size: '1fr' }, (panel) => { + if (rows.length === 0) { + panel.label('No players connected') + return + } + + panel.table({ + rows, + offset: view.playerOffset, + scrollbar: true, + size: '1fr', + onScroll: (delta) => { + view.playerOffset = Math.max(0, Math.min(maxOffset, view.playerOffset + delta)) + ui.ctx.invalidate() + }, + columns: [ + { key: 'id', title: 'ID' }, + { key: 'x', title: 'X', width: 9, align: 'right', render: p => p.position.x.toFixed(2) }, + { key: 'y', title: 'Y', width: 9, align: 'right', render: p => p.position.y.toFixed(2) }, + { key: 'z', title: 'Z', width: 9, align: 'right', render: p => p.position.z.toFixed(2) }, + { key: 'lastProcessedSequenceId', title: 'Seq', width: 10, align: 'right' }, + { key: 'trackedEntities', title: 'Tracked', width: 8, align: 'right' }, + ], + }) + }) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..a65e2ac --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,57 @@ +import process from 'node:process' +import { version } from '../package.json' +import { DASHBOARD_HELP, parseDashboardArgs, runDashboard, UsageError } from './commands/dashboard' + +const HELP = `Usage: mavonengine [options] + +Commands: + dashboard Live terminal dashboard for a running game server + +Options: + -h, --help Show help + -v, --version Show version + +Run 'mavonengine --help' for command options. +` + +async function main(argv: string[]): Promise { + const [command, ...args] = argv + + if (!command || command === '-h' || command === '--help' || command === 'help') { + process.stdout.write(HELP) + return 0 + } + + if (command === '-v' || command === '--version') { + process.stdout.write(`${version}\n`) + return 0 + } + + if (command === 'dashboard') { + const options = parseDashboardArgs(args) + + if (options === 'help') { + process.stdout.write(DASHBOARD_HELP) + return 0 + } + + if (!process.stdout.isTTY || !process.stdin.isTTY) { + process.stderr.write('mavonengine dashboard needs an interactive terminal\n') + return 1 + } + + await runDashboard(options) + return 0 + } + + throw new UsageError(`Unknown command '${command}'\n\n${HELP}`) +} + +main(process.argv.slice(2)) + .then(code => process.exit(code)) + .catch((err: Error) => { + // parseArgs throws TypeErrors with an ERR_PARSE_ARGS_* code for bad flags + const isUsage = err instanceof UsageError || (err as NodeJS.ErrnoException).code?.startsWith('ERR_PARSE_ARGS') + process.stderr.write(`mavonengine: ${err.message}\n`) + process.exit(isUsage ? 2 : 1) + }) diff --git a/packages/cli/tests/MetricsClient.test.ts b/packages/cli/tests/MetricsClient.test.ts new file mode 100644 index 0000000..08f1def --- /dev/null +++ b/packages/cli/tests/MetricsClient.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import MetricsClient, { httpSource, MetricsSourceError } from '../src/dashboard/MetricsClient' + +function response(status: number, body: unknown = {}) { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +describe('httpSource', () => { + it('sends the token as a bearer header', async () => { + const fetch = vi.fn(async () => response(200, { uptime: 1 })) + const source = httpSource({ url: 'http://localhost:8050/api/game/metrics', token: 'secret', fetch }) + + expect(await source.fetch()).toEqual({ uptime: 1 }) + expect(fetch).toHaveBeenCalledWith('http://localhost:8050/api/game/metrics', expect.objectContaining({ + headers: { Authorization: 'Bearer secret' }, + })) + }) + + it.each([ + [401, 'unauthorized'], + [403, 'forbidden'], + [404, 'disabled'], + [500, 'unreachable'], + ])('maps HTTP %i to %s', async (status, expected) => { + const source = httpSource({ url: 'http://x.test', fetch: async () => response(status) }) + + await expect(source.fetch()).rejects.toMatchObject({ status: expected }) + }) + + it('reports network errors as unreachable', async () => { + const source = httpSource({ + url: 'http://x.test', + fetch: async () => { + throw new Error('ECONNREFUSED') + }, + }) + + await expect(source.fetch()).rejects.toMatchObject({ status: 'unreachable', message: expect.stringContaining('ECONNREFUSED') }) + }) +}) + +describe('metricsClient', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('goes live after a successful poll', async () => { + const client = new MetricsClient({ label: 'test', fetch: async () => ({ uptime: 42 }) }) + const listener = vi.fn() + client.onChange(listener) + + expect(client.state.status).toBe('connecting') + + await client.poll() + + expect(client.state.status).toBe('live') + expect(client.state.snapshot?.uptime).toBe(42) + expect(client.state.lastUpdate).not.toBeNull() + expect(listener).toHaveBeenCalledWith(client.state) + }) + + it('keeps the last snapshot when a poll fails', async () => { + let fail = false + const client = new MetricsClient({ + label: 'test', + fetch: async () => { + if (fail) + throw new MetricsSourceError('gone', 'unreachable') + return { uptime: 1 } + }, + }) + + await client.poll() + fail = true + await client.poll() + + expect(client.state.status).toBe('unreachable') + expect(client.state.error).toBe('gone') + expect(client.state.failures).toBe(1) + expect(client.state.snapshot?.uptime).toBe(1) + + fail = false + await client.poll() + + expect(client.state.status).toBe('live') + expect(client.state.error).toBeNull() + expect(client.state.failures).toBe(0) + }) + + it('backs off exponentially while failing, up to the max', async () => { + const client = new MetricsClient( + { label: 'test', fetch: async () => { throw new Error('nope') } }, + { intervalMs: 1000, maxBackoffMs: 5000 }, + ) + + expect(client.nextDelay()).toBe(1000) + await client.poll() + expect(client.nextDelay()).toBe(2000) + await client.poll() + expect(client.nextDelay()).toBe(4000) + await client.poll() + expect(client.nextDelay()).toBe(5000) + }) + + it('polls on an interval until stopped', async () => { + vi.useFakeTimers() + const fetch = vi.fn(async () => ({})) + const client = new MetricsClient({ label: 'test', fetch }, { intervalMs: 1000 }) + + client.start() + await vi.advanceTimersByTimeAsync(0) + expect(fetch).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetch).toHaveBeenCalledTimes(2) + + client.stop() + await vi.advanceTimersByTimeAsync(5000) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('refresh polls immediately without doubling the schedule', async () => { + vi.useFakeTimers() + const fetch = vi.fn(async () => ({})) + const client = new MetricsClient({ label: 'test', fetch }, { intervalMs: 1000 }) + + client.start() + await vi.advanceTimersByTimeAsync(0) + await client.refresh() + expect(fetch).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetch).toHaveBeenCalledTimes(3) + + client.stop() + }) +}) diff --git a/packages/cli/tests/dashboardArgs.test.ts b/packages/cli/tests/dashboardArgs.test.ts new file mode 100644 index 0000000..1666954 --- /dev/null +++ b/packages/cli/tests/dashboardArgs.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { parseDashboardArgs, UsageError } from '../src/commands/dashboard' + +describe('parseDashboardArgs', () => { + it('defaults to the local server', () => { + const options = parseDashboardArgs([], {}) + + expect(options).not.toBe('help') + if (options === 'help') + return + + expect(options.source.label).toBe('http://localhost:8050/api/game/metrics') + expect(options.intervalMs).toBe(1000) + }) + + it('builds the url from host and port', () => { + const options = parseDashboardArgs(['--host', 'game.example.com', '--port', '9000'], {}) + + expect(options !== 'help' && options.source.label).toBe('http://game.example.com:9000/api/game/metrics') + }) + + it('wraps IPv6 hosts in brackets', () => { + const options = parseDashboardArgs(['--host', '::1'], {}) + + expect(options !== 'help' && options.source.label).toBe('http://[::1]:8050/api/game/metrics') + }) + + it('uses --url as is', () => { + const options = parseDashboardArgs(['--url', 'https://game.example.com/api/game/metrics'], {}) + + expect(options !== 'help' && options.source.label).toBe('https://game.example.com/api/game/metrics') + }) + + it('switches to the simulator with --sim', () => { + const options = parseDashboardArgs(['--sim'], {}) + + expect(options !== 'help' && options.source.label).toBe('simulation') + }) + + it('returns help', () => { + expect(parseDashboardArgs(['--help'], {})).toBe('help') + expect(parseDashboardArgs(['-h'], {})).toBe('help') + }) + + it.each([ + [['--port', '0']], + [['--port', 'abc']], + [['--interval', '10']], + [['--url', 'not a url']], + ])('rejects %j', (args) => { + expect(() => parseDashboardArgs(args, {})).toThrow(UsageError) + }) + + it('rejects unknown flags', () => { + expect(() => parseDashboardArgs(['--nope'], {})).toThrow() + }) +}) diff --git a/packages/cli/tests/format.test.ts b/packages/cli/tests/format.test.ts new file mode 100644 index 0000000..acd83f8 --- /dev/null +++ b/packages/cli/tests/format.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { formatBytes, formatDuration, formatMs, formatRate } from '../src/dashboard/format' + +describe('format', () => { + it('formats bytes', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(1536)).toBe('1.5 KiB') + expect(formatBytes(5 * 1024 ** 3)).toBe('5.0 GiB') + expect(formatBytes(Number.NaN)).toBe('0 B') + expect(formatRate(2048)).toBe('2.0 KiB/s') + }) + + it('formats durations', () => { + expect(formatDuration(5)).toBe('5s') + expect(formatDuration(65)).toBe('1m 5s') + expect(formatDuration(3725)).toBe('1h 2m 5s') + expect(formatDuration(90061)).toBe('1d 1h 1m') + }) + + it('formats ms', () => { + expect(formatMs(3.14159)).toBe('3.14 ms') + expect(formatMs(33.333)).toBe('33.3 ms') + }) +}) diff --git a/packages/cli/tests/snapshot.test.ts b/packages/cli/tests/snapshot.test.ts new file mode 100644 index 0000000..c9c4133 --- /dev/null +++ b/packages/cli/tests/snapshot.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { simulatedSource } from '../src/dashboard/simulator' +import { normalizeSnapshot } from '../src/dashboard/snapshot' + +describe('normalizeSnapshot', () => { + it('keeps a complete snapshot as is', async () => { + const snapshot = await simulatedSource().fetch() + + expect(normalizeSnapshot(snapshot)).toEqual(snapshot) + }) + + it('fills in everything for an empty response', () => { + const snapshot = normalizeSnapshot({}) + + expect(snapshot.players).toEqual([]) + expect(snapshot.cpu.history).toEqual([]) + expect(snapshot.tick.latest).toBeNull() + expect(snapshot.physics).toBeNull() + expect(snapshot.latencySimulation).toBeNull() + expect(snapshot.sampleIntervalMs).toBe(1000) + expect(snapshot.engineVersion).toBe('unknown') + }) + + it('survives garbage', () => { + expect(() => normalizeSnapshot(null)).not.toThrow() + expect(() => normalizeSnapshot('nope')).not.toThrow() + expect(normalizeSnapshot({ players: [null, 3, { id: 'a' }] }).players).toEqual([ + { id: 'a', position: { x: 0, y: 0, z: 0 }, lastProcessedSequenceId: 0, trackedEntities: 0 }, + ]) + }) + + it('replaces non-finite numbers', () => { + const snapshot = normalizeSnapshot({ cpu: { current: Number.NaN, avg1s: 'x' }, sampleIntervalMs: 0 }) + + expect(snapshot.cpu.current).toBe(0) + expect(snapshot.cpu.avg1s).toBe(0) + expect(snapshot.sampleIntervalMs).toBe(1000) + }) + + it('ignores fields it does not know', () => { + const snapshot = normalizeSnapshot({ uptime: 5, somethingNew: { a: 1 } }) + + expect(snapshot.uptime).toBe(5) + expect(snapshot).not.toHaveProperty('somethingNew') + }) +}) diff --git a/packages/cli/tests/theme.test.ts b/packages/cli/tests/theme.test.ts new file mode 100644 index 0000000..e8ac986 --- /dev/null +++ b/packages/cli/tests/theme.test.ts @@ -0,0 +1,33 @@ +import { contrast, themes } from '@profullstack/hqtui' +import { describe, expect, it } from 'vitest' +import { MIN_MUTED_CONTRAST, readableTheme } from '../src/dashboard/theme' + +describe('readableTheme', () => { + it.each(Object.keys(themes))('makes muted text readable in %s', (name) => { + const theme = readableTheme(name) + + expect(contrast(theme.muted, theme.background)).toBeGreaterThanOrEqual(MIN_MUTED_CONTRAST) + }) + + it('keeps muted dimmer than the foreground', () => { + const theme = readableTheme('dark') + + expect(contrast(theme.muted, theme.background)).toBeLessThan(contrast(theme.foreground, theme.background)) + }) + + it('leaves already readable themes alone', () => { + expect(readableTheme('highContrast').muted).toBe(themes.highContrast.muted) + }) + + it('keeps everything else from the base theme', () => { + const theme = readableTheme('nord') + + expect(theme.name).toBe('nord') + expect(theme.primary).toBe(themes.nord.primary) + expect(theme.background).toBe(themes.nord.background) + }) + + it('falls back to the default theme', () => { + expect(readableTheme().name).toBe(themes.dark.name) + }) +}) diff --git a/packages/cli/tests/view.test.ts b/packages/cli/tests/view.test.ts new file mode 100644 index 0000000..8eff679 --- /dev/null +++ b/packages/cli/tests/view.test.ts @@ -0,0 +1,85 @@ +import type { DashboardState } from '../src/dashboard/MetricsClient' +import { renderToScreen } from '@profullstack/hqtui' +import { describe, expect, it } from 'vitest' +import { simulatedSource } from '../src/dashboard/simulator' +import { normalizeSnapshot } from '../src/dashboard/snapshot' +import { renderDashboard } from '../src/dashboard/view' + +async function liveState(samples = 30): Promise { + const source = simulatedSource() + let raw: unknown + for (let i = 0; i < samples; i++) + raw = await source.fetch() + + return { + status: 'live', + source: 'http://localhost:8050/api/game/metrics', + snapshot: normalizeSnapshot(raw), + lastUpdate: Date.now(), + error: null, + failures: 0, + } +} + +function render(state: DashboardState, width = 130, height = 40) { + return renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, { playerOffset: 0 }), { width, height }) +} + +describe('dashboard view', () => { + it('shows every panel when live', async () => { + const screen = render(await liveState()) + + for (const title of ['CPU', 'Network', 'Tick', 'World', 'Players (', 'LIVE', 'q Quit']) + expect(screen.contains(title), title).toBe(true) + + expect(screen.contains('sim-01')).toBe(true) + }) + + it('fits a narrow terminal', async () => { + const screen = render(await liveState(), 80, 60) + + expect(screen.contains('CPU')).toBe(true) + expect(screen.contains('Players (')).toBe(true) + }) + + it('shows a waiting panel before the first snapshot', () => { + const screen = render({ + status: 'unauthorized', + source: 'http://x.test', + snapshot: null, + lastUpdate: null, + error: 'Metrics token missing or wrong (use --token)', + failures: 2, + }) + + expect(screen.contains('UNAUTHORIZED')).toBe(true) + expect(screen.contains('use --token')).toBe(true) + expect(screen.contains('attempt 2')).toBe(true) + }) + + it('keeps the last values and shows the error when the server drops', async () => { + const state = await liveState() + state.status = 'unreachable' + state.error = 'Can\'t reach localhost' + + const screen = render(state) + + expect(screen.contains('showing last known values')).toBe(true) + expect(screen.contains('UNREACHABLE')).toBe(true) + expect(screen.contains('CPU')).toBe(true) + }) + + it('warns when latency simulation is on', async () => { + const state = await liveState() + state.snapshot!.latencySimulation = { fixedLatency: 100, jitter: 20, packetLoss: 0.05 } + + expect(render(state).contains('LATENCY SIMULATION ON')).toBe(true) + }) + + it('shows an empty players message', async () => { + const state = await liveState() + state.snapshot!.players = [] + + expect(render(state).contains('No players connected')).toBe(true) + }) +}) diff --git a/packages/cli/tsconfig.check.json b/packages/cli/tsconfig.check.json new file mode 100644 index 0000000..c9c2fa2 --- /dev/null +++ b/packages/cli/tsconfig.check.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src", "tests"] +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..f494f0f --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "rootDir": "src", + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "strict": true, + "declaration": true, + "outDir": "dist", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 0000000..00ba1fe --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: false, + clean: true, + target: 'node22', + banner: { + js: '#!/usr/bin/env node', + }, +}) diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..f964be2 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + }, +}) From eb30bf819c80ad58feca7421696646c505c4463b Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 21:31:35 +0100 Subject: [PATCH 03/25] require node >=24 in core and cli, matching CI --- package-lock.json | 24 ++++++++++++++++++++++-- packages/cli/README.md | 2 +- packages/cli/package.json | 4 ++-- packages/cli/tsup.config.ts | 2 +- packages/core/package.json | 3 +++ 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5d416a7..f5fe1eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10925,14 +10925,24 @@ }, "devDependencies": { "@mavonengine/core": "*", - "@types/node": "^22.0.0", + "@types/node": "^24.0.0", "@vitest/coverage-v8": "^3.2.4", "tsup": "^8.5.1", "typescript": "5.9.2", "vitest": "^3.2.4" }, "engines": { - "node": ">=22.6" + "node": ">=24" + } + }, + "packages/cli/node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" } }, "packages/cli/node_modules/typescript": { @@ -10949,6 +10959,13 @@ "node": ">=14.17" } }, + "packages/cli/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "packages/core": { "name": "@mavonengine/core", "version": "0.0.20", @@ -10972,6 +10989,9 @@ "vite-plugin-glsl": "^1.4.1", "vitest": "^3.2.4" }, + "engines": { + "node": ">=24" + }, "peerDependencies": { "@dimforge/rapier3d-compat": "0.18.2", "@geckos.io/client": "^3.0.2", diff --git a/packages/cli/README.md b/packages/cli/README.md index 33d3b5c..740dc18 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2,7 +2,7 @@ Command line tools for [MavonEngine](https://mavonengine.com). -Requires Node 22.6 or newer. +Requires Node 24 or newer. ```sh npm install -D @mavonengine/cli diff --git a/packages/cli/package.json b/packages/cli/package.json index 0a2b364..160fa38 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -18,7 +18,7 @@ "dist" ], "engines": { - "node": ">=22.6" + "node": ">=24" }, "scripts": { "build": "tsup", @@ -35,7 +35,7 @@ }, "devDependencies": { "@mavonengine/core": "*", - "@types/node": "^22.0.0", + "@types/node": "^24.0.0", "@vitest/coverage-v8": "^3.2.4", "tsup": "^8.5.1", "typescript": "5.9.2", diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 00ba1fe..c7fea1f 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ format: ['esm'], dts: false, clean: true, - target: 'node22', + target: 'node24', banner: { js: '#!/usr/bin/env node', }, diff --git a/packages/core/package.json b/packages/core/package.json index 5f79bb3..7809266 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,6 +17,9 @@ "physics", "multiplayer" ], + "engines": { + "node": ">=24" + }, "exports": { "./vite.config": "./vite.config.js", "./*.glsl": "./dist/*.glsl", From bf6a1faf3e95613a37d2baa2a9f798e423a78db8 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 21:33:31 +0100 Subject: [PATCH 04/25] ci: typecheck and smoke test the cli package --- .github/workflows/node.js.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 840097b..9993ab3 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -45,6 +45,17 @@ jobs: run: npm run typecheck working-directory: ./packages/bootstrap + # Needs core's dist from the build step, the snapshot types come from there + - name: Typecheck cli + run: npm run typecheck + working-directory: ./packages/cli + + - name: Smoke test cli binary + run: | + npx mavonengine --version + npx mavonengine --help + npx mavonengine dashboard --help + - name: npm link core locally run: | npm link From 1e69fd5364d6c4556b4db18d836a6576685a9f02 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 21:47:11 +0100 Subject: [PATCH 05/25] fix core package.json key order for lint --- packages/core/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 7809266..382289b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,9 +17,6 @@ "physics", "multiplayer" ], - "engines": { - "node": ">=24" - }, "exports": { "./vite.config": "./vite.config.js", "./*.glsl": "./dist/*.glsl", @@ -37,6 +34,9 @@ "vite", "vite.config.js" ], + "engines": { + "node": ">=24" + }, "scripts": { "prepare": "node scripts/generate-version.js", "build": "node scripts/generate-version.js && rm -f tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json && find src -name '*.glsl' -o -name '*.css' | while read f; do mkdir -p dist/$(dirname ${f#src/}) && cp $f dist/${f#src/}; done", From aafc14c3283c58ddceeed4fae1bf93362b867817 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 21:47:49 +0100 Subject: [PATCH 06/25] dashboard: opaque background by default, add --transparent flag Text over a light wallpaper was unreadable with a fully transparent background. Fill with the theme colour by default and make the see-through look opt-in. --- packages/cli/README.md | 1 + packages/cli/src/commands/dashboard.ts | 13 ++++++----- packages/cli/src/dashboard/view.ts | 28 ++++++++++++++++-------- packages/cli/tests/dashboardArgs.test.ts | 8 +++++++ packages/cli/tests/view.test.ts | 18 ++++++++++++--- 5 files changed, 51 insertions(+), 17 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 740dc18..2dce07e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -34,6 +34,7 @@ npx mavonengine dashboard --sim | `--token ` | `$MAVONENGINE_METRICS_TOKEN` | Metrics token (`ServerOptions.metrics.token`) | | `--interval ` | `1000` | Poll interval | | `--theme ` | `dark` | `dark`, `dracula`, `nord`, `tokyoNight`, `gruvbox`, `matrix`, `monochrome`, `highContrast`, `light` | +| `--transparent` | | Let a transparent terminal background show through. Text can be hard to read over a light wallpaper | | `--sim` | | Simulated data instead of a server | Keys: `q` quits, `r` refreshes now, and the mouse wheel scrolls the players table. diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 1b53142..6472e7f 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -18,6 +18,8 @@ Options: --token Metrics token (or set MAVONENGINE_METRICS_TOKEN) --interval Poll interval in ms (default: 1000) --theme dark, dracula, nord, tokyoNight, gruvbox, matrix, light, … + --transparent Let a transparent terminal background show through + (text can be hard to read over a light wallpaper) --sim Show simulated data instead of connecting to a server -h, --help Show this help ` @@ -26,6 +28,7 @@ export interface DashboardOptions { source: MetricsSource intervalMs: number theme?: string + transparent: boolean } export class UsageError extends Error { } @@ -43,6 +46,7 @@ export function parseDashboardArgs(args: string[], env: Record renderDashboard(ui, theme, client.state, view)) + app.render(({ ui, theme }) => renderDashboard(ui, theme, client.state, view, { transparent: options.transparent })) client.onChange(() => app.invalidate()) diff --git a/packages/cli/src/dashboard/view.ts b/packages/cli/src/dashboard/view.ts index c0118cf..c511be2 100644 --- a/packages/cli/src/dashboard/view.ts +++ b/packages/cli/src/dashboard/view.ts @@ -12,6 +12,14 @@ export interface ViewState { playerOffset: number } +export interface RenderOptions { + /** + * Leave cells on the terminal's default background so a transparent terminal shows through. + * Text is only as readable as whatever is behind the window. + */ + transparent?: boolean +} + type PlayerRow = ServerMetricsSnapshot['players'][number] const WIDE_LAYOUT = 100 @@ -36,7 +44,7 @@ function statusColor(status: ConnectionStatus, theme: Theme): Color { /** * Draw the whole dashboard. Pure apart from `view`, so it can be rendered in tests. */ -export function renderDashboard(ui: Container, theme: Theme, state: DashboardState, view: ViewState) { +export function renderDashboard(ui: Container, theme: Theme, state: DashboardState, view: ViewState, { transparent = false }: RenderOptions = {}) { ui.column({}, (root) => { header(root, theme, state) @@ -53,14 +61,14 @@ export function renderDashboard(ui: Container, theme: Theme, state: DashboardSta } else { root.responsive({ - [WIDE_LAYOUT]: c => wideLayout(c, theme, snapshot, view), - 0: c => narrowLayout(c, theme, snapshot, view), + [WIDE_LAYOUT]: c => wideLayout(c, theme, snapshot, view, transparent), + 0: c => narrowLayout(c, theme, snapshot, view, transparent), }) } root.statusBar({ size: 1, - background: DEFAULT_COLOR, + background: transparent ? DEFAULT_COLOR : undefined, items: [ { key: 'q', label: 'Quit' }, { key: 'r', label: 'Refresh' }, @@ -113,7 +121,7 @@ function waiting(ui: Container, theme: Theme, state: DashboardState) { }) } -function wideLayout(ui: Container, theme: Theme, snapshot: ServerMetricsSnapshot, view: ViewState) { +function wideLayout(ui: Container, theme: Theme, snapshot: ServerMetricsSnapshot, view: ViewState, transparent: boolean) { ui.grid({ columns: ['1fr', '1fr'], rows: [11, 11], size: 22 }, (grid) => { grid.panel({ title: 'CPU' }, p => cpuPanel(p, theme, snapshot)) grid.panel({ title: 'Network' }, p => networkPanel(p, theme, snapshot)) @@ -121,15 +129,15 @@ function wideLayout(ui: Container, theme: Theme, snapshot: ServerMetricsSnapshot grid.panel({ title: 'World' }, p => worldPanel(p, theme, snapshot)) }) - playersPanel(ui, theme, snapshot, view) + playersPanel(ui, theme, snapshot, view, transparent) } -function narrowLayout(ui: Container, theme: Theme, snapshot: ServerMetricsSnapshot, view: ViewState) { +function narrowLayout(ui: Container, theme: Theme, snapshot: ServerMetricsSnapshot, view: ViewState, transparent: boolean) { ui.panel({ title: 'CPU', size: 9 }, p => cpuPanel(p, theme, snapshot)) ui.panel({ title: 'Network', size: 9 }, p => networkPanel(p, theme, snapshot)) ui.panel({ title: 'Tick', size: 9 }, p => tickPanel(p, theme, snapshot)) ui.panel({ title: 'World', size: 11 }, p => worldPanel(p, theme, snapshot)) - playersPanel(ui, theme, snapshot, view) + playersPanel(ui, theme, snapshot, view, transparent) } function cpuPanel(ui: Container, theme: Theme, { cpu }: ServerMetricsSnapshot) { @@ -225,7 +233,7 @@ function worldPanel(ui: Container, theme: Theme, { players, world, physics, memo }) } -function playersPanel(ui: Container, theme: Theme, { players }: ServerMetricsSnapshot, view: ViewState) { +function playersPanel(ui: Container, theme: Theme, { players }: ServerMetricsSnapshot, view: ViewState, transparent: boolean) { const rows = [...players].sort((a, b) => a.id.localeCompare(b.id)) const maxOffset = Math.max(0, rows.length - 1) view.playerOffset = Math.min(view.playerOffset, maxOffset) @@ -240,6 +248,8 @@ function playersPanel(ui: Container, theme: Theme, { players }: ServerMetricsSna rows, offset: view.playerOffset, scrollbar: true, + // Zebra stripes are solid fills, which would break transparency + zebra: !transparent, size: '1fr', onScroll: (delta) => { view.playerOffset = Math.max(0, Math.min(maxOffset, view.playerOffset + delta)) diff --git a/packages/cli/tests/dashboardArgs.test.ts b/packages/cli/tests/dashboardArgs.test.ts index 1666954..41d71c1 100644 --- a/packages/cli/tests/dashboardArgs.test.ts +++ b/packages/cli/tests/dashboardArgs.test.ts @@ -31,6 +31,14 @@ describe('parseDashboardArgs', () => { expect(options !== 'help' && options.source.label).toBe('https://game.example.com/api/game/metrics') }) + it('is opaque unless --transparent is passed', () => { + const opaque = parseDashboardArgs([], {}) + const transparent = parseDashboardArgs(['--transparent'], {}) + + expect(opaque !== 'help' && opaque.transparent).toBe(false) + expect(transparent !== 'help' && transparent.transparent).toBe(true) + }) + it('switches to the simulator with --sim', () => { const options = parseDashboardArgs(['--sim'], {}) diff --git a/packages/cli/tests/view.test.ts b/packages/cli/tests/view.test.ts index 8eff679..1d4baac 100644 --- a/packages/cli/tests/view.test.ts +++ b/packages/cli/tests/view.test.ts @@ -1,5 +1,5 @@ import type { DashboardState } from '../src/dashboard/MetricsClient' -import { renderToScreen } from '@profullstack/hqtui' +import { DEFAULT_COLOR, renderToScreen } from '@profullstack/hqtui' import { describe, expect, it } from 'vitest' import { simulatedSource } from '../src/dashboard/simulator' import { normalizeSnapshot } from '../src/dashboard/snapshot' @@ -21,8 +21,8 @@ async function liveState(samples = 30): Promise { } } -function render(state: DashboardState, width = 130, height = 40) { - return renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, { playerOffset: 0 }), { width, height }) +function render(state: DashboardState, width = 130, height = 40, transparent = false) { + return renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, { playerOffset: 0 }, { transparent }), { width, height }) } describe('dashboard view', () => { @@ -82,4 +82,16 @@ describe('dashboard view', () => { expect(render(state).contains('No players connected')).toBe(true) }) + + it('fills the status bar unless transparent', async () => { + const state = await liveState() + + const opaque = render(state) + const quit = opaque.find('Quit')! + expect(opaque.cell(quit.x, quit.y).bg).not.toBe(DEFAULT_COLOR) + + const transparent = render(state, 130, 40, true) + const quitTransparent = transparent.find('Quit')! + expect(transparent.cell(quitTransparent.x, quitTransparent.y).bg).toBe(DEFAULT_COLOR) + }) }) From ea72ce04ee0b350e44cc62ac0771469ca1594ea9 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 21:48:27 +0100 Subject: [PATCH 07/25] show server logs in the dashboard - core: LogBuffer winston transport keeps recent log entries in memory - core: GET /api/game/logs?after=, same access rules as metrics - core: metrics.logBufferSize option (default 500, 0 disables) - cli: logs panel that tails new lines, scrolls back, handles server restarts and older servers without the endpoint - cli: simulated log output for --sim --- packages/cli/README.md | 7 +- packages/cli/src/commands/dashboard.ts | 2 +- packages/cli/src/dashboard/MetricsClient.ts | 107 +++++++++++++----- packages/cli/src/dashboard/simulator.ts | 28 ++++- packages/cli/src/dashboard/snapshot.ts | 20 ++++ packages/cli/src/dashboard/view.ts | 77 ++++++++++++- packages/cli/tests/MetricsClient.test.ts | 100 +++++++++++++++- packages/cli/tests/snapshot.test.ts | 15 ++- packages/cli/tests/view.test.ts | 42 ++++++- .../src/Networking/Server/Http/HttpServer.ts | 23 +++- packages/core/src/Networking/Server/Server.ts | 13 +++ .../src/Networking/Server/Stats/LogBuffer.ts | 68 +++++++++++ packages/core/tests/Networking/Server.test.ts | 17 +++ .../Networking/Server/Http/HttpServer.test.ts | 31 +++++ .../Networking/Server/Stats/LogBuffer.test.ts | 64 +++++++++++ 15 files changed, 572 insertions(+), 42 deletions(-) create mode 100644 packages/core/src/Networking/Server/Stats/LogBuffer.ts create mode 100644 packages/core/tests/Networking/Server/Stats/LogBuffer.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 2dce07e..af6ef44 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -13,7 +13,7 @@ npx mavonengine --help A live terminal dashboard for a running game server, built with [hqtui](https://github.com/profullstack/hqtui). -It shows CPU, network bandwidth, tick timings against the tick budget, world/physics/memory stats and the connected players. It reads them from the server's `GET /api/game/metrics` endpoint, which needs a `@mavonengine/core` release that includes it. +It shows CPU, network bandwidth, tick timings against the tick budget, world/physics/memory stats, the connected players and the server's recent log output. It reads them from the server's `GET /api/game/metrics` and `GET /api/game/logs` endpoints, which need a `@mavonengine/core` release that includes them. ```sh # Local server on the default HTTP port (8050) @@ -37,7 +37,7 @@ npx mavonengine dashboard --sim | `--transparent` | | Let a transparent terminal background show through. Text can be hard to read over a light wallpaper | | `--sim` | | Simulated data instead of a server | -Keys: `q` quits, `r` refreshes now, and the mouse wheel scrolls the players table. +Keys: `q` quits, `r` refreshes now. The mouse wheel scrolls the players table and the logs; scroll the logs back to the bottom to follow new lines again. ### Server access @@ -48,6 +48,8 @@ const server = new MyServer(logger, physicsWorld, { httpPort: 8050, metrics: { token: process.env.METRICS_TOKEN, + // Recent log entries kept for the dashboard, 0 turns the logs endpoint off + logBufferSize: 500, }, }) ``` @@ -55,3 +57,4 @@ const server = new MyServer(logger, physicsWorld, { - With no token, the endpoint only answers requests from localhost. - With `NODE_ENV=production` and no token, the endpoint is turned off. - Set `metrics.enabled: false` to turn it off everywhere. +- The logs endpoint follows the same rules. It serves whatever passes your logger's level. diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 6472e7f..4d6a9e8 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -91,7 +91,7 @@ export async function runDashboard(options: DashboardOptions) { const { createApp } = await import('@profullstack/hqtui') const client = new MetricsClient(options.source, { intervalMs: options.intervalMs }) - const view: ViewState = { playerOffset: 0 } + const view: ViewState = { playerOffset: 0, logFromEnd: 0 } const app = await createApp({ fps: 15, theme: readableTheme(options.theme), paintBackground: !options.transparent }) diff --git a/packages/cli/src/dashboard/MetricsClient.ts b/packages/cli/src/dashboard/MetricsClient.ts index 91d18d6..baa892f 100644 --- a/packages/cli/src/dashboard/MetricsClient.ts +++ b/packages/cli/src/dashboard/MetricsClient.ts @@ -1,5 +1,5 @@ -import type { ServerMetricsSnapshot } from './snapshot' -import { normalizeSnapshot } from './snapshot' +import type { LogLine, ServerMetricsSnapshot } from './snapshot' +import { normalizeLogs, normalizeSnapshot } from './snapshot' export type ConnectionStatus = 'connecting' | 'live' | 'unreachable' | 'unauthorized' | 'forbidden' | 'disabled' @@ -13,8 +13,15 @@ export interface DashboardState { error: string | null /** Consecutive failed polls */ failures: number + /** Recent server log lines, oldest first */ + logs: LogLine[] + /** false once the server turned out not to serve logs, null until known */ + logsAvailable: boolean | null } +/** Log lines kept in memory by the dashboard */ +export const MAX_LOG_LINES = 1000 + export class MetricsSourceError extends Error { constructor(message: string, readonly status: ConnectionStatus) { super(message) @@ -22,11 +29,13 @@ export class MetricsSourceError extends Error { } /** - * Something that produces one raw metrics snapshot per call. + * Something that produces one raw metrics snapshot per call, + * and optionally the log entries newer than a given id. */ export interface MetricsSource { label: string fetch(): Promise + fetchLogs?(afterId: number): Promise } export interface HttpSourceOptions { @@ -37,31 +46,40 @@ export interface HttpSourceOptions { } export function httpSource({ url, token, timeoutMs = 3000, fetch = globalThis.fetch }: HttpSourceOptions): MetricsSource { - return { - label: url, - async fetch() { - let res: Response - - try { - res = await fetch(url, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - signal: AbortSignal.timeout(timeoutMs), - }) - } - catch (err) { - throw new MetricsSourceError(`Can't reach ${url}: ${(err as Error).message}`, 'unreachable') - } + // Logs live next to metrics: .../api/game/metrics -> .../api/game/logs + const logsUrl = new URL('logs', url) - if (res.status === 401) - throw new MetricsSourceError('Metrics token missing or wrong (use --token)', 'unauthorized') - if (res.status === 403) - throw new MetricsSourceError('Server only allows metrics from localhost without a token (use --token)', 'forbidden') - if (res.status === 404) - throw new MetricsSourceError('Metrics endpoint not found: it is disabled, or the server runs a @mavonengine/core release without it', 'disabled') - if (!res.ok) - throw new MetricsSourceError(`Server responded ${res.status} ${res.statusText}`, 'unreachable') + async function request(target: string) { + let res: Response - return res.json() + try { + res = await fetch(target, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + signal: AbortSignal.timeout(timeoutMs), + }) + } + catch (err) { + throw new MetricsSourceError(`Can't reach ${url}: ${(err as Error).message}`, 'unreachable') + } + + if (res.status === 401) + throw new MetricsSourceError('Metrics token missing or wrong (use --token)', 'unauthorized') + if (res.status === 403) + throw new MetricsSourceError('Server only allows metrics from localhost without a token (use --token)', 'forbidden') + if (res.status === 404) + throw new MetricsSourceError('Metrics endpoint not found: it is disabled, or the server runs a @mavonengine/core release without it', 'disabled') + if (!res.ok) + throw new MetricsSourceError(`Server responded ${res.status} ${res.statusText}`, 'unreachable') + + return res.json() + } + + return { + label: url, + fetch: () => request(url), + fetchLogs: (afterId) => { + logsUrl.searchParams.set('after', String(afterId)) + return request(logsUrl.toString()) }, } } @@ -94,6 +112,8 @@ export default class MetricsClient { lastUpdate: null, error: null, failures: 0, + logs: [], + logsAvailable: source.fetchLogs ? null : false, } } @@ -130,7 +150,7 @@ export default class MetricsClient { } /** - * Fetch one snapshot and update the state. + * Fetch one snapshot, then any new log lines, and update the state. */ async poll() { try { @@ -141,6 +161,8 @@ export default class MetricsClient { this.state.lastUpdate = Date.now() this.state.error = null this.state.failures = 0 + + await this.pollLogs() } catch (err) { this.state.status = err instanceof MetricsSourceError ? err.status : 'unreachable' @@ -151,6 +173,37 @@ export default class MetricsClient { this.listeners.forEach(listener => listener(this.state)) } + private lastLogId = 0 + + private async pollLogs() { + if (!this.source.fetchLogs || this.state.logsAvailable === false) + return + + try { + let { entries, latestId } = normalizeLogs(await this.source.fetchLogs(this.lastLogId)) + + // Ids went backwards, so the server restarted: start over from its first entry + if (latestId < this.lastLogId) { + this.state.logs = [] + this.lastLogId = 0; + ({ entries, latestId } = normalizeLogs(await this.source.fetchLogs(0))) + } + + this.state.logsAvailable = true + this.state.logs.push(...entries.filter(entry => entry.id > this.lastLogId)) + this.lastLogId = Math.max(this.lastLogId, latestId) + + if (this.state.logs.length > MAX_LOG_LINES) + this.state.logs.splice(0, this.state.logs.length - MAX_LOG_LINES) + } + catch (err) { + // Metrics work but logs don't: an older server, or logBufferSize 0 + if (err instanceof MetricsSourceError && err.status === 'disabled') + this.state.logsAvailable = false + // Anything else is transient, the next poll tries again + } + } + /** * Delay before the next poll: the normal interval, doubling per failure up to maxBackoffMs. */ diff --git a/packages/cli/src/dashboard/simulator.ts b/packages/cli/src/dashboard/simulator.ts index bfce5fb..9558611 100644 --- a/packages/cli/src/dashboard/simulator.ts +++ b/packages/cli/src/dashboard/simulator.ts @@ -1,5 +1,5 @@ import type { MetricsSource } from './MetricsClient' -import type { ServerMetricsSnapshot } from './snapshot' +import type { LogLine, ServerMetricsSnapshot } from './snapshot' const HISTORY_LENGTH = 60 @@ -39,6 +39,14 @@ export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSourc const bandwidthHistory: ServerMetricsSnapshot['bandwidth']['history'] = [] const tickHistory: ServerMetricsSnapshot['tick']['history'] = [] + const logs: LogLine[] = [] + let previousPlayers = 0 + const log = (level: string, message: string) => { + logs.push({ id: logs.length + 1, timestamp: now, level, message }) + } + log('info', 'Starting @mavon/engine - Socket Server') + log('info', 'Starting @mavon/engine - HTTP Server') + return { label: 'simulation', async fetch() { @@ -59,6 +67,19 @@ export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSourc trackedEntities: playerCount + 20 + Math.floor(random() * 5), })) + for (let i = previousPlayers; i < playerCount; i++) { + log('info', `Connected: ${players[i].id}`) + log('info', `${i + 1} total players connected`) + } + for (let i = previousPlayers - 1; i >= playerCount; i--) { + log('info', `Disconnected sim-${(i + 1).toString().padStart(2, '0')}`) + log('info', `${i} total players connected`) + } + previousPlayers = playerCount + + if (step % 5 === 0) + log('debug', `Client - Sent: ${playerCount * 4200} B, Received: ${playerCount * 600} B`) + const cpu = Math.max(0, 12 + playerCount * 2.5 + Math.sin(step / 7) * 6 + random() * 4) push(cpuHistory, { timestamp: now, cpuPercentage: Math.round(cpu * 100) / 100 }) @@ -69,6 +90,8 @@ export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSourc // Occasional spike so the max line has something to show const avgMs = 2 + playerCount * 0.6 + random() * 1.5 const maxMs = avgMs + random() * 3 + (step % 17 === 0 ? 25 : 0) + if (maxMs > budgetMs) + log('warn', `Slow tick: ${maxMs.toFixed(1)} ms (budget ${budgetMs} ms)`) const commandsPerSecond = playerCount * tickRate push(tickHistory, { timestamp: now, @@ -125,5 +148,8 @@ export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSourc return snapshot }, + async fetchLogs(afterId) { + return { entries: logs.filter(entry => entry.id > afterId), latestId: logs.length } + }, } } diff --git a/packages/cli/src/dashboard/snapshot.ts b/packages/cli/src/dashboard/snapshot.ts index 3ad0b15..3a5ee73 100644 --- a/packages/cli/src/dashboard/snapshot.ts +++ b/packages/cli/src/dashboard/snapshot.ts @@ -1,6 +1,8 @@ +import type { LogEntry } from '@mavonengine/core/Networking/Server/Stats/LogBuffer' import type { ServerMetricsSnapshot } from '@mavonengine/core/Networking/Server/Stats/ServerMetrics' export type { ServerMetricsSnapshot } +export type LogLine = LogEntry /** * The newest metrics schema this CLI understands. @@ -108,3 +110,21 @@ export function normalizeSnapshot(raw: unknown): ServerMetricsSnapshot { : null, } } + +/** + * Same as normalizeSnapshot, for a GET /api/game/logs response. + */ +export function normalizeLogs(raw: unknown): { entries: LogLine[], latestId: number } { + const r = section(raw) + const entries = arr(r.entries, e => ({ + id: num(e.id), + timestamp: num(e.timestamp), + level: str(e.level, 'info'), + message: str(e.message), + })) + + return { + entries, + latestId: num(r.latestId, entries.at(-1)?.id ?? 0), + } +} diff --git a/packages/cli/src/dashboard/view.ts b/packages/cli/src/dashboard/view.ts index c511be2..e1e2ea5 100644 --- a/packages/cli/src/dashboard/view.ts +++ b/packages/cli/src/dashboard/view.ts @@ -1,6 +1,6 @@ import type { Color, Container, Span, Theme } from '@profullstack/hqtui' import type { ConnectionStatus, DashboardState } from './MetricsClient' -import type { ServerMetricsSnapshot } from './snapshot' +import type { LogLine, ServerMetricsSnapshot } from './snapshot' import { DEFAULT_COLOR } from '@profullstack/hqtui' import { formatBytes, formatDuration, formatMs, formatPercent, formatRate } from './format' import { SUPPORTED_SCHEMA_VERSION } from './snapshot' @@ -10,6 +10,8 @@ import { SUPPORTED_SCHEMA_VERSION } from './snapshot' */ export interface ViewState { playerOffset: number + /** Log lines scrolled back from the newest, 0 follows the tail */ + logFromEnd: number } export interface RenderOptions { @@ -23,6 +25,7 @@ export interface RenderOptions { type PlayerRow = ServerMetricsSnapshot['players'][number] const WIDE_LAYOUT = 100 +const NEWLINE_RE = /\r?\n/ const STATUS_LABELS: Record = { connecting: 'CONNECTING', @@ -61,8 +64,8 @@ export function renderDashboard(ui: Container, theme: Theme, state: DashboardSta } else { root.responsive({ - [WIDE_LAYOUT]: c => wideLayout(c, theme, snapshot, view, transparent), - 0: c => narrowLayout(c, theme, snapshot, view, transparent), + [WIDE_LAYOUT]: c => wideLayout(c, theme, state, snapshot, view, transparent), + 0: c => narrowLayout(c, theme, state, snapshot, view, transparent), }) } @@ -121,7 +124,7 @@ function waiting(ui: Container, theme: Theme, state: DashboardState) { }) } -function wideLayout(ui: Container, theme: Theme, snapshot: ServerMetricsSnapshot, view: ViewState, transparent: boolean) { +function wideLayout(ui: Container, theme: Theme, state: DashboardState, snapshot: ServerMetricsSnapshot, view: ViewState, transparent: boolean) { ui.grid({ columns: ['1fr', '1fr'], rows: [11, 11], size: 22 }, (grid) => { grid.panel({ title: 'CPU' }, p => cpuPanel(p, theme, snapshot)) grid.panel({ title: 'Network' }, p => networkPanel(p, theme, snapshot)) @@ -129,15 +132,19 @@ function wideLayout(ui: Container, theme: Theme, snapshot: ServerMetricsSnapshot grid.panel({ title: 'World' }, p => worldPanel(p, theme, snapshot)) }) - playersPanel(ui, theme, snapshot, view, transparent) + ui.row({ size: '1fr' }, (row) => { + playersPanel(row, theme, snapshot, view, transparent) + logsPanel(row, theme, state, view) + }) } -function narrowLayout(ui: Container, theme: Theme, snapshot: ServerMetricsSnapshot, view: ViewState, transparent: boolean) { +function narrowLayout(ui: Container, theme: Theme, state: DashboardState, snapshot: ServerMetricsSnapshot, view: ViewState, transparent: boolean) { ui.panel({ title: 'CPU', size: 9 }, p => cpuPanel(p, theme, snapshot)) ui.panel({ title: 'Network', size: 9 }, p => networkPanel(p, theme, snapshot)) ui.panel({ title: 'Tick', size: 9 }, p => tickPanel(p, theme, snapshot)) ui.panel({ title: 'World', size: 11 }, p => worldPanel(p, theme, snapshot)) playersPanel(ui, theme, snapshot, view, transparent) + logsPanel(ui, theme, state, view) } function cpuPanel(ui: Container, theme: Theme, { cpu }: ServerMetricsSnapshot) { @@ -266,3 +273,61 @@ function playersPanel(ui: Container, theme: Theme, { players }: ServerMetricsSna }) }) } + +function formatTime(timestamp: number) { + const date = new Date(timestamp) + return [date.getHours(), date.getMinutes(), date.getSeconds()].map(n => n.toString().padStart(2, '0')).join(':') +} + +/** + * One widget row per line. Multi-line messages (like the boot banner) continue + * on rows without a time or level, and blank lines around them are dropped. + */ +export function toLogRows(lines: LogLine[]) { + return lines.flatMap(({ timestamp, level, message }) => { + const parts = message.split(NEWLINE_RE) + while (parts.length > 1 && parts[0].trim() === '') + parts.shift() + while (parts.length > 1 && parts.at(-1)!.trim() === '') + parts.pop() + + return parts.map((part, i) => i === 0 + ? { time: formatTime(timestamp), level, message: part } + : { message: `${' '.repeat(15)}${part}` }) + }) +} + +function logsPanel(ui: Container, theme: Theme, state: DashboardState, view: ViewState) { + const rows = toLogRows(state.logs) + view.logFromEnd = Math.min(view.logFromEnd, Math.max(0, rows.length - 1)) + + const title = view.logFromEnd > 0 ? `Logs (scrolled back ${view.logFromEnd}, scroll down to follow)` : 'Logs' + + ui.panel({ title, size: '1fr' }, (panel) => { + if (state.logsAvailable === false) { + panel.label('Not available: the server runs a @mavonengine/core release without GET /api/game/logs, or has metrics.logBufferSize set to 0') + return + } + + if (rows.length === 0) { + panel.label('No log output yet') + return + } + + panel.log({ + entries: rows, + fromEnd: view.logFromEnd, + scrollbar: true, + size: '1fr', + levelColors: { + VERBOSE: theme.muted, + SILLY: theme.muted, + HTTP: theme.info, + }, + onScroll: (delta) => { + view.logFromEnd = Math.max(0, Math.min(rows.length - 1, view.logFromEnd - delta * 3)) + ui.ctx.invalidate() + }, + }) + }) +} diff --git a/packages/cli/tests/MetricsClient.test.ts b/packages/cli/tests/MetricsClient.test.ts index 08f1def..5ddd16b 100644 --- a/packages/cli/tests/MetricsClient.test.ts +++ b/packages/cli/tests/MetricsClient.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import MetricsClient, { httpSource, MetricsSourceError } from '../src/dashboard/MetricsClient' +import MetricsClient, { httpSource, MAX_LOG_LINES, MetricsSourceError } from '../src/dashboard/MetricsClient' function response(status: number, body: unknown = {}) { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) @@ -134,4 +134,102 @@ describe('metricsClient', () => { client.stop() }) + + describe('logs', () => { + function logSource(pages: Array<{ entries: any[], latestId: number } | Error>) { + const calls: number[] = [] + return { + calls, + source: { + label: 'test', + fetch: async () => ({}), + fetchLogs: async (afterId: number) => { + calls.push(afterId) + const page = pages.shift()! + if (page instanceof Error) + throw page + return page + }, + }, + } + } + + const entry = (id: number) => ({ id, timestamp: 0, level: 'info', message: `line ${id}` }) + + it('appends only new lines and asks for what comes after', async () => { + const { source, calls } = logSource([ + { entries: [entry(1), entry(2)], latestId: 2 }, + { entries: [entry(3)], latestId: 3 }, + ]) + const client = new MetricsClient(source) + + await client.poll() + await client.poll() + + expect(calls).toEqual([0, 2]) + expect(client.state.logs.map(l => l.id)).toEqual([1, 2, 3]) + expect(client.state.logsAvailable).toBe(true) + }) + + it('starts over when the server restarts', async () => { + const { source, calls } = logSource([ + { entries: [entry(1), entry(2), entry(3)], latestId: 3 }, + { entries: [], latestId: 1 }, + { entries: [entry(1)], latestId: 1 }, + ]) + const client = new MetricsClient(source) + + await client.poll() + await client.poll() + + expect(calls).toEqual([0, 3, 0]) + expect(client.state.logs.map(l => l.id)).toEqual([1]) + }) + + it('stops asking once the server says logs are not available', async () => { + const { source, calls } = logSource([new MetricsSourceError('not found', 'disabled')]) + const client = new MetricsClient(source) + + await client.poll() + await client.poll() + + expect(calls).toEqual([0]) + expect(client.state.logsAvailable).toBe(false) + expect(client.state.status).toBe('live') + }) + + it('keeps trying after a transient log error', async () => { + const { source, calls } = logSource([ + new MetricsSourceError('blip', 'unreachable'), + { entries: [entry(1)], latestId: 1 }, + ]) + const client = new MetricsClient(source) + + await client.poll() + await client.poll() + + expect(calls).toEqual([0, 0]) + expect(client.state.logs).toHaveLength(1) + }) + + it('caps the number of lines kept', async () => { + const entries = Array.from({ length: MAX_LOG_LINES + 10 }, (_, i) => entry(i + 1)) + const { source } = logSource([{ entries, latestId: entries.length }]) + const client = new MetricsClient(source) + + await client.poll() + + expect(client.state.logs).toHaveLength(MAX_LOG_LINES) + expect(client.state.logs[0].id).toBe(11) + }) + + it('derives the logs url from the metrics url', async () => { + const fetch = vi.fn(async () => response(200, { entries: [], latestId: 0 })) + const source = httpSource({ url: 'http://localhost:8050/api/game/metrics', fetch }) + + await source.fetchLogs!(5) + + expect(fetch).toHaveBeenCalledWith('http://localhost:8050/api/game/logs?after=5', expect.anything()) + }) + }) }) diff --git a/packages/cli/tests/snapshot.test.ts b/packages/cli/tests/snapshot.test.ts index c9c4133..3e89194 100644 --- a/packages/cli/tests/snapshot.test.ts +++ b/packages/cli/tests/snapshot.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { simulatedSource } from '../src/dashboard/simulator' -import { normalizeSnapshot } from '../src/dashboard/snapshot' +import { normalizeLogs, normalizeSnapshot } from '../src/dashboard/snapshot' describe('normalizeSnapshot', () => { it('keeps a complete snapshot as is', async () => { @@ -44,3 +44,16 @@ describe('normalizeSnapshot', () => { expect(snapshot).not.toHaveProperty('somethingNew') }) }) + +describe('normalizeLogs', () => { + it('fills in missing fields and drops garbage entries', () => { + expect(normalizeLogs({ entries: [{ id: 4, message: 'hi' }, null, 'x'] })).toEqual({ + entries: [{ id: 4, timestamp: 0, level: 'info', message: 'hi' }], + latestId: 4, + }) + }) + + it('handles an empty response', () => { + expect(normalizeLogs(undefined)).toEqual({ entries: [], latestId: 0 }) + }) +}) diff --git a/packages/cli/tests/view.test.ts b/packages/cli/tests/view.test.ts index 1d4baac..cdf47c4 100644 --- a/packages/cli/tests/view.test.ts +++ b/packages/cli/tests/view.test.ts @@ -3,7 +3,7 @@ import { DEFAULT_COLOR, renderToScreen } from '@profullstack/hqtui' import { describe, expect, it } from 'vitest' import { simulatedSource } from '../src/dashboard/simulator' import { normalizeSnapshot } from '../src/dashboard/snapshot' -import { renderDashboard } from '../src/dashboard/view' +import { renderDashboard, toLogRows } from '../src/dashboard/view' async function liveState(samples = 30): Promise { const source = simulatedSource() @@ -18,11 +18,16 @@ async function liveState(samples = 30): Promise { lastUpdate: Date.now(), error: null, failures: 0, + logs: [ + { id: 1, timestamp: 0, level: 'info', message: 'Starting @mavon/engine - Socket Server' }, + { id: 2, timestamp: 0, level: 'warn', message: 'Slow tick: 40.1 ms (budget 33.33 ms)' }, + ], + logsAvailable: true, } } function render(state: DashboardState, width = 130, height = 40, transparent = false) { - return renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, { playerOffset: 0 }, { transparent }), { width, height }) + return renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, { playerOffset: 0, logFromEnd: 0 }, { transparent }), { width, height }) } describe('dashboard view', () => { @@ -50,6 +55,8 @@ describe('dashboard view', () => { lastUpdate: null, error: 'Metrics token missing or wrong (use --token)', failures: 2, + logs: [], + logsAvailable: null, }) expect(screen.contains('UNAUTHORIZED')).toBe(true) @@ -94,4 +101,35 @@ describe('dashboard view', () => { const quitTransparent = transparent.find('Quit')! expect(transparent.cell(quitTransparent.x, quitTransparent.y).bg).toBe(DEFAULT_COLOR) }) + + it('shows log lines', async () => { + const screen = render(await liveState()) + + expect(screen.contains('Logs')).toBe(true) + expect(screen.contains('Starting @mavon/engine - Socket Server')).toBe(true) + expect(screen.contains('WARN')).toBe(true) + }) + + it('shows logs in the narrow layout', async () => { + expect(render(await liveState(), 80, 70).contains('Slow tick')).toBe(true) + }) + + it('explains when the server does not serve logs', async () => { + const state = await liveState() + state.logs = [] + state.logsAvailable = false + + expect(render(state).contains('Not available')).toBe(true) + }) +}) + +describe('toLogRows', () => { + it('splits multi-line messages and trims blank edges', () => { + const rows = toLogRows([{ id: 1, timestamp: 0, level: 'info', message: '\n\n┌──┐\n│ME│\n└──┘\n' }]) + + expect(rows).toHaveLength(3) + expect(rows[0]).toMatchObject({ level: 'info', message: '┌──┐' }) + expect(rows[1]).not.toHaveProperty('level') + expect(rows[2].message.trim()).toBe('└──┘') + }) }) diff --git a/packages/core/src/Networking/Server/Http/HttpServer.ts b/packages/core/src/Networking/Server/Http/HttpServer.ts index c3aa592..a728dd6 100644 --- a/packages/core/src/Networking/Server/Http/HttpServer.ts +++ b/packages/core/src/Networking/Server/Http/HttpServer.ts @@ -1,5 +1,6 @@ import type { Express, NextFunction, Request, Response } from 'express' import type winston from 'winston' +import type LogBuffer from '../Stats/LogBuffer' import type ServerMetrics from '../Stats/ServerMetrics' import { Buffer } from 'node:buffer' import { timingSafeEqual } from 'node:crypto' @@ -8,7 +9,7 @@ import express from 'express' export interface MetricsEndpointOptions { /** - * Expose GET /api/game/metrics. + * Expose GET /api/game/metrics and GET /api/game/logs. * Defaults to true, but in production it stays off unless a token is set. */ enabled?: boolean @@ -23,6 +24,8 @@ export interface MetricsEndpointOptions { export interface HttpServerOptions { port?: number metrics?: MetricsEndpointOptions + /** Serves recent log entries on GET /api/game/logs when given */ + logs?: LogBuffer } const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']) @@ -32,6 +35,7 @@ export default class HttpServer { private port: number private metricsOptions: MetricsEndpointOptions + private logs?: LogBuffer constructor( private logger: winston.Logger, @@ -42,9 +46,11 @@ export default class HttpServer { this.app = app this.port = options.port ?? 8050 this.metricsOptions = options.metrics ?? {} + this.logs = options.logs this.registerHealthEndpoint() this.registerMetricsEndpoint() + this.registerLogsEndpoint() } start() { @@ -75,6 +81,21 @@ export default class HttpServer { }) } + /** + * GET /api/game/logs?after= returns the entries newer than `after`. + */ + private registerLogsEndpoint() { + this.app.get('/api/game/logs', (req, res, next) => this.authorizeMetrics(req, res, next), (req, res) => { + if (!this.logs) { + res.status(404).end() + return + } + + const after = Number(req.query.after ?? 0) + res.json(this.logs.since(Number.isFinite(after) ? after : 0)) + }) + } + private authorizeMetrics(req: Request, res: Response, next: NextFunction) { if (!this.metricsEnabled) { res.status(404).end() diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index 2955914..f8a0f6e 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -15,6 +15,7 @@ import BaseGame from '../../BaseGame' import { ServerCommand } from './Commands' import HttpServer from './Http/HttpServer' import LatencySimulator from './LatencySimulator' +import LogBuffer from './Stats/LogBuffer' import ServerMetrics, { SERVER_BANDWIDTH_ID } from './Stats/ServerMetrics' import ServerWorld from './World' @@ -32,6 +33,9 @@ export interface ServerOptions { metrics?: MetricsEndpointOptions & ServerMetricsOptions & { /** How often a CPU / bandwidth summary is logged, in ms. Default 5000 */ logIntervalMs?: number + + /** How many recent log entries are kept for GET /api/game/logs. 0 turns it off. Default 500 */ + logBufferSize?: number } } @@ -45,6 +49,7 @@ export default abstract class Server implements Serv readonly metrics: ServerMetrics readonly http: HttpServer + readonly logs?: LogBuffer private onHttpCallback?: (httpServer: Express) => void private options: ServerOptions @@ -61,6 +66,13 @@ export default abstract class Server implements Serv constructor(logger: winston.Logger, phyicsWorld?: RAPIER.World, options?: ServerOptions) { instance = this as unknown as Server + // Attached first so the boot banner is captured too + const logBufferSize = options?.metrics?.logBufferSize ?? 500 + if (logBufferSize > 0) { + this.logs = new LogBuffer(logBufferSize) + logger.add(this.logs.transport) + } + this.game = new BaseGame(logger, phyicsWorld) this.game.world = new ServerWorld() @@ -76,6 +88,7 @@ export default abstract class Server implements Serv this.http = new HttpServer(this.logger, this.metrics, { port: this.options.httpPort, metrics: this.options.metrics, + logs: this.logs, }) this.gameSocket.onConnection((channel) => { diff --git a/packages/core/src/Networking/Server/Stats/LogBuffer.ts b/packages/core/src/Networking/Server/Stats/LogBuffer.ts new file mode 100644 index 0000000..afc651d --- /dev/null +++ b/packages/core/src/Networking/Server/Stats/LogBuffer.ts @@ -0,0 +1,68 @@ +import type winston from 'winston' +import { Writable } from 'node:stream' +import { stripVTControlCharacters } from 'node:util' +import { transports } from 'winston' + +export interface LogEntry { + /** Increases by one per entry, so clients can ask for what they haven't seen */ + id: number + timestamp: number + level: string + message: string +} + +export interface LogsResponse { + entries: LogEntry[] + /** Id of the newest entry, 0 when nothing was logged yet */ + latestId: number +} + +/** + * Keeps the most recent log entries of a winston logger in memory + * so they can be served to the dashboard. + */ +export default class LogBuffer { + private entries: LogEntry[] = [] + private nextId = 1 + + /** + * Winston transport that feeds this buffer. It has no level of its own, + * so it records whatever the logger's level lets through. + */ + readonly transport: winston.transport + + constructor(private capacity = 500) { + this.transport = new transports.Stream({ + stream: new Writable({ + objectMode: true, + write: (info: winston.Logform.TransformableInfo, _encoding, callback) => { + this.push(info.level, info.message) + callback() + }, + }), + }) + } + + push(level: unknown, message: unknown, now = Date.now()) { + this.entries.push({ + id: this.nextId++, + timestamp: now, + // Formats like colorize() add ANSI codes, which the dashboard draws itself + level: stripVTControlCharacters(String(level)), + message: stripVTControlCharacters(typeof message === 'string' ? message : JSON.stringify(message) ?? String(message)), + }) + + if (this.entries.length > this.capacity) + this.entries.splice(0, this.entries.length - this.capacity) + } + + /** + * Entries newer than `afterId`, oldest first. + */ + since(afterId = 0): LogsResponse { + return { + entries: this.entries.filter(entry => entry.id > afterId), + latestId: this.nextId - 1, + } + } +} diff --git a/packages/core/tests/Networking/Server.test.ts b/packages/core/tests/Networking/Server.test.ts index b56ad36..274d2d7 100644 --- a/packages/core/tests/Networking/Server.test.ts +++ b/packages/core/tests/Networking/Server.test.ts @@ -256,6 +256,23 @@ describe('server', () => { expect((server.http as any).port).toBe(9002) }) + it('captures log output for the logs endpoint', () => { + const logger = winston.createLogger({ level: 'info', transports: [] }) + const server = new TestServer(logger) + + logger.info('from the game') + + expect(server.logs!.since().entries.at(-1)).toMatchObject({ level: 'info', message: 'from the game' }) + }) + + it('does not capture logs when logBufferSize is 0', () => { + const logger = winston.createLogger({ transports: [] }) + const server = new TestServer(logger, undefined, { metrics: { logBufferSize: 0 } }) + + expect(server.logs).toBeUndefined() + expect(logger.transports).toHaveLength(0) + }) + describe('stateSync', () => { function makeConnection(id: string) { const emit = vi.fn() diff --git a/packages/core/tests/Networking/Server/Http/HttpServer.test.ts b/packages/core/tests/Networking/Server/Http/HttpServer.test.ts index bc86d80..c9cd60d 100644 --- a/packages/core/tests/Networking/Server/Http/HttpServer.test.ts +++ b/packages/core/tests/Networking/Server/Http/HttpServer.test.ts @@ -3,6 +3,7 @@ import type { AddressInfo } from 'node:net' import type { HttpServerOptions } from '../../../../src/Networking/Server/Http/HttpServer' import { afterEach, describe, expect, it, vi } from 'vitest' import HttpServer from '../../../../src/Networking/Server/Http/HttpServer' +import LogBuffer from '../../../../src/Networking/Server/Stats/LogBuffer' const health = { serverTime: 'now', uptime: 1, players: 0, world: { entities: 0 }, physics: {} } const snapshot = { schemaVersion: 1, players: [] } @@ -78,4 +79,34 @@ describe('httpServer', () => { const withToken = await serve({ metrics: { token: 'secret' } }) expect((await withToken('/api/game/metrics?token=secret')).status).toBe(200) }) + + it('serves logs after an id', async () => { + const logs = new LogBuffer() + logs.push('info', 'one') + logs.push('warn', 'two') + + const request = await serve({ logs }) + + const all = await (await request('/api/game/logs')).json() + expect(all.latestId).toBe(2) + expect(all.entries.map((e: any) => e.message)).toEqual(['one', 'two']) + + const newer = await (await request('/api/game/logs?after=1')).json() + expect(newer.entries.map((e: any) => e.message)).toEqual(['two']) + + expect((await (await request('/api/game/logs?after=nope')).json()).entries).toHaveLength(2) + }) + + it('protects logs like metrics', async () => { + const request = await serve({ logs: new LogBuffer(), metrics: { token: 'secret' } }) + + expect((await request('/api/game/logs')).status).toBe(401) + expect((await request('/api/game/logs?token=secret')).status).toBe(200) + }) + + it('404s logs without a buffer', async () => { + const request = await serve() + + expect((await request('/api/game/logs')).status).toBe(404) + }) }) diff --git a/packages/core/tests/Networking/Server/Stats/LogBuffer.test.ts b/packages/core/tests/Networking/Server/Stats/LogBuffer.test.ts new file mode 100644 index 0000000..02a6c9d --- /dev/null +++ b/packages/core/tests/Networking/Server/Stats/LogBuffer.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import winston from 'winston' +import LogBuffer from '../../../../src/Networking/Server/Stats/LogBuffer' + +describe('logBuffer', () => { + it('records entries logged through winston', () => { + const buffer = new LogBuffer() + const logger = winston.createLogger({ level: 'info', transports: [buffer.transport] }) + + logger.info('hello') + logger.warn('careful') + logger.debug('hidden by the logger level') + + expect(buffer.since().entries.map(e => [e.id, e.level, e.message])).toEqual([ + [1, 'info', 'hello'], + [2, 'warn', 'careful'], + ]) + }) + + it('strips ANSI colors added by formats', () => { + const buffer = new LogBuffer() + const logger = winston.createLogger({ + format: winston.format.colorize({ all: true }), + transports: [buffer.transport], + }) + + logger.error('boom') + + expect(buffer.since().entries[0]).toMatchObject({ level: 'error', message: 'boom' }) + }) + + it('stringifies non-string messages', () => { + const buffer = new LogBuffer() + + buffer.push('info', { a: 1 }) + + expect(buffer.since().entries[0].message).toBe('{"a":1}') + }) + + it('returns only entries after the given id', () => { + const buffer = new LogBuffer() + buffer.push('info', 'one') + buffer.push('info', 'two') + buffer.push('info', 'three') + + expect(buffer.since(2)).toEqual({ + entries: [expect.objectContaining({ id: 3, message: 'three' })], + latestId: 3, + }) + expect(buffer.since(3)).toEqual({ entries: [], latestId: 3 }) + }) + + it('drops the oldest entries past capacity but keeps counting ids', () => { + const buffer = new LogBuffer(2) + buffer.push('info', 'one') + buffer.push('info', 'two') + buffer.push('info', 'three') + + const { entries, latestId } = buffer.since() + + expect(entries.map(e => e.id)).toEqual([2, 3]) + expect(latestId).toBe(3) + }) +}) From 87e071f8267dd9c7c8bfd6c56f240844500440e7 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 21:48:36 +0100 Subject: [PATCH 08/25] pause CPU / bandwidth log output while a dashboard is watching The server treats metrics fetched within the last 15s as a dashboard being open, skips the periodic summary and logs once when a dashboard connects or disconnects. --- .../src/Networking/Server/Http/HttpServer.ts | 15 ++++++ packages/core/src/Networking/Server/Server.ts | 29 +++++++++-- packages/core/tests/Networking/Server.test.ts | 48 ++++++++++++++++++- .../Networking/Server/Http/HttpServer.test.ts | 25 +++++++++- 4 files changed, 109 insertions(+), 8 deletions(-) diff --git a/packages/core/src/Networking/Server/Http/HttpServer.ts b/packages/core/src/Networking/Server/Http/HttpServer.ts index a728dd6..79dcfcb 100644 --- a/packages/core/src/Networking/Server/Http/HttpServer.ts +++ b/packages/core/src/Networking/Server/Http/HttpServer.ts @@ -30,12 +30,19 @@ export interface HttpServerOptions { const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']) +/** + * How recently metrics must have been requested for a dashboard to count as watching. + * Comfortably above the dashboard's default 1s poll and its 10s retry backoff. + */ +export const METRICS_CLIENT_TIMEOUT_MS = 15000 + export default class HttpServer { readonly app: Express private port: number private metricsOptions: MetricsEndpointOptions private logs?: LogBuffer + private lastMetricsRequestAt?: number constructor( private logger: winston.Logger, @@ -77,10 +84,18 @@ export default class HttpServer { private registerMetricsEndpoint() { this.app.get('/api/game/metrics', (req, res, next) => this.authorizeMetrics(req, res, next), (_req, res) => { + this.lastMetricsRequestAt = Date.now() res.json(this.metrics.snapshot()) }) } + /** + * Whether something (usually `mavonengine dashboard`) has fetched metrics recently. + */ + metricsClientActive(now = Date.now()) { + return this.lastMetricsRequestAt !== undefined && now - this.lastMetricsRequestAt < METRICS_CLIENT_TIMEOUT_MS + } + /** * GET /api/game/logs?after= returns the entries newer than `after`. */ diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index f8a0f6e..991e0ac 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -58,6 +58,7 @@ export default abstract class Server implements Serv private latencySimulator?: LatencySimulator private commandsProcessedThisTick = 0 + private dashboardWatching = false private timers: ReturnType[] = [] protected abstract onConnection(channel: ServerChannel): TClient @@ -349,11 +350,29 @@ export default abstract class Server implements Serv this.timers.push(setInterval(() => this.metrics.sample(), this.metrics.sampleIntervalMs)) - this.timers.push(setInterval(() => { - const { sent, received } = this.bandwidthTracker.getTotalSince(logIntervalMs) - this.logger.debug(`Client - Sent: ${sent} B, Received: ${received} B`) + this.timers.push(setInterval(() => this.logStats(logIntervalMs), logIntervalMs)) + } + + /** + * Periodic CPU / bandwidth summary. Skipped while a dashboard is fetching + * metrics, since it already shows the same numbers live. + */ + private logStats(logIntervalMs: number) { + const watching = this.http.metricsClientActive() + + if (watching !== this.dashboardWatching) { + this.dashboardWatching = watching + this.logger.info(watching + ? 'Metrics dashboard connected, pausing CPU / bandwidth log output' + : 'Metrics dashboard disconnected, resuming CPU / bandwidth log output') + } + + if (watching) + return + + const { sent, received } = this.bandwidthTracker.getTotalSince(logIntervalMs) + this.logger.debug(`Client - Sent: ${sent} B, Received: ${received} B`) - this.metrics.cpu.log() - }, logIntervalMs)) + this.metrics.cpu.log() } } diff --git a/packages/core/tests/Networking/Server.test.ts b/packages/core/tests/Networking/Server.test.ts index 274d2d7..2721794 100644 --- a/packages/core/tests/Networking/Server.test.ts +++ b/packages/core/tests/Networking/Server.test.ts @@ -1,6 +1,6 @@ import type { ServerChannel } from '@geckos.io/server' import type ServerWorld from '../../src/Networking/Server/World' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import winston from 'winston' import Player from '../../src/Networking/Entities/Player' import { ServerCommand } from '../../src/Networking/Server/Commands' @@ -273,6 +273,52 @@ describe('server', () => { expect(logger.transports).toHaveLength(0) }) + describe('periodic stats logging', () => { + function startServer() { + const server = new TestServer(logger, undefined, { metrics: { logIntervalMs: 1000 } }) + ;(server.gameSocket as any).listen = vi.fn() + vi.spyOn(server.http, 'start').mockImplementation(() => {}) + + const cpuLog = vi.spyOn(server.metrics.cpu, 'log').mockImplementation(() => {}) + const info = vi.spyOn(logger, 'info') + + server.start() + return { server, cpuLog, info } + } + + afterEach(() => { + vi.useRealTimers() + }) + + it('logs CPU / bandwidth when no dashboard is watching', () => { + vi.useFakeTimers() + const { server, cpuLog } = startServer() + + vi.advanceTimersByTime(1000) + server.stop() + + expect(cpuLog).toHaveBeenCalledTimes(1) + }) + + it('pauses while a dashboard is fetching metrics and says so once', () => { + vi.useFakeTimers() + const { server, cpuLog, info } = startServer() + const active = vi.spyOn(server.http, 'metricsClientActive').mockReturnValue(true) + + vi.advanceTimersByTime(3000) + + expect(cpuLog).not.toHaveBeenCalled() + expect(info.mock.calls.filter(([msg]) => String(msg).includes('dashboard connected'))).toHaveLength(1) + + active.mockReturnValue(false) + vi.advanceTimersByTime(1000) + server.stop() + + expect(cpuLog).toHaveBeenCalledTimes(1) + expect(info).toHaveBeenCalledWith(expect.stringContaining('dashboard disconnected')) + }) + }) + describe('stateSync', () => { function makeConnection(id: string) { const emit = vi.fn() diff --git a/packages/core/tests/Networking/Server/Http/HttpServer.test.ts b/packages/core/tests/Networking/Server/Http/HttpServer.test.ts index c9cd60d..2b196dd 100644 --- a/packages/core/tests/Networking/Server/Http/HttpServer.test.ts +++ b/packages/core/tests/Networking/Server/Http/HttpServer.test.ts @@ -2,7 +2,7 @@ import type { Server as NodeServer } from 'node:http' import type { AddressInfo } from 'node:net' import type { HttpServerOptions } from '../../../../src/Networking/Server/Http/HttpServer' import { afterEach, describe, expect, it, vi } from 'vitest' -import HttpServer from '../../../../src/Networking/Server/Http/HttpServer' +import HttpServer, { METRICS_CLIENT_TIMEOUT_MS } from '../../../../src/Networking/Server/Http/HttpServer' import LogBuffer from '../../../../src/Networking/Server/Stats/LogBuffer' const health = { serverTime: 'now', uptime: 1, players: 0, world: { entities: 0 }, physics: {} } @@ -10,11 +10,13 @@ const snapshot = { schemaVersion: 1, players: [] } let listener: NodeServer | undefined +let http: HttpServer + async function serve(options: HttpServerOptions = {}) { const logger = { info: vi.fn() } as any const metrics = { health: () => health, snapshot: () => snapshot } as any - const http = new HttpServer(logger, metrics, options) + http = new HttpServer(logger, metrics, options) listener = await new Promise((resolve) => { const server = http.app.listen(0, '127.0.0.1', () => resolve(server)) @@ -109,4 +111,23 @@ describe('httpServer', () => { expect((await request('/api/game/logs')).status).toBe(404) }) + + it('tracks whether metrics were fetched recently', async () => { + const request = await serve() + + expect(http.metricsClientActive()).toBe(false) + + await request('/api/game/metrics') + + expect(http.metricsClientActive()).toBe(true) + expect(http.metricsClientActive(Date.now() + METRICS_CLIENT_TIMEOUT_MS + 1)).toBe(false) + }) + + it('does not count rejected metrics requests', async () => { + const request = await serve({ metrics: { token: 'secret' } }) + + await request('/api/game/metrics') + + expect(http.metricsClientActive()).toBe(false) + }) }) From 1e4a29e3959e2310c467283bc37a37f294ecd66e Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 21:53:57 +0100 Subject: [PATCH 09/25] ci: run the built cli directly in the smoke test npm ci runs before the build, so the mavonengine bin isn't linked and npx fell back to the registry (404). --- .github/workflows/node.js.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 9993ab3..229b814 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -50,11 +50,15 @@ jobs: run: npm run typecheck working-directory: ./packages/cli + # Run the built file directly: npm ci runs before the build, so npm skipped + # linking the `mavonengine` bin (its target didn't exist yet) and npx would + # fall back to looking it up on the registry - name: Smoke test cli binary run: | - npx mavonengine --version - npx mavonengine --help - npx mavonengine dashboard --help + node dist/index.js --version + node dist/index.js --help + node dist/index.js dashboard --help + working-directory: ./packages/cli - name: npm link core locally run: | From 5db2fbe25501da1de0be6ccb24dfabb73c86a823 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 22:07:12 +0100 Subject: [PATCH 10/25] add player display names and show them in the dashboard --- packages/cli/src/dashboard/simulator.ts | 1 + packages/cli/src/dashboard/snapshot.ts | 5 ++++- packages/cli/src/dashboard/view.ts | 3 +++ packages/cli/tests/snapshot.test.ts | 2 +- packages/cli/tests/view.test.ts | 9 +++++++++ packages/core/src/Networking/Entities/Player.ts | 17 +++++++++++++++++ .../Networking/Server/Stats/ServerMetrics.ts | 2 ++ .../Server/Stats/ServerMetrics.test.ts | 15 +++++++++++++++ .../multiplayer-template/server/src/Server.ts | 1 + 9 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/dashboard/simulator.ts b/packages/cli/src/dashboard/simulator.ts index 9558611..42455fe 100644 --- a/packages/cli/src/dashboard/simulator.ts +++ b/packages/cli/src/dashboard/simulator.ts @@ -58,6 +58,7 @@ export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSourc const playerCount = Math.max(1, Math.round(6 + 5 * Math.sin(step / 20) + random() * 2)) const players = Array.from({ length: playerCount }, (_, i) => ({ id: `sim-${(i + 1).toString().padStart(2, '0')}`, + displayName: `Player ${i + 1}`, position: { x: Math.round(Math.cos(step / 10 + i) * (10 + i * 3) * 100) / 100, y: 0, diff --git a/packages/cli/src/dashboard/snapshot.ts b/packages/cli/src/dashboard/snapshot.ts index 3a5ee73..f4b9e5c 100644 --- a/packages/cli/src/dashboard/snapshot.ts +++ b/packages/cli/src/dashboard/snapshot.ts @@ -69,8 +69,11 @@ export function normalizeSnapshot(raw: unknown): ServerMetricsSnapshot { tickRate: num(s.tickRate), players: arr(s.players, (p) => { const position = section(p.position) + const id = str(p.id, '?') return { - id: str(p.id, '?'), + id, + // Older servers don't send a name + displayName: str(p.displayName, id), position: { x: num(position.x), y: num(position.y), z: num(position.z) }, lastProcessedSequenceId: num(p.lastProcessedSequenceId), trackedEntities: num(p.trackedEntities), diff --git a/packages/cli/src/dashboard/view.ts b/packages/cli/src/dashboard/view.ts index e1e2ea5..271ce26 100644 --- a/packages/cli/src/dashboard/view.ts +++ b/packages/cli/src/dashboard/view.ts @@ -244,6 +244,8 @@ function playersPanel(ui: Container, theme: Theme, { players }: ServerMetricsSna const rows = [...players].sort((a, b) => a.id.localeCompare(b.id)) const maxOffset = Math.max(0, rows.length - 1) view.playerOffset = Math.min(view.playerOffset, maxOffset) + // Display names default to the id, so the column only adds noise until a game sets them + const showNames = rows.some(p => p.displayName !== p.id) ui.panel({ title: `Players (${rows.length})`, size: '1fr' }, (panel) => { if (rows.length === 0) { @@ -263,6 +265,7 @@ function playersPanel(ui: Container, theme: Theme, { players }: ServerMetricsSna ui.ctx.invalidate() }, columns: [ + ...(showNames ? [{ key: 'displayName', title: 'Name' }] : []), { key: 'id', title: 'ID' }, { key: 'x', title: 'X', width: 9, align: 'right', render: p => p.position.x.toFixed(2) }, { key: 'y', title: 'Y', width: 9, align: 'right', render: p => p.position.y.toFixed(2) }, diff --git a/packages/cli/tests/snapshot.test.ts b/packages/cli/tests/snapshot.test.ts index 3e89194..d81dc4e 100644 --- a/packages/cli/tests/snapshot.test.ts +++ b/packages/cli/tests/snapshot.test.ts @@ -25,7 +25,7 @@ describe('normalizeSnapshot', () => { expect(() => normalizeSnapshot(null)).not.toThrow() expect(() => normalizeSnapshot('nope')).not.toThrow() expect(normalizeSnapshot({ players: [null, 3, { id: 'a' }] }).players).toEqual([ - { id: 'a', position: { x: 0, y: 0, z: 0 }, lastProcessedSequenceId: 0, trackedEntities: 0 }, + { id: 'a', displayName: 'a', position: { x: 0, y: 0, z: 0 }, lastProcessedSequenceId: 0, trackedEntities: 0 }, ]) }) diff --git a/packages/cli/tests/view.test.ts b/packages/cli/tests/view.test.ts index cdf47c4..2039cfa 100644 --- a/packages/cli/tests/view.test.ts +++ b/packages/cli/tests/view.test.ts @@ -90,6 +90,15 @@ describe('dashboard view', () => { expect(render(state).contains('No players connected')).toBe(true) }) + it('shows the name column only when a player has a name besides its id', async () => { + const state = await liveState() + expect(render(state).contains('Name')).toBe(true) + + for (const player of state.snapshot!.players) + player.displayName = player.id + expect(render(state).contains('Name')).toBe(false) + }) + it('fills the status bar unless transparent', async () => { const state = await liveState() diff --git a/packages/core/src/Networking/Entities/Player.ts b/packages/core/src/Networking/Entities/Player.ts index 091fb10..0734269 100644 --- a/packages/core/src/Networking/Entities/Player.ts +++ b/packages/core/src/Networking/Entities/Player.ts @@ -10,10 +10,27 @@ export default abstract class Player extends NetworkedLivingActor { private _lastProcessedSequenceId = 0 + private _displayName: string | null = null + get lastProcessedSequenceId() { return this._lastProcessedSequenceId } + /** + * Human readable name, shown in tools like the CLI dashboard. + * Falls back to the id until the game sets one. + */ + get displayName(): string { + return this._displayName ?? this.id + } + + /** + * Pass null to fall back to the id again. + */ + setDisplayName(name: string | null) { + this._displayName = name + } + updateLastProcessedSequenceId(sequenceId: number) { this._lastProcessedSequenceId = sequenceId } diff --git a/packages/core/src/Networking/Server/Stats/ServerMetrics.ts b/packages/core/src/Networking/Server/Stats/ServerMetrics.ts index acb2143..80f3338 100644 --- a/packages/core/src/Networking/Server/Stats/ServerMetrics.ts +++ b/packages/core/src/Networking/Server/Stats/ServerMetrics.ts @@ -19,6 +19,7 @@ export const METRICS_SCHEMA_VERSION = 1 export interface PlayerMetrics { id: string + displayName: string position: { x: number, y: number, z: number } lastProcessedSequenceId: number trackedEntities: number @@ -160,6 +161,7 @@ export default class ServerMetrics { tickRate: game.tickRate, players: this.players().map(player => ({ id: player.id, + displayName: player.displayName, position: { x: player.position.x, y: player.position.y, z: player.position.z }, lastProcessedSequenceId: player.lastProcessedSequenceId, trackedEntities: player.trackedEntities.size, diff --git a/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts b/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts index 0b4eae1..dc02edd 100644 --- a/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts +++ b/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts @@ -36,6 +36,7 @@ describe('serverMetrics', () => { player.position.set(1, 2, 3) player.updateLastProcessedSequenceId(7) player.trackedEntities.add('p1') + player.setDisplayName('Alice') const snapshot = metrics.snapshot() @@ -44,6 +45,7 @@ describe('serverMetrics', () => { expect(snapshot.tickRate).toBe(game.tickRate) expect(snapshot.players).toEqual([{ id: 'p1', + displayName: 'Alice', position: { x: 1, y: 2, z: 3 }, lastProcessedSequenceId: 7, trackedEntities: 1, @@ -54,6 +56,19 @@ describe('serverMetrics', () => { expect(snapshot.memory.rss).toBeGreaterThan(0) }) + it('uses the player id as the display name until one is set', () => { + const { game, metrics } = makeMetrics() + + const player = addPlayer(game, 'p1') + expect(metrics.snapshot().players[0].displayName).toBe('p1') + + player.setDisplayName('Alice') + expect(metrics.snapshot().players[0].displayName).toBe('Alice') + + player.setDisplayName(null) + expect(metrics.snapshot().players[0].displayName).toBe('p1') + }) + it('leaves out players that are marked for removal', () => { const { game, metrics } = makeMetrics() diff --git a/packages/multiplayer-template/server/src/Server.ts b/packages/multiplayer-template/server/src/Server.ts index 24342a3..b7494e9 100644 --- a/packages/multiplayer-template/server/src/Server.ts +++ b/packages/multiplayer-template/server/src/Server.ts @@ -61,6 +61,7 @@ export default class Server extends BaseServer { channel.on(ClientCommand.CL_INIT, (command) => { const cmd = command as CL_INIT player.name = cmd.name + player.setDisplayName(cmd.name) this.gameSocket.emit(ServerCommand.SV_CHAT, { playerId: '', From 4f1dd19d3bde2c41b015de6e0e501f616eefd4e3 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 22:27:12 +0100 Subject: [PATCH 11/25] dashboard: add overview / players / logs tabs --- packages/cli/src/commands/dashboard.ts | 10 +++++-- packages/cli/src/dashboard/view.ts | 38 ++++++++++++++++++++++++-- packages/cli/tests/view.test.ts | 32 ++++++++++++++++++++-- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 4d6a9e8..c47a924 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -5,7 +5,7 @@ import { parseArgs } from 'node:util' import MetricsClient, { httpSource } from '../dashboard/MetricsClient' import { simulatedSource } from '../dashboard/simulator' import { readableTheme } from '../dashboard/theme' -import { renderDashboard } from '../dashboard/view' +import { renderDashboard, tabForKey } from '../dashboard/view' export const DASHBOARD_HELP = `Usage: mavonengine dashboard [options] @@ -91,7 +91,7 @@ export async function runDashboard(options: DashboardOptions) { const { createApp } = await import('@profullstack/hqtui') const client = new MetricsClient(options.source, { intervalMs: options.intervalMs }) - const view: ViewState = { playerOffset: 0, logFromEnd: 0 } + const view: ViewState = { tab: 0, playerOffset: 0, logFromEnd: 0 } const app = await createApp({ fps: 15, theme: readableTheme(options.theme), paintBackground: !options.transparent }) @@ -102,6 +102,12 @@ export async function runDashboard(options: DashboardOptions) { app.on('key', (event) => { if (event.key === 'r') void client.refresh() + + const tab = tabForKey(event.key) + if (tab !== null) { + view.tab = tab + app.invalidate() + } }) app.on('exit', () => client.stop()) diff --git a/packages/cli/src/dashboard/view.ts b/packages/cli/src/dashboard/view.ts index 271ce26..053a6ed 100644 --- a/packages/cli/src/dashboard/view.ts +++ b/packages/cli/src/dashboard/view.ts @@ -9,6 +9,8 @@ import { SUPPORTED_SCHEMA_VERSION } from './snapshot' * UI state that lives across frames but isn't part of the metrics. */ export interface ViewState { + /** Index into DASHBOARD_TABS */ + tab: number playerOffset: number /** Log lines scrolled back from the newest, 0 follows the tail */ logFromEnd: number @@ -24,6 +26,8 @@ export interface RenderOptions { type PlayerRow = ServerMetricsSnapshot['players'][number] +export const DASHBOARD_TABS = ['Overview', 'Players', 'Logs'] as const + const WIDE_LAYOUT = 100 const NEWLINE_RE = /\r?\n/ @@ -36,6 +40,14 @@ const STATUS_LABELS: Record = { disabled: 'METRICS DISABLED', } +/** + * The tab a number key switches to, or null if the key isn't one. + */ +export function tabForKey(key: string): number | null { + const index = Number(key) - 1 + return Number.isInteger(index) && index >= 0 && index < DASHBOARD_TABS.length ? index : null +} + function statusColor(status: ConnectionStatus, theme: Theme): Color { if (status === 'live') return theme.success @@ -63,10 +75,29 @@ export function renderDashboard(ui: Container, theme: Theme, state: DashboardSta waiting(root, theme, state) } else { - root.responsive({ - [WIDE_LAYOUT]: c => wideLayout(c, theme, state, snapshot, view, transparent), - 0: c => narrowLayout(c, theme, state, snapshot, view, transparent), + root.tabs({ + tabs: DASHBOARD_TABS.map((name, i) => `${i + 1} ${name}`), + active: view.tab, + size: 1, + onSelect: (index) => { + view.tab = index + root.ctx.invalidate() + }, }) + + const tab = DASHBOARD_TABS[view.tab] ?? 'Overview' + if (tab === 'Players') { + playersPanel(root, theme, snapshot, view, transparent) + } + else if (tab === 'Logs') { + logsPanel(root, theme, state, view) + } + else { + root.responsive({ + [WIDE_LAYOUT]: c => wideLayout(c, theme, state, snapshot, view, transparent), + 0: c => narrowLayout(c, theme, state, snapshot, view, transparent), + }) + } } root.statusBar({ @@ -75,6 +106,7 @@ export function renderDashboard(ui: Container, theme: Theme, state: DashboardSta items: [ { key: 'q', label: 'Quit' }, { key: 'r', label: 'Refresh' }, + { key: `1-${DASHBOARD_TABS.length}`, label: 'Tabs' }, ], right: [{ label: STATUS_LABELS[state.status], color: statusColor(state.status, theme), active: state.status === 'live' }], }) diff --git a/packages/cli/tests/view.test.ts b/packages/cli/tests/view.test.ts index 2039cfa..e0b3d9c 100644 --- a/packages/cli/tests/view.test.ts +++ b/packages/cli/tests/view.test.ts @@ -3,7 +3,7 @@ import { DEFAULT_COLOR, renderToScreen } from '@profullstack/hqtui' import { describe, expect, it } from 'vitest' import { simulatedSource } from '../src/dashboard/simulator' import { normalizeSnapshot } from '../src/dashboard/snapshot' -import { renderDashboard, toLogRows } from '../src/dashboard/view' +import { renderDashboard, tabForKey, toLogRows } from '../src/dashboard/view' async function liveState(samples = 30): Promise { const source = simulatedSource() @@ -26,8 +26,8 @@ async function liveState(samples = 30): Promise { } } -function render(state: DashboardState, width = 130, height = 40, transparent = false) { - return renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, { playerOffset: 0, logFromEnd: 0 }, { transparent }), { width, height }) +function render(state: DashboardState, width = 130, height = 40, transparent = false, tab = 0) { + return renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, { tab, playerOffset: 0, logFromEnd: 0 }, { transparent }), { width, height }) } describe('dashboard view', () => { @@ -40,6 +40,32 @@ describe('dashboard view', () => { expect(screen.contains('sim-01')).toBe(true) }) + it('shows only the players on the players tab', async () => { + const screen = render(await liveState(), 130, 40, false, 1) + + expect(screen.contains('2 Players')).toBe(true) + expect(screen.contains('Players (')).toBe(true) + expect(screen.contains('sim-01')).toBe(true) + expect(screen.contains('CPU')).toBe(false) + expect(screen.contains('Slow tick')).toBe(false) + }) + + it('shows only the logs on the logs tab', async () => { + const screen = render(await liveState(), 130, 40, false, 2) + + expect(screen.contains('Slow tick')).toBe(true) + expect(screen.contains('Players (')).toBe(false) + expect(screen.contains('CPU')).toBe(false) + }) + + it('maps number keys to tabs', () => { + expect(tabForKey('1')).toBe(0) + expect(tabForKey('3')).toBe(2) + expect(tabForKey('4')).toBeNull() + expect(tabForKey('0')).toBeNull() + expect(tabForKey('q')).toBeNull() + }) + it('fits a narrow terminal', async () => { const screen = render(await liveState(), 80, 60) From 24f181cce0251ae9a98fe7e7a45279937e7b346a Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 22:37:12 +0100 Subject: [PATCH 12/25] dashboard: selectable players with a details tile (connection time, IP, RTT, traffic) --- packages/cli/src/commands/dashboard.ts | 7 +- packages/cli/src/dashboard/simulator.ts | 11 ++ packages/cli/src/dashboard/snapshot.ts | 18 +++ packages/cli/src/dashboard/view.ts | 105 +++++++++++++++++- packages/cli/tests/snapshot.test.ts | 7 +- packages/cli/tests/view.test.ts | 69 +++++++++++- .../core/src/Networking/Entities/Player.ts | 46 ++++++++ packages/core/src/Networking/Server/Server.ts | 4 +- .../Networking/Server/Stats/ServerMetrics.ts | 7 ++ .../tests/Networking/Entities/Player.test.ts | 62 +++++++++++ packages/core/tests/Networking/Server.test.ts | 1 + .../Server/Stats/ServerMetrics.test.ts | 21 ++++ 12 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 packages/core/tests/Networking/Entities/Player.test.ts diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index c47a924..01cd64f 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -5,7 +5,7 @@ import { parseArgs } from 'node:util' import MetricsClient, { httpSource } from '../dashboard/MetricsClient' import { simulatedSource } from '../dashboard/simulator' import { readableTheme } from '../dashboard/theme' -import { renderDashboard, tabForKey } from '../dashboard/view' +import { handlePlayersKey, renderDashboard, tabForKey } from '../dashboard/view' export const DASHBOARD_HELP = `Usage: mavonengine dashboard [options] @@ -91,7 +91,7 @@ export async function runDashboard(options: DashboardOptions) { const { createApp } = await import('@profullstack/hqtui') const client = new MetricsClient(options.source, { intervalMs: options.intervalMs }) - const view: ViewState = { tab: 0, playerOffset: 0, logFromEnd: 0 } + const view: ViewState = { tab: 0, playerOffset: 0, logFromEnd: 0, selectedPlayerId: null, followSelectedPlayer: true } const app = await createApp({ fps: 15, theme: readableTheme(options.theme), paintBackground: !options.transparent }) @@ -108,6 +108,9 @@ export async function runDashboard(options: DashboardOptions) { view.tab = tab app.invalidate() } + + if (handlePlayersKey(view, client.state.snapshot, event.key)) + app.invalidate() }) app.on('exit', () => client.stop()) diff --git a/packages/cli/src/dashboard/simulator.ts b/packages/cli/src/dashboard/simulator.ts index 42455fe..863855a 100644 --- a/packages/cli/src/dashboard/simulator.ts +++ b/packages/cli/src/dashboard/simulator.ts @@ -41,6 +41,7 @@ export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSourc const logs: LogLine[] = [] let previousPlayers = 0 + const joinedAt: number[] = [] const log = (level: string, message: string) => { logs.push({ id: logs.length + 1, timestamp: now, level, message }) } @@ -56,6 +57,9 @@ export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSourc // Players drift in and out on a slow wave const playerCount = Math.max(1, Math.round(6 + 5 * Math.sin(step / 20) + random() * 2)) + for (let i = previousPlayers; i < playerCount; i++) + joinedAt[i] = now + const players = Array.from({ length: playerCount }, (_, i) => ({ id: `sim-${(i + 1).toString().padStart(2, '0')}`, displayName: `Player ${i + 1}`, @@ -66,6 +70,13 @@ export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSourc }, lastProcessedSequenceId: sequence - Math.floor(random() * 3), trackedEntities: playerCount + 20 + Math.floor(random() * 5), + connectedFor: Math.round((now - joinedAt[i]) / 1000), + connection: { + address: `203.0.113.${10 + i}`, + rttMs: Math.round(20 + i * 8 + random() * 10), + bytesSent: Math.round((now - joinedAt[i]) * (25 + i)), + bytesReceived: Math.round((now - joinedAt[i]) * (4 + i / 2)), + }, })) for (let i = previousPlayers; i < playerCount; i++) { diff --git a/packages/cli/src/dashboard/snapshot.ts b/packages/cli/src/dashboard/snapshot.ts index f4b9e5c..de40028 100644 --- a/packages/cli/src/dashboard/snapshot.ts +++ b/packages/cli/src/dashboard/snapshot.ts @@ -23,6 +23,14 @@ function str(value: unknown, fallback = ''): string { return typeof value === 'string' ? value : fallback } +function numOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function strOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + function arr(value: unknown, map: (item: AnyRecord) => T): T[] { return Array.isArray(value) ? value.filter(isRecord).map(map) : [] } @@ -69,6 +77,7 @@ export function normalizeSnapshot(raw: unknown): ServerMetricsSnapshot { tickRate: num(s.tickRate), players: arr(s.players, (p) => { const position = section(p.position) + const connection = isRecord(p.connection) ? p.connection : null const id = str(p.id, '?') return { id, @@ -77,6 +86,15 @@ export function normalizeSnapshot(raw: unknown): ServerMetricsSnapshot { position: { x: num(position.x), y: num(position.y), z: num(position.z) }, lastProcessedSequenceId: num(p.lastProcessedSequenceId), trackedEntities: num(p.trackedEntities), + connectedFor: num(p.connectedFor), + connection: connection + ? { + address: strOrNull(connection.address), + rttMs: numOrNull(connection.rttMs), + bytesSent: num(connection.bytesSent), + bytesReceived: num(connection.bytesReceived), + } + : null, } }), world: { diff --git a/packages/cli/src/dashboard/view.ts b/packages/cli/src/dashboard/view.ts index 053a6ed..ca3cd80 100644 --- a/packages/cli/src/dashboard/view.ts +++ b/packages/cli/src/dashboard/view.ts @@ -12,6 +12,10 @@ export interface ViewState { /** Index into DASHBOARD_TABS */ tab: number playerOffset: number + /** Player shown in the details tile on the Players tab */ + selectedPlayerId: string | null + /** Keep the selected row on screen; off while the mouse wheel scrolls the table */ + followSelectedPlayer: boolean /** Log lines scrolled back from the newest, 0 follows the tail */ logFromEnd: number } @@ -48,6 +52,39 @@ export function tabForKey(key: string): number | null { return Number.isInteger(index) && index >= 0 && index < DASHBOARD_TABS.length ? index : null } +function sortedPlayers(players: ServerMetricsSnapshot['players']) { + return [...players].sort((a, b) => a.id.localeCompare(b.id)) +} + +/** + * Arrow keys / j k move the player selection on the Players tab, escape clears it. + * Returns whether the key was used. + */ +export function handlePlayersKey(view: ViewState, snapshot: ServerMetricsSnapshot | null, key: string): boolean { + if (DASHBOARD_TABS[view.tab] !== 'Players' || !snapshot) + return false + + if (key === 'escape') { + const had = view.selectedPlayerId !== null + view.selectedPlayerId = null + return had + } + + const delta = key === 'down' || key === 'j' ? 1 : key === 'up' || key === 'k' ? -1 : 0 + const rows = sortedPlayers(snapshot.players) + if (delta === 0 || rows.length === 0) + return false + + const current = rows.findIndex(p => p.id === view.selectedPlayerId) + const next = current === -1 + ? (delta > 0 ? 0 : rows.length - 1) + : Math.max(0, Math.min(rows.length - 1, current + delta)) + + view.selectedPlayerId = rows[next].id + view.followSelectedPlayer = true + return true +} + function statusColor(status: ConnectionStatus, theme: Theme): Color { if (status === 'live') return theme.success @@ -87,7 +124,7 @@ export function renderDashboard(ui: Container, theme: Theme, state: DashboardSta const tab = DASHBOARD_TABS[view.tab] ?? 'Overview' if (tab === 'Players') { - playersPanel(root, theme, snapshot, view, transparent) + playersTab(root, theme, snapshot, view, transparent) } else if (tab === 'Logs') { logsPanel(root, theme, state, view) @@ -107,6 +144,9 @@ export function renderDashboard(ui: Container, theme: Theme, state: DashboardSta { key: 'q', label: 'Quit' }, { key: 'r', label: 'Refresh' }, { key: `1-${DASHBOARD_TABS.length}`, label: 'Tabs' }, + ...(snapshot && DASHBOARD_TABS[view.tab] === 'Players' + ? [{ key: '↑↓', label: 'Select player' }, ...(view.selectedPlayerId ? [{ key: 'esc', label: 'Close' }] : [])] + : []), ], right: [{ label: STATUS_LABELS[state.status], color: statusColor(state.status, theme), active: state.status === 'live' }], }) @@ -272,8 +312,50 @@ function worldPanel(ui: Container, theme: Theme, { players, world, physics, memo }) } -function playersPanel(ui: Container, theme: Theme, { players }: ServerMetricsSnapshot, view: ViewState, transparent: boolean) { - const rows = [...players].sort((a, b) => a.id.localeCompare(b.id)) +function playersTab(ui: Container, theme: Theme, snapshot: ServerMetricsSnapshot, view: ViewState, transparent: boolean) { + const selected = snapshot.players.find(p => p.id === view.selectedPlayerId) + if (!selected) { + // Nothing picked yet, or the player disconnected + view.selectedPlayerId = null + playersPanel(ui, theme, snapshot, view, transparent, true) + return + } + + ui.responsive({ + [WIDE_LAYOUT]: c => c.row({ size: '1fr' }, (row) => { + playersPanel(row, theme, snapshot, view, transparent, true) + playerDetails(row, theme, selected, 42) + }), + 0: (c) => { + playersPanel(c, theme, snapshot, view, transparent, true) + playerDetails(c, theme, selected, 13) + }, + }) +} + +function playerDetails(ui: Container, theme: Theme, player: PlayerRow, size: number) { + const { connection, position } = player + + ui.panel({ title: player.displayName, size }, (panel) => { + panel.keyValues([ + { label: 'ID', value: player.id }, + { label: 'Connected', value: formatDuration(player.connectedFor) }, + { label: 'IP', value: connection ? connection.address ?? 'pending' : '—', color: theme.primary }, + { label: 'RTT', value: connection?.rttMs != null ? formatMs(connection.rttMs) : '—' }, + { label: 'Sent', value: connection ? formatBytes(connection.bytesSent) : '—' }, + { label: 'Received', value: connection ? formatBytes(connection.bytesReceived) : '—' }, + { label: 'Position', value: [position.x, position.y, position.z].map(n => n.toFixed(2)).join(', ') }, + { label: 'Last seq', value: String(player.lastProcessedSequenceId) }, + { label: 'Tracked', value: String(player.trackedEntities) }, + ], { spread: true }) + + if (!connection) + panel.label('No live connection (a bot, or an older server)') + }) +} + +function playersPanel(ui: Container, theme: Theme, { players }: ServerMetricsSnapshot, view: ViewState, transparent: boolean, selectable = false) { + const rows = sortedPlayers(players) const maxOffset = Math.max(0, rows.length - 1) view.playerOffset = Math.min(view.playerOffset, maxOffset) // Display names default to the id, so the column only adds noise until a game sets them @@ -288,12 +370,29 @@ function playersPanel(ui: Container, theme: Theme, { players }: ServerMetricsSna panel.table({ rows, offset: view.playerOffset, + selected: selectable ? rows.findIndex(p => p.id === view.selectedPlayerId) : -1, + followSelection: selectable && view.followSelectedPlayer, + // Following the selection can move the window, so keep our offset in step with what is drawn + onRow: (_row, index, y) => { + if (y === 1) + view.playerOffset = index + }, + onSelectRow: selectable + ? (visibleRow) => { + const row = rows[view.playerOffset + visibleRow] + if (!row) + return + view.selectedPlayerId = row.id === view.selectedPlayerId ? null : row.id + ui.ctx.invalidate() + } + : undefined, scrollbar: true, // Zebra stripes are solid fills, which would break transparency zebra: !transparent, size: '1fr', onScroll: (delta) => { view.playerOffset = Math.max(0, Math.min(maxOffset, view.playerOffset + delta)) + view.followSelectedPlayer = false ui.ctx.invalidate() }, columns: [ diff --git a/packages/cli/tests/snapshot.test.ts b/packages/cli/tests/snapshot.test.ts index d81dc4e..40d6853 100644 --- a/packages/cli/tests/snapshot.test.ts +++ b/packages/cli/tests/snapshot.test.ts @@ -25,10 +25,15 @@ describe('normalizeSnapshot', () => { expect(() => normalizeSnapshot(null)).not.toThrow() expect(() => normalizeSnapshot('nope')).not.toThrow() expect(normalizeSnapshot({ players: [null, 3, { id: 'a' }] }).players).toEqual([ - { id: 'a', displayName: 'a', position: { x: 0, y: 0, z: 0 }, lastProcessedSequenceId: 0, trackedEntities: 0 }, + { id: 'a', displayName: 'a', position: { x: 0, y: 0, z: 0 }, lastProcessedSequenceId: 0, trackedEntities: 0, connectedFor: 0, connection: null }, ]) }) + it('keeps unknown connection details as null', () => { + const [player] = normalizeSnapshot({ players: [{ id: 'a', connection: { address: 7, rttMs: Number.NaN, bytesSent: 10 } }] }).players + expect(player.connection).toEqual({ address: null, rttMs: null, bytesSent: 10, bytesReceived: 0 }) + }) + it('replaces non-finite numbers', () => { const snapshot = normalizeSnapshot({ cpu: { current: Number.NaN, avg1s: 'x' }, sampleIntervalMs: 0 }) diff --git a/packages/cli/tests/view.test.ts b/packages/cli/tests/view.test.ts index e0b3d9c..1d33905 100644 --- a/packages/cli/tests/view.test.ts +++ b/packages/cli/tests/view.test.ts @@ -1,9 +1,10 @@ import type { DashboardState } from '../src/dashboard/MetricsClient' +import type { ViewState } from '../src/dashboard/view' import { DEFAULT_COLOR, renderToScreen } from '@profullstack/hqtui' import { describe, expect, it } from 'vitest' import { simulatedSource } from '../src/dashboard/simulator' import { normalizeSnapshot } from '../src/dashboard/snapshot' -import { renderDashboard, tabForKey, toLogRows } from '../src/dashboard/view' +import { handlePlayersKey, renderDashboard, tabForKey, toLogRows } from '../src/dashboard/view' async function liveState(samples = 30): Promise { const source = simulatedSource() @@ -26,8 +27,12 @@ async function liveState(samples = 30): Promise { } } -function render(state: DashboardState, width = 130, height = 40, transparent = false, tab = 0) { - return renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, { tab, playerOffset: 0, logFromEnd: 0 }, { transparent }), { width, height }) +function viewState(overrides: Partial = {}): ViewState { + return { tab: 0, playerOffset: 0, logFromEnd: 0, selectedPlayerId: null, followSelectedPlayer: true, ...overrides } +} + +function render(state: DashboardState, width = 130, height = 40, transparent = false, tab = 0, view = viewState({ tab })) { + return renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, view, { transparent }), { width, height }) } describe('dashboard view', () => { @@ -58,6 +63,64 @@ describe('dashboard view', () => { expect(screen.contains('CPU')).toBe(false) }) + it('shows a details tile for the selected player', async () => { + const state = await liveState() + const player = state.snapshot!.players[0] + + const closed = render(state, 130, 40, false, 1) + expect(closed.contains(player.connection!.address!)).toBe(false) + + for (const [width, height] of [[130, 40], [80, 60]]) { + const screen = render(state, width, height, false, 1, viewState({ tab: 1, selectedPlayerId: player.id })) + expect(screen.contains(player.connection!.address!), `${width}x${height}`).toBe(true) + expect(screen.contains('Connected')).toBe(true) + expect(screen.contains('esc Close')).toBe(true) + } + }) + + it('explains a player without a connection', async () => { + const state = await liveState() + const player = state.snapshot!.players[0] + player.connection = null + + const screen = render(state, 130, 40, false, 1, viewState({ tab: 1, selectedPlayerId: player.id })) + expect(screen.contains('No live connection')).toBe(true) + }) + + it('drops the selection when the player disconnects', async () => { + const state = await liveState() + const view = viewState({ tab: 1, selectedPlayerId: 'gone' }) + + render(state, 130, 40, false, 1, view) + expect(view.selectedPlayerId).toBeNull() + }) + + it('moves the player selection with the arrow keys', async () => { + const { snapshot } = await liveState() + const ids = snapshot!.players.map(p => p.id).sort() + const view = viewState({ tab: 1 }) + + expect(handlePlayersKey(view, snapshot, 'down')).toBe(true) + expect(view.selectedPlayerId).toBe(ids[0]) + handlePlayersKey(view, snapshot, 'j') + expect(view.selectedPlayerId).toBe(ids[1]) + handlePlayersKey(view, snapshot, 'up') + handlePlayersKey(view, snapshot, 'up') + expect(view.selectedPlayerId).toBe(ids[0]) + + expect(handlePlayersKey(view, snapshot, 'escape')).toBe(true) + expect(view.selectedPlayerId).toBeNull() + expect(handlePlayersKey(view, snapshot, 'escape')).toBe(false) + }) + + it('ignores the arrow keys outside the players tab', async () => { + const { snapshot } = await liveState() + const view = viewState({ tab: 0 }) + + expect(handlePlayersKey(view, snapshot, 'down')).toBe(false) + expect(view.selectedPlayerId).toBeNull() + }) + it('maps number keys to tabs', () => { expect(tabForKey('1')).toBe(0) expect(tabForKey('3')).toBe(2) diff --git a/packages/core/src/Networking/Entities/Player.ts b/packages/core/src/Networking/Entities/Player.ts index 0734269..fa272e7 100644 --- a/packages/core/src/Networking/Entities/Player.ts +++ b/packages/core/src/Networking/Entities/Player.ts @@ -1,5 +1,18 @@ +import type { ServerChannel } from '@geckos.io/server' import NetworkedLivingActor from '../NetworkedLivingActor' +/** + * What the transport knows about a player's connection. + */ +export interface PlayerConnectionInfo { + /** Remote IP of the WebRTC connection, null until ICE picks a candidate pair */ + address: string | null + /** Round trip time in ms, null until measured */ + rttMs: number | null + bytesSent: number + bytesReceived: number +} + /** * Base class for connected clients / Players */ @@ -8,6 +21,16 @@ export default abstract class Player extends NetworkedLivingActor { trackedEntities = new Set() + /** + * Epoch ms the player was created. On the server that is when its client connected. + */ + connectedAt = Date.now() + + /** + * The client's channel. Set by the server on connection, undefined for bots and on the client. + */ + channel?: ServerChannel + private _lastProcessedSequenceId = 0 private _displayName: string | null = null @@ -31,6 +54,29 @@ export default abstract class Player extends NetworkedLivingActor { this._displayName = name } + /** + * Live details of the client's WebRTC connection, null without one. + */ + get connection(): PlayerConnectionInfo | null { + const peer = this.channel?.webrtcConnection?.peerConnection + if (!peer) + return null + + // node-datachannel throws once the peer connection is closed + try { + const rtt = peer.rtt() + return { + address: peer.getSelectedCandidatePair()?.remote.address ?? null, + rttMs: rtt >= 0 ? rtt : null, + bytesSent: peer.bytesSent(), + bytesReceived: peer.bytesReceived(), + } + } + catch { + return { address: null, rttMs: null, bytesSent: 0, bytesReceived: 0 } + } + } + updateLastProcessedSequenceId(sequenceId: number) { this._lastProcessedSequenceId = sequenceId } diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index 991e0ac..13637dc 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -3,7 +3,6 @@ import type { Data, GeckosServer, ServerChannel } from '@geckos.io/server' import type { Express } from 'express' import type winston from 'winston' import type GameObject from '../../World/GameObject' -import type Player from '../Entities/Player' import type NetworkedActor from '../NetworkedActor' import type { ClientCommand, CommandPacket, IncomingClientCommandPacket } from './Commands' import type { MetricsEndpointOptions } from './Http/HttpServer' @@ -12,6 +11,7 @@ import type { ServerMetricsOptions, ServerMetricsSource } from './Stats/ServerMe import { Buffer } from 'node:buffer' import geckos from '@geckos.io/server' import BaseGame from '../../BaseGame' +import Player from '../Entities/Player' import { ServerCommand } from './Commands' import HttpServer from './Http/HttpServer' import LatencySimulator from './LatencySimulator' @@ -96,6 +96,8 @@ export default abstract class Server implements Serv this.logger.info(`Connected: ${channel.id}`) const client = this.onConnection(channel) + if (client instanceof Player) + client.channel = channel this.game.world.entities.items.set(client.id!.toString(), client) this.logger.info(`${(this.game.world as ServerWorld).players.length} total players connected`) diff --git a/packages/core/src/Networking/Server/Stats/ServerMetrics.ts b/packages/core/src/Networking/Server/Stats/ServerMetrics.ts index 80f3338..294a784 100644 --- a/packages/core/src/Networking/Server/Stats/ServerMetrics.ts +++ b/packages/core/src/Networking/Server/Stats/ServerMetrics.ts @@ -1,5 +1,6 @@ import type winston from 'winston' import type BaseGame from '../../../BaseGame' +import type { PlayerConnectionInfo } from '../../Entities/Player' import type { LatencySimulatorOptions } from '../LatencySimulator' import type ServerWorld from '../World' import type { BandwidthSample } from './BandwidthTracker' @@ -23,6 +24,10 @@ export interface PlayerMetrics { position: { x: number, y: number, z: number } lastProcessedSequenceId: number trackedEntities: number + /** Seconds since the player connected */ + connectedFor: number + /** Null when the player has no live connection, e.g. a bot */ + connection: PlayerConnectionInfo | null } export interface ServerHealth { @@ -165,6 +170,8 @@ export default class ServerMetrics { position: { x: player.position.x, y: player.position.y, z: player.position.z }, lastProcessedSequenceId: player.lastProcessedSequenceId, trackedEntities: player.trackedEntities.size, + connectedFor: Math.max(0, Math.round((Date.now() - player.connectedAt) / 1000)), + connection: player.connection, })), world: { entities: game.world.entities.length, diff --git a/packages/core/tests/Networking/Entities/Player.test.ts b/packages/core/tests/Networking/Entities/Player.test.ts new file mode 100644 index 0000000..f8401af --- /dev/null +++ b/packages/core/tests/Networking/Entities/Player.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import Player from '../../../src/Networking/Entities/Player' + +class TestPlayer extends Player { + health = 100 + maxHealth = 100 + takeDamage(_amount: number): void { } + heal(_amount: number): void { } + update(_delta: number): void { } +} + +function withPeer(overrides: Record = {}) { + const player = new TestPlayer('p1') + player.channel = { webrtcConnection: { peerConnection: { + rtt: () => 35, + bytesSent: () => 5000, + bytesReceived: () => 700, + getSelectedCandidatePair: () => ({ remote: { address: '198.51.100.4' } }), + ...overrides, + } } } as any + return player +} + +describe('player', () => { + it('uses the id as the display name until one is set', () => { + const player = new TestPlayer('p1') + expect(player.displayName).toBe('p1') + + player.setDisplayName('Alice') + expect(player.displayName).toBe('Alice') + + player.setDisplayName(null) + expect(player.displayName).toBe('p1') + }) + + it('has no connection without a channel', () => { + expect(new TestPlayer('bot').connection).toBeNull() + }) + + it('reads its connection from the peer connection', () => { + expect(withPeer().connection).toEqual({ + address: '198.51.100.4', + rttMs: 35, + bytesSent: 5000, + bytesReceived: 700, + }) + }) + + it('leaves out what the peer connection does not know yet', () => { + const player = withPeer({ rtt: () => -1, getSelectedCandidatePair: () => null }) + expect(player.connection).toMatchObject({ address: null, rttMs: null }) + }) + + it('survives a closed peer connection', () => { + const player = withPeer({ + rtt: () => { + throw new Error('closed') + }, + }) + expect(player.connection).toEqual({ address: null, rttMs: null, bytesSent: 0, bytesReceived: 0 }) + }) +}) diff --git a/packages/core/tests/Networking/Server.test.ts b/packages/core/tests/Networking/Server.test.ts index 2721794..52b2a9b 100644 --- a/packages/core/tests/Networking/Server.test.ts +++ b/packages/core/tests/Networking/Server.test.ts @@ -78,6 +78,7 @@ describe('server', () => { expect((server.game.world as ServerWorld).players).toHaveLength(1) expect((server.game.world as ServerWorld).players[0].id).eq(randomId) + expect((server.game.world as ServerWorld).players[0].channel?.id).eq(randomId) // Simulate a disconnect from the client disconnectCallback() diff --git a/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts b/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts index dc02edd..28c1d9c 100644 --- a/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts +++ b/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts @@ -49,6 +49,8 @@ describe('serverMetrics', () => { position: { x: 1, y: 2, z: 3 }, lastProcessedSequenceId: 7, trackedEntities: 1, + connectedFor: 0, + connection: null, }]) expect(snapshot.world.entities).toBe(1) expect(snapshot.physics).toBeNull() @@ -56,6 +58,25 @@ describe('serverMetrics', () => { expect(snapshot.memory.rss).toBeGreaterThan(0) }) + it('includes how long players have been connected and their connection details', () => { + const { game, metrics } = makeMetrics() + + const p1 = addPlayer(game, 'p1') + p1.connectedAt = Date.now() - 90_000 + p1.channel = { webrtcConnection: { peerConnection: { + rtt: () => 42, + bytesSent: () => 1000, + bytesReceived: () => 200, + getSelectedCandidatePair: () => ({ remote: { address: '203.0.113.7' } }), + } } } as any + addPlayer(game, 'bot') + + const [connected, bot] = metrics.snapshot().players + expect(connected.connectedFor).toBe(90) + expect(connected.connection).toEqual({ address: '203.0.113.7', rttMs: 42, bytesSent: 1000, bytesReceived: 200 }) + expect(bot.connection).toBeNull() + }) + it('uses the player id as the display name until one is set', () => { const { game, metrics } = makeMetrics() From b732f7890ba47208554ccddd912bec839c78975e Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 22:57:12 +0100 Subject: [PATCH 13/25] add mavonengine dev and serve the client and game socket from the game server - cli: `mavonengine dev` finds the MavonEngine project upwards from the current directory and runs its dev script with the lockfile's package manager. SIGTERM / SIGHUP stop the whole process tree, since npm does not pass them on. - core: `ServerOptions.client` serves the client from the HTTP server, through Vite in middleware mode when `dev` is set and from the built `dist` otherwise, mounted after all other routes. The game socket is then attached to the HTTP server, and the separate socket port (8081) is only opened when the client is not served or `socketPort` asks for it. - template: the server serves the client (Vite only when MAVONENGINE_DEV_CLIENT=1, set by `npm run dev`), the client connects through /socket on the page's origin, and concurrently is gone. --- packages/cli/README.md | 17 + packages/cli/src/commands/dev.ts | 183 +++++++++ packages/cli/src/index.ts | 13 + packages/cli/tests/dev.test.ts | 129 +++++++ .../Server/Http/ClientMiddleware.ts | 73 ++++ .../src/Networking/Server/Http/HttpServer.ts | 45 ++- packages/core/src/Networking/Server/Server.ts | 33 +- packages/core/tests/Networking/Server.test.ts | 68 ++++ .../Server/Http/ClientMiddleware.test.ts | 112 ++++++ packages/multiplayer-template/README.md | 15 +- .../client/src/NetworkManager.ts | 7 +- .../multiplayer-template/package-lock.json | 356 ++---------------- packages/multiplayer-template/package.json | 5 +- .../multiplayer-template/server/package.json | 5 +- .../multiplayer-template/server/src/Server.ts | 5 +- .../multiplayer-template/server/src/index.ts | 10 +- .../server/tsup.config.ts | 2 + 17 files changed, 730 insertions(+), 348 deletions(-) create mode 100644 packages/cli/src/commands/dev.ts create mode 100644 packages/cli/tests/dev.test.ts create mode 100644 packages/core/src/Networking/Server/Http/ClientMiddleware.ts create mode 100644 packages/core/tests/Networking/Server/Http/ClientMiddleware.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index af6ef44..18cfb45 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -9,6 +9,23 @@ npm install -D @mavonengine/cli npx mavonengine --help ``` +## `mavonengine dev` + +Runs the `dev` script of the MavonEngine project you are in. For the multiplayer template that starts the game server and the client together. + +```sh +npx mavonengine dev + +# From somewhere else +npx mavonengine dev --cwd path/to/my-game +``` + +The project root is the nearest `package.json`, from the current directory upwards, that depends on `@mavonengine/core` and has a `dev` script, so it also works from inside `client/` or `server/`. The package manager comes from the lockfile (`npm`, `pnpm`, `yarn` or `bun`, default `npm`). Ctrl-C stops everything, and the command exits with the dev script's exit code. + +| Option | Default | | +| --- | --- | --- | +| `--cwd ` | current directory | Where to start looking for the project | + ## `mavonengine dashboard` A live terminal dashboard for a running game server, built with [hqtui](https://github.com/profullstack/hqtui). diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts new file mode 100644 index 0000000..f66b06a --- /dev/null +++ b/packages/cli/src/commands/dev.ts @@ -0,0 +1,183 @@ +import { execFileSync, spawn } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { constants } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import process from 'node:process' +import { parseArgs } from 'node:util' +import { UsageError } from './dashboard' + +export const DEV_HELP = `Usage: mavonengine dev [options] + +Run the dev script of the MavonEngine project you are in (game server and client). + +Options: + --cwd Start looking for the project here (default: current directory) + -h, --help Show this help +` + +export interface DevOptions { + cwd: string +} + +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' + +const LOCKFILES: [string, PackageManager][] = [ + ['pnpm-lock.yaml', 'pnpm'], + ['yarn.lock', 'yarn'], + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ['package-lock.json', 'npm'], +] + +const WHITESPACE_RE = /\s+/ + +/** + * Turn CLI arguments into dev options. Throws UsageError on bad input. + */ +export function parseDevArgs(args: string[], cwd = process.cwd()): DevOptions | 'help' { + const { values } = parseArgs({ + args, + options: { + cwd: { type: 'string' }, + help: { type: 'boolean', short: 'h', default: false }, + }, + strict: true, + allowPositionals: false, + }) + + if (values.help) + return 'help' + + return { cwd: resolve(cwd, values.cwd ?? '.') } +} + +function readPackageJson(dir: string): Record | undefined { + const path = join(dir, 'package.json') + if (!existsSync(path)) + return undefined + + try { + return JSON.parse(readFileSync(path, 'utf8')) + } + catch { + return undefined + } +} + +/** + * Walk up from `start` to the nearest package.json that depends on @mavonengine/core and has a dev script. + * Starting inside a workspace such as `client/` finds the project root above it. + */ +export function findProjectRoot(start: string): string | undefined { + for (let dir = resolve(start); ; dir = dirname(dir)) { + const pkg = readPackageJson(dir) + const deps = { ...pkg?.dependencies, ...pkg?.devDependencies } + if ('@mavonengine/core' in deps && typeof pkg?.scripts?.dev === 'string') + return dir + + if (dirname(dir) === dir) + return undefined + } +} + +export function detectPackageManager(root: string): PackageManager { + return LOCKFILES.find(([file]) => existsSync(join(root, file)))?.[1] ?? 'npm' +} + +/** + * Find every process below `root`, given `ps -A -o pid=,ppid=` output. + */ +export function descendants(root: number, psOutput: string): number[] { + const children = new Map() + for (const line of psOutput.trim().split('\n')) { + const [pid, ppid] = line.trim().split(WHITESPACE_RE).map(Number) + if (!children.has(ppid)) + children.set(ppid, []) + children.get(ppid)!.push(pid) + } + + const found: number[] = [] + const queue = [root] + while (queue.length) { + for (const child of children.get(queue.shift()!) ?? []) { + found.push(child) + queue.push(child) + } + } + return found +} + +/** + * Signal a process and everything it started. npm exits on SIGTERM without passing it on, + * which would leave the game server and vite running. + */ +function killTree(pid: number, signal: NodeJS.Signals) { + try { + if (process.platform === 'win32') { + execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }) + return + } + + const tree = [pid, ...descendants(pid, execFileSync('ps', ['-A', '-o', 'pid=,ppid='], { encoding: 'utf8' }))] + for (const p of tree) { + try { + process.kill(p, signal) + } + catch { + // Already gone + } + } + } + catch { + process.kill(pid, signal) + } +} + +/** + * Run the project's dev script and resolve with its exit code. + */ +export function runDev(root: string): Promise { + const pm = detectPackageManager(root) + process.stdout.write(`mavonengine: running '${pm} run dev' in ${root}\n`) + + const child = spawn(pm, ['run', 'dev'], { + cwd: root, + stdio: 'inherit', + // npm, pnpm and yarn are .cmd shims on Windows + shell: process.platform === 'win32', + }) + + // Ctrl-C reaches the child through the terminal's process group, so only keep ourselves alive until it exits. + // Signals sent to us alone (kill, closing the terminal) are passed on. + const onInterrupt = () => {} + const forward = (signal: NodeJS.Signals) => child.pid && killTree(child.pid, signal) + process.on('SIGINT', onInterrupt) + process.on('SIGTERM', forward) + process.on('SIGHUP', forward) + + return new Promise((resolvePromise, reject) => { + const cleanup = () => { + process.off('SIGINT', onInterrupt) + process.off('SIGTERM', forward) + process.off('SIGHUP', forward) + } + + child.on('error', (err: NodeJS.ErrnoException) => { + cleanup() + reject(err.code === 'ENOENT' ? new Error(`'${pm}' was not found on your PATH`) : err) + }) + + child.on('exit', (code, signal) => { + cleanup() + resolvePromise(code ?? 128 + (signal ? constants.signals[signal] : 0)) + }) + }) +} + +export async function runDevCommand(options: DevOptions): Promise { + const root = findProjectRoot(options.cwd) + if (!root) + throw new UsageError(`no MavonEngine project found in ${options.cwd} or its parents (looking for a package.json with @mavonengine/core and a dev script)`) + + return runDev(root) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a65e2ac..20952a3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,11 +1,13 @@ import process from 'node:process' import { version } from '../package.json' import { DASHBOARD_HELP, parseDashboardArgs, runDashboard, UsageError } from './commands/dashboard' +import { DEV_HELP, parseDevArgs, runDevCommand } from './commands/dev' const HELP = `Usage: mavonengine [options] Commands: dashboard Live terminal dashboard for a running game server + dev Run the project's dev servers (game server and client) Options: -h, --help Show help @@ -44,6 +46,17 @@ async function main(argv: string[]): Promise { return 0 } + if (command === 'dev') { + const options = parseDevArgs(args) + + if (options === 'help') { + process.stdout.write(DEV_HELP) + return 0 + } + + return runDevCommand(options) + } + throw new UsageError(`Unknown command '${command}'\n\n${HELP}`) } diff --git a/packages/cli/tests/dev.test.ts b/packages/cli/tests/dev.test.ts new file mode 100644 index 0000000..cfc5148 --- /dev/null +++ b/packages/cli/tests/dev.test.ts @@ -0,0 +1,129 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { descendants, detectPackageManager, findProjectRoot, parseDevArgs, runDevCommand } from '../src/commands/dev' + +let dir: string + +function writePackage(path: string, pkg: object) { + mkdirSync(path, { recursive: true }) + writeFileSync(join(path, 'package.json'), JSON.stringify(pkg)) +} + +const project = { dependencies: { '@mavonengine/core': '0.0.20' }, scripts: { dev: 'concurrently ...' } } + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mavonengine-dev-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +describe('findProjectRoot', () => { + it('finds the project in the start directory', () => { + writePackage(dir, project) + + expect(findProjectRoot(dir)).toBe(dir) + }) + + it('walks up from a workspace to the project root', () => { + writePackage(dir, project) + writePackage(join(dir, 'client'), { scripts: { dev: 'vite' } }) + mkdirSync(join(dir, 'client', 'src')) + + expect(findProjectRoot(join(dir, 'client', 'src'))).toBe(dir) + }) + + it('accepts @mavonengine/core as a dev dependency', () => { + writePackage(dir, { devDependencies: { '@mavonengine/core': '*' }, scripts: { dev: 'x' } }) + + expect(findProjectRoot(dir)).toBe(dir) + }) + + it('skips packages without a dev script', () => { + writePackage(dir, project) + writePackage(join(dir, 'lib'), { dependencies: { '@mavonengine/core': '*' } }) + + expect(findProjectRoot(join(dir, 'lib'))).toBe(dir) + }) + + it('ignores unreadable package.json files', () => { + writePackage(dir, project) + mkdirSync(join(dir, 'broken')) + writeFileSync(join(dir, 'broken', 'package.json'), '{ nope') + + expect(findProjectRoot(join(dir, 'broken'))).toBe(dir) + }) + + it('returns undefined outside a project', () => { + writePackage(dir, { scripts: { dev: 'vite' } }) + + expect(findProjectRoot(dir)).toBeUndefined() + }) +}) + +describe('detectPackageManager', () => { + it('defaults to npm', () => { + expect(detectPackageManager(dir)).toBe('npm') + }) + + it.each([ + ['package-lock.json', 'npm'], + ['pnpm-lock.yaml', 'pnpm'], + ['yarn.lock', 'yarn'], + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ])('picks the manager from %s', (lockfile, expected) => { + writeFileSync(join(dir, lockfile), '') + + expect(detectPackageManager(dir)).toBe(expected) + }) +}) + +describe('descendants', () => { + it('collects the whole tree below a process', () => { + const ps = ` + 1 0 + 10 1 + 11 10 + 12 10 + 13 11 + 20 1 + ` + + expect(descendants(10, ps).sort()).toEqual([11, 12, 13]) + expect(descendants(13, ps)).toEqual([]) + }) +}) + +describe('parseDevArgs', () => { + it('defaults to the current directory', () => { + expect(parseDevArgs([], '/work')).toEqual({ cwd: '/work' }) + }) + + it('resolves --cwd against the current directory', () => { + expect(parseDevArgs(['--cwd', 'game'], '/work')).toEqual({ cwd: '/work/game' }) + }) + + it('returns help', () => { + expect(parseDevArgs(['-h'])).toBe('help') + }) + + it('rejects unknown flags', () => { + expect(() => parseDevArgs(['--nope'])).toThrow() + }) +}) + +describe('runDevCommand', () => { + it('fails with a usage error outside a project', async () => { + await expect(runDevCommand({ cwd: dir })).rejects.toThrow(/no MavonEngine project found/) + }) + + it('runs the dev script and returns its exit code', async () => { + writePackage(dir, { ...project, scripts: { dev: 'node -e "process.exit(3)"' } }) + + expect(await runDevCommand({ cwd: dir })).toBe(3) + }) +}) diff --git a/packages/core/src/Networking/Server/Http/ClientMiddleware.ts b/packages/core/src/Networking/Server/Http/ClientMiddleware.ts new file mode 100644 index 0000000..0e10a77 --- /dev/null +++ b/packages/core/src/Networking/Server/Http/ClientMiddleware.ts @@ -0,0 +1,73 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express' +import type { Server as NodeServer } from 'node:http' +import type { ViteDevServer } from 'vite' +import type winston from 'winston' +import { existsSync } from 'node:fs' +import { extname, join, resolve } from 'node:path' +import express from 'express' + +export interface ClientOptions { + /** Client project directory, where index.html and vite.config live */ + root: string + + /** + * Serve the client source through Vite with hot reload instead of the built files. + * Off by default so production never loads Vite, whatever NODE_ENV says. + */ + dev?: boolean + + /** Built client served when `dev` is off. Default `/dist` */ + dist?: string +} + +/** + * Serve the client source through a Vite dev server in middleware mode. + * Hot reload shares the HTTP server's port. Requests that arrive while Vite is starting wait for it. + */ +export function devClientMiddleware(root: string, server: NodeServer, logger: winston.Logger): RequestHandler { + const vite: Promise = import('vite').then(({ createServer }) => createServer({ + root: resolve(root), + appType: 'spa', + server: { middlewareMode: true, hmr: { server } }, + })) + + vite.then( + (dev) => { + logger.info(`Serving the client from ${root} through Vite`) + server.on('close', () => void dev.close()) + }, + err => logger.error(`Could not start Vite for the client: ${(err as Error).message}`), + ) + + return (req: Request, res: Response, next: NextFunction) => { + vite.then(dev => dev.middlewares(req, res, next), next) + } +} + +/** + * Serve a built client, falling back to index.html for page routes so client-side routing works. + * Paths with a file extension are files, so a missing one stays a 404 instead of turning into the page. + * Returns undefined when there is nothing built at `dist`. + */ +export function staticClientMiddleware(dist: string): RequestHandler | undefined { + const index = join(dist, 'index.html') + if (!existsSync(index)) + return undefined + + const router = express.Router() + router.use(express.static(dist, { index: false })) + router.use((req, res, next) => { + const isPage = (req.method === 'GET' || req.method === 'HEAD') + && !req.path.startsWith('/api/') + && !extname(req.path) + && req.accepts('html') + if (!isPage) { + next() + return + } + + res.sendFile(resolve(index)) + }) + + return router +} diff --git a/packages/core/src/Networking/Server/Http/HttpServer.ts b/packages/core/src/Networking/Server/Http/HttpServer.ts index 79dcfcb..4f597a3 100644 --- a/packages/core/src/Networking/Server/Http/HttpServer.ts +++ b/packages/core/src/Networking/Server/Http/HttpServer.ts @@ -1,11 +1,15 @@ import type { Express, NextFunction, Request, Response } from 'express' +import type { Server as NodeServer } from 'node:http' import type winston from 'winston' import type LogBuffer from '../Stats/LogBuffer' import type ServerMetrics from '../Stats/ServerMetrics' +import type { ClientOptions } from './ClientMiddleware' import { Buffer } from 'node:buffer' import { timingSafeEqual } from 'node:crypto' +import { join } from 'node:path' import process from 'node:process' import express from 'express' +import { devClientMiddleware, staticClientMiddleware } from './ClientMiddleware' export interface MetricsEndpointOptions { /** @@ -26,6 +30,8 @@ export interface HttpServerOptions { metrics?: MetricsEndpointOptions /** Serves recent log entries on GET /api/game/logs when given */ logs?: LogBuffer + /** Serve the game client from this server, see `serveClient()` */ + client?: ClientOptions } const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']) @@ -42,7 +48,9 @@ export default class HttpServer { private port: number private metricsOptions: MetricsEndpointOptions private logs?: LogBuffer + private clientOptions?: ClientOptions private lastMetricsRequestAt?: number + private listener?: NodeServer constructor( private logger: winston.Logger, @@ -54,6 +62,7 @@ export default class HttpServer { this.port = options.port ?? 8050 this.metricsOptions = options.metrics ?? {} this.logs = options.logs + this.clientOptions = options.client this.registerHealthEndpoint() this.registerMetricsEndpoint() @@ -62,11 +71,45 @@ export default class HttpServer { start() { this.logger.info('Starting @mavon/engine - HTTP Server') - this.app.listen(this.port) + this.listener = this.app.listen(this.port) this.logger.info(`Visit game server status: http://localhost:${this.port}/api/game/health`) } + /** + * The underlying Node HTTP server, once started. + */ + get server(): NodeServer | undefined { + return this.listener + } + + /** + * Mount the game client configured in `options.client`. Call it after every other route is registered, + * because the client answers any page request that is left. + * Returns whether a client is being served. + */ + serveClient(): boolean { + const client = this.clientOptions + if (!client || !this.listener) + return false + + if (client.dev) { + this.app.use(devClientMiddleware(client.root, this.listener, this.logger)) + return true + } + + const dist = client.dist ?? join(client.root, 'dist') + const middleware = staticClientMiddleware(dist) + if (!middleware) { + this.logger.warn(`No built client at ${dist}, run the client build to serve it from the game server`) + return false + } + + this.app.use(middleware) + this.logger.info(`Serving the built client from ${dist}: http://localhost:${this.port}/`) + return true + } + get metricsEnabled() { const { enabled = true, token } = this.metricsOptions diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index 13637dc..43cda0a 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -5,6 +5,7 @@ import type winston from 'winston' import type GameObject from '../../World/GameObject' import type NetworkedActor from '../NetworkedActor' import type { ClientCommand, CommandPacket, IncomingClientCommandPacket } from './Commands' +import type { ClientOptions } from './Http/ClientMiddleware' import type { MetricsEndpointOptions } from './Http/HttpServer' import type { LatencySimulatorOptions } from './LatencySimulator' import type { ServerMetricsOptions, ServerMetricsSource } from './Stats/ServerMetrics' @@ -24,12 +25,21 @@ let instance: Server export interface ServerOptions { latencySimulation?: LatencySimulatorOptions - /** Port the geckos.io game socket listens on. Default 8081 */ - socketPort?: number + /** + * Separate port for the geckos.io game socket, or false for none. + * Defaults to none when the HTTP server serves the client, since the socket is reachable there, and 8081 otherwise. + */ + socketPort?: number | false /** Port the HTTP server listens on. Default 8050 */ httpPort?: number + /** + * Serve the game client from the HTTP server: the source through Vite when `dev` is set, the built files otherwise. + * The game socket is then also reachable on the HTTP port, so the client needs no second origin. + */ + client?: ClientOptions + metrics?: MetricsEndpointOptions & ServerMetricsOptions & { /** How often a CPU / bandwidth summary is logged, in ms. Default 5000 */ logIntervalMs?: number @@ -90,6 +100,7 @@ export default abstract class Server implements Serv port: this.options.httpPort, metrics: this.options.metrics, logs: this.logs, + client: this.options.client, }) this.gameSocket.onConnection((channel) => { @@ -325,9 +336,6 @@ export default abstract class Server implements Serv } start() { - this.logger.info('Starting @mavon/engine - Socket Server') - this.gameSocket.listen(this.options.socketPort ?? 8081) - this.http.start() this.startMetrics() @@ -337,6 +345,21 @@ export default abstract class Server implements Serv if (this.onHttpCallback) { this.onHttpCallback(this.httpServer) } + + // Last, because the client answers every page request the routes above leave + const servesClient = this.http.serveClient() && !!this.http.server + if (servesClient) { + // geckos matches its /.wrtc/v2 routes anywhere in the path, so this also answers a client + // that connects through a /socket prefix, like the template does + this.gameSocket.addServer(this.http.server!) + this.logger.info('Game socket reachable on the HTTP server') + } + + const socketPort = this.options.socketPort ?? (servesClient ? false : 8081) + if (socketPort !== false) { + this.logger.info('Starting @mavon/engine - Socket Server') + this.gameSocket.listen(socketPort) + } } /** diff --git a/packages/core/tests/Networking/Server.test.ts b/packages/core/tests/Networking/Server.test.ts index 52b2a9b..3f35607 100644 --- a/packages/core/tests/Networking/Server.test.ts +++ b/packages/core/tests/Networking/Server.test.ts @@ -257,6 +257,74 @@ describe('server', () => { expect((server.http as any).port).toBe(9002) }) + it('moves the game socket onto the HTTP server when it serves the client', () => { + const server = new TestServer(logger, undefined, { client: { root: '/game/client' } }) + const nodeServer = {} + + const listen = vi.fn() + ;(server.gameSocket as any).listen = listen + const addServer = vi.fn() + ;(server.gameSocket as any).addServer = addServer + vi.spyOn(server.http, 'start').mockImplementation(() => {}) + vi.spyOn(server.http, 'serveClient').mockReturnValue(true) + vi.spyOn(server.http, 'server', 'get').mockReturnValue(nodeServer as any) + + server.start() + server.stop() + + expect(addServer).toHaveBeenCalledWith(nodeServer) + expect(listen).not.toHaveBeenCalled() + }) + + it('still opens the socket port when asked to while serving the client', () => { + const server = new TestServer(logger, undefined, { client: { root: '/game/client' }, socketPort: 9001 }) + + const listen = vi.fn() + ;(server.gameSocket as any).listen = listen + ;(server.gameSocket as any).addServer = vi.fn() + vi.spyOn(server.http, 'start').mockImplementation(() => {}) + vi.spyOn(server.http, 'serveClient').mockReturnValue(true) + vi.spyOn(server.http, 'server', 'get').mockReturnValue({} as any) + + server.start() + server.stop() + + expect(listen).toHaveBeenCalledWith(9001) + }) + + it('falls back to port 8081 when the client is configured but not built', () => { + const server = new TestServer(logger, undefined, { client: { root: '/game/client' } }) + + const listen = vi.fn() + ;(server.gameSocket as any).listen = listen + const addServer = vi.fn() + ;(server.gameSocket as any).addServer = addServer + vi.spyOn(server.http, 'start').mockImplementation(() => {}) + vi.spyOn(server.http, 'serveClient').mockReturnValue(false) + + server.start() + server.stop() + + expect(listen).toHaveBeenCalledWith(8081) + expect(addServer).not.toHaveBeenCalled() + }) + + it('keeps the game socket on its own port when no client is served', () => { + const server = new TestServer(logger) + + const listen = vi.fn() + ;(server.gameSocket as any).listen = listen + const addServer = vi.fn() + ;(server.gameSocket as any).addServer = addServer + vi.spyOn(server.http, 'start').mockImplementation(() => {}) + + server.start() + server.stop() + + expect(addServer).not.toHaveBeenCalled() + expect(listen).toHaveBeenCalledWith(8081) + }) + it('captures log output for the logs endpoint', () => { const logger = winston.createLogger({ level: 'info', transports: [] }) const server = new TestServer(logger) diff --git a/packages/core/tests/Networking/Server/Http/ClientMiddleware.test.ts b/packages/core/tests/Networking/Server/Http/ClientMiddleware.test.ts new file mode 100644 index 0000000..c34ba3b --- /dev/null +++ b/packages/core/tests/Networking/Server/Http/ClientMiddleware.test.ts @@ -0,0 +1,112 @@ +import type { Server as NodeServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { HttpServerOptions } from '../../../../src/Networking/Server/Http/HttpServer' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import HttpServer from '../../../../src/Networking/Server/Http/HttpServer' + +const health = { serverTime: 'now', uptime: 1, players: 0, world: { entities: 0 }, physics: {} } + +let dir: string +let http: HttpServer + +async function serve(options: HttpServerOptions = {}) { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any + const metrics = { health: () => health, snapshot: () => ({}) } as any + + http = new HttpServer(logger, metrics, { port: 0, ...options }) + http.start() + const listener = http.server as NodeServer + await new Promise(resolve => listener.once('listening', resolve)) + + const served = http.serveClient() + const { port } = listener.address() as AddressInfo + + return { + served, + logger, + request: (path: string, init?: RequestInit) => fetch(`http://127.0.0.1:${port}${path}`, init), + } +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mavonengine-client-')) +}) + +afterEach(async () => { + await new Promise(resolve => http.server?.close(resolve)) + rmSync(dir, { recursive: true, force: true }) +}) + +describe('serveClient', () => { + it('serves nothing without client options', async () => { + const { served, request } = await serve() + + expect(served).toBe(false) + expect((await request('/')).status).toBe(404) + }) + + describe('built client', () => { + beforeEach(() => { + mkdirSync(join(dir, 'dist', 'assets'), { recursive: true }) + writeFileSync(join(dir, 'dist', 'index.html'), 'game') + writeFileSync(join(dir, 'dist', 'assets', 'app.js'), 'console.log(1)') + }) + + it('serves files from /dist', async () => { + const { served, request } = await serve({ client: { root: dir } }) + + expect(served).toBe(true) + expect(await (await request('/assets/app.js')).text()).toBe('console.log(1)') + }) + + it('falls back to index.html for page routes', async () => { + const { request } = await serve({ client: { root: dir } }) + + const res = await request('/lobby/42', { headers: { Accept: 'text/html' } }) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('game') + }) + + it('leaves missing assets and api routes alone', async () => { + const { request } = await serve({ client: { root: dir } }) + + expect((await request('/assets/missing.js')).status).toBe(404) + expect((await request('/src/main.ts', { headers: { Accept: 'text/html' } })).status).toBe(404) + expect((await request('/api/nope', { headers: { Accept: 'text/html' } })).status).toBe(404) + expect(await (await request('/api/game/health')).json()).toEqual(health) + }) + + it('uses a custom dist directory', async () => { + mkdirSync(join(dir, 'out')) + writeFileSync(join(dir, 'out', 'index.html'), 'out') + const { request } = await serve({ client: { root: dir, dist: join(dir, 'out') } }) + + expect(await (await request('/')).text()).toBe('out') + }) + }) + + it('warns when the client has not been built', async () => { + const { served, logger } = await serve({ client: { root: dir } }) + + expect(served).toBe(false) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('No built client')) + }) + + it('serves the client source through Vite in dev mode', async () => { + writeFileSync(join(dir, 'index.html'), '') + writeFileSync(join(dir, 'main.js'), 'export const answer = 42') + const { served, request } = await serve({ client: { root: dir, dev: true } }) + + expect(served).toBe(true) + + const page = await (await request('/', { headers: { Accept: 'text/html' } })).text() + expect(page).toContain('/@vite/client') + + expect(await (await request('/main.js')).text()).toContain('answer = 42') + expect(await (await request('/api/game/health')).json()).toEqual(health) + }, 20000) +}) diff --git a/packages/multiplayer-template/README.md b/packages/multiplayer-template/README.md index 35a6ee0..44abb28 100644 --- a/packages/multiplayer-template/README.md +++ b/packages/multiplayer-template/README.md @@ -123,7 +123,7 @@ States are layered: the server defines the logic, the client extends each state To view the scene from the server's perspective (what the authoritative physics world sees), navigate to: ``` -http://localhost:5173/#debug +http://localhost:8050/#debug ``` This enables the debug overlay and renders the server-side physics colliders and entity positions directly in the browser. @@ -136,10 +136,10 @@ From the workspace root: ```bash npm install -npm run dev # starts both client and server in watch mode +npm run dev # starts the game server in watch mode, which serves the client through Vite ``` -The client runs at `http://localhost:5173` and the server listens on port `8081`. +Open `http://localhost:8050`. The game server serves the client on its HTTP port, with hot reload for client changes. Server changes rebuild and restart the server, which reloads the page. The client connects to the game socket through `/socket` on the same port, so everything runs on `8050`. The game server exposes a JSON API with a health endpoint: @@ -147,6 +147,15 @@ The game server exposes a JSON API with a health endpoint: GET http://localhost:8050/api/game/health ``` +### Production + +```bash +npm run build +npm start --prefix server +``` + +The server serves the built client from `client/dist` on port `8050`, and the game socket is reachable on the same port, so a single process is enough. Vite is never loaded outside `npm run dev`. Behind a reverse proxy or CDN you can still serve `client/dist` yourself and forward `/socket` to the game server's port `8050`. + --- ## Credits diff --git a/packages/multiplayer-template/client/src/NetworkManager.ts b/packages/multiplayer-template/client/src/NetworkManager.ts index c8ccc17..e0fac4d 100644 --- a/packages/multiplayer-template/client/src/NetworkManager.ts +++ b/packages/multiplayer-template/client/src/NetworkManager.ts @@ -13,9 +13,10 @@ export default class NetworkManager extends BaseNetworkManager { constructor() { super({ - /** @ts-expect-error port is null in prod when behind a reverse proxy */ - port: import.meta.env.PROD ? null : 8081, - url: import.meta.env.PROD ? '/socket' : undefined, + // Same origin as the page: the game server answers /socket on its HTTP port, and so does a reverse proxy in front of it + /** @ts-expect-error port is null to use the page's own port */ + port: null, + url: '/socket', }) } diff --git a/packages/multiplayer-template/package-lock.json b/packages/multiplayer-template/package-lock.json index e19cb57..7a067f6 100644 --- a/packages/multiplayer-template/package-lock.json +++ b/packages/multiplayer-template/package-lock.json @@ -19,7 +19,6 @@ "@antfu/eslint-config": "^8.0.0", "@mavonengine/editor": "0.0.8", "@types/three": "0.184.1", - "concurrently": "^9.1.2", "eslint": "^10.1.0", "tsup": "^8.5.1", "typescript": "5.9.2" @@ -1261,9 +1260,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1277,9 +1273,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1293,9 +1286,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1309,9 +1299,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1325,9 +1312,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1341,9 +1325,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1357,9 +1338,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1373,9 +1351,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1389,9 +1364,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1405,9 +1377,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1421,9 +1390,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1437,9 +1403,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1453,9 +1416,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2309,32 +2269,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/ansis": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.0.tgz", @@ -2642,36 +2576,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -2751,21 +2655,6 @@ "node": ">=0.8.0" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2789,26 +2678,6 @@ "node": ">=18" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/color-string": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", @@ -2871,31 +2740,6 @@ "node": ">= 12.0.0" } }, - "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -3160,13 +3004,6 @@ "dev": true, "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/empathic": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", @@ -4452,16 +4289,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4577,16 +4404,6 @@ "dev": true, "license": "ISC" }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4764,16 +4581,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6895,16 +6702,6 @@ "regjsparser": "bin/parser" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7013,16 +6810,6 @@ "node": ">= 18" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -7166,19 +6953,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -7374,34 +7148,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-indent": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", @@ -7457,22 +7203,6 @@ "node": ">= 6" } }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -8157,6 +7887,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -8173,6 +7904,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -8189,6 +7921,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -8205,6 +7938,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -8221,6 +7955,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -8237,6 +7972,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -8253,6 +7989,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8269,6 +8006,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8285,6 +8023,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8301,6 +8040,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8317,6 +8057,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8333,6 +8074,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8349,6 +8091,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8365,6 +8108,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8381,6 +8125,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8397,6 +8142,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8413,6 +8159,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8429,6 +8176,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8445,6 +8193,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8461,6 +8210,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8477,6 +8227,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8493,6 +8244,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -8509,6 +8261,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -8525,6 +8278,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8541,6 +8295,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8557,6 +8312,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8722,24 +8478,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -8756,16 +8494,6 @@ "node": ">=12" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -8812,35 +8540,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -8878,7 +8577,8 @@ "devDependencies": { "@types/express": "^5.0.3", "@types/node": "^24.0.0", - "cross-env": "^10.1.0" + "cross-env": "^10.1.0", + "vite": "^6.3.5" } } } diff --git a/packages/multiplayer-template/package.json b/packages/multiplayer-template/package.json index e150b68..c019e9f 100644 --- a/packages/multiplayer-template/package.json +++ b/packages/multiplayer-template/package.json @@ -7,8 +7,8 @@ ], "license": "MIT", "scripts": { - "dev": "concurrently --kill-others -n server,client -c blue,green \"npm run dev --prefix server\" \"npm run dev --prefix client\"", - "build": "concurrently \"npm run build --prefix server\" \"npm run build --prefix client\"", + "dev": "npm run dev --prefix server", + "build": "npm run build --workspaces", "typecheck": "npm run typecheck --prefix server && npm run typecheck --prefix client", "lint": "eslint .", "lint:fix": "eslint . --fix" @@ -20,7 +20,6 @@ "@antfu/eslint-config": "^8.0.0", "@mavonengine/editor": "0.0.8", "@types/three": "0.184.1", - "concurrently": "^9.1.2", "eslint": "^10.1.0", "tsup": "^8.5.1", "typescript": "5.9.2" diff --git a/packages/multiplayer-template/server/package.json b/packages/multiplayer-template/server/package.json index 1090f55..f14c710 100644 --- a/packages/multiplayer-template/server/package.json +++ b/packages/multiplayer-template/server/package.json @@ -6,7 +6,7 @@ "main": "src/index.ts", "scripts": { "start": "cross-env LOG_LEVEL=debug node ./dist/index.js", - "dev": "tsup --watch --onSuccess \"npm run start\"", + "dev": "tsup --watch --onSuccess \"cross-env MAVONENGINE_DEV_CLIENT=1 npm run start\"", "build": "tsup", "typecheck": "tsc --noEmit" }, @@ -20,6 +20,7 @@ "devDependencies": { "@types/express": "^5.0.3", "@types/node": "^24.0.0", - "cross-env": "^10.1.0" + "cross-env": "^10.1.0", + "vite": "^6.3.5" } } diff --git a/packages/multiplayer-template/server/src/Server.ts b/packages/multiplayer-template/server/src/Server.ts index b7494e9..39a1b67 100644 --- a/packages/multiplayer-template/server/src/Server.ts +++ b/packages/multiplayer-template/server/src/Server.ts @@ -1,5 +1,6 @@ import type RAPIER from '@dimforge/rapier3d-compat' import type { ServerChannel } from '@geckos.io/server' +import type { ServerOptions } from '@mavonengine/core/Networking/Server/Server' import type winston from 'winston' import type { CL_CHAT, CL_INIT, CL_MOVE } from './Commands/Client' import type { SV_TREES } from './Commands/Server' @@ -20,8 +21,8 @@ export default class Server extends BaseServer { private treeScales: number[] = [] private trees: Tree[] = [] - constructor(logger: winston.Logger, physicsWorld?: RAPIER.World) { - super(logger, physicsWorld) + constructor(logger: winston.Logger, physicsWorld?: RAPIER.World, options?: ServerOptions) { + super(logger, physicsWorld, options) this.spawnTrees() } diff --git a/packages/multiplayer-template/server/src/index.ts b/packages/multiplayer-template/server/src/index.ts index c840108..b8dedd3 100644 --- a/packages/multiplayer-template/server/src/index.ts +++ b/packages/multiplayer-template/server/src/index.ts @@ -1,3 +1,5 @@ +import process from 'node:process' +import { fileURLToPath } from 'node:url' import RAPIER from '@dimforge/rapier3d-compat' import logger from './Logger' import Server from './Server' @@ -11,6 +13,12 @@ RAPIER.init().then(() => { groundBody, ) - const server = new Server(logger, physicsWorld) + const server = new Server(logger, physicsWorld, { + client: { + root: fileURLToPath(new URL('../../client', import.meta.url)), + // Set by `npm run dev`: serve the client source through Vite. Otherwise the built client/dist is served + dev: process.env.MAVONENGINE_DEV_CLIENT === '1', + }, + }) server.start() }) diff --git a/packages/multiplayer-template/server/tsup.config.ts b/packages/multiplayer-template/server/tsup.config.ts index 0a4e331..623e196 100644 --- a/packages/multiplayer-template/server/tsup.config.ts +++ b/packages/multiplayer-template/server/tsup.config.ts @@ -14,5 +14,7 @@ export default defineConfig({ 'express', 'winston', 'three', + // Only loaded in dev to serve the client, never bundled + 'vite', ], }) From facdeb7e9601fac0a603770cd93d8a5e78880d0b Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 23:27:12 +0100 Subject: [PATCH 14/25] cli: show the dashboard from mavonengine dev and stop the dev servers on quit - `mavonengine dev` runs the dev script with its output captured and shows the dashboard. Quitting stops the whole process tree (SIGTERM, then SIGKILL after 3s); the dev script exiting on its own closes the dashboard and prints its output. `--no-dashboard`, or no interactive terminal, keeps the old behaviour. `--port`, `--theme` and `--transparent` are passed on to the dashboard. - Until the game server first answers, a startup screen shows the engine's boot banner, a spinner and the latest dev script output, and the metrics client polls every 250ms without backoff. --- packages/cli/README.md | 10 +- packages/cli/src/commands/dashboard.ts | 31 ++- packages/cli/src/commands/dev.ts | 197 +++++++++++++++++--- packages/cli/src/dashboard/MetricsClient.ts | 10 +- packages/cli/src/dashboard/view.ts | 76 +++++++- packages/cli/tests/MetricsClient.test.ts | 24 +++ packages/cli/tests/dev.test.ts | 67 ++++++- packages/cli/tests/view.test.ts | 33 +++- 8 files changed, 401 insertions(+), 47 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 18cfb45..e1d4889 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,7 +11,9 @@ npx mavonengine --help ## `mavonengine dev` -Runs the `dev` script of the MavonEngine project you are in. For the multiplayer template that starts the game server and the client together. +Runs the `dev` script of the MavonEngine project you are in and shows the [dashboard](#mavonengine-dashboard) for it. For the multiplayer template that starts the game server and the client together. + +While the game server starts, a startup screen shows the latest output of the dev script; the dashboard takes over as soon as the server answers. Quitting the dashboard (`q` or Ctrl-C) stops the dev script and everything it started. If the dev script exits on its own, the dashboard closes and its output is printed. ```sh npx mavonengine dev @@ -20,11 +22,15 @@ npx mavonengine dev npx mavonengine dev --cwd path/to/my-game ``` -The project root is the nearest `package.json`, from the current directory upwards, that depends on `@mavonengine/core` and has a `dev` script, so it also works from inside `client/` or `server/`. The package manager comes from the lockfile (`npm`, `pnpm`, `yarn` or `bun`, default `npm`). Ctrl-C stops everything, and the command exits with the dev script's exit code. +The project root is the nearest `package.json`, from the current directory upwards, that depends on `@mavonengine/core` and has a `dev` script, so it also works from inside `client/` or `server/`. The package manager comes from the lockfile (`npm`, `pnpm`, `yarn` or `bun`, default `npm`). Without an interactive terminal, or with `--no-dashboard`, the dev script's output goes straight to the terminal instead. Ctrl-C stops everything, and the command exits with the dev script's exit code. | Option | Default | | | --- | --- | --- | | `--cwd ` | current directory | Where to start looking for the project | +| `--no-dashboard` | | Only run the dev script | +| `--port ` | `8050` | Game server HTTP port for the dashboard | +| `--theme ` | `dark` | Dashboard theme | +| `--transparent` | | Let a transparent terminal background show through | ## `mavonengine dashboard` diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 01cd64f..2067239 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -29,8 +29,16 @@ export interface DashboardOptions { intervalMs: number theme?: string transparent: boolean + /** Show the startup screen until the server first answers (`mavonengine dev`) */ + startup?: { + engineVersion?: string + output(): string + } } +/** Poll interval while waiting for a server that is still starting, so the dashboard appears as soon as it is up */ +const STARTUP_POLL_MS = 250 + export class UsageError extends Error { } /** @@ -86,16 +94,25 @@ export function parseDashboardArgs(args: string[], env: Record renderDashboard(ui, theme, client.state, view, { transparent: options.transparent })) + app.render(({ ui, theme }) => renderDashboard(ui, theme, client.state, view, { + transparent: options.transparent, + startup: options.startup && { engineVersion: options.startup.engineVersion, output: options.startup.output() }, + })) client.onChange(() => app.invalidate()) @@ -115,7 +132,13 @@ export async function runDashboard(options: DashboardOptions) { app.on('exit', () => client.stop()) + const onAbort = () => app.stop() + signal?.addEventListener('abort', onAbort) + client.start() - await app.start() + // stop() does nothing before start(), so an abort that already happened is checked here + if (!signal?.aborted) + await app.start() + signal?.removeEventListener('abort', onAbort) client.stop() } diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index f66b06a..e38ab2c 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -1,24 +1,39 @@ +import type { DashboardOptions } from './dashboard' import { execFileSync, spawn } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { constants } from 'node:os' import { dirname, join, resolve } from 'node:path' import process from 'node:process' +import { setTimeout as delay } from 'node:timers/promises' import { parseArgs } from 'node:util' -import { UsageError } from './dashboard' +import { parseDashboardArgs, runDashboard, UsageError } from './dashboard' export const DEV_HELP = `Usage: mavonengine dev [options] -Run the dev script of the MavonEngine project you are in (game server and client). +Run the dev script of the MavonEngine project you are in (game server and client) +and show the dashboard for it. Quitting the dashboard stops the dev servers. Options: - --cwd Start looking for the project here (default: current directory) - -h, --help Show this help + --cwd Start looking for the project here (default: current directory) + --no-dashboard Only run the dev script, with its output in the terminal + --port Game server HTTP port for the dashboard (default: 8050) + --theme Dashboard theme, see 'mavonengine dashboard --help' + --transparent Let a transparent terminal background show through + -h, --help Show this help ` export interface DevOptions { cwd: string + /** false with --no-dashboard */ + dashboard: DashboardOptions | false } +/** Bytes of dev script output kept while the dashboard is up, printed if the script exits on its own */ +const MAX_CAPTURED_OUTPUT = 64 * 1024 + +/** How long the dev processes get to exit after SIGTERM before they are killed */ +const STOP_TIMEOUT_MS = 3000 + export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' const LOCKFILES: [string, PackageManager][] = [ @@ -34,21 +49,36 @@ const WHITESPACE_RE = /\s+/ /** * Turn CLI arguments into dev options. Throws UsageError on bad input. */ -export function parseDevArgs(args: string[], cwd = process.cwd()): DevOptions | 'help' { +export function parseDevArgs(args: string[], cwd = process.cwd(), env: Record = process.env): DevOptions | 'help' { const { values } = parseArgs({ args, options: { cwd: { type: 'string' }, + dashboard: { type: 'boolean', default: true }, + port: { type: 'string' }, + theme: { type: 'string' }, + transparent: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h', default: false }, }, strict: true, allowPositionals: false, + allowNegative: true, }) if (values.help) return 'help' - return { cwd: resolve(cwd, values.cwd ?? '.') } + let dashboard: DashboardOptions | false = false + if (values.dashboard) { + const dashboardArgs = [ + ...(values.port ? ['--port', values.port] : []), + ...(values.theme ? ['--theme', values.theme] : []), + ...(values.transparent ? ['--transparent'] : []), + ] + dashboard = parseDashboardArgs(dashboardArgs, env) as DashboardOptions + } + + return { cwd: resolve(cwd, values.cwd ?? '.'), dashboard } } function readPackageJson(dir: string): Record | undefined { @@ -107,59 +137,111 @@ export function descendants(root: number, psOutput: string): number[] { return found } +/** + * A process and everything it started, parents first. Empty on Windows, where taskkill walks the tree itself. + */ +function processTree(pid: number): number[] { + if (process.platform === 'win32') + return [] + + try { + return [pid, ...descendants(pid, execFileSync('ps', ['-A', '-o', 'pid=,ppid='], { encoding: 'utf8' }))] + } + catch { + return [pid] + } +} + +function signalAll(pids: number[], signal: NodeJS.Signals) { + for (const p of pids) { + try { + process.kill(p, signal) + } + catch { + // Already gone + } + } +} + +function isAlive(pid: number) { + try { + process.kill(pid, 0) + return true + } + catch { + return false + } +} + /** * Signal a process and everything it started. npm exits on SIGTERM without passing it on, * which would leave the game server and vite running. */ function killTree(pid: number, signal: NodeJS.Signals) { - try { - if (process.platform === 'win32') { + if (process.platform === 'win32') { + try { execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }) - return } - - const tree = [pid, ...descendants(pid, execFileSync('ps', ['-A', '-o', 'pid=,ppid='], { encoding: 'utf8' }))] - for (const p of tree) { - try { - process.kill(p, signal) - } - catch { - // Already gone - } + catch { + // Already gone } + return } - catch { - process.kill(pid, signal) - } + + signalAll(processTree(pid), signal) +} + +export interface DevProcess { + /** Resolves with the dev script's exit code */ + exited: Promise + /** SIGTERM the whole tree, SIGKILL whatever is left after STOP_TIMEOUT_MS, and wait for the script to exit */ + stop(): Promise + /** The captured output, when started with `capture` */ + output(): string } /** - * Run the project's dev script and resolve with its exit code. + * Start the project's dev script. With `capture` its output is kept in memory instead of + * written to the terminal, which belongs to the dashboard then. */ -export function runDev(root: string): Promise { +export function startDev(root: string, { capture = false } = {}): DevProcess { const pm = detectPackageManager(root) process.stdout.write(`mavonengine: running '${pm} run dev' in ${root}\n`) const child = spawn(pm, ['run', 'dev'], { cwd: root, - stdio: 'inherit', + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', // npm, pnpm and yarn are .cmd shims on Windows shell: process.platform === 'win32', }) + let output = '' + const keep = (chunk: string) => { + output = (output + chunk).slice(-MAX_CAPTURED_OUTPUT) + } + child.stdout?.setEncoding('utf8').on('data', keep) + child.stderr?.setEncoding('utf8').on('data', keep) + + let running = true + // Ctrl-C reaches the child through the terminal's process group, so only keep ourselves alive until it exits. // Signals sent to us alone (kill, closing the terminal) are passed on. const onInterrupt = () => {} const forward = (signal: NodeJS.Signals) => child.pid && killTree(child.pid, signal) + // Last resort when we exit before the dev script, e.g. the dashboard's own signal handlers calling process.exit + const onExit = () => running && child.pid && killTree(child.pid, 'SIGTERM') process.on('SIGINT', onInterrupt) process.on('SIGTERM', forward) process.on('SIGHUP', forward) + process.on('exit', onExit) - return new Promise((resolvePromise, reject) => { + const exited = new Promise((resolvePromise, reject) => { const cleanup = () => { + running = false process.off('SIGINT', onInterrupt) process.off('SIGTERM', forward) process.off('SIGHUP', forward) + process.off('exit', onExit) } child.on('error', (err: NodeJS.ErrnoException) => { @@ -172,6 +254,67 @@ export function runDev(root: string): Promise { resolvePromise(code ?? 128 + (signal ? constants.signals[signal] : 0)) }) }) + + const stop = async () => { + if (running && child.pid) { + if (process.platform === 'win32') { + killTree(child.pid, 'SIGTERM') + } + else { + // Remembered up front: once npm exits, its children are reparented and can't be found from it anymore + const tree = processTree(child.pid) + signalAll(tree, 'SIGTERM') + + const deadline = Date.now() + STOP_TIMEOUT_MS + while (tree.some(isAlive) && Date.now() < deadline) + await delay(50) + + signalAll(tree.filter(isAlive), 'SIGKILL') + } + } + + return exited + } + + return { exited, stop, output: () => output } +} + +/** + * Run the project's dev script and resolve with its exit code. + */ +export function runDev(root: string): Promise { + return startDev(root).exited +} + +/** + * Run the dev script behind the dashboard. Quitting the dashboard stops the dev script; + * the dev script exiting on its own closes the dashboard and prints what it wrote. + */ +export async function runDevWithDashboard(root: string, dashboard: DashboardOptions): Promise { + const dev = startDev(root, { capture: true }) + const startup = { engineVersion: readPackageJson(join(root, 'node_modules', '@mavonengine', 'core'))?.version, output: dev.output } + + const devExited = new AbortController() + dev.exited.then(() => devExited.abort(), () => devExited.abort()) + + let quit = false + try { + await runDashboard({ ...dashboard, startup }, devExited.signal) + } + finally { + quit = !devExited.signal.aborted + if (quit) + await dev.stop().catch(() => {}) + } + + // The user quit, so stopping the dev servers is the expected outcome + if (quit) + return 0 + + // Rejects when the package manager could not be started + const exitCode = await dev.exited + process.stderr.write(`${dev.output()}\nmavonengine: the dev script exited with code ${exitCode}\n`) + return exitCode } export async function runDevCommand(options: DevOptions): Promise { @@ -179,5 +322,9 @@ export async function runDevCommand(options: DevOptions): Promise { if (!root) throw new UsageError(`no MavonEngine project found in ${options.cwd} or its parents (looking for a package.json with @mavonengine/core and a dev script)`) + // The dashboard needs an interactive terminal; without one (CI, piped output) just run the dev script + if (options.dashboard && process.stdout.isTTY && process.stdin.isTTY) + return runDevWithDashboard(root, options.dashboard) + return runDev(root) } diff --git a/packages/cli/src/dashboard/MetricsClient.ts b/packages/cli/src/dashboard/MetricsClient.ts index baa892f..dec3b09 100644 --- a/packages/cli/src/dashboard/MetricsClient.ts +++ b/packages/cli/src/dashboard/MetricsClient.ts @@ -88,6 +88,8 @@ export interface MetricsClientOptions { intervalMs?: number /** Longest wait between retries while the server is failing */ maxBackoffMs?: number + /** Poll this often, without backoff, until the first snapshot arrives. For a server that is still starting */ + startupIntervalMs?: number } /** @@ -98,13 +100,15 @@ export default class MetricsClient { readonly state: DashboardState private intervalMs: number private maxBackoffMs: number + private startupIntervalMs?: number private timer: ReturnType | null = null private running = false private listeners = new Set<(state: DashboardState) => void>() - constructor(private source: MetricsSource, { intervalMs = 1000, maxBackoffMs = 10000 }: MetricsClientOptions = {}) { + constructor(private source: MetricsSource, { intervalMs = 1000, maxBackoffMs = 10000, startupIntervalMs }: MetricsClientOptions = {}) { this.intervalMs = intervalMs this.maxBackoffMs = maxBackoffMs + this.startupIntervalMs = startupIntervalMs this.state = { status: 'connecting', source: source.label, @@ -206,8 +210,12 @@ export default class MetricsClient { /** * Delay before the next poll: the normal interval, doubling per failure up to maxBackoffMs. + * The startup interval until the server first answered, if one is set. */ nextDelay() { + if (this.startupIntervalMs !== undefined && !this.state.snapshot) + return this.startupIntervalMs + if (this.state.failures === 0) return this.intervalMs diff --git a/packages/cli/src/dashboard/view.ts b/packages/cli/src/dashboard/view.ts index ca3cd80..7b90b87 100644 --- a/packages/cli/src/dashboard/view.ts +++ b/packages/cli/src/dashboard/view.ts @@ -1,6 +1,7 @@ import type { Color, Container, Span, Theme } from '@profullstack/hqtui' import type { ConnectionStatus, DashboardState } from './MetricsClient' import type { LogLine, ServerMetricsSnapshot } from './snapshot' +import { stripVTControlCharacters } from 'node:util' import { DEFAULT_COLOR } from '@profullstack/hqtui' import { formatBytes, formatDuration, formatMs, formatPercent, formatRate } from './format' import { SUPPORTED_SCHEMA_VERSION } from './snapshot' @@ -26,6 +27,15 @@ export interface RenderOptions { * Text is only as readable as whatever is behind the window. */ transparent?: boolean + /** Set while `mavonengine dev` waits for the game server it started */ + startup?: StartupInfo +} + +export interface StartupInfo { + /** The project's @mavonengine/core version, if it is installed */ + engineVersion?: string + /** What the dev script printed so far */ + output: string } type PlayerRow = ServerMetricsSnapshot['players'][number] @@ -96,19 +106,23 @@ function statusColor(status: ConnectionStatus, theme: Theme): Color { /** * Draw the whole dashboard. Pure apart from `view`, so it can be rendered in tests. */ -export function renderDashboard(ui: Container, theme: Theme, state: DashboardState, view: ViewState, { transparent = false }: RenderOptions = {}) { +export function renderDashboard(ui: Container, theme: Theme, state: DashboardState, view: ViewState, { transparent = false, startup }: RenderOptions = {}) { + const { snapshot } = state + const starting = !snapshot && !!startup && (state.status === 'connecting' || state.status === 'unreachable') + ui.column({}, (root) => { header(root, theme, state) - const { snapshot } = state - if (snapshot?.latencySimulation) latencyBanner(root, theme, snapshot.latencySimulation) if (state.error && snapshot) root.text([{ text: ` ${state.error} — showing last known values`, fg: theme.danger }], { size: 1 }) - if (!snapshot) { + if (starting) { + startupScreen(root, theme, startup!) + } + else if (!snapshot) { waiting(root, theme, state) } else { @@ -148,7 +162,9 @@ export function renderDashboard(ui: Container, theme: Theme, state: DashboardSta ? [{ key: '↑↓', label: 'Select player' }, ...(view.selectedPlayerId ? [{ key: 'esc', label: 'Close' }] : [])] : []), ], - right: [{ label: STATUS_LABELS[state.status], color: statusColor(state.status, theme), active: state.status === 'live' }], + right: [starting + ? { label: 'STARTING', color: statusColor('connecting', theme) } + : { label: STATUS_LABELS[state.status], color: statusColor(state.status, theme), active: state.status === 'live' }], }) }) } @@ -183,6 +199,56 @@ function latencyBanner(ui: Container, theme: Theme, latency: NonNullable line.trim() !== '') + + ui.column({ size: '1fr' }, (column) => { + column.spacer('1fr') + for (const line of startupBanner(engineVersion)) + column.text([{ text: line, fg: theme.primary }], { align: 'center', size: 1 }) + column.spacer(1) + column.row({ size: 1 }, (row) => { + row.spacer('1fr') + // Glyph, space, label + row.spinner({ label, size: label.length + 2 }) + row.spacer('1fr') + }) + column.spacer('1fr') + }) + + ui.panel({ title: 'Dev script', size: STARTUP_OUTPUT_LINES + 2 }, (panel) => { + if (lines.length === 0) { + panel.label('No output yet') + return + } + + panel.log({ entries: lines.slice(-STARTUP_OUTPUT_LINES).map(message => ({ message })), size: '1fr' }) + }) +} + function waiting(ui: Container, theme: Theme, state: DashboardState) { ui.panel({ title: 'Game server', size: '1fr' }, (panel) => { if (state.status === 'connecting') { diff --git a/packages/cli/tests/MetricsClient.test.ts b/packages/cli/tests/MetricsClient.test.ts index 5ddd16b..86241f2 100644 --- a/packages/cli/tests/MetricsClient.test.ts +++ b/packages/cli/tests/MetricsClient.test.ts @@ -102,6 +102,30 @@ describe('metricsClient', () => { expect(client.nextDelay()).toBe(5000) }) + it('polls quickly without backoff until a starting server first answers', async () => { + let up = false + const client = new MetricsClient( + { label: 'test', fetch: async () => { + if (!up) + throw new Error('nope') + return {} + } }, + { intervalMs: 1000, startupIntervalMs: 250 }, + ) + + await client.poll() + await client.poll() + expect(client.nextDelay()).toBe(250) + + up = true + await client.poll() + expect(client.nextDelay()).toBe(1000) + + up = false + await client.poll() + expect(client.nextDelay()).toBe(2000) + }) + it('polls on an interval until stopped', async () => { vi.useFakeTimers() const fetch = vi.fn(async () => ({})) diff --git a/packages/cli/tests/dev.test.ts b/packages/cli/tests/dev.test.ts index cfc5148..47bc346 100644 --- a/packages/cli/tests/dev.test.ts +++ b/packages/cli/tests/dev.test.ts @@ -1,8 +1,9 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { descendants, detectPackageManager, findProjectRoot, parseDevArgs, runDevCommand } from '../src/commands/dev' +import process from 'node:process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { descendants, detectPackageManager, findProjectRoot, parseDevArgs, runDevCommand, startDev } from '../src/commands/dev' let dir: string @@ -100,11 +101,33 @@ describe('descendants', () => { describe('parseDevArgs', () => { it('defaults to the current directory', () => { - expect(parseDevArgs([], '/work')).toEqual({ cwd: '/work' }) + expect(parseDevArgs([], '/work')).toMatchObject({ cwd: '/work' }) }) it('resolves --cwd against the current directory', () => { - expect(parseDevArgs(['--cwd', 'game'], '/work')).toEqual({ cwd: '/work/game' }) + expect(parseDevArgs(['--cwd', 'game'], '/work')).toMatchObject({ cwd: '/work/game' }) + }) + + it('shows the dashboard for the local server by default', () => { + const options = parseDevArgs([], '/work', {}) + + expect(options).toMatchObject({ dashboard: { intervalMs: 1000, transparent: false } }) + expect((options as any).dashboard.source.label).toBe('http://localhost:8050/api/game/metrics') + }) + + it('passes dashboard options on', () => { + const options = parseDevArgs(['--port', '9000', '--theme', 'nord', '--transparent'], '/work', {}) + + expect(options).toMatchObject({ dashboard: { theme: 'nord', transparent: true } }) + expect((options as any).dashboard.source.label).toBe('http://localhost:9000/api/game/metrics') + }) + + it('turns the dashboard off with --no-dashboard', () => { + expect(parseDevArgs(['--no-dashboard'], '/work')).toEqual({ cwd: '/work', dashboard: false }) + }) + + it('rejects a bad dashboard port', () => { + expect(() => parseDevArgs(['--port', 'x'])).toThrow(/--port/) }) it('returns help', () => { @@ -118,12 +141,42 @@ describe('parseDevArgs', () => { describe('runDevCommand', () => { it('fails with a usage error outside a project', async () => { - await expect(runDevCommand({ cwd: dir })).rejects.toThrow(/no MavonEngine project found/) + await expect(runDevCommand({ cwd: dir, dashboard: false })).rejects.toThrow(/no MavonEngine project found/) }) it('runs the dev script and returns its exit code', async () => { writePackage(dir, { ...project, scripts: { dev: 'node -e "process.exit(3)"' } }) - expect(await runDevCommand({ cwd: dir })).toBe(3) + expect(await runDevCommand({ cwd: dir, dashboard: false })).toBe(3) }) }) + +describe.skipIf(process.platform === 'win32')('startDev', () => { + it('captures the output of the dev script', async () => { + writePackage(dir, { ...project, scripts: { dev: 'node -e "console.log(\'hello from dev\')"' } }) + + const dev = startDev(dir, { capture: true }) + + expect(await dev.exited).toBe(0) + expect(dev.output()).toContain('hello from dev') + }) + + it('stops the dev script and everything it started', async () => { + // A grandchild that ignores SIGTERM, like a server that hangs on shutdown + writeFileSync(join(dir, 'server.cjs'), ` + process.on('SIGTERM', () => {}) + require('node:fs').writeFileSync('server.pid', String(process.pid)) + setInterval(() => {}, 1000) + `) + writePackage(dir, { ...project, scripts: { dev: 'node server.cjs' } }) + + const dev = startDev(dir, { capture: true }) + const pidFile = join(dir, 'server.pid') + await vi.waitFor(() => expect(existsSync(pidFile)).toBe(true), { timeout: 5000 }) + const serverPid = Number(readFileSync(pidFile, 'utf8')) + + await dev.stop() + + expect(() => process.kill(serverPid, 0)).toThrow() + }, 15000) +}) diff --git a/packages/cli/tests/view.test.ts b/packages/cli/tests/view.test.ts index 1d33905..a850e5f 100644 --- a/packages/cli/tests/view.test.ts +++ b/packages/cli/tests/view.test.ts @@ -1,5 +1,5 @@ import type { DashboardState } from '../src/dashboard/MetricsClient' -import type { ViewState } from '../src/dashboard/view' +import type { StartupInfo, ViewState } from '../src/dashboard/view' import { DEFAULT_COLOR, renderToScreen } from '@profullstack/hqtui' import { describe, expect, it } from 'vitest' import { simulatedSource } from '../src/dashboard/simulator' @@ -31,8 +31,12 @@ function viewState(overrides: Partial = {}): ViewState { return { tab: 0, playerOffset: 0, logFromEnd: 0, selectedPlayerId: null, followSelectedPlayer: true, ...overrides } } -function render(state: DashboardState, width = 130, height = 40, transparent = false, tab = 0, view = viewState({ tab })) { - return renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, view, { transparent }), { width, height }) +function render(state: DashboardState, width = 130, height = 40, transparent = false, tab = 0, view = viewState({ tab }), startup?: StartupInfo) { + return renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, view, { transparent, startup }), { width, height }) +} + +function startingState(status: DashboardState['status']): DashboardState { + return { status, source: 'http://localhost:8050/api/game/metrics', snapshot: null, lastUpdate: null, error: 'Can\'t reach', failures: 3, logs: [], logsAvailable: null } } describe('dashboard view', () => { @@ -153,6 +157,29 @@ describe('dashboard view', () => { expect(screen.contains('attempt 2')).toBe(true) }) + it('shows the startup screen while the dev server starts', () => { + const screen = render(startingState('unreachable'), 130, 40, false, 0, viewState(), { + engineVersion: '0.0.20', + output: '\u001B[32mbuilding\u001B[39m\n\nESM Build success\n', + }) + + expect(screen.contains('MavonEngine')).toBe(true) + expect(screen.contains('Version: 0.0.20')).toBe(true) + expect(screen.contains('Starting the game server')).toBe(true) + expect(screen.contains('ESM Build success')).toBe(true) + expect(screen.contains('STARTING')).toBe(true) + expect(screen.contains('UNREACHABLE')).toBe(false) + }) + + it('leaves the startup screen for a server that answers with an error', () => { + const state = startingState('unauthorized') + state.error = 'Metrics token missing or wrong (use --token)' + const screen = render(state, 130, 40, false, 0, viewState(), { output: '' }) + + expect(screen.contains('Starting the game server')).toBe(false) + expect(screen.contains('use --token')).toBe(true) + }) + it('keeps the last values and shows the error when the server drops', async () => { const state = await liveState() state.status = 'unreachable' From 60682541d5115ede7b41254edb1c23404e22e69f Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 23:31:47 +0100 Subject: [PATCH 15/25] share the boot banner between the game server and the cli - core: the banner BaseGame logs moves to `Utils/BootBanner`, which imports nothing so it can be used without loading the engine. - cli: the `mavonengine dev` startup screen uses it instead of a copy. --- packages/cli/src/dashboard/view.ts | 23 ++------------- packages/core/src/BaseGame.ts | 22 +++++--------- packages/core/src/Utils/BootBanner.ts | 31 ++++++++++++++++++++ packages/core/tests/Utils/BootBanner.test.ts | 20 +++++++++++++ 4 files changed, 62 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/Utils/BootBanner.ts create mode 100644 packages/core/tests/Utils/BootBanner.test.ts diff --git a/packages/cli/src/dashboard/view.ts b/packages/cli/src/dashboard/view.ts index 7b90b87..b3a2e82 100644 --- a/packages/cli/src/dashboard/view.ts +++ b/packages/cli/src/dashboard/view.ts @@ -2,6 +2,7 @@ import type { Color, Container, Span, Theme } from '@profullstack/hqtui' import type { ConnectionStatus, DashboardState } from './MetricsClient' import type { LogLine, ServerMetricsSnapshot } from './snapshot' import { stripVTControlCharacters } from 'node:util' +import { bootBanner } from '@mavonengine/core/Utils/BootBanner' import { DEFAULT_COLOR } from '@profullstack/hqtui' import { formatBytes, formatDuration, formatMs, formatPercent, formatRate } from './format' import { SUPPORTED_SCHEMA_VERSION } from './snapshot' @@ -199,25 +200,6 @@ function latencyBanner(ui: Container, theme: Theme, latency: NonNullable { column.spacer('1fr') - for (const line of startupBanner(engineVersion)) + // The game server logs the same banner once it runs + for (const line of bootBanner({ version: engineVersion ?? 'unknown', mode: 'development' })) column.text([{ text: line, fg: theme.primary }], { align: 'center', size: 1 }) column.spacer(1) column.row({ size: 1 }, (row) => { diff --git a/packages/core/src/BaseGame.ts b/packages/core/src/BaseGame.ts index 72b9166..25ac0b4 100644 --- a/packages/core/src/BaseGame.ts +++ b/packages/core/src/BaseGame.ts @@ -4,6 +4,7 @@ import type Logger from './Utils/Logger' import type GameObjectInterface from './World/GameObjectInterface' import { version as RAPIER_VERSION } from '@dimforge/rapier3d-compat' import { Clock, Raycaster, Scene, REVISION as THREE_REVISION } from 'three' +import { bootBanner } from './Utils/BootBanner' import EventEmitter from './Utils/EventEmitter' import { version as ENGINE_VERSION } from './version' @@ -46,20 +47,13 @@ export default class BaseGame extends EventEmitter implements GameObjectInterfac this.logger?.info([ '', '', - '┌─────────────────────────────────────────┐', - '│ │', - '│ MavonEngine │', - '│ │', - `│ Version: ${ENGINE_VERSION.padEnd(28)} │`, - `│ Three.js: r${THREE_REVISION.padEnd(27)} │`, - ...(physicsWorld ? [`│ Rapier: ${RAPIER_VERSION().padEnd(28)} │`] : []), - // eslint-disable-next-line node/prefer-global/process - `│ Mode: ${(process.env.NODE_ENV ?? 'development').padEnd(28)} │`, - '│ │', - '│ https://github.com/MavonEngine/core │', - '│ https://mavonengine.com │', - '│ │', - '└─────────────────────────────────────────┘', + ...bootBanner({ + version: ENGINE_VERSION, + threeRevision: THREE_REVISION, + rapierVersion: physicsWorld ? RAPIER_VERSION() : undefined, + // eslint-disable-next-line node/prefer-global/process + mode: process.env.NODE_ENV ?? 'development', + }), '', ].join('\n')) diff --git a/packages/core/src/Utils/BootBanner.ts b/packages/core/src/Utils/BootBanner.ts new file mode 100644 index 0000000..d7d14b6 --- /dev/null +++ b/packages/core/src/Utils/BootBanner.ts @@ -0,0 +1,31 @@ +export interface BootBannerInfo { + version: string + /** Three.js revision, e.g. '180' */ + threeRevision?: string + rapierVersion?: string + mode: string +} + +/** + * The box logged when a game starts. Kept free of imports so tools like @mavonengine/cli can show it + * without loading the engine. + */ +export function bootBanner({ version, threeRevision, rapierVersion, mode }: BootBannerInfo): string[] { + const row = (label: string, value: string) => `│ ${`${label}:`.padEnd(10)}${value.padEnd(28)} │` + + return [ + '┌─────────────────────────────────────────┐', + '│ │', + '│ MavonEngine │', + '│ │', + row('Version', version), + ...(threeRevision ? [row('Three.js', `r${threeRevision}`)] : []), + ...(rapierVersion ? [row('Rapier', rapierVersion)] : []), + row('Mode', mode), + '│ │', + '│ https://github.com/MavonEngine/core │', + '│ https://mavonengine.com │', + '│ │', + '└─────────────────────────────────────────┘', + ] +} diff --git a/packages/core/tests/Utils/BootBanner.test.ts b/packages/core/tests/Utils/BootBanner.test.ts new file mode 100644 index 0000000..bb52325 --- /dev/null +++ b/packages/core/tests/Utils/BootBanner.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { bootBanner } from '../../src/Utils/BootBanner' + +describe('bootBanner', () => { + it('lists the versions in a closed box', () => { + const lines = bootBanner({ version: '0.0.20', threeRevision: '180', rapierVersion: '0.19.0', mode: 'production' }) + + expect(lines).toContain('│ Version: 0.0.20 │') + expect(lines).toContain('│ Three.js: r180 │') + expect(lines).toContain('│ Rapier: 0.19.0 │') + expect(lines).toContain('│ Mode: production │') + expect(new Set(lines.map(line => line.length))).toEqual(new Set([lines[0].length])) + }) + + it('leaves out the versions it was not given', () => { + const lines = bootBanner({ version: '0.0.20', mode: 'development' }) + + expect(lines.some(line => line.includes('Three.js') || line.includes('Rapier'))).toBe(false) + }) +}) From 88e597efc7096154baa4ca7404b98eba297a185b Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 23:41:47 +0100 Subject: [PATCH 16/25] dashboard: add an HTTP tab with the requests the game server received - core: `RequestLog` records requests on the Node HTTP server itself, so static files, Vite, `onHttp` routes, geckos.io signaling and WebSocket upgrades all show up, with status, duration, size and client address. Query strings are left out since they can hold tokens, and the dashboard's own polling is skipped. Served on GET /api/game/requests, protected like metrics; `metrics.requestLogSize` (default 500, 0 off). - cli: an HTTP tab lists the requests newest first, coloured by status. A scrolled table stays on the same request as new ones arrive. Logs and requests are followed by the same `Feed` in the metrics client, and `--sim` generates requests too. --- packages/cli/README.md | 8 +- packages/cli/src/commands/dashboard.ts | 2 +- packages/cli/src/dashboard/MetricsClient.ts | 114 +++++++++----- packages/cli/src/dashboard/simulator.ts | 23 ++- packages/cli/src/dashboard/snapshot.ts | 24 +++ packages/cli/src/dashboard/view.ts | 70 ++++++++- packages/cli/tests/MetricsClient.test.ts | 46 ++++++ packages/cli/tests/view.test.ts | 59 +++++++- .../src/Networking/Server/Http/HttpServer.ts | 24 ++- packages/core/src/Networking/Server/Server.ts | 10 ++ .../src/Networking/Server/Stats/RequestLog.ts | 139 ++++++++++++++++++ packages/core/tests/Networking/Server.test.ts | 5 + .../Networking/Server/Http/HttpServer.test.ts | 21 +++ .../Server/Stats/RequestLog.test.ts | 105 +++++++++++++ 14 files changed, 602 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/Networking/Server/Stats/RequestLog.ts create mode 100644 packages/core/tests/Networking/Server/Stats/RequestLog.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index e1d4889..265790b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -36,7 +36,7 @@ The project root is the nearest `package.json`, from the current directory upwar A live terminal dashboard for a running game server, built with [hqtui](https://github.com/profullstack/hqtui). -It shows CPU, network bandwidth, tick timings against the tick budget, world/physics/memory stats, the connected players and the server's recent log output. It reads them from the server's `GET /api/game/metrics` and `GET /api/game/logs` endpoints, which need a `@mavonengine/core` release that includes them. +It shows CPU, network bandwidth, tick timings against the tick budget, world/physics/memory stats, the connected players, the server's recent log output and the HTTP requests it received. It reads them from the server's `GET /api/game/metrics`, `GET /api/game/logs` and `GET /api/game/requests` endpoints, which need a `@mavonengine/core` release that includes them. ```sh # Local server on the default HTTP port (8050) @@ -60,7 +60,9 @@ npx mavonengine dashboard --sim | `--transparent` | | Let a transparent terminal background show through. Text can be hard to read over a light wallpaper | | `--sim` | | Simulated data instead of a server | -Keys: `q` quits, `r` refreshes now. The mouse wheel scrolls the players table and the logs; scroll the logs back to the bottom to follow new lines again. +Keys: `q` quits, `r` refreshes now, `1`–`4` switch between the Overview, Players, Logs and HTTP tabs. The mouse wheel scrolls the players table, the logs and the requests; scroll the logs back to the bottom, or the requests back to the top, to follow new ones again. + +The HTTP tab lists every request the game server's HTTP server received, newest first: the client (static files or Vite in dev), your own `onHttp` routes, the geckos.io signaling requests and WebSocket upgrades such as Vite's HMR socket, each with status, duration, size and client address. Query strings are left out since they can hold tokens, and the dashboard's own polling is not recorded. The game traffic itself is WebRTC, not HTTP, so it shows on the Players tab instead; with a separate `socketPort` the signaling requests go to that port and don't show up either. ### Server access @@ -73,6 +75,8 @@ const server = new MyServer(logger, physicsWorld, { token: process.env.METRICS_TOKEN, // Recent log entries kept for the dashboard, 0 turns the logs endpoint off logBufferSize: 500, + // Recent HTTP requests kept for the dashboard, 0 turns the requests endpoint off + requestLogSize: 500, }, }) ``` diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 2067239..e43b0bf 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -105,7 +105,7 @@ export async function runDashboard(options: DashboardOptions, signal?: AbortSign intervalMs: options.intervalMs, startupIntervalMs: options.startup ? STARTUP_POLL_MS : undefined, }) - const view: ViewState = { tab: 0, playerOffset: 0, logFromEnd: 0, selectedPlayerId: null, followSelectedPlayer: true } + const view: ViewState = { tab: 0, playerOffset: 0, logFromEnd: 0, requestTopId: null, selectedPlayerId: null, followSelectedPlayer: true } const app = await createApp({ fps: 15, theme: readableTheme(options.theme), paintBackground: !options.transparent }) diff --git a/packages/cli/src/dashboard/MetricsClient.ts b/packages/cli/src/dashboard/MetricsClient.ts index dec3b09..6762e39 100644 --- a/packages/cli/src/dashboard/MetricsClient.ts +++ b/packages/cli/src/dashboard/MetricsClient.ts @@ -1,5 +1,5 @@ -import type { LogLine, ServerMetricsSnapshot } from './snapshot' -import { normalizeLogs, normalizeSnapshot } from './snapshot' +import type { LogLine, RequestLine, ServerMetricsSnapshot } from './snapshot' +import { normalizeLogs, normalizeRequests, normalizeSnapshot } from './snapshot' export type ConnectionStatus = 'connecting' | 'live' | 'unreachable' | 'unauthorized' | 'forbidden' | 'disabled' @@ -17,11 +17,18 @@ export interface DashboardState { logs: LogLine[] /** false once the server turned out not to serve logs, null until known */ logsAvailable: boolean | null + /** Recent HTTP requests to the server, oldest first */ + requests: RequestLine[] + /** false once the server turned out not to record requests, null until known */ + requestsAvailable: boolean | null } /** Log lines kept in memory by the dashboard */ export const MAX_LOG_LINES = 1000 +/** HTTP requests kept in memory by the dashboard */ +export const MAX_REQUEST_LINES = 1000 + export class MetricsSourceError extends Error { constructor(message: string, readonly status: ConnectionStatus) { super(message) @@ -30,12 +37,13 @@ export class MetricsSourceError extends Error { /** * Something that produces one raw metrics snapshot per call, - * and optionally the log entries newer than a given id. + * and optionally the log entries / HTTP requests newer than a given id. */ export interface MetricsSource { label: string fetch(): Promise fetchLogs?(afterId: number): Promise + fetchRequests?(afterId: number): Promise } export interface HttpSourceOptions { @@ -48,6 +56,7 @@ export interface HttpSourceOptions { export function httpSource({ url, token, timeoutMs = 3000, fetch = globalThis.fetch }: HttpSourceOptions): MetricsSource { // Logs live next to metrics: .../api/game/metrics -> .../api/game/logs const logsUrl = new URL('logs', url) + const requestsUrl = new URL('requests', url) async function request(target: string) { let res: Response @@ -81,6 +90,58 @@ export function httpSource({ url, token, timeoutMs = 3000, fetch = globalThis.fe logsUrl.searchParams.set('after', String(afterId)) return request(logsUrl.toString()) }, + fetchRequests: (afterId) => { + requestsUrl.searchParams.set('after', String(afterId)) + return request(requestsUrl.toString()) + }, + } +} + +/** + * Follows an endpoint whose entries have increasing ids (logs, HTTP requests), + * keeping the newest `max` of them. + */ +class Feed { + entries: T[] = [] + /** false once the server turned out not to serve it, null until known */ + available: boolean | null + private lastId = 0 + + constructor( + private fetchPage: ((afterId: number) => Promise) | undefined, + private normalize: (raw: unknown) => { entries: T[], latestId: number }, + private max: number, + ) { + this.available = fetchPage ? null : false + } + + async poll() { + if (!this.fetchPage || this.available === false) + return + + try { + let { entries, latestId } = this.normalize(await this.fetchPage(this.lastId)) + + // Ids went backwards, so the server restarted: start over from its first entry + if (latestId < this.lastId) { + this.entries = [] + this.lastId = 0; + ({ entries, latestId } = this.normalize(await this.fetchPage(0))) + } + + this.available = true + this.entries.push(...entries.filter(entry => entry.id > this.lastId)) + this.lastId = Math.max(this.lastId, latestId) + + if (this.entries.length > this.max) + this.entries.splice(0, this.entries.length - this.max) + } + catch (err) { + // Metrics work but this doesn't: an older server, or it is turned off in the server options + if (err instanceof MetricsSourceError && err.status === 'disabled') + this.available = false + // Anything else is transient, the next poll tries again + } } } @@ -104,11 +165,15 @@ export default class MetricsClient { private timer: ReturnType | null = null private running = false private listeners = new Set<(state: DashboardState) => void>() + private logs: Feed + private requests: Feed constructor(private source: MetricsSource, { intervalMs = 1000, maxBackoffMs = 10000, startupIntervalMs }: MetricsClientOptions = {}) { this.intervalMs = intervalMs this.maxBackoffMs = maxBackoffMs this.startupIntervalMs = startupIntervalMs + this.logs = new Feed(source.fetchLogs?.bind(source), normalizeLogs, MAX_LOG_LINES) + this.requests = new Feed(source.fetchRequests?.bind(source), normalizeRequests, MAX_REQUEST_LINES) this.state = { status: 'connecting', source: source.label, @@ -117,7 +182,9 @@ export default class MetricsClient { error: null, failures: 0, logs: [], - logsAvailable: source.fetchLogs ? null : false, + logsAvailable: this.logs.available, + requests: [], + requestsAvailable: this.requests.available, } } @@ -154,7 +221,7 @@ export default class MetricsClient { } /** - * Fetch one snapshot, then any new log lines, and update the state. + * Fetch one snapshot, then any new log lines and requests, and update the state. */ async poll() { try { @@ -166,7 +233,11 @@ export default class MetricsClient { this.state.error = null this.state.failures = 0 - await this.pollLogs() + await Promise.all([this.logs.poll(), this.requests.poll()]) + this.state.logs = this.logs.entries + this.state.logsAvailable = this.logs.available + this.state.requests = this.requests.entries + this.state.requestsAvailable = this.requests.available } catch (err) { this.state.status = err instanceof MetricsSourceError ? err.status : 'unreachable' @@ -177,37 +248,6 @@ export default class MetricsClient { this.listeners.forEach(listener => listener(this.state)) } - private lastLogId = 0 - - private async pollLogs() { - if (!this.source.fetchLogs || this.state.logsAvailable === false) - return - - try { - let { entries, latestId } = normalizeLogs(await this.source.fetchLogs(this.lastLogId)) - - // Ids went backwards, so the server restarted: start over from its first entry - if (latestId < this.lastLogId) { - this.state.logs = [] - this.lastLogId = 0; - ({ entries, latestId } = normalizeLogs(await this.source.fetchLogs(0))) - } - - this.state.logsAvailable = true - this.state.logs.push(...entries.filter(entry => entry.id > this.lastLogId)) - this.lastLogId = Math.max(this.lastLogId, latestId) - - if (this.state.logs.length > MAX_LOG_LINES) - this.state.logs.splice(0, this.state.logs.length - MAX_LOG_LINES) - } - catch (err) { - // Metrics work but logs don't: an older server, or logBufferSize 0 - if (err instanceof MetricsSourceError && err.status === 'disabled') - this.state.logsAvailable = false - // Anything else is transient, the next poll tries again - } - } - /** * Delay before the next poll: the normal interval, doubling per failure up to maxBackoffMs. * The startup interval until the server first answered, if one is set. diff --git a/packages/cli/src/dashboard/simulator.ts b/packages/cli/src/dashboard/simulator.ts index 863855a..6245896 100644 --- a/packages/cli/src/dashboard/simulator.ts +++ b/packages/cli/src/dashboard/simulator.ts @@ -1,5 +1,5 @@ import type { MetricsSource } from './MetricsClient' -import type { LogLine, ServerMetricsSnapshot } from './snapshot' +import type { LogLine, RequestLine, ServerMetricsSnapshot } from './snapshot' const HISTORY_LENGTH = 60 @@ -45,6 +45,11 @@ export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSourc const log = (level: string, message: string) => { logs.push({ id: logs.length + 1, timestamp: now, level, message }) } + const requests: RequestLine[] = [] + const request = (method: string, path: string, status: number | null, bytes: number, address: string) => { + requests.push({ id: requests.length + 1, timestamp: now, method, path, status, durationMs: Math.round((1 + random() * 20) * 100) / 100, bytes, address }) + } + log('info', 'Starting @mavon/engine - Socket Server') log('info', 'Starting @mavon/engine - HTTP Server') @@ -80,6 +85,14 @@ export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSourc })) for (let i = previousPlayers; i < playerCount; i++) { + // What a browser loading the client and joining asks for + const address = players[i].connection.address + request('GET', '/', 200, 1240, address) + const cached = step % 3 === 0 + request('GET', '/assets/index-4f2a9c1e.js', cached ? 304 : 200, cached ? 210 : 412_000, address) + request('GET', '/favicon.ico', 404, 160, address) + request('POST', '/.wrtc/v2/connections', 200, 2900, address) + request('POST', `/.wrtc/v2/connections/${players[i].id}/additional-candidates`, 200, 600, address) log('info', `Connected: ${players[i].id}`) log('info', `${i + 1} total players connected`) } @@ -89,6 +102,11 @@ export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSourc } previousPlayers = playerCount + if (step % 10 === 0) + request('GET', '/api/game/health', 200, 180, '198.51.100.7') + if (step % 23 === 0) + request('GET', '/wp-login.php', 404, 160, '192.0.2.66') + if (step % 5 === 0) log('debug', `Client - Sent: ${playerCount * 4200} B, Received: ${playerCount * 600} B`) @@ -163,5 +181,8 @@ export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSourc async fetchLogs(afterId) { return { entries: logs.filter(entry => entry.id > afterId), latestId: logs.length } }, + async fetchRequests(afterId) { + return { entries: requests.filter(entry => entry.id > afterId), latestId: requests.length } + }, } } diff --git a/packages/cli/src/dashboard/snapshot.ts b/packages/cli/src/dashboard/snapshot.ts index de40028..0bbfc57 100644 --- a/packages/cli/src/dashboard/snapshot.ts +++ b/packages/cli/src/dashboard/snapshot.ts @@ -1,8 +1,10 @@ import type { LogEntry } from '@mavonengine/core/Networking/Server/Stats/LogBuffer' +import type { RequestLogEntry } from '@mavonengine/core/Networking/Server/Stats/RequestLog' import type { ServerMetricsSnapshot } from '@mavonengine/core/Networking/Server/Stats/ServerMetrics' export type { ServerMetricsSnapshot } export type LogLine = LogEntry +export type RequestLine = RequestLogEntry /** * The newest metrics schema this CLI understands. @@ -149,3 +151,25 @@ export function normalizeLogs(raw: unknown): { entries: LogLine[], latestId: num latestId: num(r.latestId, entries.at(-1)?.id ?? 0), } } + +/** + * Same as normalizeSnapshot, for a GET /api/game/requests response. + */ +export function normalizeRequests(raw: unknown): { entries: RequestLine[], latestId: number } { + const r = section(raw) + const entries = arr(r.entries, e => ({ + id: num(e.id), + timestamp: num(e.timestamp), + method: str(e.method, '?'), + path: str(e.path), + status: numOrNull(e.status), + durationMs: num(e.durationMs), + bytes: num(e.bytes), + address: strOrNull(e.address), + })) + + return { + entries, + latestId: num(r.latestId, entries.at(-1)?.id ?? 0), + } +} diff --git a/packages/cli/src/dashboard/view.ts b/packages/cli/src/dashboard/view.ts index b3a2e82..74682e3 100644 --- a/packages/cli/src/dashboard/view.ts +++ b/packages/cli/src/dashboard/view.ts @@ -1,6 +1,6 @@ import type { Color, Container, Span, Theme } from '@profullstack/hqtui' import type { ConnectionStatus, DashboardState } from './MetricsClient' -import type { LogLine, ServerMetricsSnapshot } from './snapshot' +import type { LogLine, RequestLine, ServerMetricsSnapshot } from './snapshot' import { stripVTControlCharacters } from 'node:util' import { bootBanner } from '@mavonengine/core/Utils/BootBanner' import { DEFAULT_COLOR } from '@profullstack/hqtui' @@ -20,6 +20,8 @@ export interface ViewState { followSelectedPlayer: boolean /** Log lines scrolled back from the newest, 0 follows the tail */ logFromEnd: number + /** Request at the top of the HTTP table while scrolled, null follows the newest */ + requestTopId: number | null } export interface RenderOptions { @@ -41,7 +43,7 @@ export interface StartupInfo { type PlayerRow = ServerMetricsSnapshot['players'][number] -export const DASHBOARD_TABS = ['Overview', 'Players', 'Logs'] as const +export const DASHBOARD_TABS = ['Overview', 'Players', 'Logs', 'HTTP'] as const const WIDE_LAYOUT = 100 const NEWLINE_RE = /\r?\n/ @@ -144,6 +146,9 @@ export function renderDashboard(ui: Container, theme: Theme, state: DashboardSta else if (tab === 'Logs') { logsPanel(root, theme, state, view) } + else if (tab === 'HTTP') { + requestsPanel(root, theme, state, view, transparent) + } else { root.responsive({ [WIDE_LAYOUT]: c => wideLayout(c, theme, state, snapshot, view, transparent), @@ -514,3 +519,64 @@ function logsPanel(ui: Container, theme: Theme, state: DashboardState, view: Vie }) }) } + +function statusCodeColor(status: number | null, theme: Theme): Color | undefined { + if (status === null || status >= 500) + return theme.danger + if (status >= 400) + return theme.warning + if (status >= 300) + return theme.info + return theme.success +} + +function requestsPanel(ui: Container, theme: Theme, state: DashboardState, view: ViewState, transparent: boolean) { + // Newest first, so new requests appear at the top without scrolling + const rows = [...state.requests].reverse() + // Anchored to a request rather than a row number, so arriving requests don't move a scrolled table + const anchor = view.requestTopId === null ? 0 : rows.findIndex(r => r.id <= view.requestTopId!) + const offset = Math.max(0, anchor) + const errors = rows.filter(r => r.status === null || r.status >= 400).length + + const title = `HTTP requests (${rows.length}${errors ? `, ${errors} error${errors === 1 ? '' : 's'}` : ''})${offset > 0 ? ', scroll up to follow' : ''}` + + ui.panel({ title, size: '1fr' }, (panel) => { + if (state.requestsAvailable === false) { + panel.label('Not available: the server runs a @mavonengine/core release without GET /api/game/requests, or has metrics.requestLogSize set to 0') + return + } + + if (rows.length === 0) { + panel.label('No requests yet') + return + } + + panel.table({ + rows, + offset, + scrollbar: true, + // Zebra stripes are solid fills, which would break transparency + zebra: !transparent, + size: '1fr', + // The table won't scroll past the last full page, so keep the anchor on the row it actually drew + onRow: (_row, index, y) => { + if (y === 1) + view.requestTopId = index === 0 ? null : rows[index].id + }, + onScroll: (delta) => { + const next = Math.max(0, Math.min(rows.length - 1, offset + delta)) + view.requestTopId = next === 0 ? null : rows[next].id + ui.ctx.invalidate() + }, + columns: [ + { key: 'time', title: 'Time', width: 8, color: theme.muted, render: r => formatTime(r.timestamp) }, + { key: 'method', title: 'Method', width: 7, color: r => r.method === 'WS' ? theme.primary : undefined }, + { key: 'status', title: 'Status', width: 6, align: 'right', color: r => statusCodeColor(r.status, theme), render: r => r.status === null ? '—' : String(r.status) }, + { key: 'durationMs', title: 'Duration', width: 10, align: 'right', render: r => formatMs(r.durationMs) }, + { key: 'bytes', title: 'Size', width: 10, align: 'right', render: r => formatBytes(r.bytes) }, + { key: 'address', title: 'Client', width: 18, color: theme.muted, render: r => r.address ?? '—' }, + { key: 'path', title: 'Path' }, + ], + }) + }) +} diff --git a/packages/cli/tests/MetricsClient.test.ts b/packages/cli/tests/MetricsClient.test.ts index 86241f2..d88c1b2 100644 --- a/packages/cli/tests/MetricsClient.test.ts +++ b/packages/cli/tests/MetricsClient.test.ts @@ -247,6 +247,52 @@ describe('metricsClient', () => { expect(client.state.logs[0].id).toBe(11) }) + it('follows requests the same way as logs', async () => { + const request = (id: number) => ({ id, timestamp: 0, method: 'GET', path: `/${id}`, status: 200, durationMs: 1, bytes: 1, address: null }) + const pages = [{ entries: [request(1), request(2)], latestId: 2 }, { entries: [request(3)], latestId: 3 }] + const calls: number[] = [] + const client = new MetricsClient({ + label: 'test', + fetch: async () => ({}), + fetchRequests: async (afterId: number) => { + calls.push(afterId) + return pages.shift()! + }, + }) + + expect(client.state.logsAvailable).toBe(false) + await client.poll() + await client.poll() + + expect(calls).toEqual([0, 2]) + expect(client.state.requests.map(r => r.path)).toEqual(['/1', '/2', '/3']) + expect(client.state.requestsAvailable).toBe(true) + }) + + it('marks requests unavailable on an older server without failing the poll', async () => { + const client = new MetricsClient({ + label: 'test', + fetch: async () => ({}), + fetchRequests: async () => { + throw new MetricsSourceError('not found', 'disabled') + }, + }) + + await client.poll() + + expect(client.state.requestsAvailable).toBe(false) + expect(client.state.status).toBe('live') + }) + + it('derives the requests url from the metrics url', async () => { + const fetch = vi.fn(async () => response(200, { entries: [], latestId: 0 })) + const source = httpSource({ url: 'http://localhost:8050/api/game/metrics', fetch }) + + await source.fetchRequests!(7) + + expect(fetch).toHaveBeenCalledWith('http://localhost:8050/api/game/requests?after=7', expect.anything()) + }) + it('derives the logs url from the metrics url', async () => { const fetch = vi.fn(async () => response(200, { entries: [], latestId: 0 })) const source = httpSource({ url: 'http://localhost:8050/api/game/metrics', fetch }) diff --git a/packages/cli/tests/view.test.ts b/packages/cli/tests/view.test.ts index a850e5f..6b43066 100644 --- a/packages/cli/tests/view.test.ts +++ b/packages/cli/tests/view.test.ts @@ -24,11 +24,17 @@ async function liveState(samples = 30): Promise { { id: 2, timestamp: 0, level: 'warn', message: 'Slow tick: 40.1 ms (budget 33.33 ms)' }, ], logsAvailable: true, + requests: [ + { id: 1, timestamp: 0, method: 'GET', path: '/assets/index.js', status: 200, durationMs: 3.2, bytes: 412000, address: '203.0.113.10' }, + { id: 2, timestamp: 0, method: 'GET', path: '/favicon.ico', status: 404, durationMs: 0.8, bytes: 160, address: '203.0.113.10' }, + { id: 3, timestamp: 0, method: 'WS', path: '/hmr', status: 101, durationMs: 1.1, bytes: 129, address: '127.0.0.1' }, + ], + requestsAvailable: true, } } function viewState(overrides: Partial = {}): ViewState { - return { tab: 0, playerOffset: 0, logFromEnd: 0, selectedPlayerId: null, followSelectedPlayer: true, ...overrides } + return { tab: 0, playerOffset: 0, logFromEnd: 0, requestTopId: null, selectedPlayerId: null, followSelectedPlayer: true, ...overrides } } function render(state: DashboardState, width = 130, height = 40, transparent = false, tab = 0, view = viewState({ tab }), startup?: StartupInfo) { @@ -36,7 +42,7 @@ function render(state: DashboardState, width = 130, height = 40, transparent = f } function startingState(status: DashboardState['status']): DashboardState { - return { status, source: 'http://localhost:8050/api/game/metrics', snapshot: null, lastUpdate: null, error: 'Can\'t reach', failures: 3, logs: [], logsAvailable: null } + return { status, source: 'http://localhost:8050/api/game/metrics', snapshot: null, lastUpdate: null, error: 'Can\'t reach', failures: 3, logs: [], logsAvailable: null, requests: [], requestsAvailable: null } } describe('dashboard view', () => { @@ -67,6 +73,49 @@ describe('dashboard view', () => { expect(screen.contains('CPU')).toBe(false) }) + it('shows HTTP requests, newest first, on the http tab', async () => { + const screen = render(await liveState(), 130, 40, false, 3) + + expect(screen.contains('4 HTTP')).toBe(true) + expect(screen.contains('HTTP requests (3, 1 error)')).toBe(true) + for (const text of ['/assets/index.js', '/favicon.ico', '404', 'WS', '/hmr', '402.3 KiB']) + expect(screen.contains(text), text).toBe(true) + expect(screen.find('/hmr')!.y).toBeLessThan(screen.find('/favicon.ico')!.y) + expect(screen.contains('Slow tick')).toBe(false) + }) + + it('keeps a scrolled http table on the same request as new ones arrive', async () => { + const state = await liveState() + const request = (id: number) => ({ id, timestamp: 0, method: 'GET', path: `/page-${id}`, status: 200, durationMs: 1, bytes: 10, address: null }) + state.requests = Array.from({ length: 80 }, (_, i) => request(i + 1)) + const view = viewState({ tab: 3, requestTopId: 60 }) + + state.requests.push(request(81)) + const screen = render(state, 130, 40, false, 3, view) + + expect(screen.find('/page-60 ')!.y).toBe(screen.find('Time')!.y + 1) + expect(screen.contains('/page-81')).toBe(false) + expect(screen.contains('scroll up to follow')).toBe(true) + expect(view.requestTopId).toBe(60) + }) + + it('follows the newest requests again once everything fits', async () => { + const view = viewState({ tab: 3, requestTopId: 2 }) + + const screen = render(await liveState(), 130, 40, false, 3, view) + + expect(screen.contains('/hmr')).toBe(true) + expect(view.requestTopId).toBeNull() + }) + + it('explains when the server does not record requests', async () => { + const state = await liveState() + state.requests = [] + state.requestsAvailable = false + + expect(render(state, 130, 40, false, 3).contains('requestLogSize')).toBe(true) + }) + it('shows a details tile for the selected player', async () => { const state = await liveState() const player = state.snapshot!.players[0] @@ -127,8 +176,8 @@ describe('dashboard view', () => { it('maps number keys to tabs', () => { expect(tabForKey('1')).toBe(0) - expect(tabForKey('3')).toBe(2) - expect(tabForKey('4')).toBeNull() + expect(tabForKey('4')).toBe(3) + expect(tabForKey('5')).toBeNull() expect(tabForKey('0')).toBeNull() expect(tabForKey('q')).toBeNull() }) @@ -150,6 +199,8 @@ describe('dashboard view', () => { failures: 2, logs: [], logsAvailable: null, + requests: [], + requestsAvailable: null, }) expect(screen.contains('UNAUTHORIZED')).toBe(true) diff --git a/packages/core/src/Networking/Server/Http/HttpServer.ts b/packages/core/src/Networking/Server/Http/HttpServer.ts index 4f597a3..ef5fb25 100644 --- a/packages/core/src/Networking/Server/Http/HttpServer.ts +++ b/packages/core/src/Networking/Server/Http/HttpServer.ts @@ -2,6 +2,7 @@ import type { Express, NextFunction, Request, Response } from 'express' import type { Server as NodeServer } from 'node:http' import type winston from 'winston' import type LogBuffer from '../Stats/LogBuffer' +import type RequestLog from '../Stats/RequestLog' import type ServerMetrics from '../Stats/ServerMetrics' import type { ClientOptions } from './ClientMiddleware' import { Buffer } from 'node:buffer' @@ -13,7 +14,7 @@ import { devClientMiddleware, staticClientMiddleware } from './ClientMiddleware' export interface MetricsEndpointOptions { /** - * Expose GET /api/game/metrics and GET /api/game/logs. + * Expose GET /api/game/metrics, GET /api/game/logs and GET /api/game/requests. * Defaults to true, but in production it stays off unless a token is set. */ enabled?: boolean @@ -30,6 +31,8 @@ export interface HttpServerOptions { metrics?: MetricsEndpointOptions /** Serves recent log entries on GET /api/game/logs when given */ logs?: LogBuffer + /** Records the requests this server receives and serves them on GET /api/game/requests when given */ + requests?: RequestLog /** Serve the game client from this server, see `serveClient()` */ client?: ClientOptions } @@ -48,6 +51,7 @@ export default class HttpServer { private port: number private metricsOptions: MetricsEndpointOptions private logs?: LogBuffer + private requests?: RequestLog private clientOptions?: ClientOptions private lastMetricsRequestAt?: number private listener?: NodeServer @@ -62,16 +66,19 @@ export default class HttpServer { this.port = options.port ?? 8050 this.metricsOptions = options.metrics ?? {} this.logs = options.logs + this.requests = options.requests this.clientOptions = options.client this.registerHealthEndpoint() this.registerMetricsEndpoint() this.registerLogsEndpoint() + this.registerRequestsEndpoint() } start() { this.logger.info('Starting @mavon/engine - HTTP Server') this.listener = this.app.listen(this.port) + this.requests?.attach(this.listener) this.logger.info(`Visit game server status: http://localhost:${this.port}/api/game/health`) } @@ -154,6 +161,21 @@ export default class HttpServer { }) } + /** + * GET /api/game/requests?after= returns the HTTP requests recorded after `after`. + */ + private registerRequestsEndpoint() { + this.app.get('/api/game/requests', (req, res, next) => this.authorizeMetrics(req, res, next), (req, res) => { + if (!this.requests) { + res.status(404).end() + return + } + + const after = Number(req.query.after ?? 0) + res.json(this.requests.since(Number.isFinite(after) ? after : 0)) + }) + } + private authorizeMetrics(req: Request, res: Response, next: NextFunction) { if (!this.metricsEnabled) { res.status(404).end() diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index 43cda0a..2af2903 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -17,6 +17,7 @@ import { ServerCommand } from './Commands' import HttpServer from './Http/HttpServer' import LatencySimulator from './LatencySimulator' import LogBuffer from './Stats/LogBuffer' +import RequestLog from './Stats/RequestLog' import ServerMetrics, { SERVER_BANDWIDTH_ID } from './Stats/ServerMetrics' import ServerWorld from './World' @@ -46,6 +47,9 @@ export interface ServerOptions { /** How many recent log entries are kept for GET /api/game/logs. 0 turns it off. Default 500 */ logBufferSize?: number + + /** How many recent HTTP requests are kept for GET /api/game/requests. 0 turns it off. Default 500 */ + requestLogSize?: number } } @@ -60,6 +64,7 @@ export default abstract class Server implements Serv readonly metrics: ServerMetrics readonly http: HttpServer readonly logs?: LogBuffer + readonly requests?: RequestLog private onHttpCallback?: (httpServer: Express) => void private options: ServerOptions @@ -95,11 +100,16 @@ export default abstract class Server implements Serv this.latencySimulator = new LatencySimulator(this.options.latencySimulation, this.logger) } + const requestLogSize = this.options.metrics?.requestLogSize ?? 500 + if (requestLogSize > 0) + this.requests = new RequestLog(requestLogSize) + this.metrics = new ServerMetrics(this, this.logger, this.options.metrics) this.http = new HttpServer(this.logger, this.metrics, { port: this.options.httpPort, metrics: this.options.metrics, logs: this.logs, + requests: this.requests, client: this.options.client, }) diff --git a/packages/core/src/Networking/Server/Stats/RequestLog.ts b/packages/core/src/Networking/Server/Stats/RequestLog.ts new file mode 100644 index 0000000..49ab5a3 --- /dev/null +++ b/packages/core/src/Networking/Server/Stats/RequestLog.ts @@ -0,0 +1,139 @@ +import type { IncomingMessage, Server as NodeServer, ServerResponse } from 'node:http' +import type { Duplex } from 'node:stream' +import { Buffer } from 'node:buffer' +import { performance } from 'node:perf_hooks' + +export interface RequestLogEntry { + /** Increases by one per entry, so clients can ask for what they haven't seen */ + id: number + /** When the request arrived */ + timestamp: number + /** HTTP method, or `WS` for a WebSocket upgrade */ + method: string + /** Path without the query string, which can hold tokens */ + path: string + /** Response status, null when the connection closed before one was sent */ + status: number | null + /** Until the response finished, or until the handshake answer for a WebSocket */ + durationMs: number + /** Bytes written to the connection for this response, headers included */ + bytes: number + address: string | null +} + +export interface RequestLogResponse { + entries: RequestLogEntry[] + /** Id of the newest entry, 0 when nothing was recorded yet */ + latestId: number +} + +/** Requests the dashboard makes every poll, which would bury everything else */ +export const UNLOGGED_PATHS = new Set(['/api/game/metrics', '/api/game/logs', '/api/game/requests']) + +const STATUS_LINE_RE = /^HTTP\/\d(?:\.\d)? (\d{3})/ + +/** + * Keeps the most recent requests to a Node HTTP server in memory so they can be served to the dashboard. + * It listens on the server itself rather than inside Express, so static files, the Vite dev server, + * geckos.io signaling and WebSocket upgrades show up too. + */ +export default class RequestLog { + private entries: RequestLogEntry[] = [] + private nextId = 1 + + constructor(private capacity = 500) { } + + /** + * Record every request and WebSocket upgrade `server` receives from now on. + */ + attach(server: NodeServer) { + // First in line, so nothing has been written yet when a request is picked up + server.prependListener('request', (req: IncomingMessage, res: ServerResponse) => this.onRequest(req, res)) + server.prependListener('upgrade', (req: IncomingMessage, socket: Duplex) => { + this.onUpgrade(req, socket) + + // With no other listener, Node would have closed the connection. Listening must not change that + if (server.listenerCount('upgrade') === 1) + socket.destroy() + }) + } + + push(entry: Omit) { + this.entries.push({ id: this.nextId++, ...entry }) + + if (this.entries.length > this.capacity) + this.entries.splice(0, this.entries.length - this.capacity) + } + + /** + * Entries newer than `afterId`, oldest first. + */ + since(afterId = 0): RequestLogResponse { + return { + entries: this.entries.filter(entry => entry.id > afterId), + latestId: this.nextId - 1, + } + } + + private onRequest(req: IncomingMessage, res: ServerResponse) { + const path = pathOf(req) + if (UNLOGGED_PATHS.has(path)) + return + + const timestamp = Date.now() + const start = performance.now() + // Keep-alive connections carry one request after another, so the difference is this response + const written = req.socket.bytesWritten + + res.once('close', () => this.push({ + timestamp, + method: req.method ?? '?', + path, + status: res.headersSent ? res.statusCode : null, + durationMs: performance.now() - start, + bytes: req.socket.bytesWritten - written, + address: req.socket.remoteAddress ?? null, + })) + } + + /** + * Upgrades are answered by writing straight to the socket, so the first write carries the status line. + */ + private onUpgrade(req: IncomingMessage, socket: Duplex) { + const timestamp = Date.now() + const start = performance.now() + let recorded = false + const write = socket.write + + const record = (status: number | null, bytes: number) => { + if (recorded) + return + recorded = true + socket.write = write + this.push({ + timestamp, + method: 'WS', + path: pathOf(req), + status, + durationMs: performance.now() - start, + bytes, + address: req.socket.remoteAddress ?? null, + }) + } + + socket.write = function (this: Duplex, chunk: any, ...rest: any[]) { + const head = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('latin1', 0, 32) : '' + const status = STATUS_LINE_RE.exec(head)?.[1] + record(status ? Number(status) : null, typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk?.length ?? 0) + return write.apply(this, [chunk, ...rest] as Parameters) + } as Duplex['write'] + + socket.once('close', () => record(null, 0)) + } +} + +function pathOf(req: IncomingMessage) { + const url = req.url ?? '/' + const query = url.indexOf('?') + return query === -1 ? url : url.slice(0, query) +} diff --git a/packages/core/tests/Networking/Server.test.ts b/packages/core/tests/Networking/Server.test.ts index 3f35607..abb4bb8 100644 --- a/packages/core/tests/Networking/Server.test.ts +++ b/packages/core/tests/Networking/Server.test.ts @@ -342,6 +342,11 @@ describe('server', () => { expect(logger.transports).toHaveLength(0) }) + it('records HTTP requests unless requestLogSize is 0', () => { + expect(new TestServer(logger).requests).toBeDefined() + expect(new TestServer(logger, undefined, { metrics: { requestLogSize: 0 } }).requests).toBeUndefined() + }) + describe('periodic stats logging', () => { function startServer() { const server = new TestServer(logger, undefined, { metrics: { logIntervalMs: 1000 } }) diff --git a/packages/core/tests/Networking/Server/Http/HttpServer.test.ts b/packages/core/tests/Networking/Server/Http/HttpServer.test.ts index 2b196dd..04ed2d2 100644 --- a/packages/core/tests/Networking/Server/Http/HttpServer.test.ts +++ b/packages/core/tests/Networking/Server/Http/HttpServer.test.ts @@ -4,6 +4,7 @@ import type { HttpServerOptions } from '../../../../src/Networking/Server/Http/H import { afterEach, describe, expect, it, vi } from 'vitest' import HttpServer, { METRICS_CLIENT_TIMEOUT_MS } from '../../../../src/Networking/Server/Http/HttpServer' import LogBuffer from '../../../../src/Networking/Server/Stats/LogBuffer' +import RequestLog from '../../../../src/Networking/Server/Stats/RequestLog' const health = { serverTime: 'now', uptime: 1, players: 0, world: { entities: 0 }, physics: {} } const snapshot = { schemaVersion: 1, players: [] } @@ -112,6 +113,26 @@ describe('httpServer', () => { expect((await request('/api/game/logs')).status).toBe(404) }) + it('serves recorded requests after an id, protected like metrics', async () => { + const requests = new RequestLog() + requests.push({ timestamp: 0, method: 'GET', path: '/one', status: 200, durationMs: 1, bytes: 10, address: null }) + requests.push({ timestamp: 0, method: 'GET', path: '/two', status: 404, durationMs: 1, bytes: 10, address: null }) + + const request = await serve({ requests, metrics: { token: 'secret' } }) + + expect((await request('/api/game/requests')).status).toBe(401) + + const newer = await (await request('/api/game/requests?after=1&token=secret')).json() + expect(newer.latestId).toBe(2) + expect(newer.entries.map((e: any) => e.path)).toEqual(['/two']) + }) + + it('404s requests without a request log', async () => { + const request = await serve() + + expect((await request('/api/game/requests')).status).toBe(404) + }) + it('tracks whether metrics were fetched recently', async () => { const request = await serve() diff --git a/packages/core/tests/Networking/Server/Stats/RequestLog.test.ts b/packages/core/tests/Networking/Server/Stats/RequestLog.test.ts new file mode 100644 index 0000000..1bc9460 --- /dev/null +++ b/packages/core/tests/Networking/Server/Stats/RequestLog.test.ts @@ -0,0 +1,105 @@ +import type { AddressInfo } from 'node:net' +import { createServer, request as httpRequest } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import RequestLog from '../../../../src/Networking/Server/Stats/RequestLog' + +let server: ReturnType | undefined + +async function listen(handler: Parameters[0]) { + server = createServer(handler) + const log = new RequestLog() + log.attach(server) + + await new Promise(resolve => server!.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + + return { log, port } +} + +/** Ask for a WebSocket upgrade and resolve with the status line, or null if the connection closes first */ +function upgrade(port: number, path: string) { + return new Promise((resolve) => { + const req = httpRequest({ port, host: '127.0.0.1', path, headers: { Connection: 'Upgrade', Upgrade: 'websocket' } }) + req.on('upgrade', (res, socket) => { + socket.destroy() + resolve(`${res.statusCode}`) + }) + req.on('error', () => resolve(null)) + req.on('close', () => resolve(null)) + req.end() + }) +} + +/** Lets the server side 'close' events run */ +const settle = () => new Promise(resolve => setTimeout(resolve, 20)) + +describe('requestLog', () => { + afterEach(() => { + server?.closeAllConnections() + server?.close() + server = undefined + }) + + it('records requests with status, size and timing, without the query string', async () => { + const { log, port } = await listen((req, res) => { + res.statusCode = req.url!.startsWith('/missing') ? 404 : 200 + res.end('hello') + }) + + await (await fetch(`http://127.0.0.1:${port}/index.html?token=secret`)).text() + await (await fetch(`http://127.0.0.1:${port}/missing`, { method: 'POST' })).text() + await settle() + + const { entries, latestId } = log.since() + expect(latestId).toBe(2) + expect(entries.map(e => [e.method, e.path, e.status])).toEqual([ + ['GET', '/index.html', 200], + ['POST', '/missing', 404], + ]) + expect(entries[0].bytes).toBeGreaterThan('hello'.length) + expect(entries[0].durationMs).toBeGreaterThanOrEqual(0) + expect(entries[0].address).toContain('127.0.0.1') + }) + + it('skips the dashboard\'s own polling', async () => { + const { log, port } = await listen((_req, res) => res.end()) + + await fetch(`http://127.0.0.1:${port}/api/game/metrics`) + await fetch(`http://127.0.0.1:${port}/api/game/logs?after=3`) + await fetch(`http://127.0.0.1:${port}/api/game/requests`) + await fetch(`http://127.0.0.1:${port}/api/game/health`) + await settle() + + expect(log.since().entries.map(e => e.path)).toEqual(['/api/game/health']) + }) + + it('records WebSocket upgrades with the handshake status', async () => { + const { log, port } = await listen((_req, res) => res.end()) + server!.on('upgrade', (_req, socket) => { + socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n') + }) + + expect(await upgrade(port, '/hmr?token=abc')).toBe('101') + await settle() + + expect(log.since().entries).toEqual([expect.objectContaining({ method: 'WS', path: '/hmr', status: 101 })]) + }) + + it('closes upgrades nothing else handles, like Node does without listeners', async () => { + const { log, port } = await listen((_req, res) => res.end()) + + expect(await upgrade(port, '/ws')).toBeNull() + await settle() + + expect(log.since().entries).toEqual([expect.objectContaining({ method: 'WS', path: '/ws', status: null })]) + }) + + it('returns only entries after the given id and drops the oldest past capacity', () => { + const log = new RequestLog(2) + for (const path of ['/a', '/b', '/c']) + log.push({ timestamp: 0, method: 'GET', path, status: 200, durationMs: 1, bytes: 0, address: null }) + + expect(log.since().entries.map(e => e.id)).toEqual([2, 3]) + expect(log.since(2)).toEqual({ entries: [expect.objectContaining({ path: '/c' })], latestId: 3 }) + }) +}) From 09ddabf1d7c6694bad7422d428a4250e131c5ce8 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 23:45:13 +0100 Subject: [PATCH 17/25] multiplayer-template: add a favicon --- packages/multiplayer-template/client/index.html | 1 + .../client/public/favicon.ico | Bin 0 -> 7792 bytes 2 files changed, 1 insertion(+) create mode 100644 packages/multiplayer-template/client/public/favicon.ico diff --git a/packages/multiplayer-template/client/index.html b/packages/multiplayer-template/client/index.html index 24a0552..c1fd80d 100644 --- a/packages/multiplayer-template/client/index.html +++ b/packages/multiplayer-template/client/index.html @@ -5,6 +5,7 @@ Multiplayer Template + diff --git a/packages/multiplayer-template/client/public/favicon.ico b/packages/multiplayer-template/client/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..45fc6e999f3b8494d232f76ec571ecacb33887aa GIT binary patch literal 7792 zcmb`LRZtv2l&%K`*TFSmaCdiihu|9AT?c|QcyI~s1P>kv8a!yQ;O_1&H`%SbyAQYS z(>`>a{<}~2>4(3ozHR^j1^@(rK!Cpy3|N5$0C)fZ02utA`Zp2)fCB^o*x3G4$6^Bj z+o%9Ql$wexItnq$-&AyYIVp|5@$U`*5YpfEfP_Swe|x~VYsg9fYQ{+q{t78eU3n{I zWdOt9J`w;X!WIDcFXe9|{@VZmVE(^j{2Tc@mJj=1v!Hz7fA{|-a%s6<0syeG@>1d- z=+o~g{df?&(rPVNsOpE#jYANHnDg_}6xOs{?7 zwBMeqZmd#d>~k!n8(B7$ukOFOvMKE;CR6oE>G|5(%cX1_{eJz!DnsJN(YOC($W;bzv8h!m zDzNy)<=+sJ56tnEb&^GAesF!MIQe|W3j@EK3BU`TonadP#LqW$HaOFL{ITjJ%uhU% zCFGIFuJZPF>~K5{o__z~V_s%_xD^Mee7&9q`@C-BN(OWsS=~9w!L53EBn!jG<#x!YuPJRxdLd& z_F%}MyMXit^VIQiZk3=i?w_^frB$SABuqp8ALa)Cod*Ahxt|G7ivEiK6?23Chq?b% z08~NEuKwN+!GGqKLs!Q|-Fe1hm*J6Mswo?&?sPYN43MG+aMGY)+xq3I`J>Id`=}p%E zOe=~5r=B9!HkHRx2Tp>=8Q9M+rCV(3EMaI1>tBP$1%~XVXQHh*7S>d?+-Bq~WM-3t?aX z1h6H$Mk6b8B`<_uHJAjvhu?60@v-q!DboAl8$|@2CPP7jHAhYsR3W57uG0X(PP@-0 zi$sU!C%PoEtE)>Z|0%8D?CMNr*t^TC@)1wTtf`RiwY?!`H7;fB|8+wx8kut%l+U+a z&aRQEpYT!It<-pX%;?eH7cm>ZvyXZ9nPyLc_XJ&2=F{yW3 zt@mL1-jubSmp5phXBQuK(@+lK0H|MTi3O9-FOVdkS9ey@B&>Pc-173+xVH7#DvJ4Ur#o}egoF*76pLs(sqXL|+$ppY zjq;aUg7Smm4(HtKwK?m+EBB|b{6cndV>x6FXN|8{GmEP?0y)e5@QK3#wSl**wU8y} zmNQY6u@57>EUh(X;&t8elvH_&DMLzP;QM%f76L`y*GucGrcBTUCpax@yxlXmJ9bDhd*_?>}#AC&JJO#Ig*f3 z<1SU@6Qrn9Q?mQkXM=yuZ^WcSL^uQNA6}+sxAu-SThjbEXM3$H2~(<-wQHD2MGb5n z$#B#5s0Ti4&!H;1n{}Lxr0I_io`+a5Vu=;X|185Dva9R%Sn>NdU452X%lbXX*ze?K ze$$sahyUJ7T0YZ`hOr`3kM6yH*UwRlj|YbK=1WJIR!%0KN6KdrluKtzPD_grO0kke zDSa#Kech&zz7&6BW{Z%xqSd92xdD|5eHP}4mOm}eHCo>x!$ekRhSyN9 zr&%vwU-o3yjzTrnykBv9Ifp>XBchot>P)x zebZ!ugQ71%GIZg5x)dK3Lk(R^`>d)19WZ(0X=;o#g(>xz%f^-1OsYpslFXYfTF5l2 zN2M(3KLFJ=0Wew=xSGb2GDGdVfE5Z>ey^!zeu22aU_WM5-mAa25@<|r@i@M;e6Xv&@}dl&9cr*; zP8WWCjY{FPl#D5a3qj!Op+G|0#+n@Mf%f8rb|4#|@5O$TN#VOqNI}|0TDa;k-rCQ4 zf>>tSBA`1B%oXKyw`eR#_rz0(g7vxJwBTq;&kGkMGQ3@bLTH(6{1BNk_+=P%4^cJJR z5oeh=(^C?0O`E4{WbhrI(kG2fB!KdWSGtvIwC9@g zZ!5W`wsTDjE*~D(Mg^F5ElJyaou?S*GgGLSO2w$yAcLPfLZ`7mc!&x8MlYzuhOIHR z-)TI&H%@zljAoHkP`)7Xv3zYU-b0#w5c~6#2F6%FKYc7QW|vpi4|1yhstrAByZRvl zc8D0vfq(78sy%6Ds@~OTcE(h7y1iBQ(M@ycB+*1*8c!wg$EsZy7tU4)iQJCAn-|Me z@#|yE>TZ1JbhN}>v34?Fh)oJUUC3`3MGHP6$sV32CFx5_P2z?eD~iOu>w>~!sLF~` z3e%6=NjIf0Q8wvBn1%Md!Vl5R`W9&aVZQU1*h#NqMvHTp+YWYaD7Pp6a2Zp=^J zS3ii{X$ma#Uv6afT5%dBtFYED7bMQ66Ex2?Nw5t0Hj4Oyo`RtaNP#(Cg8p|L&v5X5 z-k=|Jw$GI5Pb!lh*=C`;#o<4c;&4W>-ef~Cfi`u&5jmJqL~vDQZZ0XA?!slVpJ93j zN5~e-y4$nvrotf)dGqAF`b7k)GwaScOh0KSR0k8HG{}+O5UF@MabqJ+(f;AY{|9LP z@;rd;KY(W5)M@gs_+J4Uw*LV%|0)2Mcpd(p1nc%6(C8v->Dd~P=%m$1YouyZg@uK| zBQ+48cdj$Lk`8_zp1Sg&&Dk41CfgkuHOU}jH+h`)KUjd!Oy?9g2#)vBD8;^O4`Sze zvI+)aBNcoqcU*N>Ovsg}X!AT;c3-{9b)V><_6gjx{EWkwyX6~fju_85Q3NC~??QcN zQL!6q!d9g(p_2TDatf6KC$`kX5utL@! z&8Te6tZy%T^02PW0KYX65#%`{aWI1Sq!L>eeblM&+Qu293o2};3vl$O`k^3PA2D2< zaL+{~REusFk>dHEa$(}cGLDkKH!ghT$aMudIg$f}cd?p3&3XoZjbK}Q6KXReLhPbT z^rsc1CCd+@?Sfd*Xr$M-)+fgVrwsof!&pT}Mb_7*W)yY3Y=K8-#5Vv~J_=Px_pm8L z223GLs4N6~9wUk8>rcFpwN8ziihc(yi3CKXY5GfIy>L__a9&14l{hcl7h#KYG?m6D z{b45aRKR3URlSH^GUOTrf~C@zlE=?hSv|o0)cN_(#9h0{2f8%R-NyDkMAiK8OQ`JB(J^#!n^g}^qJFe$nt2CgER`Zm(|-ym>@61_u|02$&lUcrm*GC1>)42#$3@VvHx8D zONB;+C(*Zc0pi5w_Uc1!@O3+STjTHJdQ#jN2lp{^2#MGk96|9U99D=C4kJrRYj*OQ&MQ@^MW)#YNz^0V3TE#?q|H&y%P1@F$h%=t00>Yt6} zsl|c$VvX_}w64u)E&{JxE@|mmPA!R?K1vg_C^!2qON&xt)CelVG!Kr=b;dCBJ7k5?T#t9F30=19l#bdku%2{*Qc6?H*mFS+|fx|+BlYj)`d zH6~aV^ql-TUoyR#nxHjk^d$=P9eZa{kA90UFFCW(8slk)PDNA)#7fv&PqeEf!m9f= z_*mRSgx`NIGybvSO#S~)T7&3jbAM4d|SNfyc(so<@BRvg+f-R-q*p5(+ za#j@*3G6M(RzIUL_iDBz#sr0RakUBX>S(y=DjH(N5BE-LaMTSj2`Q zBMXp+VqRk7*Gs3%h`iX9SZ!uX|2AkUZnTfO9iK%ZJ66FIVSUG2t?2%zK)&SVxr%67 zQkRx+SlTD7&C!^WzGQ+B3&+#XU%xmw376>DHy#HW#USn0We^t5zC)IN#5_`PeC+B} z5&6>WM}pDy1lg*4FUz;ihr4Zf+byO#a$#Oq7jbBee}p#Tnx9WfD^}_ZpfYt^bO>3& z67tqqPQGH@f*Koiaduw3bZj0%&VM4ml?U6|dHb2sJUEcguPu0 zQbs{QTqSYh_q4e9LG~AN?LXKjR_+%N&uRPZsPmyAwDCoS?+XwAT`7l7117AIiIN(m-uHWDY{^0_ndbH8KDY|d5b zKNY?8Y?N7R?~6)18D3b4BB+^DyN`>>i1TY;EHAs-{$eWKiv5A|!bafXzP0!4$j5np zn#lLz1T)JV=XSTW2XhVG)0-twr0sFXM(5~@g`4rI?XQ|DofeI3jh(N2FlfpF#}V`5 zw#t@4ZHecZr5~=M?v=X#w0*nnLVdgNlysII6s!$X!K_+tYYFfhp_dT=v-sN{xM!?$nB*lzJCBLgh*~Cx4 zF`hu1G62l^JJ+c4v1o`L+^gSVob+WK8&{4(Z!HIdh)vHdx#K?&@CShI3L=jBIW~nb$NT{gnHIyYUT{>LbC698v|Mv4>en*p>U7b6 zcR^M*HO=VgY%KJ6M^-$rH-)*^(kyf0h;??*t4!P0Jeck+leeR-ZB(#3*Yy}~ilrmc zniU{$=FW|o`;u>Y|7*HrqJAPhAJ-mVY%(p00G%Nyq$ZS{(}IQ=g`Bx`29ZYV94@=D zx>2_zXQnWv_WLrkY`mAlKwiJd`(YuYxaIb3vVGN0f@8B%s(uaIbtCBLJJtS2J29)% z@1hV@v5Vrvh`o!SDww!+sjP)~#&b zOj8=quj<(yy+_G~IG$pfgtr@Ytms@xlpNz`TNi0$Rccq_ap1|SHW)SP%(>@g27G_8 zaB{wSh%qqpJ|B(i@_#++ntoB4l7FPr^lV4zrpcU#|26|YY9n9g$M|XSisklRG53KY zD3Obl5jdD?hFq;?_qJr>z?j9ldzO|OJ!Z=qUqf`|du*TDk=c8)`eO5$^{`MYy2GER zOUO=iWrgEefA=@jYF(t8!*nE8unXA5#y(}dj|heu3$=uoIE9&MnoBw86D~uQH(Z3_ z6y9>7EtS(6>dRPMXW;XX`!m^1Rk9&em53P|T&*Hv?U7W6_H181%~9HlRACmd^y{YM z#KG!9;G<)fYsO3vUYu7{7<`)TNr)>?-4{FrTQU%2pPw?S_X!o6kl<@|%3cVlZcCPT z5^?jACPwh{J{6*OaJ-tn;t_VfX9_>?ASbDfrk=0|p7z)rzJ?7{DzMiIuA`at{gevI zu+pN15USy%ec{AM@6av08{4WzHK5?^o{_EN$d7qIjAJM}O#J}4Y7kuQ>?}PZV5rOa zSwwx5#-RN!H7AVlrrFT9q_MN}a^g*|8rR5(Tt$mLS{DYRLfC!akqKw-4VH~S95ZeWcJuC-{@JoHtv~#Ip6S&Ov5XBgaC57r(D?RM{UDnCTRYuU$ zp+7$fJ6mY$x}ri0PEm_h%fi0F!Fis!YTpiC5MR!ede?)f^LN-3S33=#V4Fo?@-prw zccqPMP(ZsGh_N^p9^qE+l_I(dV-tp7f?+=bG(HdkyP7p0|7e!ye|SXX(JXx-3F~Mi zhrAF>3bIiU=jub)BX7zatgfM--{vG5?{;1ERk* zKp3GY1$+sSSFfbe{H#VL{OWSDQ5L=o@jEwD+_Kf(p+Bei`*_!B3HNrRbIpHeLEomW z-a|0MksmU+u#qO=GmGT`^Ws%kcQmPdZ1hLlnOdf^&qGp$u2r}KE`y`HPoxio1myQX zU^-sz&NE)&*Uaqrw=Ro*9pKo1TLx=xSyaJ0n_n#^_ ztHp2V#5)*xY6{kd2|#KOOY8RpCM-{?#<86*8>h?DW7atJ!r*8QYj+n0f|yTv7md;q zdoP`@V`GRJ9Onsp+b&ngCgyi4R!;n01|SEKo&f{uc0gYXnT{dEVqbFDYK@45xtjG* zUtZ_3P1}+mT4$BPx4Iw%prXNNkZuJ2^y8!XvSL=}-mZ+%%$wV0I$u>&Tr{IL$HcvJ zL1Dt=e5PUseR>&$ech6iQX*AmPDxb~^6kZO)srYE zEECur_|wk4%qzFB@V>pHZcK1vUA}BNX*FwXH+^|rt|$;(Cs<&EM>u_5;bbc6JjdQ_ z27ZbpQ80x<$$?;;(TQ(JxviT3&`Tyg%7-TSRT{TKiyJ7=I>*gj=)u$*bL53w$ttVg zyl$vTpT{J+WGY!(T?98^_P|D^n5@kGDpNiDj96V`ZP=VgeJWp~$2BCF(+Kq5EYg!L zWyZ`yJbN`NgDas>>NoJir{`Fk+P|6Wj(b_pLVBBC< zmdf#NddpONg}(9NqBNgrjzYzk=WnqH(nF(GRuL+V{hkO&2pBT43&orxB`*g0mR~XP zV!vyq>U**NV2aeBx~@?6NKvHc%cpzzJoK&qFxD}y_%62+BF}5HBgq1b@(X!~^^=Q4 zR^~BO0K=(Ujf@f=cC_?iCI!6JhY4jjy8iJ!=$fOO&`t5D2A#pn$2Z%&N|J^uwb(L_ zWWRpPZ4`sx%CC>Hu=OJ#z+8{G%d053zE?2h6V=Z}xox0pgSeI{8AzH)3I_aReM}2f zb^i|co8hhY!tT-t>ue(|ti>c0=7@oq%a&eDErPZ8yewdgJUj%0MmDH>asm;j^gN>o zY{7#BKdlTTqGIy)k;Z~Su^#1cI0C61rzUjTfMrtH5$(GtXol*2Nxxdye8r5vH7Pmb zP|8rb4?$`WxBdk~N+a$eIX*QNzJlHC?r;}_#7Re}$S`j+fKH`j;8$SiKrFN zMQwQAk3-c__NUlyXaa;`)((a_)|1^oyYr{ogTV-snvI9)z$UBb+mkFEZ36kU6=DQ- zi$n6Wqxu`vl_b)qOmuwZ#Y1*It4zLb$y)?huu4kiVx*psRoh{9GQ$;{G}yEh$bTMa TRDSds?I^q8-%rs0{fqiPXq;9% literal 0 HcmV?d00001 From 102433381752e7f2df53238a5c8ed78d6108f579 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 23:52:08 +0100 Subject: [PATCH 18/25] dashboard: show the logo on the startup screen --- packages/cli/src/dashboard/logo.ts | 92 ++++++++++++++++++++++++++++++ packages/cli/src/dashboard/view.ts | 50 +++++++++++++++- packages/cli/tests/view.test.ts | 15 +++++ 3 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/dashboard/logo.ts diff --git a/packages/cli/src/dashboard/logo.ts b/packages/cli/src/dashboard/logo.ts new file mode 100644 index 0000000..460b074 --- /dev/null +++ b/packages/cli/src/dashboard/logo.ts @@ -0,0 +1,92 @@ +// The MavonEngine logo (https://mavonengine.com/community/assets/site-image-kipwrzwn.png), cropped to the emblem + +/** + * The logo at a few sizes, largest first. One string per pixel row, six hex digits + * per pixel, `------` where the backdrop was cut out. + */ +export const LOGO_SIZES: string[][] = [ + [ + '------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------------------174c6b1f627f216682194f6f------------------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------184a6c2164813d98a958cbd357cdd53e9cae236680194b69------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------1b50723a8aa255b7c344a2b33a9dad399cae3f9caf51b3c13c92a51c5474------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------16436524648047a0b14da7b72e7590185a7a2c859d2e8ba1195d7b276c8848a0b34aa5b5296b851a4868------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------1d56763a8ca250b1be3e8b9e194e6d174c6e29789444b6c646bbc92c7f991a5274194f713884984cacb83d93a5215e7b194668------------------------------------------------------------------------', + '------------------------------------------------------------------1c4f6f357f9655b2bf479eae245c77------205974358da53796ad389bb03a9bb03693a93692a823637f------20566f4090a353b1bf39879c1d5775174366------------------------------------------------------------', + '------------------------------------------------------------2a6a844da6b555acb82f6e87------------296c85358ba225667f14496726768e297b91154865215e7732879e28748b194965------2665804ca3b34eabba29728b------------------------------------------------------------', + '------------------------------------------------1d546d3e8fa152b1be378397194a67------245e7a398fa636879d------------1d54703395aa349aad1e5a73------------2e7e943690a8256782------174869307b904aabba3c93a51d546f------------------------------------------------', + '------------------------------------194b67347b9161c0cc53b0be1e5b78------1b4e6b2e7d94348ca2225c75------------2d76903492a926728c236c86348da32e7a94------------1f53703188a131859d1e5772------16577441a3b556c4ce3583951a4f6a------------------------------------', + '------------------------------25637d49a5b552b1bf3a8a9f4aa2b43d91a52d7d963490a7266a83------------1e516e338ba32e7c941c4b66------------------2a728c338da5215975------------23627d3491a82d839b368fa441a4b533889e4aa8b649abb8286b83------------------------------', + '------------------1e55723c93a54ab1c0307e97174b6c------1d506b3a889b56bfca3c90a4------1a4a672e7c963795ab1f526f------------------------------------1d4d693691a732839a1d516e------33829953bdc93d92a41f5571------17496b2a758f47adba4197a8215c76------------------', + '------------2b728b50bcc84dbbc92780991e64811f5c781d4f6c------------255d774499ab4db3c041a9b92d7e96------------------------------------------------------------2d799343aabb4fb3c148a1b226647c------------1b4c681f5e7b1e6782257d9545b2c155c3ce318198------------', + '------1e516d4dc6d463ecf54fc0cc3fa3b43b9caf3898aa3c9daf34869c1c4c69------------2c6c8348a5b23c91a11f526e------------------------------------------------1d4c6a3b8a9d4eaeb7327a8f------------------347f943c9cae3597a93a9caf3ea2b44bbac862e6f156d4df225d75------', + '------205a774abed14eb6d841a6bc3290a315516e------------307991419fb32c758d------------------3680964ca8b72d6b85------------------------------------29637c4ea9b53b8798------------------2a6c85439fb1368397------------164d6d318ba045aabb4bb1d44dc4da23657f------', + '------1f5b7a43abbe3e7fb4336097357d9c4dabbb358299154665------1e4e6a3385983992a6225c78------------1f53714197aa3d91a6225a771844651845662156743b8ba1449cad245a76------------1e526b388ea0398d9d1f5670------------2f7a914dafbb3b87a2305e913e7eb643aabf236881------', + '------2056753da3b52859874260a51c35681f50744292a84ba7b723607a------------225c763993a8388ca3205171------1b44672b6b892a6a881d53761d52772868882c708c1b4b6c------1948683483993d97a7256379------------1e58754aa6b64ba0b0255977------3f5ea12a59883d9eb023647d------', + '------1d54723b9faf1e4971384f8f364b88------------27627f45a1b237879d1d4e6e------------2e7591409db52f7996194067------19456a215b80225c801a456b------------2d718f419eb3337f97------------------337f9549a7b62e7089------------2f427b3d55941c436a3b9bad24637d------', + '------1c536c3b9eae1b476a2b40783d4d8f------------------1d456738839a4ea8ba397f9a1c476d------1e4e723385a0368ca7255c801f4e772d6d912d6e921f517923587e3989a43b8fa91f5576------16426530758e4da8b73c8ca01f496b------------------3848842e457d------3a9aaa216078------', + '------1d53703b9dad1b4668273b7437468b------2f6c8d396199------------27577b4597ae4ba5b92c6c8c1b40691b3d682c6f93358cad307ba04196b3429ab52e7aa0388eaf2f78991a3f67------28658449a2b34aa2b42a607f------------32558d327495------32407f2b4079------3a99a8206078------', + '------1d51713898ad1c4366263a7139438c------3b8ea95f9ae14e62b42d4380------------2b688843a1b64297b2295b8622457a27548b3376a650b7ce51bbd0347ca925568c20457724557f3e93ad48a8b931728e------------2c40784e61ac6394e24198b5------333f7f2b3f75------3895a7205f78------', + '------1c51703494ab------27397038478e------378fa7417db04b5eb03f54a33d51a2273873------204572367aa14da8c63c76ad335c9f3e83b658c6da59c8dc3e88b82f5e9d396fa649a8c43782a01f476d------23346a42549d4a5ca95164b54776b13e93ac------324281293f76------3693a71f5a78------', + '------1c4f6e3494ab------28397339498f------328ba1286788384f942a41802a3b794452a73d4c9a273a79243d7a3256974078b6448fc147a9cd4bc5d94bc7dc48a7cf4590c4427cb7305995223a7422346e35478e4455a3303e812d3c7e4256a02b5f873b91a9------324381293f77------3692a8215978------', + '------1c4e6e3593ad1d4166283a7437468f------2f879c21607c2541744073b224597e2132693e4f995160b74356a638549d448abf50c4df4ccde146c9dc44c8dc4ac9e054c5e14b91c536569d4154a4505fb53d4e991f2f651f47704077b12d4b86215577368da6------3242802b4078------3591a9205978------', + '------1d4d6e3691ad1d406627387334448b------2f859d205f7c243a6d456eb23a93b22a668e1e356a2f41874567b2468ec84a9dce4da6d451c7e245cbdd44cadc4ecae34eaad54b9bce488ec64968b53143861b2f6523527c3c8fae4677b329407a1e51713288a3------304080293e76------348ca91f5978------', + '------1d4a6f348dac1c3f65253670324388------2e839c205f7e2639703c509b265a843c9bb6327da01f3e702333732b3c853341964160b150a5d644b8d741b9d54badd74066b4314196293883222e711e366f2e70973f9eba2c60893a5196283c771d52713186a1------2f3c7e293a73------338aa51f5479------', + '------1b4a6e348bac------24366f32418a------2c819d1f5d7e24376e394a941b406a2d7c9b40aec6358bad2859892136742a387f3545963f6db73e8dc53c90c53d75b834469a29358220306e2551843586a943a9c2398fab204570384b92273d771e50732e82a1------2e3b7d293872------3189a51f5379------', + '------1c4a6e3287aa------243570313f8a------2a7e9c1f5b7d22346935458d------266d8d2a6d9022567f378bb13784ae26448027357e2e47933066a63169a72c499326337e243e7c3781aa3a90b3255a82245d812f76961d406735478d263a761d4e6f2d7e9f------2d3a7c283874------3085a81c5279------', + '------1c486e3284ab1c3d68263472303f89------287c991e597a213268324089------266d8d2a698d------1b376528638d3285ae2b5f952745832857902958922543832a58903586ad2c6e951c3b6a------24587c2e7595------3243892437711c4c6f2c7a9d------2c3a7e283875------2f82a71d507a------', + '------1b47703081ab1d3c6726347430408b------2777981d567920316731418a------266b8b286588------------------2148752d6f9c2b6093244c80244b7f2a5c912e729f244f7b------------------2256792d7395------3041872436711c486e28779b------2d3b80293879------2f7fa61b5077------', + '------1a446f2d7fac1e3d6e27367b303f8f------26749a1d547b21326a30408c------26658a275f82------------------------------------1d3c681e3d6b1c3465------------------------------2051732c6f92------2e3e862335721a466c257499------2d3d8329387b------2f7da81b4f78------', + '------1a436f2e7eae1f3e6f28377f2e3d8e183b652c86ae22678f1f336a2c3b87------246088275d82------------------------------------------------------------------------------------1f50712a6b8e------2b397f21346f1f5a832a87b01a3f662c39852838811d38692f7eaa1b4c77------', + '------1b426e2e7fae1f4171263c803161ab2c7fa92a779f2a74a02e62a02b3d8b------266088255c84------------------------------------273b72293e77------------------------------------1f4f762a6a90------28387d2d609d2876a027719b2d7fa83364ae2940871d3d722f7dae1b4a77------', + '------1a3f6a2e7daf22578b2f77b23a80c9285090------------2744842e4295------26618f275c85------------------------------------2736722a3878------------------------------------204f772b6b92------2b3b84284585------------274385417ecd337db9255990317cb21b4674------', + '------1b3c693690c6368bc22057881d37732d3b902c3a8f------1c2a662e398e------265d8b265780------------------------------------262f73293178------------------------------------1f4972296790------2c3684202a67------2c3587333d952438782251853487bb3d9ad21b4371------', + '------------2b659b3585c021568b------------202e72333d98313c972d378e------245784275483283072252e6f------------------------262d74292e79------------------------222c6729327521487a275f8b------2c3484333a9438409a28337a------------2152853583bc306fa4------------', + '------------------1d41722d71a73172ac1e4376------------2f368c2f3696------235486244f80------373c92353a90------------------272c7b292d7f------------------313788383e95222b681e4373275b8b------2c3586343893------------1a3c6f2d6ea73075ae214678------------------', + '------------------------------235087347bb52a699f------------2c378f1b346a285c95254f85------------24296937399729307b------272c7b292d7f------252b6f353891282d71------------1e42742b62991b376a2b36881e2c6a------265d96347bb6265a92------------------------------', + '------------------------------------------255a912e74ae2c589c3250a62c629d2f66a5283a87232770------------------33368e37379934349932329635359235358e------------------23286f2838842d64a22e66a13451a42b52992a6faa255c951a3667------------------------------------', + '------------------------------------------------1d3b6f2f69aa3475be27538c------------24277031318e252872------------------3834a03934a3222568------------22276a32338e282b76------------234c803672b6306cab1d3b6f------------------------------------------------', + '------------------------------------------------------------------------------------------------2e2d842a2b7b------------262673282877------------282972302f86------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------292d72413ea3323285------------------302f862b2b7d2d2b892e2b892a2a7a302f87262468------------2d2c7d3e3ca02b2f7b------------------------------------------------------------------------', + '------------------------------------------------------------------------------------34328a3b369b272770------------2d2a7e4037ac4136b0302c86------------2524673a3397363390------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------2325653a3599353290------------2a26782c277c------------322e853e379d2a2870------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------332e883c35a02e2a812f2a84302a852c277939329634308a------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------------------362f914338b54438b7382f96262469------------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------------------------252369242369------------------------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------', + ], + [ + '------------------------------------------------------------------------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------215c79368ea13790a4225f7b------------------------------------------------------------------------', + '------------------------------------------------------------1a47652f728f439cae3fa0b03d9eb0409aac3077911c4967------------------------------------------------------------', + '------------------------------------------------------235e7c3d8fa23d8da1226582308ca4318fa52267843a899f3d90a326637f------------------------------------------------------', + '------------------------------------------2255713c889d4294a527627b1e536e2d7d963594aa3594a82e7f981e5772255e773e8ea13c8ba0225a77------------------------------------------', + '------------------------------------2b6d834294a63172881e526e2a6c862c738a1b4d6926738c26758d1a4d67276f87286f891d53712d6e873d93a52b6e85------------------------------------', + '------------------------2560794a9eaf53b1bf205d79205b772a738b215771------2a738d28728a276f8729728b------1f536f2a738d21627d1c5e7a44a9ba43a1b027657c------------------------', + '------------1c4b673282973b90a42864812d6e85439dad3689a0194a66225b772d78911d4a66------------------2d778f25617a1a486532839b409fb02b738a24617d388da13584971e4f6a------------', + '------225c764ebdca42aaba2779922668831c4b671f4f6b38849942a0b02e7c93------------------------------------30799144a4b33a8a9e1f526d------2467812779923da4b651c1cd286880------', + '------338aa056cee33ca1b326738a21607c328097296982------23597339869a30728a------------------------306e843b8899276076------28677f34829720627b2570883ca0b255cbe13a97ac------', + '------30819a3e83b131679539879e2d7089------25627b2f7a91225976------2b69843076901d51711d4f702f758e2f6e86------1e526c2f7a8c27667d------2c6e863c8c9f326a933d7fb0328ca1------', + '------2d7b912c6088344989------2e6c8738889d2156731a47672c728d317a96204f731c486c1f577b1f567b1d4a6e1e4d703078922e758e1849651d536d3a8a9e33748c------3247852a5c8530859a------', + '------2c798e24567a344483------------1d446639819a3a829c225175215477307b9828698c2d6c902d6c9129688c347e9c2258791d4e6f37809639839a1f4667------------3345812251732e8396------', + '------2c778f2353752f3e7e20436c498ebc3f549c213b6c2a5f7f3a89a3317394244f7d2d6a99449ebd449fbd2d6e9b214e792e728f3d8ca32c6483213d6a3c50954b8dbf1f4266313f7c214e6e2e8194------', + '------28738e225073304081214a723c8aad465da93648922d3e81234174336e994184b33a75ae4eb1cf4eb3d13976af3f81b032719620426c2b3d7a3c4a924d60af4289b022496e30417d204c6d2d7c94------', + '------28728e235075303f831f486d2b7c9327457a3159902e4383424f9f3a4f9c3c6dac4bb4d549c8dd47c6dd4db4d83e70ae394f993e4d9a2d3e7e30568c2f4b872f799520476b30417e204d6e2c7a95------', + '------2a6f8e234e752d3d7f1e456c2a7891233e703c74a82c6f9329437d39579f417ab74ca5d449c4dd47c7de4ca8d4407ab73b579f273f7b2a658b3c75a52740772b718d1d44682e3f7d1f496d2b7795------', + '------286b8e214d732b3b7c1c426b2877932338702d4e872f809d3388a927508427347c3a59a5408cc53f90c5385da825307a2649803380a43789a630518a253d75296e8e1c41682d3b7b1e466a297394------', + '------27698e214b752b3a7e1c406b26729221366928457a236283265f862c6b9530709e2947892d56982c5798284488306e9d2f709825567e2966872a477d243972276a8c1a3f682c3b7b1e466c286f96------', + '------27658e2149772b39801d3f6c246e8e20326726427a235f801e456c------1f47712a6091265287264f862a6091234b74------------27638328447b22356f2464891b3e692d3b7e204470266d94------', + '------24618e224a7c2c38851d416f2571981f3369253e7a21597c------------------------------1d3765------------------------245d7f25407821336e23688f1b416d2c3981214474256b95------', + '------24608e234c7f2d4e98276c9a28729a2a5692233c7522567c------------------------------------------------------------245a7f213b71295491256e97276a9830529b22487f266796------', + '------2461902d76ab2b66a52a4b93------213774243e7e23557f------------------------------222f66------------------------245a80253c78223874------2c438f3167a92f76ae28689b------', + '------214c7c317bb51c4375------2732812c358b253779214f7b243e71------------------1f2865212866------------------20386d22527e2437752f368a2c35821e2a651d4173327cb2255383------', + '------------1a3765285f96275d92------252c7524367d214d7e203a6c272c6e2c327e------20276a21276a------2b317a282f721c3365234f7f243779272f7a------25588e29629a1d3b69------------', + '------------------------1f44762860982a56982e579e295591212e70------------2e31823233933131912c2f7f------------212c6e2855902e579e2a5396265e9620477b------------------------', + '------------------------------------254b85264a83------------27287524286d------282878282979------222667272a75------------24467c244b83------------------------------------', + '------------------------------------------------272b6e323284------------2728722d2b842d2b83292873------------2e2f7f282d73------------------------------------------------', + '------------------------------------------------------------312e832b2a79------2f2a842f2b87------2a2873332f85232565------------------------------------------------------', + '------------------------------------------------------------------292872312d86302b87302a84302b802b2973------------------------------------------------------------------', + '------------------------------------------------------------------------------322b88332c8a------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------------------------------------------------------------------------', + ], +] diff --git a/packages/cli/src/dashboard/view.ts b/packages/cli/src/dashboard/view.ts index 74682e3..f0630c3 100644 --- a/packages/cli/src/dashboard/view.ts +++ b/packages/cli/src/dashboard/view.ts @@ -3,8 +3,9 @@ import type { ConnectionStatus, DashboardState } from './MetricsClient' import type { LogLine, RequestLine, ServerMetricsSnapshot } from './snapshot' import { stripVTControlCharacters } from 'node:util' import { bootBanner } from '@mavonengine/core/Utils/BootBanner' -import { DEFAULT_COLOR } from '@profullstack/hqtui' +import { DEFAULT_COLOR, hex } from '@profullstack/hqtui' import { formatBytes, formatDuration, formatMs, formatPercent, formatRate } from './format' +import { LOGO_SIZES } from './logo' import { SUPPORTED_SCHEMA_VERSION } from './snapshot' /** @@ -208,14 +209,57 @@ function latencyBanner(ui: Container, theme: Theme, latency: NonNullable { + const value = row?.slice(x * 6, x * 6 + 6) + return value && value !== '------' ? hex(Number.parseInt(value, 16)) : null + } + + const lines: Span[][] = [] + for (let y = 0; y < pixels.length; y += 2) { + const line: Span[] = [] + for (let x = 0; x < pixels[y].length / 6; x++) { + const top = pixel(pixels[y], x) + const bottom = pixel(pixels[y + 1], x) + if (top !== null) + line.push({ text: '▀', fg: top, bg: bottom ?? undefined }) + else if (bottom !== null) + line.push({ text: '▄', fg: bottom }) + else + line.push({ text: ' ' }) + } + lines.push(line) + } + + return lines +} + +const LOGOS = LOGO_SIZES.map(logoLines) + function startupScreen(ui: Container, theme: Theme, { engineVersion, output }: StartupInfo) { const label = 'Starting the game server…' const lines = stripVTControlCharacters(output).split(NEWLINE_RE).filter(line => line.trim() !== '') + // The game server logs the same banner once it runs + const banner = bootBanner({ version: engineVersion ?? 'unknown', mode: 'development' }) ui.column({ size: '1fr' }, (column) => { + const { unicode, colors } = column.ctx.capabilities + // Banner, the spacers around it and the spinner have to fit too. Too few colours make a smudge. + const logo = unicode && (colors === 'truecolor' || colors === 'ansi256') + ? LOGOS.find(l => l.length + banner.length + 3 <= column.height && l[0].length <= column.width) + : undefined + column.spacer('1fr') - // The game server logs the same banner once it runs - for (const line of bootBanner({ version: engineVersion ?? 'unknown', mode: 'development' })) + if (logo) { + for (const line of logo) + column.text(line, { align: 'center', size: 1 }) + column.spacer(1) + } + for (const line of banner) column.text([{ text: line, fg: theme.primary }], { align: 'center', size: 1 }) column.spacer(1) column.row({ size: 1 }, (row) => { diff --git a/packages/cli/tests/view.test.ts b/packages/cli/tests/view.test.ts index 6b43066..389263f 100644 --- a/packages/cli/tests/view.test.ts +++ b/packages/cli/tests/view.test.ts @@ -222,6 +222,21 @@ describe('dashboard view', () => { expect(screen.contains('UNREACHABLE')).toBe(false) }) + it('draws the logo above the banner when the terminal is tall enough', () => { + const startup = { engineVersion: '0.0.20', output: '' } + const logo = (height: number, capabilities = {}) => renderToScreen( + ({ ui, theme }) => renderDashboard(ui, theme, startingState('unreachable'), viewState(), { startup }), + { width: 130, height, capabilities }, + ).contains('▀') + + expect(logo(60)).toBe(true) + // Room for the smaller size only + expect(logo(46)).toBe(true) + expect(logo(30)).toBe(false) + expect(logo(60, { unicode: false })).toBe(false) + expect(logo(60, { colors: 'ansi16' })).toBe(false) + }) + it('leaves the startup screen for a server that answers with an error', () => { const state = startingState('unauthorized') state.error = 'Metrics token missing or wrong (use --token)' From 9066eaf09669b15e347c41909ff0769bee57cc47 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Mon, 26 Oct 2026 23:58:41 +0100 Subject: [PATCH 19/25] cli: add mavonengine create, bootstrapping a project on the startup screen Downloads the multiplayer template, extracts it and installs its dependencies like @mavonengine/create-bootstrap, with each step and its output shown under the logo boot screen. Then it starts the dev script and the dashboard takes over once the game server answers. --- package-lock.json | 106 +++++++++---- packages/cli/README.md | 18 +++ packages/cli/package.json | 5 +- packages/cli/src/commands/create.ts | 197 +++++++++++++++++++++++++ packages/cli/src/commands/dashboard.ts | 11 +- packages/cli/src/commands/dev.ts | 43 ++++-- packages/cli/src/create/project.ts | 188 +++++++++++++++++++++++ packages/cli/src/dashboard/view.ts | 11 +- packages/cli/src/index.ts | 13 ++ packages/cli/tests/create.test.ts | 123 +++++++++++++++ packages/cli/tests/view.test.ts | 13 ++ 11 files changed, 680 insertions(+), 48 deletions(-) create mode 100644 packages/cli/src/commands/create.ts create mode 100644 packages/cli/src/create/project.ts create mode 100644 packages/cli/tests/create.test.ts diff --git a/package-lock.json b/package-lock.json index f5fe1eb..9a44d26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1723,7 +1723,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.60.1", @@ -1737,7 +1738,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.1", @@ -1751,7 +1753,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.60.1", @@ -1765,7 +1768,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.60.1", @@ -1779,7 +1783,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.60.1", @@ -1793,7 +1798,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.60.1", @@ -1807,7 +1813,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.60.1", @@ -1821,7 +1828,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.60.1", @@ -1835,7 +1843,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.60.1", @@ -1849,7 +1858,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.60.1", @@ -1863,7 +1873,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.60.1", @@ -1877,7 +1888,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.60.1", @@ -1891,7 +1903,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.60.1", @@ -1905,7 +1918,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.60.1", @@ -1919,7 +1933,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.60.1", @@ -1933,7 +1948,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.60.1", @@ -1947,7 +1963,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.1", @@ -1961,7 +1978,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.60.1", @@ -1975,7 +1993,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.60.1", @@ -1989,7 +2008,8 @@ "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.60.1", @@ -2003,7 +2023,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.60.1", @@ -2017,7 +2038,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.60.1", @@ -2031,7 +2053,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.60.1", @@ -2045,7 +2068,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.60.1", @@ -2059,7 +2083,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@sindresorhus/base62": { "version": "1.0.0", @@ -5404,6 +5429,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -9933,6 +9959,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -9950,6 +9977,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -9967,6 +9995,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -9984,6 +10013,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -10001,6 +10031,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -10018,6 +10049,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -10035,6 +10067,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10052,6 +10085,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10069,6 +10103,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10086,6 +10121,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10103,6 +10139,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10120,6 +10157,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10137,6 +10175,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10154,6 +10193,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10171,6 +10211,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10188,6 +10229,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10205,6 +10247,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10222,6 +10265,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10239,6 +10283,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10256,6 +10301,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10273,6 +10319,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10290,6 +10337,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -10307,6 +10355,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -10324,6 +10373,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -10341,6 +10391,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -10358,6 +10409,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -10918,13 +10970,15 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@profullstack/hqtui": "0.11.1" + "@profullstack/hqtui": "0.11.1", + "adm-zip": "^0.5.16" }, "bin": { "mavonengine": "dist/index.js" }, "devDependencies": { "@mavonengine/core": "*", + "@types/adm-zip": "^0.5.7", "@types/node": "^24.0.0", "@vitest/coverage-v8": "^3.2.4", "tsup": "^8.5.1", diff --git a/packages/cli/README.md b/packages/cli/README.md index 265790b..dddb1eb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -9,6 +9,24 @@ npm install -D @mavonengine/cli npx mavonengine --help ``` +## `mavonengine create` + +Creates a new project from the multiplayer template, installs its dependencies and starts it like [`mavonengine dev`](#mavonengine-dev). It does the same as `@mavonengine/create-bootstrap`, but in the dashboard. + +```sh +npx @mavonengine/cli create my-game +``` + +Asks for a name if none is given. While the template downloads and `npm install` runs, the startup screen shows each step and its output. Then the dev script starts, and the dashboard takes over as soon as the game server answers. Quitting during setup stops it and leaves what was created so far in the project directory. Without an interactive terminal, or with `--no-dashboard`, the progress and the dev script's output go straight to the terminal. + +| Option | Default | | +| --- | --- | --- | +| `--cwd ` | current directory | Where to create the project directory | +| `--no-dashboard` | | Print the progress instead of showing the dashboard | +| `--port ` | `8050` | Game server HTTP port for the dashboard | +| `--theme ` | `dark` | Dashboard theme | +| `--transparent` | | Let a transparent terminal background show through | + ## `mavonengine dev` Runs the `dev` script of the MavonEngine project you are in and shows the [dashboard](#mavonengine-dashboard) for it. For the multiplayer template that starts the game server and the client together. diff --git a/packages/cli/package.json b/packages/cli/package.json index 160fa38..3a24ab8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -7,6 +7,7 @@ "keywords": [ "mavonengine", "cli", + "create", "dashboard", "game-server", "monitoring" @@ -31,10 +32,12 @@ "lint:fix": "eslint . --fix" }, "dependencies": { - "@profullstack/hqtui": "0.11.1" + "@profullstack/hqtui": "0.11.1", + "adm-zip": "^0.5.16" }, "devDependencies": { "@mavonengine/core": "*", + "@types/adm-zip": "^0.5.7", "@types/node": "^24.0.0", "@vitest/coverage-v8": "^3.2.4", "tsup": "^8.5.1", diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts new file mode 100644 index 0000000..af1c4fb --- /dev/null +++ b/packages/cli/src/commands/create.ts @@ -0,0 +1,197 @@ +import type { CreateReporter } from '../create/project' +import type { MetricsSource } from '../dashboard/MetricsClient' +import type { DashboardOptions } from './dashboard' +import type { DevOptions, DevProcess } from './dev' +import { existsSync } from 'node:fs' +import process from 'node:process' +import { createInterface } from 'node:readline/promises' +import { parseArgs } from 'node:util' +import { createProject, PROJECT_NAME_RE, projectDirFor } from '../create/project' +import { MetricsSourceError } from '../dashboard/MetricsClient' +import { runDashboard, UsageError } from './dashboard' +import { DEV_FLAGS, installedEngineVersion, runDev, startDev, toDevOptions } from './dev' + +export const CREATE_HELP = `Usage: mavonengine create [name] [options] + +Create a MavonEngine project from the multiplayer template, install its +dependencies and start it like 'mavonengine dev'. Asks for a name if none is given. + +Options: + --cwd Create the project in here (default: current directory) + --no-dashboard Print the progress and the dev script's output instead + --port Game server HTTP port for the dashboard (default: 8050) + --theme Dashboard theme, see 'mavonengine dashboard --help' + --transparent Let a transparent terminal background show through + -h, --help Show this help +` + +export interface CreateCommandOptions extends DevOptions { + /** Asked for when missing */ + projectName?: string +} + +/** Bytes of setup output kept for the startup screen, printed if setup fails */ +const MAX_SETUP_OUTPUT = 64 * 1024 + +/** + * Turn CLI arguments into create options. Throws UsageError on bad input. + */ +export function parseCreateArgs(args: string[], cwd = process.cwd(), env: Record = process.env): CreateCommandOptions | 'help' { + const { values, positionals } = parseArgs({ + args, + options: DEV_FLAGS, + strict: true, + allowPositionals: true, + allowNegative: true, + }) + + if (values.help) + return 'help' + + if (positionals.length > 1) + throw new UsageError(`expected one project name, got ${positionals.length}`) + + const projectName = positionals[0]?.trim() || undefined + if (projectName) + checkProjectName(projectName) + + return { ...toDevOptions(values, cwd, env), projectName } +} + +function checkProjectName(name: string) { + if (!PROJECT_NAME_RE.test(name)) + throw new UsageError(`invalid project name '${name}'`) +} + +async function promptProjectName(): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + try { + return (await rl.question('Project name: ')).trim() + } + finally { + rl.close() + } +} + +/** + * A metrics source that reports 'connecting' until `ready()`, so the startup screen stays up + * while the project is set up, even if something else already answers on the port. + */ +export function gatedSource(source: MetricsSource, ready: () => boolean): MetricsSource { + return { + ...source, + fetch: () => ready() ? source.fetch() : Promise.reject(new MetricsSourceError('Creating the project', 'connecting')), + } +} + +/** + * Create the project on the startup screen, then run its dev script behind the dashboard. + * Quitting stops whatever is running. A failed step closes the dashboard and prints the output. + */ +export async function runCreateWithDashboard(projectName: string, parentDir: string, dashboard: DashboardOptions): Promise { + let label = 'Creating the project…' + let output = '' + let engineVersion: string | undefined + let dev: DevProcess | undefined + + const report: CreateReporter = { + step: (text) => { + label = text + }, + output: (chunk) => { + output = (output + chunk).slice(-MAX_SETUP_OUTPUT) + }, + } + + // Aborted by the user quitting, stops the download or install + const setup = new AbortController() + // Aborted when setup fails or the dev script exits, closes the dashboard + const done = new AbortController() + + const work = (async () => { + const dir = await createProject({ projectName, parentDir, report, signal: setup.signal }) + setup.signal.throwIfAborted() + + engineVersion = installedEngineVersion(dir) + label = 'Starting the game server…' + dev = startDev(dir, { capture: true }) + return dev.exited + })() + work.then(() => done.abort(), () => done.abort()) + + const startup = () => dev + ? { engineVersion, output: dev.output() } + : { engineVersion, label, output, outputTitle: 'Setup' } + + let quit = false + try { + await runDashboard({ ...dashboard, source: gatedSource(dashboard.source, () => dev !== undefined), startup }, done.signal) + } + finally { + quit = !done.signal.aborted + if (quit) { + setup.abort() + await dev?.stop().catch(() => {}) + } + } + + if (quit) { + if (dev) + return 0 + + await work.catch(() => {}) + process.stderr.write(`mavonengine: stopped creating the project, what was set up so far is in ${projectDirFor(projectName, parentDir)}\n`) + return 130 + } + + try { + const exitCode = await work + process.stderr.write(`${dev!.output()}\nmavonengine: the dev script exited with code ${exitCode}\n`) + return exitCode + } + catch (err) { + // Rejects when setup failed or the package manager could not be started + process.stderr.write(output) + throw err + } +} + +/** + * Create the project with progress in the terminal, then run its dev script there. + */ +async function runCreatePlain(projectName: string, parentDir: string): Promise { + const report: CreateReporter = { + step: () => {}, + output: chunk => process.stdout.write(chunk), + } + + const dir = await createProject({ projectName, parentDir, report }) + process.stdout.write(`\nmavonengine: your project is ready at ${dir}\n`) + return runDev(dir) +} + +export async function runCreateCommand(options: CreateCommandOptions): Promise { + const interactive = !!process.stdout.isTTY && !!process.stdin.isTTY + + let projectName = options.projectName + if (!projectName) { + if (!interactive) + throw new UsageError('a project name is required, e.g. mavonengine create my-game') + + projectName = await promptProjectName() + if (!projectName) + throw new UsageError('a project name is required') + checkProjectName(projectName) + } + + // Checked again by createProject, but failing here doesn't flash the dashboard + const dir = projectDirFor(projectName, options.cwd) + if (existsSync(dir)) + throw new UsageError(`${dir} already exists`) + + // The dashboard needs an interactive terminal; without one (CI, piped output) progress goes to the terminal + if (options.dashboard && interactive) + return runCreateWithDashboard(projectName, options.cwd, options.dashboard) + + return runCreatePlain(projectName, options.cwd) +} diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index e43b0bf..2f6a498 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -1,5 +1,5 @@ import type { MetricsSource } from '../dashboard/MetricsClient' -import type { ViewState } from '../dashboard/view' +import type { StartupInfo, ViewState } from '../dashboard/view' import process from 'node:process' import { parseArgs } from 'node:util' import MetricsClient, { httpSource } from '../dashboard/MetricsClient' @@ -29,11 +29,8 @@ export interface DashboardOptions { intervalMs: number theme?: string transparent: boolean - /** Show the startup screen until the server first answers (`mavonengine dev`) */ - startup?: { - engineVersion?: string - output(): string - } + /** Show the startup screen until the server first answers (`mavonengine dev`), read every frame */ + startup?(): StartupInfo } /** Poll interval while waiting for a server that is still starting, so the dashboard appears as soon as it is up */ @@ -111,7 +108,7 @@ export async function runDashboard(options: DashboardOptions, signal?: AbortSign app.render(({ ui, theme }) => renderDashboard(ui, theme, client.state, view, { transparent: options.transparent, - startup: options.startup && { engineVersion: options.startup.engineVersion, output: options.startup.output() }, + startup: options.startup?.(), })) client.onChange(() => app.invalidate()) diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index e38ab2c..c35fad2 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -46,20 +46,25 @@ const LOCKFILES: [string, PackageManager][] = [ const WHITESPACE_RE = /\s+/ +/** Flags shared by `mavonengine dev` and `mavonengine create` */ +export const DEV_FLAGS = { + cwd: { type: 'string' }, + dashboard: { type: 'boolean', default: true }, + port: { type: 'string' }, + theme: { type: 'string' }, + transparent: { type: 'boolean', default: false }, + help: { type: 'boolean', short: 'h', default: false }, +} as const + +type DevFlags = ReturnType>['values'] + /** * Turn CLI arguments into dev options. Throws UsageError on bad input. */ export function parseDevArgs(args: string[], cwd = process.cwd(), env: Record = process.env): DevOptions | 'help' { const { values } = parseArgs({ args, - options: { - cwd: { type: 'string' }, - dashboard: { type: 'boolean', default: true }, - port: { type: 'string' }, - theme: { type: 'string' }, - transparent: { type: 'boolean', default: false }, - help: { type: 'boolean', short: 'h', default: false }, - }, + options: DEV_FLAGS, strict: true, allowPositionals: false, allowNegative: true, @@ -68,6 +73,10 @@ export function parseDevArgs(args: string[], cwd = process.cwd(), env: Record): DevOptions { let dashboard: DashboardOptions | false = false if (values.dashboard) { const dashboardArgs = [ @@ -110,6 +119,13 @@ export function findProjectRoot(start: string): string | undefined { } } +/** + * The @mavonengine/core version installed in a project, if any. + */ +export function installedEngineVersion(root: string): string | undefined { + return readPackageJson(join(root, 'node_modules', '@mavonengine', 'core'))?.version +} + export function detectPackageManager(root: string): PackageManager { return LOCKFILES.find(([file]) => existsSync(join(root, file)))?.[1] ?? 'npm' } @@ -206,7 +222,6 @@ export interface DevProcess { */ export function startDev(root: string, { capture = false } = {}): DevProcess { const pm = detectPackageManager(root) - process.stdout.write(`mavonengine: running '${pm} run dev' in ${root}\n`) const child = spawn(pm, ['run', 'dev'], { cwd: root, @@ -219,6 +234,13 @@ export function startDev(root: string, { capture = false } = {}): DevProcess { const keep = (chunk: string) => { output = (output + chunk).slice(-MAX_CAPTURED_OUTPUT) } + + // The terminal may already belong to the dashboard + const announce = `mavonengine: running '${pm} run dev' in ${root}\n` + if (capture) + keep(announce) + else + process.stdout.write(announce) child.stdout?.setEncoding('utf8').on('data', keep) child.stderr?.setEncoding('utf8').on('data', keep) @@ -292,7 +314,8 @@ export function runDev(root: string): Promise { */ export async function runDevWithDashboard(root: string, dashboard: DashboardOptions): Promise { const dev = startDev(root, { capture: true }) - const startup = { engineVersion: readPackageJson(join(root, 'node_modules', '@mavonengine', 'core'))?.version, output: dev.output } + const engineVersion = installedEngineVersion(root) + const startup = () => ({ engineVersion, output: dev.output() }) const devExited = new AbortController() dev.exited.then(() => devExited.abort(), () => devExited.abort()) diff --git a/packages/cli/src/create/project.ts b/packages/cli/src/create/project.ts new file mode 100644 index 0000000..1cccf0f --- /dev/null +++ b/packages/cli/src/create/project.ts @@ -0,0 +1,188 @@ +import { spawn } from 'node:child_process' +import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import process from 'node:process' +import { Readable, Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import AdmZip from 'adm-zip' +import { formatBytes } from '../dashboard/format' + +// Same template as @mavonengine/create-bootstrap +export const TEMPLATE_URL = 'https://github.com/MavonEngine/Core/archive/refs/tags/0.0.14-alpha.zip' +export const TEMPLATE_SUBPATH = 'packages/multiplayer-template' + +export const PROJECT_NAME_RE = /^[\w@./-][\w@./\- ]*$/ + +/** + * Where creating a project reports to: the startup screen, or the terminal without a dashboard. + */ +export interface CreateReporter { + /** What is happening right now, one line */ + step(label: string): void + /** Output of the commands that run, as it arrives */ + output(chunk: string): void +} + +export interface CreateProjectOptions { + projectName: string + /** The project is created in a new directory named after it in here */ + parentDir: string + report: CreateReporter + /** Stops the download or the install */ + signal?: AbortSignal + templateUrl?: string + fetch?: typeof globalThis.fetch + /** Install the dependencies, default true */ + install?: boolean +} + +export function projectDirFor(projectName: string, parentDir: string) { + return resolve(parentDir, projectName) +} + +/** + * Download `url` to a temporary file, reporting the bytes received. + */ +export async function downloadZip(url: string, { report, signal, fetch = globalThis.fetch }: Pick): Promise { + const response = await fetch(url, { signal }) + + if (!response.ok || !response.body) + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) + + const total = Number(response.headers.get('content-length')) || 0 + let received = 0 + const progress = new Transform({ + transform(chunk: Uint8Array, _encoding, callback) { + received += chunk.length + report.step(`Downloading the template… ${formatBytes(received)}${total ? ` of ${formatBytes(total)}` : ''}`) + callback(null, chunk) + }, + }) + + const dest = join(tmpdir(), `mavonengine-create-${process.pid}-${Date.now()}.zip`) + try { + await pipeline(Readable.fromWeb(response.body as import('node:stream/web').ReadableStream), progress, createWriteStream(dest), { signal }) + } + catch (err) { + rmSync(dest, { force: true }) + throw err + } + + return dest +} + +/** + * Copy `templateSubpath` out of a GitHub archive (everything is under one root folder) into `destDir`. + * Returns the number of files written. + */ +export function extractTemplate(zipPath: string, templateSubpath: string, destDir: string): number { + const entries = new AdmZip(zipPath).getEntries() + + if (entries.length === 0) + throw new Error('Downloaded zip archive is empty') + + const rootFolder = entries[0].entryName.split('/')[0] + const templatePrefix = `${rootFolder}/${templateSubpath}/` + const templateEntries = entries.filter(e => e.entryName.startsWith(templatePrefix)) + + if (templateEntries.length === 0) + throw new Error(`Template path "${templateSubpath}" not found in zip archive (root: ${rootFolder})`) + + const root = resolve(destDir) + let files = 0 + for (const entry of templateEntries) { + const relativePath = entry.entryName.slice(templatePrefix.length) + if (!relativePath) + continue + + const outputPath = resolve(root, relativePath) + // A crafted archive could point outside the project with ../ + const inside = relative(root, outputPath) + if (inside.startsWith('..') || isAbsolute(inside)) + throw new Error(`Refusing to extract ${entry.entryName} outside the project`) + + if (entry.isDirectory) { + mkdirSync(outputPath, { recursive: true }) + } + else { + mkdirSync(dirname(outputPath), { recursive: true }) + writeFileSync(outputPath, entry.getData()) + files++ + } + } + + return files +} + +/** + * Run a command with its output going to the reporter. Rejects on a non-zero exit. + */ +export function runCommand(command: string, args: string[], cwd: string, { report, signal }: Pick): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + // npm is a .cmd shim on Windows + shell: process.platform === 'win32', + signal, + }) + + child.stdout.setEncoding('utf8').on('data', report.output) + child.stderr.setEncoding('utf8').on('data', report.output) + + child.on('error', (err: NodeJS.ErrnoException) => { + reject(err.code === 'ENOENT' ? new Error(`'${command}' was not found on your PATH`) : err) + }) + + child.on('exit', (code, exitSignal) => { + if (code === 0) + resolvePromise() + else + reject(new Error(`'${command} ${args.join(' ')}' ${code === null ? `was stopped by ${exitSignal}` : `exited with code ${code}`}`)) + }) + }) +} + +/** + * Create a project from the multiplayer template: download, extract, name it and install its dependencies. + * Resolves with the project directory. + */ +export async function createProject({ projectName, parentDir, report, signal, templateUrl = TEMPLATE_URL, fetch, install = true }: CreateProjectOptions): Promise { + const projectDir = projectDirFor(projectName, parentDir) + + if (existsSync(projectDir)) + throw new Error(`Directory "${projectDir}" already exists`) + + report.output(`Creating project "${projectName}" in ${projectDir}\n`) + mkdirSync(projectDir, { recursive: true }) + + report.step('Downloading the template…') + report.output(`Downloading the template from ${templateUrl}\n`) + const zipPath = await downloadZip(templateUrl, { report, signal, fetch }) + + try { + report.step('Extracting the template…') + const files = extractTemplate(zipPath, TEMPLATE_SUBPATH, projectDir) + report.output(`Extracted ${files} files\n`) + } + finally { + rmSync(zipPath, { force: true }) + } + + const pkgJsonPath = join(projectDir, 'package.json') + if (existsSync(pkgJsonPath)) { + const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) + pkg.name = projectName + writeFileSync(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}\n`) + } + + if (install) { + signal?.throwIfAborted() + report.step('Installing dependencies…') + report.output('Running \'npm install\'\n') + await runCommand('npm', ['install'], projectDir, { report, signal }) + } + + return projectDir +} diff --git a/packages/cli/src/dashboard/view.ts b/packages/cli/src/dashboard/view.ts index f0630c3..e053f38 100644 --- a/packages/cli/src/dashboard/view.ts +++ b/packages/cli/src/dashboard/view.ts @@ -31,7 +31,7 @@ export interface RenderOptions { * Text is only as readable as whatever is behind the window. */ transparent?: boolean - /** Set while `mavonengine dev` waits for the game server it started */ + /** Set while `mavonengine dev` or `mavonengine create` waits for the game server it started */ startup?: StartupInfo } @@ -40,6 +40,10 @@ export interface StartupInfo { engineVersion?: string /** What the dev script printed so far */ output: string + /** Under the banner, default 'Starting the game server…' */ + label?: string + /** Of the output panel, default 'Dev script' */ + outputTitle?: string } type PlayerRow = ServerMetricsSnapshot['players'][number] @@ -240,8 +244,7 @@ function logoLines(pixels: string[]): Span[][] { const LOGOS = LOGO_SIZES.map(logoLines) -function startupScreen(ui: Container, theme: Theme, { engineVersion, output }: StartupInfo) { - const label = 'Starting the game server…' +function startupScreen(ui: Container, theme: Theme, { engineVersion, output, label = 'Starting the game server…', outputTitle = 'Dev script' }: StartupInfo) { const lines = stripVTControlCharacters(output).split(NEWLINE_RE).filter(line => line.trim() !== '') // The game server logs the same banner once it runs const banner = bootBanner({ version: engineVersion ?? 'unknown', mode: 'development' }) @@ -271,7 +274,7 @@ function startupScreen(ui: Container, theme: Theme, { engineVersion, output }: S column.spacer('1fr') }) - ui.panel({ title: 'Dev script', size: STARTUP_OUTPUT_LINES + 2 }, (panel) => { + ui.panel({ title: outputTitle, size: STARTUP_OUTPUT_LINES + 2 }, (panel) => { if (lines.length === 0) { panel.label('No output yet') return diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 20952a3..ecc3f82 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,11 +1,13 @@ import process from 'node:process' import { version } from '../package.json' +import { CREATE_HELP, parseCreateArgs, runCreateCommand } from './commands/create' import { DASHBOARD_HELP, parseDashboardArgs, runDashboard, UsageError } from './commands/dashboard' import { DEV_HELP, parseDevArgs, runDevCommand } from './commands/dev' const HELP = `Usage: mavonengine [options] Commands: + create Create a project from the multiplayer template and start it dashboard Live terminal dashboard for a running game server dev Run the project's dev servers (game server and client) @@ -29,6 +31,17 @@ async function main(argv: string[]): Promise { return 0 } + if (command === 'create') { + const options = parseCreateArgs(args) + + if (options === 'help') { + process.stdout.write(CREATE_HELP) + return 0 + } + + return runCreateCommand(options) + } + if (command === 'dashboard') { const options = parseDashboardArgs(args) diff --git a/packages/cli/tests/create.test.ts b/packages/cli/tests/create.test.ts new file mode 100644 index 0000000..99325b7 --- /dev/null +++ b/packages/cli/tests/create.test.ts @@ -0,0 +1,123 @@ +import { Buffer } from 'node:buffer' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import AdmZip from 'adm-zip' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { gatedSource, parseCreateArgs } from '../src/commands/create' +import { createProject, extractTemplate, TEMPLATE_SUBPATH } from '../src/create/project' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mavonengine-create-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function templateZip(files: Record) { + const zip = new AdmZip() + for (const [path, content] of Object.entries(files)) + zip.addFile(path, Buffer.from(content)) + return zip.toBuffer() +} + +const template = { + [`Core-0.0.14-alpha/${TEMPLATE_SUBPATH}/package.json`]: JSON.stringify({ name: '@mavonengine/multiplayer-template', scripts: { dev: 'x' } }), + [`Core-0.0.14-alpha/${TEMPLATE_SUBPATH}/server/index.ts`]: 'export {}', + 'Core-0.0.14-alpha/packages/core/package.json': '{}', +} + +function recorder() { + const steps: string[] = [] + let output = '' + return { steps, output: () => output, report: { step: (s: string) => steps.push(s), output: (c: string) => output += c } } +} + +describe('parseCreateArgs', () => { + it('takes the project name and the dev options', () => { + const options = parseCreateArgs(['my-game', '--port', '9000', '--cwd', 'games'], '/home') as any + + expect(options.projectName).toBe('my-game') + expect(options.cwd).toBe('/home/games') + expect(options.dashboard.source.label).toContain(':9000') + }) + + it('leaves the name to the prompt when missing', () => { + expect((parseCreateArgs(['--no-dashboard'], '/home') as any).projectName).toBeUndefined() + }) + + it('rejects bad names and extra positionals', () => { + expect(() => parseCreateArgs(['-bad'])).toThrow() + expect(() => parseCreateArgs(['a', 'b'])).toThrow('one project name') + }) + + it('shows help', () => { + expect(parseCreateArgs(['-h'])).toBe('help') + }) +}) + +describe('extractTemplate', () => { + it('copies only the template folder', () => { + const zipPath = join(dir, 't.zip') + new AdmZip(templateZip(template)).writeZip(zipPath) + + expect(extractTemplate(zipPath, TEMPLATE_SUBPATH, join(dir, 'out'))).toBe(2) + expect(existsSync(join(dir, 'out', 'server', 'index.ts'))).toBe(true) + expect(existsSync(join(dir, 'out', 'packages'))).toBe(false) + }) + + it('fails when the template is missing', () => { + const zipPath = join(dir, 't.zip') + new AdmZip(templateZip({ 'Core/README.md': '' })).writeZip(zipPath) + + expect(() => extractTemplate(zipPath, TEMPLATE_SUBPATH, join(dir, 'out'))).toThrow('not found') + }) +}) + +describe('createProject', () => { + const fetchZip = (async () => new Response(templateZip(template))) as typeof fetch + + it('downloads, extracts and names the project, reporting each step', async () => { + const { steps, output, report } = recorder() + + const projectDir = await createProject({ projectName: 'my-game', parentDir: dir, report, fetch: fetchZip, install: false }) + + expect(projectDir).toBe(join(dir, 'my-game')) + expect(JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')).name).toBe('my-game') + expect(steps[0]).toBe('Downloading the template…') + expect(steps.some(s => s.includes(' B'))).toBe(true) + expect(steps.at(-1)).toBe('Extracting the template…') + expect(output()).toContain('Extracted 2 files') + }) + + it('refuses an existing directory', async () => { + mkdirSync(join(dir, 'taken')) + + await expect(createProject({ projectName: 'taken', parentDir: dir, report: recorder().report, fetch: fetchZip, install: false })) + .rejects + .toThrow('already exists') + }) + + it('reports a failed download', async () => { + const notFound = (async () => new Response('nope', { status: 404, statusText: 'Not Found' })) as typeof fetch + + await expect(createProject({ projectName: 'x', parentDir: dir, report: recorder().report, fetch: notFound, install: false })) + .rejects + .toThrow('404 Not Found') + }) +}) + +describe('gatedSource', () => { + it('keeps reporting connecting until ready', async () => { + let ready = false + const source = gatedSource({ label: 'http://localhost:8050', fetch: async () => ({ ok: true }) }, () => ready) + + await expect(source.fetch()).rejects.toMatchObject({ status: 'connecting' }) + ready = true + await expect(source.fetch()).resolves.toEqual({ ok: true }) + expect(source.label).toBe('http://localhost:8050') + }) +}) diff --git a/packages/cli/tests/view.test.ts b/packages/cli/tests/view.test.ts index 389263f..1f4e784 100644 --- a/packages/cli/tests/view.test.ts +++ b/packages/cli/tests/view.test.ts @@ -222,6 +222,19 @@ describe('dashboard view', () => { expect(screen.contains('UNREACHABLE')).toBe(false) }) + it('shows what mavonengine create is doing on the startup screen', () => { + const screen = render(startingState('connecting'), 130, 40, false, 0, viewState(), { + output: 'Running \'npm install\'\nadded 312 packages\n', + label: 'Installing dependencies…', + outputTitle: 'Setup', + }) + + expect(screen.contains('Installing dependencies')).toBe(true) + expect(screen.contains('Setup')).toBe(true) + expect(screen.contains('added 312 packages')).toBe(true) + expect(screen.contains('Starting the game server')).toBe(false) + }) + it('draws the logo above the banner when the terminal is tall enough', () => { const startup = { engineVersion: '0.0.20', output: '' } const logo = (height: number, capabilities = {}) => renderToScreen( From 616946f3640e1e0ae22330fdbb10d2566ef8f564 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Tue, 27 Oct 2026 00:04:52 +0100 Subject: [PATCH 20/25] bootstrap: deprecate create-bootstrap in favour of mavonengine create create-bootstrap is now a thin wrapper that prints a deprecation notice and runs `mavonengine create` from @mavonengine/cli on the same terminal, so it still goes from setup straight into the dev dashboard. The docs point at `npx @mavonengine/cli create` instead. --- .github/workflows/node.js.yml | 1 - README.md | 2 +- eslint.config.mjs | 9 - package-lock.json | 5 +- packages/bootstrap/package-lock.json | 2897 ------------------- packages/bootstrap/package.json | 7 +- packages/bootstrap/src/config.ts | 4 - packages/bootstrap/src/create.ts | 55 - packages/bootstrap/src/downloader.ts | 27 - packages/bootstrap/src/extractor.ts | 45 - packages/bootstrap/src/index.ts | 41 +- packages/bootstrap/src/prompt.ts | 13 - packages/bootstrap/src/runner.ts | 18 - packages/bootstrap/src/wrapper.ts | 60 + packages/bootstrap/tests/create.test.ts | 166 -- packages/bootstrap/tests/downloader.test.ts | 51 - packages/bootstrap/tests/extractor.test.ts | 120 - packages/bootstrap/tests/wrapper.test.ts | 23 + packages/cli/README.md | 2 +- packages/cli/src/create/project.ts | 1 - packages/core/README.md | 2 +- packages/multiplayer-template/README.md | 2 +- 22 files changed, 104 insertions(+), 3447 deletions(-) delete mode 100644 packages/bootstrap/package-lock.json delete mode 100644 packages/bootstrap/src/config.ts delete mode 100644 packages/bootstrap/src/create.ts delete mode 100644 packages/bootstrap/src/downloader.ts delete mode 100644 packages/bootstrap/src/extractor.ts delete mode 100644 packages/bootstrap/src/prompt.ts delete mode 100644 packages/bootstrap/src/runner.ts create mode 100644 packages/bootstrap/src/wrapper.ts delete mode 100644 packages/bootstrap/tests/create.test.ts delete mode 100644 packages/bootstrap/tests/downloader.test.ts delete mode 100644 packages/bootstrap/tests/extractor.test.ts create mode 100644 packages/bootstrap/tests/wrapper.test.ts diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 229b814..25bacf1 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -29,7 +29,6 @@ jobs: cache: 'npm' cache-dependency-path: | package-lock.json - packages/bootstrap/package-lock.json packages/multiplayer-template/package-lock.json - name: Build and test diff --git a/README.md b/README.md index acc0717..3de8648 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ rendering, physics, networking, animation, and debugging into a single cohesive ## Get Started ```bash -npx @mavonengine/create-bootstrap +npx @mavonengine/cli create my-game ``` | URL | Description | diff --git a/eslint.config.mjs b/eslint.config.mjs index f7cf0ba..0381e62 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,13 +11,4 @@ export default antfu( { files: ['**/*.{ts,tsx}'], }, - { - /** - * Allow console logs in bootstrap package - */ - files: ['packages/bootstrap/**'], - rules: { - 'no-console': 'off', - }, - }, ) diff --git a/package-lock.json b/package-lock.json index 9a44d26..4091739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10934,16 +10934,15 @@ }, "packages/bootstrap": { "name": "@mavonengine/create-bootstrap", - "version": "0.0.9", + "version": "0.1.0", "license": "MIT", "dependencies": { - "adm-zip": "^0.5.16" + "@mavonengine/cli": ">=0.0.1 <1" }, "bin": { "create-bootstrap": "dist/index.js" }, "devDependencies": { - "@types/adm-zip": "^0.5.7", "@types/node": "^22.0.0", "@vitest/coverage-v8": "^3.2.4", "tsup": "^8.5.1", diff --git a/packages/bootstrap/package-lock.json b/packages/bootstrap/package-lock.json deleted file mode 100644 index a015d56..0000000 --- a/packages/bootstrap/package-lock.json +++ /dev/null @@ -1,2897 +0,0 @@ -{ - "name": "@mavonengine/create-bootstrap", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@mavonengine/create-bootstrap", - "version": "0.0.1", - "dependencies": { - "adm-zip": "^0.5.16" - }, - "bin": { - "create-bootstrap": "dist/index.js", - "create-mavonengine-bootstrap": "dist/index.js" - }, - "devDependencies": { - "@types/adm-zip": "^0.5.7", - "@types/node": "^22.0.0", - "@vitest/coverage-v8": "^3.2.4", - "tsup": "^8.5.1", - "typescript": "5.9.2", - "vitest": "^3.2.4" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/adm-zip": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", - "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", - "license": "MIT", - "engines": { - "node": ">=12.0" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/bundle-require": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", - "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "load-tsconfig": "^0.2.3" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "peerDependencies": { - "esbuild": ">=0.18" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fix-dts-default-cjs-exports": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", - "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/load-tsconfig": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", - "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tsup": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", - "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.27.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "^0.7.6", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - } - } -} diff --git a/packages/bootstrap/package.json b/packages/bootstrap/package.json index 0326799..4417d84 100644 --- a/packages/bootstrap/package.json +++ b/packages/bootstrap/package.json @@ -1,8 +1,8 @@ { "name": "@mavonengine/create-bootstrap", "type": "module", - "version": "0.0.9", - "description": "Bootstrap a MavonEngine multiplayer project", + "version": "0.1.0", + "description": "Deprecated: use 'npx @mavonengine/cli create'. Forwards to it.", "license": "MIT", "bin": { "create-bootstrap": "./dist/index.js" @@ -21,10 +21,9 @@ "lint:fix": "eslint . --fix" }, "dependencies": { - "adm-zip": "^0.5.16" + "@mavonengine/cli": ">=0.0.1 <1" }, "devDependencies": { - "@types/adm-zip": "^0.5.7", "@types/node": "^22.0.0", "@vitest/coverage-v8": "^3.2.4", "tsup": "^8.5.1", diff --git a/packages/bootstrap/src/config.ts b/packages/bootstrap/src/config.ts deleted file mode 100644 index d1e057c..0000000 --- a/packages/bootstrap/src/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const TAG_URL - = 'https://github.com/MavonEngine/Core/archive/refs/tags/0.0.14-alpha.zip' - -export const TEMPLATE_SUBPATH = 'packages/multiplayer-template' diff --git a/packages/bootstrap/src/create.ts b/packages/bootstrap/src/create.ts deleted file mode 100644 index 787b306..0000000 --- a/packages/bootstrap/src/create.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import process from 'node:process' -import { TAG_URL, TEMPLATE_SUBPATH } from './config.js' -import { downloadZip } from './downloader.js' -import { extractTemplate } from './extractor.js' -import { runCommand } from './runner.js' - -export interface CreateOptions { - projectName: string - targetDir?: string - skipInstall?: boolean - skipDev?: boolean -} - -export async function createProject({ - projectName, - targetDir, - skipInstall = false, - skipDev = false, -}: CreateOptions): Promise { - const projectDir = resolve(targetDir ?? process.cwd(), projectName) - - if (existsSync(projectDir)) { - throw new Error(`Directory "${projectDir}" already exists`) - } - - console.log(`\nCreating project "${projectName}"...`) - mkdirSync(projectDir, { recursive: true }) - - console.log(`Downloading template from ${TAG_URL}...`) - const zipPath = await downloadZip(TAG_URL) - - console.log('Extracting template...') - extractTemplate({ zipPath, templateSubpath: TEMPLATE_SUBPATH, destDir: projectDir }) - - const pkgJsonPath = join(projectDir, 'package.json') - if (existsSync(pkgJsonPath)) { - const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) - pkg.name = projectName - writeFileSync(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}\n`) - } - - if (!skipInstall) { - console.log('Installing dependencies...') - runCommand('npm', ['install'], projectDir) - } - - if (!skipDev) { - console.log('Starting dev server...') - runCommand('npm', ['run', 'dev'], projectDir) - } - - return projectDir -} diff --git a/packages/bootstrap/src/downloader.ts b/packages/bootstrap/src/downloader.ts deleted file mode 100644 index f767bca..0000000 --- a/packages/bootstrap/src/downloader.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createWriteStream } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Readable } from 'node:stream' -import { pipeline } from 'node:stream/promises' - -export async function downloadZip(url: string): Promise { - const response = await fetch(url) - - if (!response.ok) { - throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) - } - - const dest = join(tmpdir(), `mavonengine-bootstrap-${Date.now()}.zip`) - const fileStream = createWriteStream(dest) - - await pipeline(Readable.fromWeb(response.body as import('node:stream/web').ReadableStream), fileStream) - - return dest -} - -export function extractZipRootFolder(zipEntries: string[]): string { - const first = zipEntries.find(e => e.includes('/')) - if (!first) - throw new Error('Could not determine root folder in zip archive') - return first.split('/')[0] -} diff --git a/packages/bootstrap/src/extractor.ts b/packages/bootstrap/src/extractor.ts deleted file mode 100644 index 128b002..0000000 --- a/packages/bootstrap/src/extractor.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { mkdirSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import AdmZip from 'adm-zip' - -export interface ExtractOptions { - zipPath: string - templateSubpath: string - destDir: string -} - -export function extractTemplate({ zipPath, templateSubpath, destDir }: ExtractOptions): void { - const zip = new AdmZip(zipPath) - const entries = zip.getEntries() - - if (entries.length === 0) { - throw new Error('Downloaded zip archive is empty') - } - - const rootFolder = entries[0].entryName.split('/')[0] - const templatePrefix = `${rootFolder}/${templateSubpath}/` - - const templateEntries = entries.filter(e => e.entryName.startsWith(templatePrefix)) - - if (templateEntries.length === 0) { - throw new Error( - `Template path "${templateSubpath}" not found in zip archive (root: ${rootFolder})`, - ) - } - - for (const entry of templateEntries) { - const relativePath = entry.entryName.slice(templatePrefix.length) - if (!relativePath) - continue - - const outputPath = join(destDir, relativePath) - - if (entry.isDirectory) { - mkdirSync(outputPath, { recursive: true }) - } - else { - mkdirSync(dirname(outputPath), { recursive: true }) - writeFileSync(outputPath, entry.getData()) - } - } -} diff --git a/packages/bootstrap/src/index.ts b/packages/bootstrap/src/index.ts index 5b102a7..edaf091 100644 --- a/packages/bootstrap/src/index.ts +++ b/packages/bootstrap/src/index.ts @@ -1,34 +1,17 @@ import process from 'node:process' -import { createProject } from './create.js' -import { promptProjectName } from './prompt.js' +import { DEPRECATION_NOTICE, isSupportedNode, MIN_NODE_MAJOR, runCreate } from './wrapper.js' -const PROJECT_NAME_RE = /^[\w@./-][\w@./\- ]*$/ +// Deprecated: everything happens in `mavonengine create` now, this only forwards to it +process.stderr.write(DEPRECATION_NOTICE) -async function main(): Promise { - let projectName = process.argv[2]?.trim() - - if (!projectName) { - projectName = await promptProjectName() - } - - if (!projectName) { - console.error('Error: project name is required') - process.exit(1) - } - - if (!PROJECT_NAME_RE.test(projectName)) { - console.error('Error: invalid project name') - process.exit(1) - } - - try { - const dir = await createProject({ projectName }) - console.log(`\nDone! Your project is ready at ${dir}`) - } - catch (err) { - console.error(`\nError: ${(err as Error).message}`) - process.exit(1) - } +if (!isSupportedNode(process.version)) { + process.stderr.write(`@mavonengine/cli needs Node ${MIN_NODE_MAJOR} or newer, this is ${process.version}\n`) + process.exit(1) } -main() +runCreate(process.argv.slice(2)) + .then(code => process.exit(code)) + .catch((err: Error) => { + process.stderr.write(`create-bootstrap: could not start @mavonengine/cli: ${err.message}\n`) + process.exit(1) + }) diff --git a/packages/bootstrap/src/prompt.ts b/packages/bootstrap/src/prompt.ts deleted file mode 100644 index 882532e..0000000 --- a/packages/bootstrap/src/prompt.ts +++ /dev/null @@ -1,13 +0,0 @@ -import process from 'node:process' -import { createInterface } from 'node:readline' - -export function promptProjectName(): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }) - - return new Promise((resolve) => { - rl.question('Project name: ', (answer) => { - rl.close() - resolve(answer.trim()) - }) - }) -} diff --git a/packages/bootstrap/src/runner.ts b/packages/bootstrap/src/runner.ts deleted file mode 100644 index 8e1e4df..0000000 --- a/packages/bootstrap/src/runner.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { spawnSync } from 'node:child_process' -import process from 'node:process' - -export function runCommand(command: string, args: string[], cwd: string): void { - const result = spawnSync(command, args, { - cwd, - stdio: 'inherit', - shell: process.platform === 'win32', - }) - - if (result.error) { - throw result.error - } - - if (result.status !== 0) { - throw new Error(`Command "${command} ${args.join(' ')}" exited with code ${result.status}`) - } -} diff --git a/packages/bootstrap/src/wrapper.ts b/packages/bootstrap/src/wrapper.ts new file mode 100644 index 0000000..de00297 --- /dev/null +++ b/packages/bootstrap/src/wrapper.ts @@ -0,0 +1,60 @@ +import { spawn } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { constants } from 'node:os' +import { dirname, join } from 'node:path' +import process from 'node:process' + +/** @mavonengine/cli needs this Node major or newer */ +export const MIN_NODE_MAJOR = 24 + +const VERSION_PREFIX_RE = /^v/ + +export const DEPRECATION_NOTICE = 'create-bootstrap is deprecated, use \'npx @mavonengine/cli create\' instead\n' + +export function isSupportedNode(version: string): boolean { + return Number(version.replace(VERSION_PREFIX_RE, '').split('.')[0]) >= MIN_NODE_MAJOR +} + +/** + * Path of the `mavonengine` bin of the installed @mavonengine/cli. + */ +export function cliBinPath(from: string = import.meta.url): string { + const pkgPath = createRequire(from).resolve('@mavonengine/cli/package.json') + const { bin } = JSON.parse(readFileSync(pkgPath, 'utf8')) + return join(dirname(pkgPath), typeof bin === 'string' ? bin : bin.mavonengine) +} + +/** + * Run `mavonengine create` with these arguments on this terminal, so it can show its dashboard. + * Resolves with its exit code. + */ +export function runCreate(args: string[]): Promise { + const child = spawn(process.execPath, [cliBinPath(), 'create', ...args], { stdio: 'inherit' }) + + // Ctrl-C reaches the cli through the terminal's process group, so wait for it to exit. + // Signals sent to us alone (kill, closing the terminal) are passed on. + const onInterrupt = () => {} + const forward = (signal: NodeJS.Signals) => child.kill(signal) + process.on('SIGINT', onInterrupt) + process.on('SIGTERM', forward) + process.on('SIGHUP', forward) + + return new Promise((resolve, reject) => { + const cleanup = () => { + process.off('SIGINT', onInterrupt) + process.off('SIGTERM', forward) + process.off('SIGHUP', forward) + } + + child.on('error', (err) => { + cleanup() + reject(err) + }) + + child.on('exit', (code, signal) => { + cleanup() + resolve(code ?? 128 + (signal ? constants.signals[signal] : 0)) + }) + }) +} diff --git a/packages/bootstrap/tests/create.test.ts b/packages/bootstrap/tests/create.test.ts deleted file mode 100644 index e9b102f..0000000 --- a/packages/bootstrap/tests/create.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Buffer } from 'node:buffer' -import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import AdmZip from 'adm-zip' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { TAG_URL } from '../src/config.ts' - -import { createProject } from '../src/create.ts' -import { downloadZip } from '../src/downloader.ts' -import { runCommand } from '../src/runner.ts' - -vi.mock('../src/downloader.ts', () => ({ - downloadZip: vi.fn(), -})) - -vi.mock('../src/runner.ts', () => ({ - runCommand: vi.fn(), -})) - -function createFakeZip(destPath: string): void { - const zip = new AdmZip() - zip.addFile('Core-0.0.6-alpha/', Buffer.alloc(0)) - zip.addFile('Core-0.0.6-alpha/packages/', Buffer.alloc(0)) - zip.addFile('Core-0.0.6-alpha/packages/multiplayer-template/', Buffer.alloc(0)) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/package.json', - Buffer.from( - JSON.stringify({ name: '@mavonengine/multiplayer-template', version: '0.0.1' }, null, 2), - ), - ) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/', - Buffer.alloc(0), - ) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/index.ts', - Buffer.from('export {}'), - ) - zip.writeZip(destPath) -} - -function createFakeZipWithoutPackageJson(destPath: string): void { - const zip = new AdmZip() - zip.addFile('Core-0.0.6-alpha/', Buffer.alloc(0)) - zip.addFile('Core-0.0.6-alpha/packages/', Buffer.alloc(0)) - zip.addFile('Core-0.0.6-alpha/packages/multiplayer-template/', Buffer.alloc(0)) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/', - Buffer.alloc(0), - ) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/index.ts', - Buffer.from('export {}'), - ) - zip.writeZip(destPath) -} - -describe('createProject', () => { - let tmpDir: string - let fakeZipPath: string - - beforeEach(() => { - tmpDir = join(tmpdir(), `mavon-create-test-${Date.now()}`) - mkdirSync(tmpDir, { recursive: true }) - fakeZipPath = join(tmpDir, 'fake.zip') - createFakeZip(fakeZipPath) - - vi.mocked(downloadZip).mockResolvedValue(fakeZipPath) - vi.mocked(runCommand).mockReturnValue(undefined) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }) - vi.restoreAllMocks() - }) - - it('creates the project directory', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }) - expect(existsSync(join(tmpDir, 'my-game'))).toBe(true) - }) - - it('extracts template files into project directory', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }) - expect(existsSync(join(tmpDir, 'my-game', 'src', 'index.ts'))).toBe(true) - }) - - it('renames package.json name to the project name', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }) - const pkg = JSON.parse(readFileSync(join(tmpDir, 'my-game', 'package.json'), 'utf-8')) - expect(pkg.name).toBe('my-game') - }) - - it('runs npm install', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }) - expect(runCommand).toHaveBeenCalledWith('npm', ['install'], join(tmpDir, 'my-game')) - }) - - it('runs npm run dev after install', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir }) - expect(runCommand).toHaveBeenCalledWith('npm', ['run', 'dev'], join(tmpDir, 'my-game')) - }) - - it('skips npm install when skipInstall is true', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipInstall: true, skipDev: true }) - expect(runCommand).not.toHaveBeenCalledWith('npm', ['install'], expect.any(String)) - }) - - it('still runs npm run dev when only skipInstall is true', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipInstall: true }) - expect(runCommand).not.toHaveBeenCalledWith('npm', ['install'], expect.any(String)) - expect(runCommand).toHaveBeenCalledWith('npm', ['run', 'dev'], join(tmpDir, 'my-game')) - }) - - it('throws if project directory already exists', async () => { - mkdirSync(join(tmpDir, 'existing-project')) - await expect( - createProject({ projectName: 'existing-project', targetDir: tmpDir, skipDev: true }), - ).rejects.toThrow('already exists') - }) - - it('returns the absolute path to the created project', async () => { - const dir = await createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }) - expect(dir).toBe(join(tmpDir, 'my-game')) - }) - - it('downloads from the configured TAG_URL', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }) - expect(downloadZip).toHaveBeenCalledWith(TAG_URL) - }) - - it('does not throw when template has no package.json', async () => { - const zipWithoutPkg = join(tmpDir, 'no-pkg.zip') - createFakeZipWithoutPackageJson(zipWithoutPkg) - vi.mocked(downloadZip).mockResolvedValue(zipWithoutPkg) - - const dir = await createProject({ projectName: 'no-pkg-game', targetDir: tmpDir, skipDev: true }) - expect(existsSync(join(dir, 'src', 'index.ts'))).toBe(true) - expect(existsSync(join(dir, 'package.json'))).toBe(false) - }) - - it('propagates error when npm install fails', async () => { - vi.mocked(runCommand).mockImplementation((cmd, args) => { - if (cmd === 'npm' && args[0] === 'install') { - throw new Error('npm install failed') - } - }) - - await expect( - createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }), - ).rejects.toThrow('npm install failed') - }) - - it('propagates error when npm run dev fails', async () => { - vi.mocked(runCommand).mockImplementation((cmd, args) => { - if (cmd === 'npm' && args[0] === 'run') { - throw new Error('dev server failed') - } - }) - - await expect( - createProject({ projectName: 'my-game', targetDir: tmpDir }), - ).rejects.toThrow('dev server failed') - }) -}) diff --git a/packages/bootstrap/tests/downloader.test.ts b/packages/bootstrap/tests/downloader.test.ts deleted file mode 100644 index 738219d..0000000 --- a/packages/bootstrap/tests/downloader.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { downloadZip, extractZipRootFolder } from '../src/downloader.ts' - -describe('extractZipRootFolder', () => { - it('returns the root folder name from a list of zip entries', () => { - const entries = ['Core-0.0.6-alpha/', 'Core-0.0.6-alpha/README.md', 'Core-0.0.6-alpha/src/'] - expect(extractZipRootFolder(entries)).toBe('Core-0.0.6-alpha') - }) - - it('throws if no entry contains a slash', () => { - expect(() => extractZipRootFolder(['README.md'])).toThrow( - 'Could not determine root folder in zip archive', - ) - }) - - it('throws on empty entry list', () => { - expect(() => extractZipRootFolder([])).toThrow( - 'Could not determine root folder in zip archive', - ) - }) -}) - -describe('downloadZip', () => { - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn()) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('throws when the server returns a non-ok response', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 404, - statusText: 'Not Found', - } as Response) - - await expect(downloadZip('https://example.com/fake.zip')).rejects.toThrow('404') - }) - - it('throws when the server returns a 500 error', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - } as Response) - - await expect(downloadZip('https://example.com/fake.zip')).rejects.toThrow('500') - }) -}) diff --git a/packages/bootstrap/tests/extractor.test.ts b/packages/bootstrap/tests/extractor.test.ts deleted file mode 100644 index 80fe8a3..0000000 --- a/packages/bootstrap/tests/extractor.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Buffer } from 'node:buffer' -import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import AdmZip from 'adm-zip' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { extractTemplate } from '../src/extractor.ts' - -function createTestZip(destPath: string): void { - const zip = new AdmZip() - - zip.addFile('Core-0.0.6-alpha/', Buffer.alloc(0)) - zip.addFile('Core-0.0.6-alpha/README.md', Buffer.from('# Core')) - zip.addFile('Core-0.0.6-alpha/packages/', Buffer.alloc(0)) - zip.addFile('Core-0.0.6-alpha/packages/multiplayer-template/', Buffer.alloc(0)) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/package.json', - Buffer.from(JSON.stringify({ name: '@mavonengine/multiplayer-template', version: '0.0.1' })), - ) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/', - Buffer.alloc(0), - ) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/index.ts', - Buffer.from('export {}'), - ) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/deeply/nested/util.ts', - Buffer.from('export const x = 1'), - ) - - zip.writeZip(destPath) -} - -describe('extractTemplate', () => { - let tmpDir: string - let zipPath: string - let destDir: string - - beforeEach(() => { - tmpDir = join(tmpdir(), `mavon-extractor-test-${Date.now()}`) - mkdirSync(tmpDir, { recursive: true }) - zipPath = join(tmpDir, 'test.zip') - destDir = join(tmpDir, 'output') - mkdirSync(destDir, { recursive: true }) - createTestZip(zipPath) - }) - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }) - }) - - it('extracts only the template subpath contents into destDir', () => { - extractTemplate({ - zipPath, - templateSubpath: 'packages/multiplayer-template', - destDir, - }) - - expect(existsSync(join(destDir, 'package.json'))).toBe(true) - expect(existsSync(join(destDir, 'src', 'index.ts'))).toBe(true) - }) - - it('does not extract files outside the template path', () => { - extractTemplate({ - zipPath, - templateSubpath: 'packages/multiplayer-template', - destDir, - }) - - expect(existsSync(join(destDir, 'README.md'))).toBe(false) - }) - - it('preserves file contents', () => { - extractTemplate({ - zipPath, - templateSubpath: 'packages/multiplayer-template', - destDir, - }) - - const content = readFileSync(join(destDir, 'src', 'index.ts'), 'utf-8') - expect(content).toBe('export {}') - }) - - it('creates deeply nested directories and files', () => { - extractTemplate({ - zipPath, - templateSubpath: 'packages/multiplayer-template', - destDir, - }) - - const content = readFileSync(join(destDir, 'src', 'deeply', 'nested', 'util.ts'), 'utf-8') - expect(content).toBe('export const x = 1') - }) - - it('throws when template subpath is not found in zip', () => { - expect(() => - extractTemplate({ - zipPath, - templateSubpath: 'packages/nonexistent', - destDir, - }), - ).toThrow('not found in zip archive') - }) - - it('throws when zip is empty', () => { - const emptyZip = new AdmZip() - const emptyZipPath = join(tmpDir, 'empty.zip') - emptyZip.writeZip(emptyZipPath) - - expect(() => - extractTemplate({ - zipPath: emptyZipPath, - templateSubpath: 'packages/multiplayer-template', - destDir, - }), - ).toThrow('empty') - }) -}) diff --git a/packages/bootstrap/tests/wrapper.test.ts b/packages/bootstrap/tests/wrapper.test.ts new file mode 100644 index 0000000..d0477b0 --- /dev/null +++ b/packages/bootstrap/tests/wrapper.test.ts @@ -0,0 +1,23 @@ +import { existsSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { cliBinPath, isSupportedNode } from '../src/wrapper.ts' + +describe('isSupportedNode', () => { + it.each([ + ['v24.0.0', true], + ['v25.1.2', true], + ['v22.11.0', false], + ['v18.20.4', false], + ])('%s -> %s', (version, expected) => { + expect(isSupportedNode(version)).toBe(expected) + }) +}) + +describe('cliBinPath', () => { + it('points at the mavonengine bin of @mavonengine/cli', () => { + const bin = cliBinPath() + + expect(bin.replaceAll('\\', '/')).toMatch(/cli\/dist\/index\.js$/) + expect(existsSync(bin.replace(/dist[/\\]index\.js$/, 'package.json'))).toBe(true) + }) +}) diff --git a/packages/cli/README.md b/packages/cli/README.md index dddb1eb..e66ea1f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,7 +11,7 @@ npx mavonengine --help ## `mavonengine create` -Creates a new project from the multiplayer template, installs its dependencies and starts it like [`mavonengine dev`](#mavonengine-dev). It does the same as `@mavonengine/create-bootstrap`, but in the dashboard. +Creates a new project from the multiplayer template, installs its dependencies and starts it like [`mavonengine dev`](#mavonengine-dev). It replaces `@mavonengine/create-bootstrap`, which now forwards to it. ```sh npx @mavonengine/cli create my-game diff --git a/packages/cli/src/create/project.ts b/packages/cli/src/create/project.ts index 1cccf0f..5d91be1 100644 --- a/packages/cli/src/create/project.ts +++ b/packages/cli/src/create/project.ts @@ -8,7 +8,6 @@ import { pipeline } from 'node:stream/promises' import AdmZip from 'adm-zip' import { formatBytes } from '../dashboard/format' -// Same template as @mavonengine/create-bootstrap export const TEMPLATE_URL = 'https://github.com/MavonEngine/Core/archive/refs/tags/0.0.14-alpha.zip' export const TEMPLATE_SUBPATH = 'packages/multiplayer-template' diff --git a/packages/core/README.md b/packages/core/README.md index acc0717..3de8648 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,7 +8,7 @@ rendering, physics, networking, animation, and debugging into a single cohesive ## Get Started ```bash -npx @mavonengine/create-bootstrap +npx @mavonengine/cli create my-game ``` | URL | Description | diff --git a/packages/multiplayer-template/README.md b/packages/multiplayer-template/README.md index 44abb28..f07079a 100644 --- a/packages/multiplayer-template/README.md +++ b/packages/multiplayer-template/README.md @@ -3,7 +3,7 @@ Bootstrap this template instantly with: ```bash -npx @mavonengine/create-bootstrap +npx @mavonengine/cli create my-game ``` A ready-to-use multiplayer 3D game template built on the [MavonEngine](https://mavonengine.com/) framework. It demonstrates a client-server architecture with authoritative physics, real-time state synchronisation, player animations, chat, and a 3D world with environmental objects. From ff94443bcbf3cc0ef12ea7025183db9f9727f39b Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Tue, 27 Oct 2026 00:11:27 +0100 Subject: [PATCH 21/25] cli: open the game in the browser once the dev server is up `mavonengine dev` and `mavonengine create` open the page the game server serves in the default browser as soon as the server first answers, unless --no-open is passed, the terminal is an SSH session, or the server doesn't answer / with a page. `o` in the dashboard opens it again any time. --- packages/cli/README.md | 8 ++-- packages/cli/src/browser.ts | 55 ++++++++++++++++++++++++ packages/cli/src/commands/create.ts | 1 + packages/cli/src/commands/dashboard.ts | 23 +++++++++- packages/cli/src/commands/dev.ts | 8 +++- packages/cli/src/dashboard/view.ts | 5 ++- packages/cli/tests/browser.test.ts | 36 ++++++++++++++++ packages/cli/tests/dashboardArgs.test.ts | 10 +++++ packages/cli/tests/dev.test.ts | 6 +++ packages/cli/tests/view.test.ts | 10 +++++ 10 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/browser.ts create mode 100644 packages/cli/tests/browser.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index e66ea1f..3c9d11c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,7 +17,7 @@ Creates a new project from the multiplayer template, installs its dependencies a npx @mavonengine/cli create my-game ``` -Asks for a name if none is given. While the template downloads and `npm install` runs, the startup screen shows each step and its output. Then the dev script starts, and the dashboard takes over as soon as the game server answers. Quitting during setup stops it and leaves what was created so far in the project directory. Without an interactive terminal, or with `--no-dashboard`, the progress and the dev script's output go straight to the terminal. +Asks for a name if none is given. While the template downloads and `npm install` runs, the startup screen shows each step and its output. Then the dev script starts, the dashboard takes over as soon as the game server answers, and the game opens in the browser. Quitting during setup stops it and leaves what was created so far in the project directory. Without an interactive terminal, or with `--no-dashboard`, the progress and the dev script's output go straight to the terminal. | Option | Default | | | --- | --- | --- | @@ -26,12 +26,13 @@ Asks for a name if none is given. While the template downloads and `npm install` | `--port ` | `8050` | Game server HTTP port for the dashboard | | `--theme ` | `dark` | Dashboard theme | | `--transparent` | | Let a transparent terminal background show through | +| `--no-open` | | Don't open the game in the browser once the server is up | ## `mavonengine dev` Runs the `dev` script of the MavonEngine project you are in and shows the [dashboard](#mavonengine-dashboard) for it. For the multiplayer template that starts the game server and the client together. -While the game server starts, a startup screen shows the latest output of the dev script; the dashboard takes over as soon as the server answers. Quitting the dashboard (`q` or Ctrl-C) stops the dev script and everything it started. If the dev script exits on its own, the dashboard closes and its output is printed. +While the game server starts, a startup screen shows the latest output of the dev script; the dashboard takes over as soon as the server answers. Once the server is up, the game it serves opens in your default browser (not over SSH, and only if the server answers `/` with a page); `--no-open` turns that off, and `o` opens it again any time. Quitting the dashboard (`q` or Ctrl-C) stops the dev script and everything it started. If the dev script exits on its own, the dashboard closes and its output is printed. ```sh npx mavonengine dev @@ -49,6 +50,7 @@ The project root is the nearest `package.json`, from the current directory upwar | `--port ` | `8050` | Game server HTTP port for the dashboard | | `--theme ` | `dark` | Dashboard theme | | `--transparent` | | Let a transparent terminal background show through | +| `--no-open` | | Don't open the game in the browser once the server is up | ## `mavonengine dashboard` @@ -78,7 +80,7 @@ npx mavonengine dashboard --sim | `--transparent` | | Let a transparent terminal background show through. Text can be hard to read over a light wallpaper | | `--sim` | | Simulated data instead of a server | -Keys: `q` quits, `r` refreshes now, `1`–`4` switch between the Overview, Players, Logs and HTTP tabs. The mouse wheel scrolls the players table, the logs and the requests; scroll the logs back to the bottom, or the requests back to the top, to follow new ones again. +Keys: `q` quits, `r` refreshes now, `o` opens the game the server serves in the browser, `1`–`4` switch between the Overview, Players, Logs and HTTP tabs. The mouse wheel scrolls the players table, the logs and the requests; scroll the logs back to the bottom, or the requests back to the top, to follow new ones again. The HTTP tab lists every request the game server's HTTP server received, newest first: the client (static files or Vite in dev), your own `onHttp` routes, the geckos.io signaling requests and WebSocket upgrades such as Vite's HMR socket, each with status, duration, size and client address. Query strings are left out since they can hold tokens, and the dashboard's own polling is not recorded. The game traffic itself is WebRTC, not HTTP, so it shows on the Players tab instead; with a separate `socketPort` the signaling requests go to that port and don't show up either. diff --git a/packages/cli/src/browser.ts b/packages/cli/src/browser.ts new file mode 100644 index 0000000..970abd2 --- /dev/null +++ b/packages/cli/src/browser.ts @@ -0,0 +1,55 @@ +import { spawn } from 'node:child_process' +import process from 'node:process' + +const AMPERSAND_RE = /&/g + +/** + * The command that opens `url` in the default browser on `platform`. + */ +export function openCommand(url: string, platform: NodeJS.Platform = process.platform): [string, string[]] { + if (platform === 'darwin') + return ['open', [url]] + + // `start` treats its first quoted argument as the window title, and & ends a cmd command + if (platform === 'win32') + return ['cmd', ['/c', 'start', '""', url.replace(AMPERSAND_RE, '^&')]] + + return ['xdg-open', [url]] +} + +/** + * Open `url` in the default browser, without waiting for it. Failing to is not an error worth stopping for. + */ +export function openBrowser(url: string) { + const [command, args] = openCommand(url) + + try { + const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsHide: true }) + child.on('error', () => {}) + child.unref() + } + catch { + // No opener on this system + } +} + +/** + * Whether the browser would open on a screen in front of the user, rather than on the far end of an SSH session. + */ +export function canOpenBrowserHere(env: Record = process.env) { + return !env.SSH_CONNECTION && !env.SSH_TTY +} + +/** + * Whether `url` answers with a page, so a server that doesn't serve the client isn't opened as a 404. + */ +export async function servesPage(url: string, { timeoutMs = 3000, fetch = globalThis.fetch } = {}): Promise { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }) + await res.body?.cancel() + return res.ok && (res.headers.get('content-type') ?? '').includes('text/html') + } + catch { + return false + } +} diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index af1c4fb..0ef659d 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -22,6 +22,7 @@ Options: --port Game server HTTP port for the dashboard (default: 8050) --theme Dashboard theme, see 'mavonengine dashboard --help' --transparent Let a transparent terminal background show through + --no-open Don't open the game in the browser once the server is up -h, --help Show this help ` diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 2f6a498..2439db5 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -2,6 +2,7 @@ import type { MetricsSource } from '../dashboard/MetricsClient' import type { StartupInfo, ViewState } from '../dashboard/view' import process from 'node:process' import { parseArgs } from 'node:util' +import { openBrowser, servesPage } from '../browser' import MetricsClient, { httpSource } from '../dashboard/MetricsClient' import { simulatedSource } from '../dashboard/simulator' import { readableTheme } from '../dashboard/theme' @@ -29,6 +30,10 @@ export interface DashboardOptions { intervalMs: number theme?: string transparent: boolean + /** Page the game server serves the client on, opened with `o` */ + clientUrl?: string + /** Open `clientUrl` in the browser once the server first answers, if it serves a page there */ + openBrowser?: boolean /** Show the startup screen until the server first answers (`mavonengine dev`), read every frame */ startup?(): StartupInfo } @@ -88,7 +93,7 @@ export function parseDashboardArgs(args: string[], env: Record renderDashboard(ui, theme, client.state, view, { transparent: options.transparent, startup: options.startup?.(), + openKey: !!options.clientUrl, })) client.onChange(() => app.invalidate()) + const { clientUrl } = options + if (clientUrl && options.openBrowser) { + const off = client.onChange(async (state) => { + if (state.status !== 'live') + return + + off() + if (await servesPage(clientUrl)) + openBrowser(clientUrl) + }) + } + app.on('key', (event) => { if (event.key === 'r') void client.refresh() + if (event.key === 'o' && options.clientUrl) + openBrowser(options.clientUrl) + const tab = tabForKey(event.key) if (tab !== null) { view.tab = tab diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index c35fad2..eaf6ba7 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -6,12 +6,14 @@ import { dirname, join, resolve } from 'node:path' import process from 'node:process' import { setTimeout as delay } from 'node:timers/promises' import { parseArgs } from 'node:util' +import { canOpenBrowserHere } from '../browser' import { parseDashboardArgs, runDashboard, UsageError } from './dashboard' export const DEV_HELP = `Usage: mavonengine dev [options] Run the dev script of the MavonEngine project you are in (game server and client) -and show the dashboard for it. Quitting the dashboard stops the dev servers. +and show the dashboard for it. Once the game server is up, the game opens in the +browser. Quitting the dashboard stops the dev servers. Options: --cwd Start looking for the project here (default: current directory) @@ -19,6 +21,7 @@ Options: --port Game server HTTP port for the dashboard (default: 8050) --theme Dashboard theme, see 'mavonengine dashboard --help' --transparent Let a transparent terminal background show through + --no-open Don't open the game in the browser once the server is up -h, --help Show this help ` @@ -53,6 +56,7 @@ export const DEV_FLAGS = { port: { type: 'string' }, theme: { type: 'string' }, transparent: { type: 'boolean', default: false }, + open: { type: 'boolean', default: true }, help: { type: 'boolean', short: 'h', default: false }, } as const @@ -85,6 +89,8 @@ export function toDevOptions(values: DevFlags, cwd: string, env: Record { + it('uses the opener of each platform', () => { + expect(openCommand('http://localhost:8050/', 'darwin')).toEqual(['open', ['http://localhost:8050/']]) + expect(openCommand('http://localhost:8050/', 'linux')).toEqual(['xdg-open', ['http://localhost:8050/']]) + expect(openCommand('http://localhost:8050/?a=1&b=2', 'win32')).toEqual(['cmd', ['/c', 'start', '""', 'http://localhost:8050/?a=1^&b=2']]) + }) +}) + +describe('canOpenBrowserHere', () => { + it('is false over SSH', () => { + expect(canOpenBrowserHere({})).toBe(true) + expect(canOpenBrowserHere({ SSH_CONNECTION: '10.0.0.1 51234 10.0.0.2 22' })).toBe(false) + expect(canOpenBrowserHere({ SSH_TTY: '/dev/ttys001' })).toBe(false) + }) +}) + +describe('servesPage', () => { + const answer = (body: string, init: ResponseInit) => (async () => new Response(body, init)) as typeof fetch + + it('is true for an HTML page', async () => { + expect(await servesPage('http://localhost:8050/', { fetch: answer('', { headers: { 'content-type': 'text/html; charset=utf-8' } }) })).toBe(true) + }) + + it('is false for errors, other content and unreachable servers', async () => { + const refused = (async () => { + throw new Error('ECONNREFUSED') + }) as typeof fetch + + expect(await servesPage('http://localhost:8050/', { fetch: answer('Cannot GET /', { status: 404, headers: { 'content-type': 'text/html' } }) })).toBe(false) + expect(await servesPage('http://localhost:8050/', { fetch: answer('{}', { headers: { 'content-type': 'application/json' } }) })).toBe(false) + expect(await servesPage('http://localhost:8050/', { fetch: refused })).toBe(false) + }) +}) diff --git a/packages/cli/tests/dashboardArgs.test.ts b/packages/cli/tests/dashboardArgs.test.ts index 41d71c1..c683a34 100644 --- a/packages/cli/tests/dashboardArgs.test.ts +++ b/packages/cli/tests/dashboardArgs.test.ts @@ -25,6 +25,16 @@ describe('parseDashboardArgs', () => { expect(options !== 'help' && options.source.label).toBe('http://[::1]:8050/api/game/metrics') }) + it('opens the client from the root of the metrics server', () => { + const local = parseDashboardArgs(['--port', '9000'], {}) + const proxied = parseDashboardArgs(['--url', 'https://game.example.com/api/game/metrics'], {}) + const sim = parseDashboardArgs(['--sim'], {}) + + expect(local !== 'help' && local.clientUrl).toBe('http://localhost:9000/') + expect(proxied !== 'help' && proxied.clientUrl).toBe('https://game.example.com/') + expect(sim !== 'help' && sim.clientUrl).toBeUndefined() + }) + it('uses --url as is', () => { const options = parseDashboardArgs(['--url', 'https://game.example.com/api/game/metrics'], {}) diff --git a/packages/cli/tests/dev.test.ts b/packages/cli/tests/dev.test.ts index 47bc346..866c093 100644 --- a/packages/cli/tests/dev.test.ts +++ b/packages/cli/tests/dev.test.ts @@ -108,6 +108,12 @@ describe('parseDevArgs', () => { expect(parseDevArgs(['--cwd', 'game'], '/work')).toMatchObject({ cwd: '/work/game' }) }) + it('opens the browser unless --no-open is passed or the terminal is an SSH session', () => { + expect(parseDevArgs([], '/work', {})).toMatchObject({ dashboard: { openBrowser: true, clientUrl: 'http://localhost:8050/' } }) + expect(parseDevArgs(['--no-open'], '/work', {})).toMatchObject({ dashboard: { openBrowser: false } }) + expect(parseDevArgs([], '/work', { SSH_TTY: '/dev/ttys001' })).toMatchObject({ dashboard: { openBrowser: false } }) + }) + it('shows the dashboard for the local server by default', () => { const options = parseDevArgs([], '/work', {}) diff --git a/packages/cli/tests/view.test.ts b/packages/cli/tests/view.test.ts index 1f4e784..d477ec2 100644 --- a/packages/cli/tests/view.test.ts +++ b/packages/cli/tests/view.test.ts @@ -208,6 +208,16 @@ describe('dashboard view', () => { expect(screen.contains('attempt 2')).toBe(true) }) + it('shows the open key once the server answers, when there is a client to open', async () => { + const state = await liveState() + const withKey = renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, viewState(), { openKey: true }), { width: 130, height: 40 }) + const starting = renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, startingState('connecting'), viewState(), { openKey: true }), { width: 130, height: 40 }) + + expect(withKey.contains('Open game')).toBe(true) + expect(render(state).contains('Open game')).toBe(false) + expect(starting.contains('Open game')).toBe(false) + }) + it('shows the startup screen while the dev server starts', () => { const screen = render(startingState('unreachable'), 130, 40, false, 0, viewState(), { engineVersion: '0.0.20', From 96079b5f6645f63a6e2ccbcafd020a298e8c78ad Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Tue, 27 Oct 2026 19:59:21 +0100 Subject: [PATCH 22/25] package lock --- package-lock.json | 102 ++++++++++++---------------------------------- 1 file changed, 25 insertions(+), 77 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4091739..3b9ef2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1723,8 +1723,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.60.1", @@ -1738,8 +1737,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.1", @@ -1753,8 +1751,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.60.1", @@ -1768,8 +1765,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.60.1", @@ -1783,8 +1779,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.60.1", @@ -1798,8 +1793,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.60.1", @@ -1813,8 +1807,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.60.1", @@ -1828,8 +1821,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.60.1", @@ -1843,8 +1835,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.60.1", @@ -1858,8 +1849,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.60.1", @@ -1873,8 +1863,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.60.1", @@ -1888,8 +1877,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.60.1", @@ -1903,8 +1891,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.60.1", @@ -1918,8 +1905,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.60.1", @@ -1933,8 +1919,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.60.1", @@ -1948,8 +1933,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.60.1", @@ -1963,8 +1947,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.1", @@ -1978,8 +1961,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.60.1", @@ -1993,8 +1975,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.60.1", @@ -2008,8 +1989,7 @@ "optional": true, "os": [ "openbsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.60.1", @@ -2023,8 +2003,7 @@ "optional": true, "os": [ "openharmony" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.60.1", @@ -2038,8 +2017,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.60.1", @@ -2053,8 +2031,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.60.1", @@ -2068,8 +2045,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.60.1", @@ -2083,8 +2059,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@sindresorhus/base62": { "version": "1.0.0", @@ -5429,7 +5404,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -9959,7 +9933,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -9977,7 +9950,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -9995,7 +9967,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -10013,7 +9984,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -10031,7 +10001,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -10049,7 +10018,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -10067,7 +10035,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -10085,7 +10052,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -10103,7 +10069,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10121,7 +10086,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10139,7 +10103,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10157,7 +10120,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10175,7 +10137,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10193,7 +10154,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10211,7 +10171,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10229,7 +10188,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10247,7 +10205,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10265,7 +10222,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -10283,7 +10239,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -10301,7 +10256,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -10319,7 +10273,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -10337,7 +10290,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -10355,7 +10307,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -10373,7 +10324,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -10391,7 +10341,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -10409,7 +10358,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } From 121ae15356b2d2859cbcef57712926e43ad3f5c5 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Tue, 27 Oct 2026 20:04:05 +0100 Subject: [PATCH 23/25] cli: resolve expected paths in tests so they pass on Windows --- packages/cli/tests/create.test.ts | 4 ++-- packages/cli/tests/dev.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/tests/create.test.ts b/packages/cli/tests/create.test.ts index 99325b7..2fbe15f 100644 --- a/packages/cli/tests/create.test.ts +++ b/packages/cli/tests/create.test.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import AdmZip from 'adm-zip' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { gatedSource, parseCreateArgs } from '../src/commands/create' @@ -41,7 +41,7 @@ describe('parseCreateArgs', () => { const options = parseCreateArgs(['my-game', '--port', '9000', '--cwd', 'games'], '/home') as any expect(options.projectName).toBe('my-game') - expect(options.cwd).toBe('/home/games') + expect(options.cwd).toBe(resolve('/home/games')) expect(options.dashboard.source.label).toContain(':9000') }) diff --git a/packages/cli/tests/dev.test.ts b/packages/cli/tests/dev.test.ts index 866c093..a5219f3 100644 --- a/packages/cli/tests/dev.test.ts +++ b/packages/cli/tests/dev.test.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import process from 'node:process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { descendants, detectPackageManager, findProjectRoot, parseDevArgs, runDevCommand, startDev } from '../src/commands/dev' @@ -101,11 +101,11 @@ describe('descendants', () => { describe('parseDevArgs', () => { it('defaults to the current directory', () => { - expect(parseDevArgs([], '/work')).toMatchObject({ cwd: '/work' }) + expect(parseDevArgs([], '/work')).toMatchObject({ cwd: resolve('/work') }) }) it('resolves --cwd against the current directory', () => { - expect(parseDevArgs(['--cwd', 'game'], '/work')).toMatchObject({ cwd: '/work/game' }) + expect(parseDevArgs(['--cwd', 'game'], '/work')).toMatchObject({ cwd: resolve('/work/game') }) }) it('opens the browser unless --no-open is passed or the terminal is an SSH session', () => { @@ -129,7 +129,7 @@ describe('parseDevArgs', () => { }) it('turns the dashboard off with --no-dashboard', () => { - expect(parseDevArgs(['--no-dashboard'], '/work')).toEqual({ cwd: '/work', dashboard: false }) + expect(parseDevArgs(['--no-dashboard'], '/work')).toEqual({ cwd: resolve('/work'), dashboard: false }) }) it('rejects a bad dashboard port', () => { From b12d2cb032b01ee51aee7378af4fa41449b146e2 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Tue, 27 Oct 2026 20:05:40 +0100 Subject: [PATCH 24/25] cli: wait for SIGKILLed dev processes to be gone before stop() resolves --- packages/cli/src/commands/dev.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index eaf6ba7..3ef7d89 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -36,6 +36,8 @@ const MAX_CAPTURED_OUTPUT = 64 * 1024 /** How long the dev processes get to exit after SIGTERM before they are killed */ const STOP_TIMEOUT_MS = 3000 +/** How long killed processes get to disappear before stop() gives up waiting */ +const KILL_TIMEOUT_MS = 2000 export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' @@ -195,6 +197,12 @@ function isAlive(pid: number) { } } +async function waitForExit(pids: number[], timeoutMs: number) { + const deadline = Date.now() + timeoutMs + while (pids.some(isAlive) && Date.now() < deadline) + await delay(50) +} + /** * Signal a process and everything it started. npm exits on SIGTERM without passing it on, * which would leave the game server and vite running. @@ -292,12 +300,11 @@ export function startDev(root: string, { capture = false } = {}): DevProcess { // Remembered up front: once npm exits, its children are reparented and can't be found from it anymore const tree = processTree(child.pid) signalAll(tree, 'SIGTERM') + await waitForExit(tree, STOP_TIMEOUT_MS) - const deadline = Date.now() + STOP_TIMEOUT_MS - while (tree.some(isAlive) && Date.now() < deadline) - await delay(50) - + // SIGKILL takes effect asynchronously, so wait for those to be gone too signalAll(tree.filter(isAlive), 'SIGKILL') + await waitForExit(tree, KILL_TIMEOUT_MS) } } From d22176eec13b46b9dda1d26928651980e12ed697 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Tue, 27 Oct 2026 20:11:25 +0100 Subject: [PATCH 25/25] prepare release 0.0.15-alpha: core 0.0.21, template on core 0.0.21, cli template tag --- package-lock.json | 2 +- packages/cli/src/create/project.ts | 2 +- packages/core/package.json | 2 +- packages/multiplayer-template/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b9ef2f..db227d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10969,7 +10969,7 @@ }, "packages/core": { "name": "@mavonengine/core", - "version": "0.0.20", + "version": "0.0.21", "license": "MIT", "devDependencies": { "@dimforge/rapier3d-compat": "0.18.2", diff --git a/packages/cli/src/create/project.ts b/packages/cli/src/create/project.ts index 5d91be1..992aedd 100644 --- a/packages/cli/src/create/project.ts +++ b/packages/cli/src/create/project.ts @@ -8,7 +8,7 @@ import { pipeline } from 'node:stream/promises' import AdmZip from 'adm-zip' import { formatBytes } from '../dashboard/format' -export const TEMPLATE_URL = 'https://github.com/MavonEngine/Core/archive/refs/tags/0.0.14-alpha.zip' +export const TEMPLATE_URL = 'https://github.com/MavonEngine/Core/archive/refs/tags/0.0.15-alpha.zip' export const TEMPLATE_SUBPATH = 'packages/multiplayer-template' export const PROJECT_NAME_RE = /^[\w@./-][\w@./\- ]*$/ diff --git a/packages/core/package.json b/packages/core/package.json index 382289b..fc487e1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@mavonengine/core", "type": "module", - "version": "0.0.20", + "version": "0.0.21", "description": "", "author": "", "license": "MIT", diff --git a/packages/multiplayer-template/package.json b/packages/multiplayer-template/package.json index c019e9f..beb2cbf 100644 --- a/packages/multiplayer-template/package.json +++ b/packages/multiplayer-template/package.json @@ -14,7 +14,7 @@ "lint:fix": "eslint . --fix" }, "dependencies": { - "@mavonengine/core": "0.0.20" + "@mavonengine/core": "0.0.21" }, "devDependencies": { "@antfu/eslint-config": "^8.0.0",