This commit is contained in:
SowinskiBraeden committed 2026-09-05 13:10:36 -07:00
1 parent 3ecced51c8
commit 3adb45886a
66 files changed
+9227 -3679

No files matched your search

+2
View File
@@ -20,6 +20,7 @@
"express": "^5.1.0",
"pg": "^8.16.0",
"pino": "^9.7.0",
"ws": "^8.21.3",
"zod": "^3.25.0"
},
"devDependencies": {
@@ -27,6 +28,7 @@
"@types/node": "^24.0.0",
"@types/pg": "^8.15.0",
"@types/supertest": "^6.0.0",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.0",
"pino-pretty": "^13.0.0",
"supertest": "^7.1.0",
+13 -4
View File
@@ -11,11 +11,14 @@ import type { Logger } from './lib/logger.js';
import { createAuthRouter } from './modules/auth/auth-routes.js';
import { csrfProtection, sessionResolver } from './modules/auth/auth-middleware.js';
import type { SessionService } from './modules/auth/session-service.js';
import type { ConfigEditorService } from './modules/config/config-editor-service.js';
import type { ConfigSyncService } from './modules/config/config-sync.js';
import type { ServerModsService } from './modules/config/mods-service.js';
import type { PerformanceSettingsService } from './modules/config/performance-service.js';
import type { ServerMetricsService } from './modules/servers/metrics-service.js';
import type { ResourceHistoryService } from './modules/servers/resource-history.js';
import type { MissionCatalog } from './modules/reforger-logs/missions-catalog.js';
import type { MissionsService } from './modules/reforger-logs/missions-catalog.js';
import type { ConsoleHub } from './modules/pterodactyl/console-hub.js';
import type { GameServerProvider } from './modules/pterodactyl/types.js';
import type { LogPathResolver } from './modules/reforger-logs/ingestion/log-path-resolver.js';
import type { IngestionScheduler } from './modules/reforger-logs/ingestion/scheduler.js';
@@ -24,7 +27,7 @@ import type { ServerService } from './modules/servers/server-service.js';
import { createInviteRouter } from './modules/invites/invite-routes.js';
import { createUserRouter } from './modules/users/user-routes.js';
import { createWorkshopRouter } from './modules/workshop/workshop-routes.js';
import type { WorkshopClient } from './modules/workshop/workshop-client.js';
import type { WorkshopCache } from './modules/workshop/workshop-cache.js';
export type AppDeps = {
env: Env;
@@ -33,14 +36,17 @@ export type AppDeps = {
sessions: SessionService;
servers: ServerService;
provider: GameServerProvider;
workshop: WorkshopClient;
metrics: ServerMetricsService;
consoleHub: ConsoleHub | null;
workshop: WorkshopCache;
scheduler: IngestionScheduler | null;
resolveLogPath: LogPathResolver | null;
configSync: ConfigSyncService | null;
configEditor: ConfigEditorService | null;
mods: ServerModsService | null;
performance: PerformanceSettingsService | null;
resourceHistory: ResourceHistoryService | null;
missions: MissionCatalog | null;
missions: MissionsService;
};
export function createApp(deps: AppDeps) {
@@ -108,9 +114,12 @@ export function createApp(deps: AppDeps) {
createServerRouter({
service: deps.servers,
provider: deps.provider,
metrics: deps.metrics,
consoleHub: deps.consoleHub,
scheduler: deps.scheduler,
resolveLogPath: deps.resolveLogPath,
configSync: deps.configSync,
configEditor: deps.configEditor,
mods: deps.mods,
performance: deps.performance,
resourceHistory: deps.resourceHistory,
+10 -1
View File
@@ -23,14 +23,23 @@ const envSchema = z
DEV_AUTH_BYPASS: booleanString,
REFORGER_WORKSHOP_API_BASE_URL: z.string().url().default('https://api.reforgermods.net'),
/** Optional paid-tier key; the free public tier needs no credentials. */
REFORGER_WORKSHOP_API_KEY: z.string().default(''),
PTERODACTYL_BASE_URL: z.string().default(''),
PTERODACTYL_CLIENT_API_KEY: z.string().default(''),
PTERODACTYL_SERVER_ID: z.string().default(''),
USE_MOCK_PTERODACTYL: booleanString,
/**
* Proxy Pterodactyl's Wings websocket for the live console and real-time
* resource metrics. Turn off to fall back to REST polling only.
*/
PTERODACTYL_WEBSOCKET_ENABLED: z
.enum(['true', 'false'])
.default('true')
.transform((v) => v === 'true'),
REFORGER_CONFIG_PATH: z.string().default('/config.json'),
REFORGER_CONFIG_SYNC_INTERVAL_SECONDS: z.coerce.number().int().min(60).max(86400).default(300),
REFORGER_ADMIN_LOG_PATH: z.string().default(''),
REFORGER_LOG_DIRECTORY: z.string().default(''),
+83 -32
View File
@@ -3,14 +3,19 @@ import { createDb } from './db/client.js';
import { isPterodactylConfigured, loadEnv } from './env.js';
import { createLogger } from './lib/logger.js';
import { SessionService } from './modules/auth/session-service.js';
import { ConfigEditorService } from './modules/config/config-editor-service.js';
import { ConfigFileGateway } from './modules/config/config-file-gateway.js';
import { ConfigSyncService } from './modules/config/config-sync.js';
import { ServerModsService } from './modules/config/mods-service.js';
import { PerformanceSettingsService } from './modules/config/performance-service.js';
import { ServerMetricsService } from './modules/servers/metrics-service.js';
import { ResourceHistoryService } from './modules/servers/resource-history.js';
import { MissionCatalog } from './modules/reforger-logs/missions-catalog.js';
import { MissionCatalog, MissionsService } from './modules/reforger-logs/missions-catalog.js';
import type { ConsoleHub } from './modules/pterodactyl/console-hub.js';
import { MockConsoleHub } from './modules/pterodactyl/mock-console-hub.js';
import { MockGameServerProvider } from './modules/pterodactyl/mock-provider.js';
import { PterodactylProvider } from './modules/pterodactyl/pterodactyl-provider.js';
import { WingsConsoleHub } from './modules/pterodactyl/wings-socket.js';
import type { GameServerProvider } from './modules/pterodactyl/types.js';
import { DrizzleIngestionStore } from './modules/reforger-logs/ingestion/drizzle-store.js';
import { LogIngestionService } from './modules/reforger-logs/ingestion/ingestion-service.js';
@@ -18,6 +23,7 @@ import { createLogPathResolver } from './modules/reforger-logs/ingestion/log-pat
import { PterodactylLogSource } from './modules/reforger-logs/ingestion/pterodactyl-log-source.js';
import { IngestionScheduler } from './modules/reforger-logs/ingestion/scheduler.js';
import { ServerService } from './modules/servers/server-service.js';
import { WorkshopCache } from './modules/workshop/workshop-cache.js';
import { WorkshopClient } from './modules/workshop/workshop-client.js';
const logger = createLogger();
@@ -26,34 +32,72 @@ const env = loadEnv();
const { db, pool } = createDb(env.DATABASE_URL);
const mockLogPath = env.REFORGER_ADMIN_LOG_PATH || '/profile/logs/console.log';
const provider: GameServerProvider = env.USE_MOCK_PTERODACTYL
const mockProvider = env.USE_MOCK_PTERODACTYL
? new MockGameServerProvider({ logPath: mockLogPath })
: new PterodactylProvider({
baseUrl: env.PTERODACTYL_BASE_URL,
apiKey: env.PTERODACTYL_CLIENT_API_KEY,
});
: null;
const provider: GameServerProvider =
mockProvider ??
new PterodactylProvider({
baseUrl: env.PTERODACTYL_BASE_URL,
apiKey: env.PTERODACTYL_CLIENT_API_KEY,
});
const sessions = new SessionService(db, env.OWNER_DISCORD_ID);
const servers = new ServerService(db);
const workshop = new WorkshopClient({ baseUrl: env.REFORGER_WORKSHOP_API_BASE_URL });
const configSync = isPterodactylConfigured(env)
? new ConfigSyncService(provider, servers, logger, env.REFORGER_CONFIG_PATH)
: null;
const gateway = new ConfigFileGateway(provider, env.REFORGER_CONFIG_PATH);
const mods = configSync ? new ServerModsService(gateway, configSync, logger) : null;
const performance = configSync ? new PerformanceSettingsService(gateway, configSync, logger) : null;
const resourceHistory = new ResourceHistoryService(provider, logger);
// Log ingestion runs when a backend is configured and we know where logs live.
const logsConfigured =
isPterodactylConfigured(env) &&
Boolean(env.REFORGER_ADMIN_LOG_PATH || env.REFORGER_LOG_DIRECTORY || env.USE_MOCK_PTERODACTYL);
const workshop = new WorkshopCache(
new WorkshopClient({
baseUrl: env.REFORGER_WORKSHOP_API_BASE_URL,
apiKey: env.REFORGER_WORKSHOP_API_KEY,
}),
logger,
);
const primaryServer = (await servers.listServers())[0] ?? null;
const providerServerId = primaryServer
? (primaryServer.pterodactylServerId ?? primaryServer.slug)
: '';
/**
* The live console feed. Under mock mode it is simulated; against a real
* Pterodactyl it is the Wings websocket, which is the only source that shows
* install/update/mod-download output as it happens.
*/
let consoleHub: ConsoleHub | null = null;
if (mockProvider) {
consoleHub = new MockConsoleHub(mockProvider);
} else if (isPterodactylConfigured(env) && env.PTERODACTYL_WEBSOCKET_ENABLED && providerServerId) {
consoleHub = new WingsConsoleHub({
baseUrl: env.PTERODACTYL_BASE_URL,
apiKey: env.PTERODACTYL_CLIENT_API_KEY,
serverId: providerServerId,
logger,
});
} else {
logger.info('live console disabled (websocket off or backend not configured)');
}
const gateway = new ConfigFileGateway(provider, env.REFORGER_CONFIG_PATH);
const configured = isPterodactylConfigured(env);
const configSync = configured ? new ConfigSyncService(gateway, servers, logger) : null;
const configEditor = configSync
? new ConfigEditorService(gateway, provider, configSync, logger)
: null;
const mods = configured ? new ServerModsService(gateway, workshop, logger) : null;
const performance = configEditor
? new PerformanceSettingsService(gateway, configEditor, logger)
: null;
const metrics = new ServerMetricsService(provider, consoleHub);
const resourceHistory = new ResourceHistoryService(metrics, logger);
// Log ingestion runs when a backend is configured and we know where logs live.
// It no longer powers the console view — only player sessions and killfeed,
// which are parsed out of the game's own log file.
const logsConfigured =
configured &&
Boolean(env.REFORGER_ADMIN_LOG_PATH || env.REFORGER_LOG_DIRECTORY || env.USE_MOCK_PTERODACTYL);
const resolveLogPath =
logsConfigured && primaryServer
? createLogPathResolver({
@@ -65,10 +109,11 @@ const resolveLogPath =
})
: null;
const missions =
const missionCatalog =
resolveLogPath && primaryServer
? new MissionCatalog(provider, resolveLogPath, providerServerId)
: null;
const missions = new MissionsService(workshop, missionCatalog);
let scheduler: IngestionScheduler | null = null;
if (resolveLogPath) {
@@ -94,10 +139,13 @@ const app = createApp({
sessions,
servers,
provider,
metrics,
consoleHub,
workshop,
scheduler,
resolveLogPath,
configSync,
configEditor,
mods,
performance,
resourceHistory,
@@ -111,6 +159,8 @@ const httpServer = app.listen(env.PORT, () => {
);
});
consoleHub?.start();
if (scheduler && resolveLogPath && primaryServer) {
scheduler.start([
{
@@ -121,20 +171,21 @@ if (scheduler && resolveLogPath && primaryServer) {
]);
}
if (primaryServer && isPterodactylConfigured(env)) {
if (primaryServer && configured) {
resourceHistory.start([{ serverId: primaryServer.id, providerServerId }]);
}
// Import the real config.json at startup and on an interval so the panel
// always reflects what the server actually runs.
let configSyncTimer: ReturnType<typeof setInterval> | null = null;
if (configSync) {
void configSync.syncAllQuietly();
configSyncTimer = setInterval(
() => void configSync.syncAllQuietly(),
env.REFORGER_CONFIG_SYNC_INTERVAL_SECONDS * 1000,
);
configSyncTimer.unref();
// Import the real config.json once at startup so the panel reflects what the
// server actually runs. Writes trigger their own sync, and every read is live,
// so there is deliberately no background polling loop here.
if (configSync && primaryServer) {
void configSync
.syncAllQuietly()
.then(() => mods?.getMods(primaryServer))
// Prime the Workshop cache with the installed mod list so the first visit
// to the Mods page renders without waiting on the network.
.then((installed) => workshop.warm((installed?.mods ?? []).map((mod) => mod.modId)))
.catch(() => undefined);
}
// Hourly cleanup of expired sessions.
@@ -151,10 +202,10 @@ async function shutdown(signal: string) {
logger.info({ signal }, 'shutting down');
httpServer.close();
clearInterval(sessionCleanup);
if (configSyncTimer) clearInterval(configSyncTimer);
resourceHistory.stop();
await consoleHub?.stop();
if (scheduler) await scheduler.stop();
if (provider instanceof MockGameServerProvider) provider.dispose();
if (mockProvider) mockProvider.dispose();
await pool.end();
process.exit(0);
}
+3
View File
@@ -38,6 +38,9 @@ export class ApiError extends Error {
static rateLimited(message = 'Too many requests. Try again shortly.') {
return new ApiError('RATE_LIMITED', message);
}
static conflict(message = 'This resource changed since you loaded it.') {
return new ApiError('CONFLICT', message);
}
static upstream(message = 'An upstream service is unavailable.') {
return new ApiError('UPSTREAM_UNAVAILABLE', message);
}
@@ -0,0 +1,188 @@
import type {
ConfigPatchOp,
ConfigPatchResult,
ConfigRawResponse,
ConfigTreeResponse,
StartupMirror,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import type { Logger } from '../../lib/logger.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { ServerRecord } from '../servers/server-service.js';
import type { ConfigFileGateway } from './config-file-gateway.js';
import { applyConfigOps, flattenConfig, verifyConfigOps } from './config-tree.js';
import type { ConfigSyncService } from './config-sync.js';
import { detectStartupMirrors, mirrorsForPath } from './startup-mirrors.js';
function providerId(server: ServerRecord): string {
return server.pterodactylServerId ?? server.slug;
}
/**
* The general-purpose config.json editor: every key the file actually
* contains, addressed by path, patched one field at a time.
*
* The old editor could only reach eleven hardcoded keys and submitted all of
* them on every save, so a stale form quietly reverted whatever anyone else
* had changed. Here only the paths the user touched are sent, and the write is
* refused outright if the file moved since it was loaded.
*/
export class ConfigEditorService {
constructor(
private readonly gateway: ConfigFileGateway,
private readonly provider: GameServerProvider,
private readonly configSync: ConfigSyncService,
private readonly logger: Logger,
) {}
private async loadMirrors(
server: ServerRecord,
root: Record<string, unknown>,
): Promise<StartupMirror[]> {
const variables = await this.provider
.listStartupVariables(providerId(server))
.then((list) =>
list.map((variable) => ({
name: variable.name,
description: variable.description,
envVariable: variable.envVariable,
value: variable.serverValue,
defaultValue: variable.defaultValue,
isEditable: variable.isEditable,
})),
)
.catch(() => []);
return detectStartupMirrors(root, variables);
}
async getTree(server: ServerRecord): Promise<ConfigTreeResponse> {
const document = await this.gateway.download(providerId(server));
return {
entries: flattenConfig(document.root),
mirrors: await this.loadMirrors(server, document.root),
revision: document.revision,
fetchedAt: new Date().toISOString(),
};
}
async getRaw(server: ServerRecord): Promise<ConfigRawResponse> {
const document = await this.gateway.download(providerId(server));
return {
content: document.raw,
revision: document.revision,
fetchedAt: new Date().toISOString(),
};
}
async putRaw(
server: ServerRecord,
content: string,
expectedRevision?: string,
): Promise<ConfigRawResponse> {
const document = await this.gateway.replace(providerId(server), content, expectedRevision);
await this.afterWrite(server);
return {
content: document.raw,
revision: document.revision,
fetchedAt: new Date().toISOString(),
};
}
/**
* Applies field-level edits. When `writeStartupVars` is set, any changed path
* that the egg also templates from a startup variable is written to both
* places, so the change survives the next restart.
*/
async patch(
server: ServerRecord,
ops: readonly ConfigPatchOp[],
options: { expectedRevision?: string; writeStartupVars?: boolean } = {},
): Promise<ConfigPatchResult> {
if (ops.length === 0) {
const current = await this.gateway.download(providerId(server));
return {
changedPaths: [],
startupVarsWritten: [],
revision: current.revision,
fetchedAt: new Date().toISOString(),
requiresRestart: true,
};
}
let changedPaths: string[] = [];
const document = await this.gateway.mutate(providerId(server), {
expectedRevision: options.expectedRevision,
apply: (root) => {
changedPaths = applyConfigOps(root, ops);
},
verify: (readBack) => {
const failed = verifyConfigOps(readBack, ops);
if (failed) {
throw ApiError.upstream(
`Config write verification failed for "${failed}" — the file on the server does not match. Check config.json.bak.`,
);
}
},
});
const startupVarsWritten = options.writeStartupVars
? await this.mirrorChangedPaths(server, document.root, ops, changedPaths)
: [];
await this.afterWrite(server);
this.logger.info(
{ serverId: server.id, changedPaths, startupVarsWritten },
'config.json updated',
);
return {
changedPaths,
startupVarsWritten,
revision: document.revision,
fetchedAt: new Date().toISOString(),
requiresRestart: true,
};
}
private async mirrorChangedPaths(
server: ServerRecord,
root: Record<string, unknown>,
ops: readonly ConfigPatchOp[],
changedPaths: readonly string[],
): Promise<string[]> {
const mirrors = await this.loadMirrors(server, root);
const written: string[] = [];
for (const path of changedPaths) {
const op = ops.find((candidate) => candidate.path === path);
if (!op || op.value === null) continue;
for (const mirror of mirrorsForPath(path, mirrors)) {
try {
await this.provider.updateStartupVariable(
providerId(server),
mirror.envVariable,
String(op.value),
);
written.push(mirror.envVariable);
} catch (error) {
// Some eggs mark variables read-only; that is not fatal for the
// config write that already succeeded.
this.logger.warn(
{ serverId: server.id, envVariable: mirror.envVariable, err: String(error) },
'startup variable mirror write failed',
);
}
}
}
return written;
}
private async afterWrite(server: ServerRecord): Promise<void> {
await this.configSync.sync(server).catch((error) => {
this.logger.warn(
{ serverId: server.id, err: String(error) },
'post-write config sync failed',
);
});
}
}
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { ApiError } from '../../lib/errors.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
@@ -9,22 +10,64 @@ export function asRecord(value: unknown): Record<string, unknown> | null {
: null;
}
export type ConfigDocument = {
raw: string;
root: Record<string, unknown>;
/** Content hash of `raw`. Callers echo it back to prove what they edited. */
revision: string;
/** Indentation detected in the source file, reused when writing. */
indent: string;
};
function revisionOf(raw: string): string {
return createHash('sha256').update(raw).digest('hex').slice(0, 16);
}
/** Keeps the file looking the way its author left it instead of reformatting. */
function detectIndent(raw: string): string {
const match = /\n([ \t]+)"/.exec(raw);
return match?.[1] ?? ' ';
}
/**
* Shared read-modify-write access to the server's config.json: size-guarded
* download + parse, and a write path that backs the previous content up to
* `<config>.bak` and verifies the upload by downloading it again. Callers
* mutate only their own keys on the parsed document so everything else in the
* file passes through untouched.
* Shared read-modify-write access to the server's config.json.
*
* Three properties matter here, and all three were missing before:
*
* 1. **Serialisation.** Every mutation runs under a per-server lock, so a mod
* list write and a settings write can no longer interleave, lose each
* other's changes, and overwrite the same `.bak`.
* 2. **Optimistic concurrency.** Callers pass the revision their edits were
* based on; if the file moved underneath them the write is rejected as a
* conflict instead of silently reverting somebody else's change.
* 3. **Read-back verification.** The upload is downloaded again and checked
* before the call is reported as successful.
*
* Callers mutate only their own keys on the parsed document, so everything
* else in the file passes through untouched.
*/
export class ConfigFileGateway {
/** Tail of the pending operation chain, per provider server id. */
private readonly locks = new Map<string, Promise<unknown>>();
constructor(
private readonly provider: GameServerProvider,
readonly configPath: string,
) {}
async download(
providerServerId: string,
): Promise<{ raw: string; root: Record<string, unknown> }> {
private withLock<T>(key: string, task: () => Promise<T>): Promise<T> {
const previous = this.locks.get(key) ?? Promise.resolve();
const next = previous.then(task, task);
// Swallow rejections on the chain itself so one failure cannot poison
// every subsequent operation.
this.locks.set(
key,
next.catch(() => undefined),
);
return next;
}
private async read(providerServerId: string): Promise<ConfigDocument> {
const file = await this.provider.downloadTextFile(
providerServerId,
this.configPath,
@@ -40,27 +83,93 @@ export class ConfigFileGateway {
throw ApiError.upstream('Server config.json is not valid JSON.');
}
const root = asRecord(parsed);
if (!root || !asRecord(root.game)) {
if (!root) {
throw ApiError.upstream('Server config.json is not a JSON object.');
}
if (!asRecord(root.game)) {
throw ApiError.upstream('Server config.json has no "game" section; refusing to modify it.');
}
return { raw: file.content, root };
return {
raw: file.content,
root,
revision: revisionOf(file.content),
indent: detectIndent(file.content),
};
}
/** Reads the current document. Waits for any in-flight write to finish. */
async download(providerServerId: string): Promise<ConfigDocument> {
return this.withLock(providerServerId, () => this.read(providerServerId));
}
/**
* Backs up `previousRaw`, writes the mutated document, downloads it again
* and hands the verified parsed result to `verify` (throw there to fail).
* Applies `apply` to a freshly-read document and writes it back, keeping a
* `<config>.bak` of the previous content. `verify` receives the re-downloaded
* document and should throw if the change did not land.
*
* Pass `expectedRevision` to reject the write when the file changed since the
* caller loaded it.
*/
async write(
async mutate(
providerServerId: string,
root: Record<string, unknown>,
previousRaw: string,
verify: (readBack: Record<string, unknown>) => void,
): Promise<Record<string, unknown>> {
await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, previousRaw);
const serialized = `${JSON.stringify(root, null, 4)}\n`;
await this.provider.writeTextFile(providerServerId, this.configPath, serialized);
const readBack = await this.download(providerServerId);
verify(readBack.root);
return readBack.root;
options: {
expectedRevision?: string | undefined;
apply: (root: Record<string, unknown>) => void;
verify: (readBack: Record<string, unknown>) => void;
},
): Promise<ConfigDocument> {
return this.withLock(providerServerId, async () => {
const current = await this.read(providerServerId);
if (options.expectedRevision && options.expectedRevision !== current.revision) {
throw ApiError.conflict(
'config.json changed on the server since you loaded it. Refresh and re-apply your changes.',
);
}
options.apply(current.root);
const serialized = `${JSON.stringify(current.root, null, current.indent)}\n`;
if (serialized === current.raw) {
return current; // nothing to do; skip the write and the backup churn
}
await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, current.raw);
await this.provider.writeTextFile(providerServerId, this.configPath, serialized);
const readBack = await this.read(providerServerId);
options.verify(readBack.root);
return readBack;
});
}
/** Replaces the whole file. Used by the raw JSON editor. */
async replace(
providerServerId: string,
content: string,
expectedRevision?: string,
): Promise<ConfigDocument> {
let parsed: unknown;
try {
parsed = JSON.parse(content.replace(/^\uFEFF/, ''));
} catch {
throw ApiError.validation('That is not valid JSON.');
}
const root = asRecord(parsed);
if (!root || !asRecord(root.game)) {
throw ApiError.validation('config.json must be an object with a "game" section.');
}
return this.withLock(providerServerId, async () => {
const current = await this.read(providerServerId);
if (expectedRevision && expectedRevision !== current.revision) {
throw ApiError.conflict(
'config.json changed on the server since you loaded it. Refresh and re-apply your changes.',
);
}
const normalized = content.endsWith('\n') ? content : `${content}\n`;
if (normalized === current.raw) return current;
await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, current.raw);
await this.provider.writeTextFile(providerServerId, this.configPath, normalized);
return this.read(providerServerId);
});
}
}
+21 -17
View File
@@ -1,10 +1,13 @@
import type { ReforgerServerConfig } from '@reforger-panel/shared';
import { sanitizeErrorMessage, type Logger } from '../../lib/logger.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { ServerRecord, ServerService } from '../servers/server-service.js';
import { parseReforgerConfigJson } from './reforger-config-file.js';
import type { ConfigFileGateway } from './config-file-gateway.js';
import { mapReforgerConfig } from './reforger-config-file.js';
const CONFIG_MAX_BYTES = 256 * 1024;
export type LiveConfig = {
config: ReforgerServerConfig;
revision: string;
};
export type ConfigSyncResult = {
serverName: string;
@@ -13,30 +16,31 @@ export type ConfigSyncResult = {
};
/**
* Reads the server's real config.json (via the provider, read-only) and keeps
* the server row's name/maxPlayers in line with what the server actually
* runs. Configuration is always served live; no revision history is kept.
* Reads the server's real config.json and keeps the server row's
* name/maxPlayers in line with what the server actually runs. Configuration is
* always served live; no revision history is kept.
*
* Reads go through the shared gateway so they queue behind in-flight writes
* and return the same content revision the editors use for conflict detection.
*/
export class ConfigSyncService {
constructor(
private readonly provider: GameServerProvider,
private readonly gateway: ConfigFileGateway,
private readonly servers: ServerService,
private readonly logger: Logger,
private readonly configPath: string,
) {}
async getLiveConfig(server: ServerRecord): Promise<ReforgerServerConfig> {
const providerServerId = server.pterodactylServerId ?? server.slug;
const file = await this.provider.downloadTextFile(
providerServerId,
this.configPath,
CONFIG_MAX_BYTES,
);
return parseReforgerConfigJson(file.content);
private providerId(server: ServerRecord): string {
return server.pterodactylServerId ?? server.slug;
}
async getLiveConfig(server: ServerRecord): Promise<LiveConfig> {
const document = await this.gateway.download(this.providerId(server));
return { config: mapReforgerConfig(document.root), revision: document.revision };
}
async sync(server: ServerRecord): Promise<ConfigSyncResult> {
const config = await this.getLiveConfig(server);
const { config } = await this.getLiveConfig(server);
const maxPlayers = config.maxPlayers > 0 ? config.maxPlayers : null;
if (server.name !== config.serverName || server.maxPlayers !== maxPlayers) {
await this.servers.updateServerInfo(server.id, {
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { applyConfigOps, flattenConfig, readAtPath, verifyConfigOps } from './config-tree.js';
function sampleConfig(): Record<string, unknown> {
return {
bindAddress: '0.0.0.0',
bindPort: 2001,
game: {
name: 'Test Server',
maxPlayers: 16,
gameProperties: { serverMaxViewDistance: 2500, battlEye: true },
mods: [{ modId: 'AAAA000000000001' }],
},
operating: { aiLimit: 40 },
};
}
describe('flattenConfig', () => {
it('exposes every leaf by dotted path', () => {
const paths = flattenConfig(sampleConfig()).map((entry) => entry.path);
expect(paths).toContain('bindPort');
expect(paths).toContain('game.gameProperties.serverMaxViewDistance');
expect(paths).toContain('operating.aiLimit');
});
it('hides the mod list, which the Mods page owns', () => {
const paths = flattenConfig(sampleConfig()).map((entry) => entry.path);
expect(paths).not.toContain('game.mods');
});
it('records value types so the editor can pick an input', () => {
const entries = flattenConfig(sampleConfig());
expect(entries.find((entry) => entry.path === 'bindPort')?.type).toBe('number');
expect(entries.find((entry) => entry.path === 'bindAddress')?.type).toBe('string');
expect(entries.find((entry) => entry.path === 'game.gameProperties.battlEye')?.type).toBe(
'boolean',
);
});
});
describe('applyConfigOps', () => {
it('touches only the given paths and reports what changed', () => {
const root = sampleConfig();
const changed = applyConfigOps(root, [
{ path: 'game.maxPlayers', value: 64 },
{ path: 'bindPort', value: 2001 }, // unchanged
]);
expect(changed).toEqual(['game.maxPlayers']);
expect(readAtPath(root, 'game.maxPlayers')).toBe(64);
expect(readAtPath(root, 'game.name')).toBe('Test Server');
});
it('creates missing intermediate sections when setting a new key', () => {
const root = sampleConfig();
applyConfigOps(root, [{ path: 'operating.playerSaveTime', value: 180 }]);
applyConfigOps(root, [{ path: 'rcon.port', value: 19999 }]);
expect(readAtPath(root, 'operating.playerSaveTime')).toBe(180);
expect(readAtPath(root, 'rcon.port')).toBe(19999);
});
it('removes a key on null so the game default applies', () => {
const root = sampleConfig();
const changed = applyConfigOps(root, [{ path: 'operating.aiLimit', value: null }]);
expect(changed).toEqual(['operating.aiLimit']);
expect('aiLimit' in (root.operating as Record<string, unknown>)).toBe(false);
});
it('does not create sections just to delete from them', () => {
const root = sampleConfig();
const changed = applyConfigOps(root, [{ path: 'nothing.here', value: null }]);
expect(changed).toEqual([]);
expect('nothing' in root).toBe(false);
});
it('leaves the mod list untouched', () => {
const root = sampleConfig();
applyConfigOps(root, [{ path: 'game.maxPlayers', value: 32 }]);
expect((root.game as Record<string, unknown>).mods).toEqual([{ modId: 'AAAA000000000001' }]);
});
});
describe('verifyConfigOps', () => {
it('passes when every op landed', () => {
const root = sampleConfig();
const ops = [
{ path: 'game.maxPlayers', value: 64 },
{ path: 'operating.aiLimit', value: null },
];
applyConfigOps(root, ops);
expect(verifyConfigOps(root, ops)).toBeNull();
});
it('names the first path that did not land', () => {
const root = sampleConfig();
expect(verifyConfigOps(root, [{ path: 'game.maxPlayers', value: 64 }])).toBe('game.maxPlayers');
});
});
+146
View File
@@ -0,0 +1,146 @@
import type { ConfigEntry, ConfigPatchOp, ConfigValueType } from '@reforger-panel/shared';
import { asRecord } from './config-file-gateway.js';
/**
* `game.mods` is owned by the Mods page, which understands versions and
* dependencies. Hand-editing a 90-entry array in a generic key editor is a
* good way to break a server, so it is hidden from the flat view (the raw JSON
* tab still shows it).
*/
const HIDDEN_PATHS = new Set(['game.mods']);
function typeOf(value: unknown): ConfigValueType {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
switch (typeof value) {
case 'string':
return 'string';
case 'number':
return 'number';
case 'boolean':
return 'boolean';
default:
return 'object';
}
}
/**
* Flattens config.json into dotted leaf paths (`game.gameProperties.battlEye`)
* so the editor can search and address every value the file actually contains,
* rather than only the handful of keys the panel happens to know about.
*
* Arrays are surfaced as single non-recursive entries carrying their JSON text;
* the flat editor renders them read-only and defers to the raw tab.
*/
export function flattenConfig(root: Record<string, unknown>): ConfigEntry[] {
const entries: ConfigEntry[] = [];
const walk = (node: Record<string, unknown>, prefix: string): void => {
for (const [key, value] of Object.entries(node)) {
const path = prefix ? `${prefix}.${key}` : key;
if (HIDDEN_PATHS.has(path)) continue;
const child = asRecord(value);
if (child) {
walk(child, path);
continue;
}
if (Array.isArray(value)) {
entries.push({ path, value: null, type: 'array', raw: JSON.stringify(value) });
continue;
}
entries.push({
path,
value: value as string | number | boolean | null,
type: typeOf(value),
});
}
};
walk(root, '');
entries.sort((a, b) => a.path.localeCompare(b.path));
return entries;
}
export function readAtPath(
root: Record<string, unknown>,
path: string,
): string | number | boolean | null {
const segments = path.split('.');
let node: unknown = root;
for (const segment of segments) {
const record = asRecord(node);
if (!record || !(segment in record)) return null;
node = record[segment];
}
if (node === undefined || asRecord(node) || Array.isArray(node)) return null;
return node as string | number | boolean | null;
}
/**
* Applies patch operations in place and returns the paths that actually
* changed. `value: null` removes the key entirely so the game's own default
* applies — the same semantics the performance form has always had.
*
* Only the supplied paths are touched, which is the core fix for the old
* whole-object form submit that silently reverted concurrent edits.
*/
export function applyConfigOps(
root: Record<string, unknown>,
ops: readonly ConfigPatchOp[],
): string[] {
const changed: string[] = [];
for (const op of ops) {
const segments = op.path.split('.').filter(Boolean);
const leaf = segments.pop();
if (!leaf) continue;
if (op.value === null) {
// Removal: never create the intermediate objects on the way down.
let node: Record<string, unknown> | null = root;
for (const segment of segments) {
node = asRecord(node[segment]);
if (!node) break;
}
if (node && leaf in node) {
delete node[leaf];
changed.push(op.path);
}
continue;
}
let node: Record<string, unknown> = root;
for (const segment of segments) {
const child = asRecord(node[segment]);
if (child) {
node = child;
} else {
const created: Record<string, unknown> = {};
node[segment] = created;
node = created;
}
}
if (node[leaf] !== op.value) {
node[leaf] = op.value;
changed.push(op.path);
}
}
return changed;
}
/** Verifies that a write landed, for the gateway's read-back check. */
export function verifyConfigOps(
readBack: Record<string, unknown>,
ops: readonly ConfigPatchOp[],
): string | null {
for (const op of ops) {
const actual = readAtPath(readBack, op.path);
if (op.value === null) {
if (actual !== null) return op.path;
} else if (actual !== op.value) {
return op.path;
}
}
return null;
}
+275 -34
View File
@@ -1,13 +1,41 @@
import type {
ModOverviewEntry,
ModResolveResponse,
ModsOverviewResponse,
ModWorkshopInfo,
ReforgerConfigMod,
ResolvedMod,
ServerModsResponse,
UpdateModsResult,
WorkshopDependency,
WorkshopModDetail,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import type { Logger } from '../../lib/logger.js';
import { OFFICIAL_SCENARIO_IDS } from '../reforger-logs/missions-catalog.js';
import type { WorkshopCache } from '../workshop/workshop-cache.js';
import type { ServerRecord } from '../servers/server-service.js';
import { asRecord, type ConfigFileGateway } from './config-file-gateway.js';
import type { ConfigSyncService } from './config-sync.js';
import { readAtPath } from './config-tree.js';
/**
* How long the overview waits for cold Workshop lookups before answering with
* what it has. A large modlist on a cold cache cannot be resolved inside one
* request without either blocking for a minute or tripping the upstream rate
* limit, so the response is returned immediately with `warming: true` and the
* client refetches while the background fill completes.
*/
const WARM_WAIT_MS = 2_500;
/** Guard against a pathological dependency graph. */
const MAX_RESOLVED_MODS = 400;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
timer.unref?.();
});
}
function readMods(root: Record<string, unknown>): ReforgerConfigMod[] {
const game = asRecord(root.game);
@@ -28,15 +56,32 @@ function readMods(root: Record<string, unknown>): ReforgerConfigMod[] {
.filter((mod): mod is ReforgerConfigMod => mod !== null);
}
function toWorkshopInfo(detail: WorkshopModDetail): ModWorkshopInfo {
return {
name: detail.name,
author: detail.author,
summary: detail.summary,
imageUrl: detail.imageUrl,
workshopUrl: detail.workshopUrl,
latestVersion: detail.version,
gameVersion: detail.gameVersion,
sizeBytes: detail.sizeBytes,
scenarioCount: detail.scenarioCount,
dependencyCount: detail.dependencyCount,
obsolete: detail.obsolete,
tags: detail.tags,
};
}
/**
* Manages the `game.mods` array of the server's real config.json through the
* shared ConfigFileGateway (backup + read-back verification; all other config
* fields pass through untouched). Changes apply on the next server restart.
* Owns the `game.mods` array of the server's real config.json, and joins it
* with Workshop metadata so the panel can show versions, sizes, dependencies
* and available updates without the browser making one request per mod.
*/
export class ServerModsService {
constructor(
private readonly gateway: ConfigFileGateway,
private readonly configSync: ConfigSyncService,
private readonly workshop: WorkshopCache,
private readonly logger: Logger,
) {}
@@ -45,51 +90,247 @@ export class ServerModsService {
}
async getMods(server: ServerRecord): Promise<ServerModsResponse> {
const { root } = await this.gateway.download(this.providerId(server));
return { mods: readMods(root), fetchedAt: new Date().toISOString() };
const document = await this.gateway.download(this.providerId(server));
const mods = readMods(document.root);
// Keep the cache aligned with what is actually installed.
this.workshop.warm(mods.map((mod) => mod.modId));
return { mods, revision: document.revision, fetchedAt: new Date().toISOString() };
}
async setMods(server: ServerRecord, mods: ReforgerConfigMod[]): Promise<UpdateModsResult> {
/**
* Everything the Mods page needs in one request: installed mods joined with
* cached Workshop metadata, update availability, dependency gaps and the
* reverse "required by" edges that make removals safe.
*/
async getOverview(server: ServerRecord): Promise<ModsOverviewResponse> {
const document = await this.gateway.download(this.providerId(server));
const installed = readMods(document.root);
const ids = installed.map((mod) => mod.modId.toUpperCase());
const installedIds = new Set(ids);
// Start every lookup, then answer with whatever resolved in time.
await Promise.race([
Promise.allSettled(ids.map((id) => this.workshop.tryGetMod(id))),
delay(WARM_WAIT_MS),
]);
const details = new Map<string, WorkshopModDetail | null | undefined>();
for (const id of ids) details.set(id, this.workshop.peekMod(id));
// Reverse dependency edges: which installed mods need each mod.
const requiredBy = new Map<string, string[]>();
for (const [id, detail] of details) {
if (!detail) continue;
for (const dependency of detail.dependencies) {
const dependents = requiredBy.get(dependency.id) ?? [];
dependents.push(id);
requiredBy.set(dependency.id, dependents);
}
}
const unresolvedIds: string[] = [];
let warming = false;
let totalSizeBytes: number | null = null;
let updatesAvailable = 0;
const mods: ModOverviewEntry[] = installed.map((mod) => {
const id = mod.modId.toUpperCase();
const detail = details.get(id);
if (detail === undefined) warming = true;
if (detail === null) unresolvedIds.push(id);
const pinnedVersion = mod.version ?? null;
const workshop = detail ? toWorkshopInfo(detail) : null;
const updateAvailable = Boolean(
pinnedVersion && workshop?.latestVersion && pinnedVersion !== workshop.latestVersion,
);
if (updateAvailable) updatesAvailable += 1;
if (workshop?.sizeBytes) totalSizeBytes = (totalSizeBytes ?? 0) + workshop.sizeBytes;
const missingDependencies: WorkshopDependency[] = (detail?.dependencies ?? []).filter(
(dependency) => !installedIds.has(dependency.id),
);
return {
modId: id,
configName: mod.name ?? null,
pinnedVersion,
workshop,
updateAvailable,
missingDependencies,
requiredBy: (requiredBy.get(id) ?? []).filter((dependent) => dependent !== id),
};
});
return {
mods,
revision: document.revision,
fetchedAt: new Date().toISOString(),
totalSizeBytes,
updatesAvailable,
unresolvedIds,
warming,
orphanedMission: this.detectOrphanedMission(document.root, details, warming),
};
}
/**
* Flags a configured scenario that nothing installed can provide — the usual
* cause of a server that boots to the wrong mission after a mod removal.
* Only reported once every mod resolved, so a warming cache never produces a
* false alarm.
*/
private detectOrphanedMission(
root: Record<string, unknown>,
details: Map<string, WorkshopModDetail | null | undefined>,
warming: boolean,
): { scenarioId: string } | null {
if (warming) return null;
const scenarioId = readAtPath(root, 'game.scenarioId');
if (typeof scenarioId !== 'string' || !scenarioId) return null;
if (OFFICIAL_SCENARIO_IDS.has(scenarioId)) return null;
for (const detail of details.values()) {
if (!detail) return null; // an unresolved mod might well provide it
if (detail.scenarios.some((scenario) => scenario.scenarioId === scenarioId)) return null;
}
return { scenarioId };
}
/**
* Expands a desired mod list into everything needed to make it load: the
* requested mods plus the transitive dependency closure, with sizes so the
* UI can show the download cost before anything is written.
*/
async resolve(desired: readonly ReforgerConfigMod[]): Promise<ModResolveResponse> {
const requested = new Map<string, ReforgerConfigMod>();
for (const mod of desired) requested.set(mod.modId.toUpperCase(), mod);
const resolved = new Map<string, ResolvedMod>();
const unresolvedIds: string[] = [];
const queue = [...requested.keys()];
const seen = new Set(queue);
for (const id of queue) {
const mod = requested.get(id);
resolved.set(id, {
modId: id,
name: mod?.name ?? null,
version: mod?.version ?? null,
sizeBytes: null,
viaDependency: false,
requiredBy: [],
});
}
while (queue.length > 0 && resolved.size < MAX_RESOLVED_MODS) {
// Resolve a whole level at a time so the pacer can overlap requests.
const level = queue.splice(0, queue.length);
const details = await Promise.all(level.map((id) => this.workshop.tryGetMod(id)));
for (let index = 0; index < level.length; index += 1) {
const id = level[index]!;
const detail = details[index] ?? null;
const entry = resolved.get(id)!;
if (!detail) {
unresolvedIds.push(id);
continue;
}
entry.name = entry.name ?? detail.name;
entry.sizeBytes = detail.sizeBytes;
for (const dependency of detail.dependencies) {
const existing = resolved.get(dependency.id);
if (existing) {
if (!existing.requiredBy.includes(id)) existing.requiredBy.push(id);
continue;
}
resolved.set(dependency.id, {
modId: dependency.id,
name: dependency.name,
version: null,
sizeBytes: dependency.sizeBytes,
viaDependency: true,
requiredBy: [id],
});
if (!seen.has(dependency.id)) {
seen.add(dependency.id);
queue.push(dependency.id);
}
}
}
}
const mods = [...resolved.values()];
const sized = mods.filter((mod) => mod.sizeBytes !== null);
return {
mods,
addedDependencies: mods.filter((mod) => mod.viaDependency),
totalSizeBytes:
sized.length > 0 ? sized.reduce((sum, mod) => sum + (mod.sizeBytes ?? 0), 0) : null,
unresolvedIds,
};
}
/**
* Writes the mod list. `expectedRevision` is required in practice: it is how
* a stale browser tab is stopped from reverting somebody else's change.
*/
async setMods(
server: ServerRecord,
mods: readonly ReforgerConfigMod[],
expectedRevision?: string,
): Promise<UpdateModsResult> {
const providerId = this.providerId(server);
const { raw, root } = await this.gateway.download(providerId);
const previous = readMods(root);
const before = await this.gateway.download(providerId);
const previous = readMods(before.root);
const previousIds = new Set(previous.map((mod) => mod.modId.toUpperCase()));
const nextIds = new Set(mods.map((mod) => mod.modId.toUpperCase()));
const added = [...nextIds].filter((id) => !previousIds.has(id)).length;
const removed = [...previousIds].filter((id) => !nextIds.has(id)).length;
const previousById = new Map(previous.map((mod) => [mod.modId.toUpperCase(), mod]));
const nextById = new Map(mods.map((mod) => [mod.modId.toUpperCase(), mod]));
const game = asRecord(root.game)!;
game.mods = mods.map((mod) => ({
modId: mod.modId.toUpperCase(),
const added = [...nextById.keys()].filter((id) => !previousById.has(id)).length;
const removed = [...previousById.keys()].filter((id) => !nextById.has(id)).length;
const changed = [...nextById].filter(([id, mod]) => {
const existing = previousById.get(id);
return existing !== undefined && (existing.version ?? null) !== (mod.version ?? null);
}).length;
const normalized = [...nextById.entries()].map(([id, mod]) => ({
modId: id,
...(mod.name ? { name: mod.name } : {}),
...(mod.version ? { version: mod.version } : {}),
}));
const expectedIds = [...nextById.keys()].sort();
const verified = await this.gateway.write(providerId, root, raw, (readBack) => {
const verifyIds = readMods(readBack)
.map((mod) => mod.modId.toUpperCase())
.sort();
if (JSON.stringify(verifyIds) !== JSON.stringify([...nextIds].sort())) {
throw ApiError.upstream(
'Config write verification failed — the file on the server does not match. Check config.json.bak.',
);
}
const document = await this.gateway.mutate(providerId, {
expectedRevision: expectedRevision ?? before.revision,
apply: (root) => {
const game = asRecord(root.game)!;
game.mods = normalized;
},
verify: (readBack) => {
const actual = readMods(readBack)
.map((mod) => mod.modId.toUpperCase())
.sort();
if (JSON.stringify(actual) !== JSON.stringify(expectedIds)) {
throw ApiError.upstream(
'Config write verification failed — the file on the server does not match. Check config.json.bak.',
);
}
},
});
await this.configSync.sync(server).catch((error) => {
this.logger.warn(
{ serverId: server.id, err: String(error) },
'post-write config sync failed',
);
});
const result = readMods(document.root);
this.workshop.warm(result.map((mod) => mod.modId));
this.logger.info({ serverId: server.id, added, removed }, 'server mods updated');
this.logger.info({ serverId: server.id, added, removed, changed }, 'server mods updated');
return {
mods: readMods(verified),
mods: result,
revision: document.revision,
fetchedAt: new Date().toISOString(),
added,
removed,
changed,
requiresRestart: true,
};
}
@@ -1,83 +1,59 @@
import type {
ConfigPatchOp,
PerformanceSettings,
PerformanceSettingsPatch,
PerformanceSettingsResponse,
} from '@reforger-panel/shared';
import type { Logger } from '../../lib/logger.js';
import type { ServerRecord } from '../servers/server-service.js';
import { asRecord, type ConfigFileGateway } from './config-file-gateway.js';
import type { ConfigSyncService } from './config-sync.js';
import type { ConfigEditorService } from './config-editor-service.js';
import type { ConfigFileGateway } from './config-file-gateway.js';
import { readAtPath } from './config-tree.js';
/** Where each performance field lives inside config.json. */
const FIELD_LOCATIONS: Record<
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string]
> = {
scenarioId: ['game', 'scenarioId'],
maxPlayers: ['game', 'maxPlayers'],
serverMaxViewDistance: ['gameProperties', 'serverMaxViewDistance'],
networkViewDistance: ['gameProperties', 'networkViewDistance'],
serverMinGrassDistance: ['gameProperties', 'serverMinGrassDistance'],
disableThirdPerson: ['gameProperties', 'disableThirdPerson'],
fastValidation: ['gameProperties', 'fastValidation'],
battlEye: ['gameProperties', 'battlEye'],
disableAI: ['operating', 'disableAI'],
aiLimit: ['operating', 'aiLimit'],
playerSaveTime: ['operating', 'playerSaveTime'],
slotReservationTimeout: ['operating', 'slotReservationTimeout'],
lobbyPlayerSynchronise: ['operating', 'lobbyPlayerSynchronise'],
/** Where each curated performance field lives inside config.json. */
export const PERFORMANCE_FIELD_PATHS: Record<keyof PerformanceSettings, string> = {
scenarioId: 'game.scenarioId',
maxPlayers: 'game.maxPlayers',
serverMaxViewDistance: 'game.gameProperties.serverMaxViewDistance',
networkViewDistance: 'game.gameProperties.networkViewDistance',
serverMinGrassDistance: 'game.gameProperties.serverMinGrassDistance',
disableThirdPerson: 'game.gameProperties.disableThirdPerson',
fastValidation: 'game.gameProperties.fastValidation',
battlEye: 'game.gameProperties.battlEye',
disableAI: 'operating.disableAI',
aiLimit: 'operating.aiLimit',
playerSaveTime: 'operating.playerSaveTime',
slotReservationTimeout: 'operating.slotReservationTimeout',
lobbyPlayerSynchronise: 'operating.lobbyPlayerSynchronise',
};
function sectionFor(
root: Record<string, unknown>,
section: 'game' | 'gameProperties' | 'operating',
createMissing: boolean,
): Record<string, unknown> | null {
const game = asRecord(root.game)!;
if (section === 'game') return game;
if (section === 'gameProperties') {
let props = asRecord(game.gameProperties);
if (!props && createMissing) {
props = {};
game.gameProperties = props;
}
return props;
}
let operating = asRecord(root.operating);
if (!operating && createMissing) {
operating = {};
root.operating = operating;
}
return operating;
}
const PERFORMANCE_FIELDS = Object.keys(PERFORMANCE_FIELD_PATHS) as (keyof PerformanceSettings)[];
export function readPerformanceSettings(root: Record<string, unknown>): PerformanceSettings {
const result = {} as Record<keyof PerformanceSettings, number | boolean | string | null>;
for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string],
][]) {
const container = sectionFor(root, section, false);
const value = container?.[key];
const validType =
field === 'scenarioId'
? typeof value === 'string'
: typeof value === 'number' || typeof value === 'boolean';
result[field] = validType ? (value as number | boolean | string) : null;
const result = {} as Record<keyof PerformanceSettings, string | number | boolean | null>;
for (const field of PERFORMANCE_FIELDS) {
const value = readAtPath(root, PERFORMANCE_FIELD_PATHS[field]);
const expected = field === 'scenarioId' ? 'string' : ['number', 'boolean'];
const matches =
typeof expected === 'string' ? typeof value === expected : expected.includes(typeof value);
result[field] = matches ? value : null;
}
return result as PerformanceSettings;
}
/**
* Edits the performance-related keys of the live config.json. A `null` value
* removes the key from the file entirely so the game's own default applies —
* network/identity fields (bind address, ports, passwords, rcon…) are never
* touched by this service.
* The curated view of config.json: typed, range-validated fields for the
* settings the panel understands well.
*
* It now shares the patch engine with the general key editor, so a save only
* ever touches the fields the caller explicitly included. Previously the form
* posted all thirteen values on every submit, which meant a form loaded before
* somebody else's change silently reverted it.
*/
export class PerformanceSettingsService {
constructor(
private readonly gateway: ConfigFileGateway,
private readonly configSync: ConfigSyncService,
private readonly editor: ConfigEditorService,
private readonly logger: Logger,
) {}
@@ -86,59 +62,48 @@ export class PerformanceSettingsService {
}
async get(server: ServerRecord): Promise<PerformanceSettingsResponse> {
const { root } = await this.gateway.download(this.providerId(server));
return { settings: readPerformanceSettings(root), fetchedAt: new Date().toISOString() };
const document = await this.gateway.download(this.providerId(server));
return {
settings: readPerformanceSettings(document.root),
revision: document.revision,
fetchedAt: new Date().toISOString(),
};
}
async update(
server: ServerRecord,
patch: PerformanceSettingsPatch,
options: { expectedRevision?: string; writeStartupVars?: boolean } = {},
): Promise<PerformanceSettingsResponse & { changedFields: string[]; requiresRestart: true }> {
const providerId = this.providerId(server);
const { raw, root } = await this.gateway.download(providerId);
const before = readPerformanceSettings(root);
const before = await this.get(server);
// Only fields the caller actually sent, and only where the value differs.
const ops: ConfigPatchOp[] = [];
const changedFields: string[] = [];
for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string],
][]) {
if (!(field in patch)) continue; // untouched fields stay as-is
const next = patch[field] as number | boolean | string | null;
if (before[field] === next) continue;
for (const field of PERFORMANCE_FIELDS) {
if (!(field in patch)) continue;
const next = patch[field] ?? null;
if (before.settings[field] === next) continue;
ops.push({ path: PERFORMANCE_FIELD_PATHS[field], value: next });
changedFields.push(field);
if (next === null) {
const container = sectionFor(root, section, false);
if (container) delete container[key];
} else {
const container = sectionFor(root, section, true)!;
container[key] = next;
}
}
if (changedFields.length > 0) {
await this.gateway.write(providerId, root, raw, (readBack) => {
const after = readPerformanceSettings(readBack);
for (const field of changedFields) {
if (
after[field as keyof PerformanceSettings] !== patch[field as keyof PerformanceSettings]
) {
throw new Error('Config write verification failed. Check config.json.bak.');
}
}
});
await this.configSync.sync(server).catch((error) => {
this.logger.warn(
{ serverId: server.id, err: String(error) },
'post-write config sync failed',
);
});
this.logger.info({ serverId: server.id, changedFields }, 'performance settings updated');
if (ops.length === 0) {
return { ...before, changedFields: [], requiresRestart: true };
}
const result = await this.editor.patch(server, ops, {
expectedRevision: options.expectedRevision ?? before.revision,
writeStartupVars: options.writeStartupVars ?? true,
});
this.logger.info({ serverId: server.id, changedFields }, 'performance settings updated');
const after = await this.get(server);
return {
settings: { ...before, ...patch } as PerformanceSettings,
fetchedAt: new Date().toISOString(),
settings: after.settings,
revision: result.revision,
fetchedAt: result.fetchedAt,
changedFields,
requiresRestart: true,
};
@@ -38,7 +38,7 @@ const REAL_SHAPE = {
lobbyPlayerSynchronise: true,
disableAI: false,
aiLimit: -1,
playerSaveTime: 120
playerSaveTime: 120,
},
};
@@ -46,7 +46,7 @@ describe('parseReforgerConfigJson', () => {
it('maps a real-shaped config.json into the panel model', () => {
const config = parseReforgerConfigJson(JSON.stringify(REAL_SHAPE));
expect(config).toEqual({
serverName: 'DazzledCorp Training Grounds',
serverName: 'DZR Training Grounds',
maxPlayers: 16,
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
disableAI: false,
@@ -57,7 +57,7 @@ describe('parseReforgerConfigJson', () => {
disableThirdPerson: true,
mods: [
{ modId: '591AF5BDA9F7CE8B', name: 'Some Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5' },
{ modId: '5AAF0CCE3F001FB5', name: undefined, version: undefined },
],
});
});
@@ -0,0 +1,80 @@
import type { StartupMirror, StartupVariable } from '@reforger-panel/shared';
import { readAtPath } from './config-tree.js';
/**
* Reforger eggs commonly regenerate config.json from Pterodactyl startup
* variables when the container boots. When that happens, editing the file
* alone looks like it worked — the panel writes it, verifies it, and then the
* next restart silently throws the change away.
*
* This map is how the panel notices. Only pairs whose startup variable
* actually exists on the egg are reported, so eggs that do not template
* anything produce no warnings at all.
*/
export const STARTUP_MIRROR_MAP: readonly { envVariable: string; configPath: string }[] = [
{ envVariable: 'SCENARIO_ID', configPath: 'game.scenarioId' },
{ envVariable: 'MISSION_ID', configPath: 'game.scenarioId' },
{ envVariable: 'MAX_PLAYERS', configPath: 'game.maxPlayers' },
{ envVariable: 'SERVER_NAME', configPath: 'game.name' },
{ envVariable: 'HOSTNAME', configPath: 'game.name' },
{ envVariable: 'SERVER_PASSWORD', configPath: 'game.password' },
{ envVariable: 'ADMIN_PASSWORD', configPath: 'game.passwordAdmin' },
{ envVariable: 'GAME_PORT', configPath: 'bindPort' },
{ envVariable: 'SERVER_PORT', configPath: 'bindPort' },
{ envVariable: 'BIND_PORT', configPath: 'bindPort' },
{ envVariable: 'SERVER_IP', configPath: 'bindAddress' },
{ envVariable: 'BIND_ADDRESS', configPath: 'bindAddress' },
{ envVariable: 'A2S_PORT', configPath: 'a2s.port' },
{ envVariable: 'RCON_PORT', configPath: 'rcon.port' },
{ envVariable: 'RCON_PASSWORD', configPath: 'rcon.password' },
{ envVariable: 'CROSS_PLATFORM', configPath: 'game.crossPlatform' },
{ envVariable: 'CROSSPLAY', configPath: 'game.crossPlatform' },
{ envVariable: 'BATTLEYE', configPath: 'game.gameProperties.battlEye' },
{ envVariable: 'VISIBLE', configPath: 'game.visible' },
{ envVariable: 'DISABLE_THIRD_PERSON', configPath: 'game.gameProperties.disableThirdPerson' },
{ envVariable: 'VIEW_DISTANCE', configPath: 'game.gameProperties.serverMaxViewDistance' },
];
/** Loose comparison — startup variables are always strings. */
function sameValue(startupValue: string, configValue: string | number | boolean | null): boolean {
if (configValue === null) return startupValue === '';
if (typeof configValue === 'boolean') {
const normalized = startupValue.trim().toLowerCase();
return configValue
? ['1', 'true', 'yes'].includes(normalized)
: ['0', 'false', 'no', ''].includes(normalized);
}
return String(configValue).trim() === startupValue.trim();
}
export function detectStartupMirrors(
root: Record<string, unknown>,
variables: readonly StartupVariable[],
): StartupMirror[] {
const byEnv = new Map(variables.map((variable) => [variable.envVariable, variable]));
const mirrors: StartupMirror[] = [];
const seen = new Set<string>();
for (const { envVariable, configPath } of STARTUP_MIRROR_MAP) {
const variable = byEnv.get(envVariable);
if (!variable) continue;
const key = `${envVariable}:${configPath}`;
if (seen.has(key)) continue;
seen.add(key);
const configValue = readAtPath(root, configPath);
mirrors.push({
envVariable,
configPath,
startupValue: variable.value,
configValue,
conflict: !sameValue(variable.value, configValue),
});
}
return mirrors;
}
/** Startup variables that mirror a given config path, for "write both". */
export function mirrorsForPath(path: string, mirrors: readonly StartupMirror[]): StartupMirror[] {
return mirrors.filter((mirror) => mirror.configPath === path);
}
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import type { ConsoleEvent, LiveStats } from './console-hub.js';
import { BaseConsoleHub, mapPowerState, stripAnsi } from './console-hub.js';
const ESC = String.fromCharCode(27);
class TestHub extends BaseConsoleHub {
start(): void {
this.setConnected(true);
}
async stop(): Promise<void> {
this.setConnected(false);
}
emitOutput(stream: 'console' | 'install' | 'daemon', chunk: string): void {
this.pushOutput(stream, chunk);
}
emitStats(stats: LiveStats): void {
this.setStats(stats);
}
emitStatus(status: Parameters<BaseConsoleHub['setStatus']>[0]): void {
this.setStatus(status);
}
}
function stats(overrides: Partial<LiveStats> = {}): LiveStats {
return {
status: 'online',
cpuPercent: 38,
memoryBytes: 1024,
diskBytes: 2048,
networkRxBytes: 10,
networkTxBytes: 20,
uptimeMs: 1000,
at: Date.now(),
...overrides,
};
}
describe('mapPowerState', () => {
it('maps every Wings state, defaulting to unknown', () => {
expect(mapPowerState('running')).toBe('online');
expect(mapPowerState('starting')).toBe('starting');
expect(mapPowerState('stopping')).toBe('stopping');
expect(mapPowerState('offline')).toBe('offline');
expect(mapPowerState(undefined)).toBe('unknown');
expect(mapPowerState('something-new')).toBe('unknown');
});
});
describe('stripAnsi', () => {
it('removes the colour escapes Wings wraps console output in', () => {
expect(stripAnsi(`${ESC}[0;32mSuccess!${ESC}[0m`)).toBe('Success!');
});
it('leaves plain text alone', () => {
expect(stripAnsi('NETWORK : Server is ready')).toBe('NETWORK : Server is ready');
});
});
describe('BaseConsoleHub', () => {
it('splits chunks into lines and numbers them for de-duplication', () => {
const hub = new TestHub();
hub.emitOutput('console', 'first\r\nsecond\n\nthird\n');
const lines = hub.backlog().lines;
expect(lines.map((line) => line.text)).toEqual(['first', 'second', 'third']);
expect(lines.map((line) => line.seq)).toEqual([1, 2, 3]);
});
it('keeps install output distinguishable from game output', () => {
const hub = new TestHub();
hub.emitOutput('install', 'Downloading mod 595F2BF2F44836FB');
expect(hub.backlog().lines[0]!.stream).toBe('install');
});
it('replays the backlog and current state to a late subscriber', () => {
const hub = new TestHub();
hub.start();
hub.emitOutput('console', 'boot line');
hub.emitStatus('starting');
const backlog = hub.backlog();
expect(backlog.lines).toHaveLength(1);
expect(backlog.status).toBe('starting');
expect(backlog.connected).toBe(true);
});
it('fans events out to subscribers until they unsubscribe', () => {
const hub = new TestHub();
const events: ConsoleEvent[] = [];
const unsubscribe = hub.subscribe((event) => events.push(event));
hub.emitOutput('console', 'one');
hub.emitStatus('online');
unsubscribe();
hub.emitOutput('console', 'two');
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ type: 'line' });
expect(events[1]).toMatchObject({ type: 'status', status: 'online' });
});
it('does not re-emit an unchanged status', () => {
const hub = new TestHub();
const events: ConsoleEvent[] = [];
hub.subscribe((event) => events.push(event));
hub.emitStatus('online');
hub.emitStatus('online');
expect(events.filter((event) => event.type === 'status')).toHaveLength(1);
});
it('takes the status carried by a stats frame', () => {
const hub = new TestHub();
hub.emitStats(stats({ status: 'starting' }));
expect(hub.latestStatus()).toBe('starting');
expect(hub.latestStats()?.cpuPercent).toBe(38);
});
it('a throwing subscriber cannot break the feed for others', () => {
const hub = new TestHub();
const seen: string[] = [];
hub.subscribe(() => {
throw new Error('bad subscriber');
});
hub.subscribe((event) => {
if (event.type === 'line') seen.push(event.line.text);
});
hub.emitOutput('console', 'still delivered');
expect(seen).toEqual(['still delivered']);
});
});
@@ -0,0 +1,148 @@
import type {
ConsoleBacklog,
ConsoleLine,
ConsoleLineStream,
ServerStatus,
} from '@reforger-panel/shared';
/** Maps a hosting backend's power state onto the panel's status vocabulary. */
export function mapPowerState(state: string | undefined): ServerStatus {
switch (state) {
case 'running':
return 'online';
case 'offline':
return 'offline';
case 'starting':
return 'starting';
case 'stopping':
return 'stopping';
default:
return 'unknown';
}
}
/** A resource frame pushed by Wings, or synthesised by the mock provider. */
export type LiveStats = {
status: ServerStatus;
cpuPercent: number;
memoryBytes: number;
diskBytes: number;
networkRxBytes: number;
networkTxBytes: number;
uptimeMs: number;
/** Unix ms the frame arrived. */
at: number;
};
export type ConsoleEvent =
| { type: 'line'; line: ConsoleLine }
| { type: 'status'; status: ServerStatus }
| { type: 'stats'; stats: LiveStats };
/**
* A live feed of a game server's console, status and resource usage.
*
* The panel previously reconstructed "live" output by repeatedly downloading
* the *game's* log file, which meant nothing was visible until the game itself
* had started and created that file — the install, update and mod download
* phases were invisible. A hub instead carries whatever the hosting backend is
* emitting, in real time.
*/
export interface ConsoleHub {
start(): void;
stop(): Promise<void>;
/** Returns an unsubscribe function. */
subscribe(listener: (event: ConsoleEvent) => void): () => void;
/** Recent lines plus current state, so a new viewer sees context instantly. */
backlog(): ConsoleBacklog;
latestStats(): LiveStats | null;
latestStatus(): ServerStatus;
}
const MAX_BACKLOG_LINES = 2_000;
/**
* Wings colourises console output with ANSI escapes; the panel styles lines
* itself, so they are stripped before they reach the browser. Built from a
* char code rather than a literal escape to keep this file plain ASCII.
*/
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, 'g');
export function stripAnsi(value: string): string {
return value.replace(ANSI_PATTERN, '');
}
/**
* Shared line buffer, subscriber fan-out and state tracking. Both the real
* Wings hub and the mock hub build on this.
*/
export abstract class BaseConsoleHub implements ConsoleHub {
private readonly listeners = new Set<(event: ConsoleEvent) => void>();
private readonly lines: ConsoleLine[] = [];
private seq = 0;
private status: ServerStatus = 'unknown';
private stats: LiveStats | null = null;
protected connected = false;
abstract start(): void;
abstract stop(): Promise<void>;
subscribe(listener: (event: ConsoleEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
backlog(): ConsoleBacklog {
return { lines: [...this.lines], status: this.status, connected: this.connected };
}
latestStats(): LiveStats | null {
return this.stats;
}
latestStatus(): ServerStatus {
return this.status;
}
private emit(event: ConsoleEvent): void {
for (const listener of this.listeners) {
try {
listener(event);
} catch {
// A broken subscriber must never take the upstream feed down.
}
}
}
/** Splits on newlines and pushes each non-empty line into the ring buffer. */
protected pushOutput(stream: ConsoleLineStream, chunk: string): void {
const at = Date.now();
for (const raw of stripAnsi(chunk).split(/\r?\n/)) {
const text = raw.replace(/\r/g, '').trimEnd();
if (!text) continue;
const line: ConsoleLine = { seq: ++this.seq, stream, text, at };
this.lines.push(line);
this.emit({ type: 'line', line });
}
if (this.lines.length > MAX_BACKLOG_LINES) {
this.lines.splice(0, this.lines.length - MAX_BACKLOG_LINES);
}
}
protected setStatus(status: ServerStatus): void {
if (this.status === status) return;
this.status = status;
this.emit({ type: 'status', status });
}
protected setStats(stats: LiveStats): void {
this.stats = stats;
// Wings reports the state alongside every stats frame; trust it.
this.setStatus(stats.status);
this.emit({ type: 'stats', stats });
}
protected setConnected(connected: boolean): void {
this.connected = connected;
}
}
@@ -0,0 +1,106 @@
import type { ServerStatus } from '@reforger-panel/shared';
import { BaseConsoleHub } from './console-hub.js';
import type { MockGameServerProvider } from './mock-provider.js';
const STATS_INTERVAL_MS = 2_000;
/**
* Simulates the Wings feed so the whole panel — live console, status strip and
* resource graphs — works under USE_MOCK_PTERODACTYL. The startup script
* deliberately includes the update and mod-download phase, since that is the
* part the real websocket exists to surface.
*/
const BOOT_SCRIPT: { delayMs: number; stream: 'console' | 'install' | 'daemon'; text: string }[] = [
{
delayMs: 100,
stream: 'daemon',
text: 'Pulling Docker container image, ensuring it is up to date.',
},
{ delayMs: 600, stream: 'daemon', text: 'Finished pulling Docker container image.' },
{ delayMs: 900, stream: 'console', text: 'Redirecting stderr to stdout.' },
{
delayMs: 1200,
stream: 'console',
text: 'Update state (0x5) verifying install, progress: 46.12 (1039 / 2253)',
},
{ delayMs: 1800, stream: 'console', text: 'Success! App "1874900" fully installed.' },
{
delayMs: 2200,
stream: 'console',
text: 'Downloading workshop mod 591AF5BDA9F7CE8B (Mock Sample Mod)',
},
{
delayMs: 2900,
stream: 'console',
text: 'Mod 591AF5BDA9F7CE8B downloaded (4.2 MiB), verifying.',
},
{
delayMs: 3300,
stream: 'console',
text: 'ENGINE : Enfusion engine build: 1.3.0.42 (mock)',
},
{ delayMs: 3700, stream: 'console', text: 'DEFAULT : Loading world.' },
{ delayMs: 4100, stream: 'console', text: 'DEFAULT : Game successfully created.' },
{ delayMs: 4300, stream: 'console', text: 'NETWORK : Server is ready to accept connections' },
];
export class MockConsoleHub extends BaseConsoleHub {
private statsTimer: ReturnType<typeof setInterval> | null = null;
private bootTimers: ReturnType<typeof setTimeout>[] = [];
private unsubscribe: (() => void) | null = null;
constructor(private readonly provider: MockGameServerProvider) {
super();
}
start(): void {
if (this.statsTimer) return;
this.setConnected(true);
this.setStatus(this.provider.currentStatus);
this.unsubscribe = this.provider.onStatusChange((status) => this.onStatusChange(status));
this.statsTimer = setInterval(() => void this.pushStats(), STATS_INTERVAL_MS);
this.statsTimer.unref?.();
void this.pushStats();
this.pushOutput('daemon', 'Attached to mock Pterodactyl console.');
}
async stop(): Promise<void> {
if (this.statsTimer) clearInterval(this.statsTimer);
this.statsTimer = null;
for (const timer of this.bootTimers) clearTimeout(timer);
this.bootTimers = [];
this.unsubscribe?.();
this.unsubscribe = null;
this.setConnected(false);
}
private onStatusChange(status: ServerStatus): void {
this.setStatus(status);
if (status === 'starting') this.playBootScript();
if (status === 'stopping') this.pushOutput('daemon', 'Stopping server container.');
if (status === 'offline') this.pushOutput('daemon', 'Server marked as offline.');
}
private playBootScript(): void {
for (const timer of this.bootTimers) clearTimeout(timer);
this.bootTimers = BOOT_SCRIPT.map((step) => {
const timer = setTimeout(() => this.pushOutput(step.stream, step.text), step.delayMs);
timer.unref?.();
return timer;
});
}
private async pushStats(): Promise<void> {
const resources = await this.provider.getServerResources();
this.setStats({
status: resources.status,
cpuPercent: resources.cpuPercent,
memoryBytes: resources.memoryBytes,
diskBytes: resources.diskBytes,
networkRxBytes: resources.networkRxBytes,
networkTxBytes: resources.networkTxBytes,
uptimeMs: resources.uptimeMs,
at: Date.now(),
});
}
}
@@ -7,6 +7,7 @@ import { ApiError } from '../../lib/errors.js';
import type {
DownloadableFile,
GameServerProvider,
ProviderServerLimits,
ProviderServerResources,
ServerFileEntry,
} from './types.js';
@@ -138,11 +139,11 @@ export class MockGameServerProvider implements GameServerProvider {
}
private transition(to: ServerStatus, after: number, thenTo: ServerStatus) {
this.status = to;
this.setStatus(to);
if (this.transitionTimer) clearTimeout(this.transitionTimer);
this.transitionTimer = setTimeout(() => {
this.status = thenTo;
if (thenTo === 'online') this.startedAt = Date.now();
this.setStatus(thenTo);
this.transitionTimer = null;
}, after);
this.transitionTimer.unref?.();
@@ -152,6 +153,32 @@ export class MockGameServerProvider implements GameServerProvider {
return this.status;
}
/** Exposed so the panel can subscribe to simulated power transitions. */
get currentStatus(): ServerStatus {
return this.status;
}
onStatusChange(listener: (status: ServerStatus) => void): () => void {
this.statusListeners.add(listener);
return () => this.statusListeners.delete(listener);
}
private readonly statusListeners = new Set<(status: ServerStatus) => void>();
private setStatus(status: ServerStatus): void {
if (this.status === status) return;
this.status = status;
for (const listener of this.statusListeners) listener(status);
}
async getServerLimits(): Promise<ProviderServerLimits> {
return {
cpuLimitPercent: 400,
memoryLimitBytes: 8 * 1024 ** 3,
diskLimitBytes: 40 * 1024 ** 3,
};
}
async getServerResources(): Promise<ProviderServerResources> {
const online = this.status === 'online';
const wobble = (base: number, spread: number) => base + (Math.random() - 0.5) * spread;
@@ -183,8 +210,8 @@ export class MockGameServerProvider implements GameServerProvider {
this.transition('stopping', STOP_DELAY_MS, 'starting');
setTimeout(() => {
if (this.status === 'starting') {
this.status = 'online';
this.startedAt = Date.now();
this.setStatus('online');
}
}, STOP_DELAY_MS + START_DELAY_MS).unref?.();
}
@@ -215,6 +242,30 @@ export class MockGameServerProvider implements GameServerProvider {
}
private startupVariables = [
{
name: 'Server Name',
description: 'Templated into config.json at boot by the egg.',
envVariable: 'SERVER_NAME',
serverValue: 'Mock Reforger Server',
defaultValue: 'Arma Reforger Server',
isEditable: true,
},
{
name: 'Max Players',
description: 'Templated into config.json at boot by the egg.',
envVariable: 'MAX_PLAYERS',
serverValue: '16',
defaultValue: '64',
isEditable: true,
},
{
name: 'Scenario ID',
description: 'Templated into config.json at boot by the egg.',
envVariable: 'SCENARIO_ID',
serverValue: '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf',
defaultValue: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
isEditable: true,
},
{
name: 'Server Password',
description: 'Password required to join the server.',
@@ -5,9 +5,11 @@ import type {
ServerStatus,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import { mapPowerState } from './console-hub.js';
import type {
DownloadableFile,
GameServerProvider,
ProviderServerLimits,
ProviderServerResources,
ServerFileEntry,
} from './types.js';
@@ -15,6 +17,8 @@ import type {
const DEFAULT_TIMEOUT_MS = 10_000;
const DOWNLOAD_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024;
/** Plan limits change rarely, but not never; a minute keeps them honest. */
const LIMITS_CACHE_TTL_MS = 60_000;
type PterodactylOptions = {
baseUrl: string;
@@ -60,21 +64,6 @@ type PterodactylScheduleAttributes = {
};
};
function mapState(state: string): ServerStatus {
switch (state) {
case 'running':
return 'online';
case 'offline':
return 'offline';
case 'starting':
return 'starting';
case 'stopping':
return 'stopping';
default:
return 'unknown';
}
}
/**
* Pterodactyl Client API provider. Uses only client-scoped endpoints (status,
* resources, power, read-only file access). Errors are sanitized: they carry
@@ -143,9 +132,9 @@ export class PterodactylProvider implements GameServerProvider {
}
}
private async getLimits(serverId: string) {
async getServerLimits(serverId: string): Promise<ProviderServerLimits> {
const cached = this.limitsCache.get(serverId);
if (cached && Date.now() - cached.fetchedAt < 5 * 60_000) return cached;
if (cached && Date.now() - cached.fetchedAt < LIMITS_CACHE_TTL_MS) return cached;
const data = await this.request<{
attributes?: { limits?: { cpu?: number; memory?: number; disk?: number } };
}>('server details', `/servers/${encodeURIComponent(serverId)}`);
@@ -182,14 +171,14 @@ export class PterodactylProvider implements GameServerProvider {
const attrs = data.attributes ?? {};
const res = attrs.resources ?? {};
const limits = await this.getLimits(serverId).catch(() => ({
const limits = await this.getServerLimits(serverId).catch(() => ({
cpuLimitPercent: null,
memoryLimitBytes: null,
diskLimitBytes: null,
}));
return {
status: mapState(attrs.current_state ?? 'unknown'),
status: mapPowerState(attrs.current_state),
cpuPercent: res.cpu_absolute ?? 0,
cpuLimitPercent: limits.cpuLimitPercent,
memoryBytes: res.memory_bytes ?? 0,
+12
View File
@@ -4,6 +4,12 @@ import type {
ServerStatus,
} from '@reforger-panel/shared';
export type ProviderServerLimits = {
cpuLimitPercent: number | null;
memoryLimitBytes: number | null;
diskLimitBytes: number | null;
};
export type ProviderServerResources = {
status: ServerStatus;
cpuPercent: number;
@@ -42,6 +48,12 @@ export type DownloadableFile = {
export interface GameServerProvider {
getServerStatus(serverId: string): Promise<ServerStatus>;
getServerResources(serverId: string): Promise<ProviderServerResources>;
/**
* Plan limits, cached by the implementation. Split out from resources so a
* live websocket stats frame (which carries usage but not limits) can still
* be rendered with a denominator.
*/
getServerLimits(serverId: string): Promise<ProviderServerLimits>;
startServer(serverId: string): Promise<void>;
stopServer(serverId: string): Promise<void>;
@@ -0,0 +1,240 @@
import WebSocket from 'ws';
import type { Logger } from '../../lib/logger.js';
import { BaseConsoleHub, mapPowerState, type LiveStats } from './console-hub.js';
type WebsocketCredentials = { token: string; socket: string };
type WingsMessage = { event?: string; args?: unknown[] };
const CREDENTIAL_TIMEOUT_MS = 10_000;
const RECONNECT_BASE_MS = 1_000;
const RECONNECT_MAX_MS = 30_000;
/** Wings tokens last 10 minutes; refresh well before the warning arrives. */
const TOKEN_REFRESH_MS = 8 * 60_000;
/**
* Keeps one authenticated Wings websocket open for the server and republishes
* everything it emits: console output (including the SteamCMD/mod-download
* phase that never reaches the game's own log file), install output, power
* state transitions, and resource frames.
*
* One upstream connection is shared by every panel viewer, and the base class
* keeps a line backlog so a browser attaching mid-session immediately sees
* context instead of an empty pane.
*/
export class WingsConsoleHub extends BaseConsoleHub {
private socket: WebSocket | null = null;
private stopped = true;
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private refreshTimer: ReturnType<typeof setInterval> | null = null;
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
constructor(
private readonly options: {
baseUrl: string;
apiKey: string;
serverId: string;
logger: Logger;
fetchImpl?: typeof fetch;
},
) {
super();
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
}
start(): void {
if (!this.stopped) return;
this.stopped = false;
this.refreshTimer = setInterval(() => void this.reauthenticate(), TOKEN_REFRESH_MS);
this.refreshTimer.unref?.();
void this.connect();
}
async stop(): Promise<void> {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.refreshTimer) clearInterval(this.refreshTimer);
this.reconnectTimer = null;
this.refreshTimer = null;
const socket = this.socket;
this.socket = null;
this.setConnected(false);
socket?.close();
}
/** Fetches a fresh websocket token from the Pterodactyl client API. */
private async fetchCredentials(): Promise<WebsocketCredentials> {
const url = `${this.baseUrl}/api/client/servers/${encodeURIComponent(
this.options.serverId,
)}/websocket`;
const response = await this.fetchImpl(url, {
headers: {
Authorization: `Bearer ${this.options.apiKey}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(CREDENTIAL_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`websocket credentials returned HTTP ${response.status}`);
}
const body = (await response.json()) as { data?: { token?: string; socket?: string } };
const token = body.data?.token;
const socket = body.data?.socket;
if (!token || !socket) throw new Error('websocket credentials response was incomplete');
return { token, socket };
}
private scheduleReconnect(): void {
if (this.stopped || this.reconnectTimer) return;
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** this.reconnectAttempt);
this.reconnectAttempt = Math.min(this.reconnectAttempt + 1, 5);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.connect();
}, delay);
this.reconnectTimer.unref?.();
}
private async connect(): Promise<void> {
if (this.stopped || this.socket) return;
let credentials: WebsocketCredentials;
try {
credentials = await this.fetchCredentials();
} catch (error) {
this.options.logger.warn(
{ err: error instanceof Error ? error.message : String(error) },
'wings websocket credentials failed',
);
this.scheduleReconnect();
return;
}
if (this.stopped) return;
// Wings only upgrades connections whose Origin matches the panel URL
// (see wings router/websocket GetHandler.CheckOrigin), which is why this
// uses `ws` rather than Node's built-in WebSocket.
const socket = new WebSocket(credentials.socket, { origin: this.baseUrl });
this.socket = socket;
socket.on('open', () => {
this.reconnectAttempt = 0;
this.setConnected(true);
this.send(socket, 'auth', credentials.token);
});
socket.on('message', (raw: WebSocket.RawData) => this.handleMessage(String(raw)));
socket.on('error', (error: Error) => {
this.options.logger.debug({ err: error.message }, 'wings websocket error');
});
socket.on('close', () => {
if (this.socket === socket) this.socket = null;
this.setConnected(false);
this.scheduleReconnect();
});
}
private send(socket: WebSocket, event: string, ...args: string[]): void {
if (socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ event, args }));
}
/** Re-auths the live socket with a fresh token before the old one expires. */
private async reauthenticate(): Promise<void> {
const socket = this.socket;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
try {
const { token } = await this.fetchCredentials();
this.send(socket, 'auth', token);
} catch (error) {
this.options.logger.debug(
{ err: error instanceof Error ? error.message : String(error) },
'wings token refresh failed',
);
}
}
private handleMessage(raw: string): void {
let message: WingsMessage;
try {
message = JSON.parse(raw) as WingsMessage;
} catch {
return;
}
const arg = typeof message.args?.[0] === 'string' ? (message.args[0] as string) : '';
switch (message.event) {
case 'auth success':
// Ask for the recent backlog and an immediate resource frame so the
// panel is populated the moment it attaches.
if (this.socket) {
this.send(this.socket, 'send logs');
this.send(this.socket, 'send stats');
}
break;
case 'console output':
this.pushOutput('console', arg);
break;
case 'install output':
this.pushOutput('install', arg);
break;
case 'install started':
this.pushOutput('daemon', 'Installation started.');
break;
case 'install completed':
this.pushOutput('daemon', 'Installation completed.');
break;
case 'daemon message':
this.pushOutput('daemon', arg);
break;
case 'daemon error':
case 'jwt error':
this.pushOutput('daemon', arg);
void this.reauthenticate();
break;
case 'token expiring':
case 'token expired':
void this.reauthenticate();
break;
case 'status':
this.setStatus(mapPowerState(arg));
break;
case 'stats':
this.handleStats(arg);
break;
default:
break;
}
}
private handleStats(payload: string): void {
let parsed: {
state?: string;
cpu_absolute?: number;
memory_bytes?: number;
disk_bytes?: number;
uptime?: number;
network?: { rx_bytes?: number; tx_bytes?: number };
};
try {
parsed = JSON.parse(payload) as typeof parsed;
} catch {
return;
}
const stats: LiveStats = {
status: mapPowerState(parsed.state),
cpuPercent: parsed.cpu_absolute ?? 0,
memoryBytes: parsed.memory_bytes ?? 0,
diskBytes: parsed.disk_bytes ?? 0,
networkRxBytes: parsed.network?.rx_bytes ?? 0,
networkTxBytes: parsed.network?.tx_bytes ?? 0,
uptimeMs: parsed.uptime ?? 0,
at: Date.now(),
};
this.setStats(stats);
}
}
@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { WorkshopModDetail, WorkshopScenario } from '@reforger-panel/shared';
import type { WorkshopCache } from '../workshop/workshop-cache.js';
import {
hasScenarioTag,
mergeMissions,
MissionsService,
OFFICIAL_MISSIONS,
OFFICIAL_SCENARIO_IDS,
parseMissionList,
scenariosFromWorkshopMod,
} from './missions-catalog.js';
import type { MissionCatalog, ParsedMission } from './missions-catalog.js';
// Verbatim shape from a real console.log (server runs with -listScenarios).
const LOG = [
@@ -23,12 +26,12 @@ describe('parseMissionList', () => {
it('parses scenario ids, display names, and section sources', () => {
const missions = parseMissionList(LOG);
expect(missions).toHaveLength(4);
expect(missions[0]).toEqual({
expect(missions[0]).toMatchObject({
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
name: 'Conflict - Everon',
source: 'official',
});
expect(missions[3]).toEqual({
expect(missions[3]).toMatchObject({
scenarioId: '{ABCDEF0123456789}Missions/CustomOps.conf',
name: 'Custom Ops',
source: 'workshop',
@@ -36,8 +39,7 @@ describe('parseMissionList', () => {
});
it('deduplicates repeated listings (multiple boots in one file)', () => {
const missions = parseMissionList(`${LOG}\n${LOG}`);
expect(missions).toHaveLength(4);
expect(parseMissionList(`${LOG}\n${LOG}`)).toHaveLength(4);
});
it('returns an empty list when no listing is present', () => {
@@ -45,62 +47,128 @@ describe('parseMissionList', () => {
});
});
describe('workshop scenario helpers', () => {
it('recognizes scenario tag variants from the workshop', () => {
expect(hasScenarioTag(['SCENARIOS_MP'])).toBe(true);
expect(hasScenarioTag(['scenario sp'])).toBe(true);
expect(hasScenarioTag(['WEAPONS'])).toBe(false);
describe('OFFICIAL_MISSIONS', () => {
it('covers the vanilla scenarios with well-formed ids', () => {
expect(OFFICIAL_MISSIONS.length).toBeGreaterThan(15);
for (const mission of OFFICIAL_MISSIONS) {
expect(mission.scenarioId).toMatch(/^\{[0-9A-F]{16}\}Missions\/.+\.conf$/);
expect(mission.name).not.toMatch(/^#AR-/);
}
expect(OFFICIAL_SCENARIO_IDS.has('{ECC61978EDCC2B5A}Missions/23_Campaign.conf')).toBe(true);
});
});
it('converts mod scenarios into mission entries', () => {
const missions = scenariosFromWorkshopMod({
id: 'ABC',
name: 'Scenario Pack',
author: 'Author',
imageUrl: null,
size: null,
rating: null,
workshopUrl: null,
version: null,
gameVersion: null,
subscribers: null,
downloads: null,
createdAtText: null,
lastModifiedText: null,
summary: null,
description: null,
license: null,
tags: [],
dependencies: [],
scenarios: [
function scenario(id: string, name: string): WorkshopScenario {
return {
scenarioId: id,
name,
gameMode: 'Campaign',
author: null,
description: null,
playerCount: 64,
};
}
function fakeWorkshop(mods: Record<string, { name: string; scenarios: WorkshopScenario[] }>) {
return {
tryGetMod: vi.fn(async (id: string) => {
const mod = mods[id];
if (!mod) return null;
return {
id,
name: mod.name,
scenarioCount: mod.scenarios.length,
scenarios: mod.scenarios,
} as unknown as WorkshopModDetail;
}),
getScenarios: vi.fn(async (id: string) => mods[id]?.scenarios ?? []),
} as unknown as WorkshopCache;
}
function fakeCatalog(missions: ParsedMission[]): MissionCatalog {
return {
list: async () => ({ missions, fetchedAt: new Date().toISOString() }),
} as MissionCatalog;
}
describe('MissionsService', () => {
it('always offers the vanilla scenarios as one group', async () => {
const service = new MissionsService(fakeWorkshop({}), null);
const result = await service.list([]);
const official = result.groups.find((group) => group.id === 'official')!;
expect(official.kind).toBe('official');
expect(official.missions.length).toBeGreaterThan(15);
});
it('adds one group per installed mod that ships scenarios', async () => {
const workshop = fakeWorkshop({
AAAA000000000001: {
name: 'Scenario Pack',
scenarios: [scenario('{1111111111111111}Missions/RaidNight.conf', 'Raid Night')],
},
BBBB000000000002: { name: 'Weapons Only', scenarios: [] },
});
const service = new MissionsService(workshop, null);
const result = await service.list([
{ modId: 'aaaa000000000001' },
{ modId: 'BBBB000000000002' },
]);
const modGroups = result.groups.filter((group) => group.kind === 'mod');
expect(modGroups).toHaveLength(1);
expect(modGroups[0]!.label).toBe('Scenario Pack');
expect(modGroups[0]!.missions[0]).toEqual({
scenarioId: '{1111111111111111}Missions/RaidNight.conf',
name: 'Raid Night',
gameMode: 'Campaign',
playerCount: 64,
});
});
it('reports mods it could not resolve rather than silently dropping them', async () => {
const service = new MissionsService(fakeWorkshop({}), null);
const result = await service.list([{ modId: 'CCCC000000000003' }]);
expect(result.incompleteModIds).toEqual(['CCCC000000000003']);
});
it("prefers the server's own naming for official scenarios", async () => {
const service = new MissionsService(
fakeWorkshop({}),
fakeCatalog([
{
name: 'Raid Night',
description: null,
scenarioId: '{1111111111111111}Missions/RaidNight.conf',
gamemode: 'Coop',
playerCount: 32,
imageUrl: null,
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
name: 'Conflict - Everon (from log)',
gameMode: null,
playerCount: null,
source: 'official',
},
],
});
expect(missions).toEqual([
{
scenarioId: '{1111111111111111}Missions/RaidNight.conf',
name: 'Raid Night',
source: 'mod: Scenario Pack',
},
]);
]),
);
const official = (await service.list([])).groups.find((group) => group.id === 'official')!;
const everon = official.missions.find(
(mission) => mission.scenarioId === '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
)!;
expect(everon.name).toBe('Conflict - Everon (from log)');
// Only listed once, despite also being in the bundled set.
expect(
official.missions.filter((mission) => mission.scenarioId === everon.scenarioId),
).toHaveLength(1);
});
it('deduplicates mission groups while preserving first source', () => {
const merged = mergeMissions(
[{ scenarioId: 'same', name: 'From Log', source: 'workshop' }],
[{ scenarioId: 'same', name: 'From Mod', source: 'mod: Pack' }],
[{ scenarioId: 'other', name: 'Other', source: 'mod: Pack' }],
it('surfaces log-reported scenarios that no mod group covers', async () => {
const service = new MissionsService(
fakeWorkshop({}),
fakeCatalog([
{
scenarioId: '{ABCDEF0123456789}Missions/CustomOps.conf',
name: 'Custom Ops',
gameMode: null,
playerCount: null,
source: 'workshop',
},
]),
);
expect(merged).toEqual([
{ scenarioId: 'same', name: 'From Log', source: 'workshop' },
{ scenarioId: 'other', name: 'Other', source: 'mod: Pack' },
]);
const fallback = (await service.list([])).groups.find((group) => group.id === 'server-log')!;
expect(fallback.missions[0]!.name).toBe('Custom Ops');
});
});
@@ -1,17 +1,156 @@
import type { MissionInfo, MissionsResponse, WorkshopModDetail } from '@reforger-panel/shared';
import type {
MissionGroup,
MissionInfo,
MissionsResponse,
ReforgerConfigMod,
} from '@reforger-panel/shared';
import type { WorkshopCache } from '../workshop/workshop-cache.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { LogPathResolver } from './ingestion/log-path-resolver.js';
const CATALOG_TTL_MS = 10 * 60 * 1000;
const CATALOG_MAX_BYTES = 2 * 1024 * 1024;
export const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf';
export const DEFAULT_MISSION: MissionInfo = {
scenarioId: DEFAULT_SCENARIO_ID,
name: 'Campaign - Montignac',
source: 'official',
};
const SCENARIO_TAGS = new Set(['scenario', 'scenario mp', 'scenario sp']);
/** Kept as the "known-good fallback" target for orphaned-mission recovery. */
export const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf';
/**
* Scenarios that ship with the base game.
*
* Compiled from the scenario ids live vanilla servers actually report (i.e.
* servers whose scenario is not provided by a Workshop mod), so the list
* reflects the shipped game rather than guesswork. It is a seed, not a closed
* set: anything the server itself prints at boot with `-listScenarios` is
* merged on top, so a game update that adds a mission still shows up.
*/
export const OFFICIAL_MISSIONS: readonly MissionInfo[] = [
{
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
name: 'Conflict - Everon',
gameMode: 'Conflict',
playerCount: 128,
},
{
scenarioId: '{C41618FD18E9D714}Missions/23_Campaign_Arland.conf',
name: 'Conflict - Arland',
gameMode: 'Conflict',
playerCount: 128,
},
{
scenarioId: '{9C6054B42A044DEC}Missions/23_Campaign_Cain.conf',
name: 'Conflict - Kolguyev',
gameMode: 'Conflict',
playerCount: 128,
},
{
scenarioId: '{28802845ADA64D52}Missions/23_Campaign_NorthCentral.conf',
name: 'Conflict - Northern Everon',
gameMode: 'Conflict',
playerCount: 64,
},
{
scenarioId: DEFAULT_SCENARIO_ID,
name: 'Conflict - Montignac',
gameMode: 'Conflict',
playerCount: 64,
},
{
scenarioId: '{0220741028718E7F}Missions/23_Campaign_HQC_Everon.conf',
name: 'Commander - Everon',
gameMode: 'Commander',
playerCount: 128,
},
{
scenarioId: '{68D1240A11492545}Missions/23_Campaign_HQC_Arland.conf',
name: 'Commander - Arland',
gameMode: 'Commander',
playerCount: 128,
},
{
scenarioId: '{BB5345C22DD2B655}Missions/23_Campaign_HQC_Cain.conf',
name: 'Commander - Kolguyev',
gameMode: 'Commander',
playerCount: 128,
},
{
scenarioId: '{DAA03C6E6099D50F}Missions/24_CombatOps.conf',
name: 'Combat Ops - Arland',
gameMode: 'Combat Ops',
playerCount: 16,
},
{
scenarioId: '{DFAC5FABD11F2390}Missions/26_CombatOpsEveron.conf',
name: 'Combat Ops - Everon',
gameMode: 'Combat Ops',
playerCount: 16,
},
{
scenarioId: '{CB347F2F10065C9C}Missions/CombatOpsCain.conf',
name: 'Combat Ops - Kolguyev',
gameMode: 'Combat Ops',
playerCount: 16,
},
{
scenarioId: '{59AD59368755F41A}Missions/21_GM_Eden.conf',
name: 'Game Master - Everon',
gameMode: 'Game Master',
playerCount: 64,
},
{
scenarioId: '{2BBBE828037C6F4B}Missions/22_GM_Arland.conf',
name: 'Game Master - Arland',
gameMode: 'Game Master',
playerCount: 64,
},
{
scenarioId: '{F45C6C15D31252E6}Missions/27_GM_Cain.conf',
name: 'Game Master - Kolguyev',
gameMode: 'Game Master',
playerCount: 64,
},
{
scenarioId: '{3F2E005F43DBD2F8}Missions/CAH_Briars_Coast.conf',
name: 'Capture & Hold - The Briars',
gameMode: 'Capture & Hold',
playerCount: 32,
},
{
scenarioId: '{589945FB9FA7B97D}Missions/CAH_Concrete_Plant.conf',
name: 'Capture & Hold - Concrete Plant',
gameMode: 'Capture & Hold',
playerCount: 32,
},
{
scenarioId: '{9405201CBD22A30C}Missions/CAH_Factory.conf',
name: 'Capture & Hold - Almara Factory',
gameMode: 'Capture & Hold',
playerCount: 32,
},
{
scenarioId: '{1CD06B409C6FAE56}Missions/CAH_Forest.conf',
name: "Capture & Hold - Simon's Wood",
gameMode: 'Capture & Hold',
playerCount: 32,
},
{
scenarioId: '{7C491B1FCC0FF0E1}Missions/CAH_LeMoule.conf',
name: 'Capture & Hold - Le Moule',
gameMode: 'Capture & Hold',
playerCount: 32,
},
{
scenarioId: '{2B4183DF23E88249}Missions/CAH_Morton.conf',
name: 'Capture & Hold - Morton',
gameMode: 'Capture & Hold',
playerCount: 32,
},
];
export const OFFICIAL_SCENARIO_IDS = new Set(
OFFICIAL_MISSIONS.map((mission) => mission.scenarioId),
);
export type ParsedMission = MissionInfo & { source: string };
/**
* Scenario listing printed at boot when the server runs with -listScenarios
@@ -22,8 +161,8 @@ const SCENARIO_TAGS = new Set(['scenario', 'scenario mp', 'scenario sp']);
const SECTION_PATTERN = /SCRIPT\s*:\s*(.+ scenarios) \(\d+ entr/i;
const MISSION_PATTERN = /SCRIPT\s*:\s*(\{[0-9A-Fa-f]{16}\}\S+\.conf)(?:\s+\((.+)\))?\s*$/;
export function parseMissionList(logContent: string): MissionInfo[] {
const missions: MissionInfo[] = [];
export function parseMissionList(logContent: string): ParsedMission[] {
const missions: ParsedMission[] = [];
const seen = new Set<string>();
let currentSource = 'official';
for (const line of logContent.split('\n')) {
@@ -38,6 +177,8 @@ export function parseMissionList(logContent: string): MissionInfo[] {
missions.push({
scenarioId: mission[1]!,
name: mission[2] ?? mission[1]!.slice(mission[1]!.lastIndexOf('/') + 1),
gameMode: null,
playerCount: null,
source: currentSource,
});
}
@@ -45,47 +186,13 @@ export function parseMissionList(logContent: string): MissionInfo[] {
return missions;
}
export function scenariosFromWorkshopMod(mod: WorkshopModDetail): MissionInfo[] {
return mod.scenarios.map((scenario) => ({
scenarioId: scenario.scenarioId,
name: scenario.name,
source: `mod: ${mod.name}`,
}));
}
function normalizeTag(tag: string): string {
return tag
.trim()
.toLowerCase()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.replace(/^scenarios\b/, 'scenario');
}
export function hasScenarioTag(tags: string[]): boolean {
return tags.some((tag) => SCENARIO_TAGS.has(normalizeTag(tag)));
}
export function mergeMissions(...groups: MissionInfo[][]): MissionInfo[] {
const merged: MissionInfo[] = [];
const seen = new Set<string>();
for (const group of groups) {
for (const mission of group) {
if (seen.has(mission.scenarioId)) continue;
seen.add(mission.scenarioId);
merged.push(mission);
}
}
return merged;
}
/**
* Extracts the available-missions dropdown data from the server's current
* Extracts the scenarios the server itself reported at boot from its
* console.log. Cached briefly; a fresh boot log always carries the listing
* near the top, so the head of the file is enough.
*/
export class MissionCatalog {
private cache: { missions: MissionInfo[]; fetchedAt: string; expiresAt: number } | null = null;
private cache: { missions: ParsedMission[]; fetchedAt: string; expiresAt: number } | null = null;
constructor(
private readonly provider: GameServerProvider,
@@ -93,7 +200,7 @@ export class MissionCatalog {
private readonly providerServerId: string,
) {}
async list(force = false): Promise<MissionsResponse> {
async list(force = false): Promise<{ missions: ParsedMission[]; fetchedAt: string | null }> {
if (!force && this.cache && this.cache.expiresAt > Date.now()) {
return { missions: this.cache.missions, fetchedAt: this.cache.fetchedAt };
}
@@ -121,3 +228,106 @@ export class MissionCatalog {
return { missions: [], fetchedAt: null };
}
}
function dedupe(missions: MissionInfo[]): MissionInfo[] {
const seen = new Set<string>();
const result: MissionInfo[] = [];
for (const mission of missions) {
if (seen.has(mission.scenarioId)) continue;
seen.add(mission.scenarioId);
result.push(mission);
}
return result;
}
/**
* Builds the mission picker: one list of the vanilla scenarios, then one group
* per installed mod that ships scenarios.
*
* Scenario ids come from the Workshop v2 `scenarios[].gameId` field, which is
* an actual id rather than something scraped out of prose, and a mod is asked
* for scenarios based on its reported `scenarioCount` rather than a tag
* heuristic — so mods that ship missions without tagging themselves as
* scenario mods are no longer missed.
*/
export class MissionsService {
constructor(
private readonly workshop: WorkshopCache,
private readonly catalog: MissionCatalog | null,
) {}
async list(installedMods: readonly ReforgerConfigMod[]): Promise<MissionsResponse> {
const fromLog = this.catalog
? await this.catalog.list().catch(() => ({ missions: [], fetchedAt: null }))
: { missions: [] as ParsedMission[], fetchedAt: null };
const logOfficial = fromLog.missions.filter((mission) => mission.source === 'official');
const logOther = fromLog.missions.filter((mission) => mission.source !== 'official');
const groups: MissionGroup[] = [
{
id: 'official',
label: 'Official (vanilla)',
kind: 'official',
// The server's own listing wins on naming; the bundled set fills in
// whatever a rotated log no longer mentions.
missions: dedupe([...logOfficial, ...OFFICIAL_MISSIONS]).sort((a, b) =>
a.name.localeCompare(b.name),
),
},
];
const incompleteModIds: string[] = [];
const modIds = installedMods.map((mod) => mod.modId.toUpperCase());
const details = await Promise.all(modIds.map((id) => this.workshop.tryGetMod(id)));
for (let index = 0; index < modIds.length; index += 1) {
const modId = modIds[index]!;
const detail = details[index];
if (!detail) {
incompleteModIds.push(modId);
continue;
}
if (detail.scenarioCount === 0) continue;
const scenarios = await this.workshop.getScenarios(modId);
if (scenarios.length === 0) {
// Reported scenarios we could not enumerate — say so rather than
// silently showing a shorter list than the server has.
if (detail.scenarioCount > 0) incompleteModIds.push(modId);
continue;
}
groups.push({
id: modId,
label: detail.name,
kind: 'mod',
missions: scenarios.map((scenario) => ({
scenarioId: scenario.scenarioId,
name: scenario.name,
gameMode: scenario.gameMode,
playerCount: scenario.playerCount,
})),
});
}
// Anything the server reported that no group covers (mods the Workshop
// does not know about, hand-installed missions).
const covered = new Set(
groups.flatMap((group) => group.missions.map((mission) => mission.scenarioId)),
);
const uncovered = logOther.filter((mission) => !covered.has(mission.scenarioId));
if (uncovered.length > 0) {
groups.push({
id: 'server-log',
label: 'Reported by the server',
kind: 'mod',
missions: dedupe(uncovered),
});
}
return {
groups: groups.filter((group) => group.missions.length > 0),
incompleteModIds,
fetchedAt: new Date().toISOString(),
};
}
}
@@ -0,0 +1,68 @@
import type { ServerResources, ServerStatus } from '@reforger-panel/shared';
import type { ConsoleHub } from '../pterodactyl/console-hub.js';
import type { GameServerProvider, ProviderServerLimits } from '../pterodactyl/types.js';
/**
* A Wings `stats` frame arrives roughly every two seconds. Anything older than
* this is treated as gone stale and we fall back to the REST endpoint.
*/
const LIVE_MAX_AGE_MS = 10_000;
const NO_LIMITS: ProviderServerLimits = {
cpuLimitPercent: null,
memoryLimitBytes: null,
diskLimitBytes: null,
};
/**
* Single source of truth for "what is the server doing right now".
*
* Resource numbers previously came only from a 10-second REST poll, so every
* tile and graph in the panel lagged reality and disagreed with what
* Pterodactyl itself displayed. When the console hub is connected its pushed
* frames are authoritative; the REST call remains as the fallback for when the
* websocket is down or disabled.
*/
export class ServerMetricsService {
constructor(
private readonly provider: GameServerProvider,
private readonly hub: ConsoleHub | null,
) {}
private liveStats() {
const stats = this.hub?.latestStats();
if (!stats) return null;
return Date.now() - stats.at <= LIVE_MAX_AGE_MS ? stats : null;
}
async getStatus(serverId: string): Promise<ServerStatus> {
const live = this.liveStats();
if (live) return live.status;
const hubStatus = this.hub?.latestStatus();
if (hubStatus && hubStatus !== 'unknown') return hubStatus;
return this.provider.getServerStatus(serverId);
}
async getResources(serverId: string): Promise<ServerResources> {
const live = this.liveStats();
if (live) {
const limits = await this.provider.getServerLimits(serverId).catch(() => NO_LIMITS);
return {
status: live.status,
cpuPercent: live.cpuPercent,
cpuLimitPercent: limits.cpuLimitPercent,
memoryBytes: live.memoryBytes,
memoryLimitBytes: limits.memoryLimitBytes,
diskBytes: live.diskBytes,
diskLimitBytes: limits.diskLimitBytes,
networkRxBytes: live.networkRxBytes,
networkTxBytes: live.networkTxBytes,
uptimeMs: live.uptimeMs,
fetchedAt: new Date(live.at).toISOString(),
source: 'live',
};
}
const resources = await this.provider.getServerResources(serverId);
return { ...resources, fetchedAt: new Date().toISOString(), source: 'poll' };
}
}
@@ -1,6 +1,6 @@
import type { ResourceHistoryResponse, ResourceSample } from '@reforger-panel/shared';
import type { Logger } from '../../lib/logger.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { ServerMetricsService } from './metrics-service.js';
export const SAMPLE_INTERVAL_SECONDS = 15;
const MAX_SAMPLES = 240; // ~1 hour window
@@ -9,8 +9,12 @@ type RawSample = ResourceSample & { rxTotal: number; txTotal: number };
/**
* In-memory rolling window of resource usage for the dashboard graphs.
* Network rates are derived from the provider's cumulative rx/tx counters;
* history is intentionally not persisted (it is telemetry, not records).
* Network rates are derived from the cumulative rx/tx counters; history is
* intentionally not persisted (it is telemetry, not records).
*
* Samples come from ServerMetricsService, so when the Wings websocket is up
* this reads an already-pushed frame rather than issuing its own HTTP request
* every 15 seconds.
*/
export class ResourceHistoryService {
private samples = new Map<string, RawSample[]>();
@@ -18,7 +22,7 @@ export class ResourceHistoryService {
private servers: { serverId: string; providerServerId: string }[] = [];
constructor(
private readonly provider: GameServerProvider,
private readonly metrics: ServerMetricsService,
private readonly logger: Logger,
private readonly intervalSeconds: number = SAMPLE_INTERVAL_SECONDS,
) {}
@@ -62,7 +66,7 @@ export class ResourceHistoryService {
}
private async sampleOne(serverId: string, providerServerId: string): Promise<void> {
const resources = await this.provider.getServerResources(providerServerId);
const resources = await this.metrics.getResources(providerServerId);
const previous = this.samples.get(serverId)?.at(-1);
const now = Date.now();
+354 -255
View File
@@ -1,54 +1,54 @@
import { Router } from 'express';
import { z } from 'zod';
import type {
ConfigPatchOp,
LogIngestionHealth,
MissionInfo,
ModDependencyIssue,
ServerResources,
ReforgerConfigMod,
ServerStatus,
ServerSummary,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import { rateLimit } from '../../lib/rate-limit.js';
import { requireAuth, requireCapability } from '../auth/auth-middleware.js';
import type { ConfigEditorService } from '../config/config-editor-service.js';
import type { ConfigSyncService } from '../config/config-sync.js';
import type { ServerModsService } from '../config/mods-service.js';
import type { PerformanceSettingsService } from '../config/performance-service.js';
import type { ResourceHistoryService } from './resource-history.js';
import type { ConsoleHub } from '../pterodactyl/console-hub.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { LogPathResolver } from '../reforger-logs/ingestion/log-path-resolver.js';
import type { IngestionScheduler, ScheduledServer } from '../reforger-logs/ingestion/scheduler.js';
import type { MissionCatalog } from '../reforger-logs/missions-catalog.js';
import {
DEFAULT_MISSION,
DEFAULT_SCENARIO_ID,
hasScenarioTag,
mergeMissions,
scenariosFromWorkshopMod,
} from '../reforger-logs/missions-catalog.js';
import type { MissionsService } from '../reforger-logs/missions-catalog.js';
import type { WorkshopCache } from '../workshop/workshop-cache.js';
import type { ServerMetricsService } from './metrics-service.js';
import type { ResourceHistoryService } from './resource-history.js';
import type { ServerRecord, ServerService } from './server-service.js';
import type { WorkshopClient } from '../workshop/workshop-client.js';
const slugSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, 'Invalid server slug.');
export type ServerRouterDeps = {
service: ServerService;
provider: GameServerProvider;
metrics: ServerMetricsService;
consoleHub: ConsoleHub | null;
scheduler: IngestionScheduler | null;
resolveLogPath: LogPathResolver | null;
configSync: ConfigSyncService | null;
configEditor: ConfigEditorService | null;
mods: ServerModsService | null;
performance: PerformanceSettingsService | null;
resourceHistory: ResourceHistoryService | null;
missions: MissionCatalog | null;
workshop: WorkshopClient;
missions: MissionsService;
workshop: WorkshopCache;
staleAfterSeconds: number;
mockMode: boolean;
};
const revisionSchema = z.string().regex(/^[a-f0-9]{8,64}$/, 'Invalid revision.');
// Validation ranges follow the Bohemia server-config reference. Only provided
// keys are touched; `null` removes the key (the game default applies).
const performanceBodySchema = z
const performanceSettingsSchema = z
.object({
scenarioId: z
.string()
@@ -73,6 +73,48 @@ const performanceBodySchema = z
.partial()
.strict();
const performanceBodySchema = z.object({
settings: performanceSettingsSchema,
expectedRevision: revisionSchema.optional(),
writeStartupVars: z.boolean().default(true),
});
/**
* Dotted config paths only — no array indices, no prototype-polluting
* segments. `game.mods` is owned by the mods endpoints, which understand
* versions and dependencies, so it is refused here.
*/
const configPathSchema = z
.string()
.max(200)
.regex(/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/, 'Invalid config path.')
.refine((path) => !path.split('.').some((segment) => segment === '__proto__'), 'Invalid path.')
.refine((path) => path !== 'game.mods' && !path.startsWith('game.mods.'), {
message: 'The mod list is managed on the Mods page.',
});
const configPatchBodySchema = z.object({
ops: z
.array(
z.object({
path: configPathSchema,
value: z.union([z.string().max(4000), z.number(), z.boolean(), z.null()]),
}),
)
.min(1)
.max(200),
expectedRevision: revisionSchema.optional(),
writeStartupVars: z.boolean().default(false),
});
const configRawBodySchema = z.object({
content: z
.string()
.min(2)
.max(256 * 1024),
expectedRevision: revisionSchema.optional(),
});
const startupVariableBodySchema = z.object({
key: z.string().regex(/^[A-Z0-9_]{1,64}$/, 'Invalid variable name.'),
value: z.string().max(500),
@@ -91,31 +133,43 @@ const scheduleIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'Invalid sche
// Reforger Workshop mod IDs are 16 hex characters (see the Bohemia server
// config reference); name/version are free-ish text with sane caps.
const modEntrySchema = z.object({
modId: z.string().regex(/^[A-Fa-f0-9]{16}$/, 'Invalid mod id.'),
name: z.string().trim().max(200).optional(),
version: z
.string()
.trim()
.max(32)
.regex(/^[\w.+-]*$/, 'Invalid version.')
.optional(),
});
const modsBodySchema = z.object({
mods: z
.array(
z.object({
modId: z.string().regex(/^[A-Fa-f0-9]{16}$/, 'Invalid mod id.'),
name: z.string().trim().max(200).optional(),
version: z
.string()
.trim()
.max(32)
.regex(/^[\w.+-]*$/, 'Invalid version.')
.optional(),
}),
)
.max(200),
mods: z.array(modEntrySchema).max(300),
expectedRevision: revisionSchema.optional(),
});
const modsResolveBodySchema = z.object({
mods: z.array(modEntrySchema).max(300),
});
function providerId(server: ServerRecord): string {
return server.pterodactylServerId ?? server.slug;
}
/** Rejects duplicate mod ids up front instead of silently collapsing them. */
function assertNoDuplicates(mods: readonly ReforgerConfigMod[]): void {
const ids = mods.map((mod) => mod.modId.toUpperCase());
if (new Set(ids).size !== ids.length) {
throw ApiError.validation('Duplicate mod ids in the list.');
}
}
export function createServerRouter(deps: ServerRouterDeps): Router {
const router = Router();
const { service, provider } = deps;
const { service, provider, metrics } = deps;
const powerRateLimit = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'power' });
const writeRateLimit = rateLimit({ windowMs: 60_000, max: 20, keyPrefix: 'configwrite' });
const syncRateLimit = rateLimit({ windowMs: 60_000, max: 6, keyPrefix: 'logsync' });
router.use(requireAuth);
@@ -128,10 +182,24 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
return server;
}
function requireMods(): ServerModsService {
if (!deps.mods) {
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
}
return deps.mods;
}
function requireConfigEditor(): ConfigEditorService {
if (!deps.configEditor) {
throw ApiError.notConfigured('Config editing requires a configured game server backend.');
}
return deps.configEditor;
}
async function toSummary(server: ServerRecord): Promise<ServerSummary> {
let status = server.status as ServerStatus;
try {
status = await provider.getServerStatus(providerId(server));
status = await metrics.getStatus(providerId(server));
if (status !== server.status) {
await service.updateStatus(server.id, status);
}
@@ -151,6 +219,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
};
}
// ---------- server identity & telemetry ----------
router.get('/', async (_req, res, next) => {
try {
const servers = await service.listServers();
@@ -162,8 +232,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.get('/:slug', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
res.json(await toSummary(server));
res.json(await toSummary(await loadServer(req.params.slug)));
} catch (error) {
next(error);
}
@@ -172,9 +241,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.get('/:slug/resources', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const resources = await provider.getServerResources(providerId(server));
const body: ServerResources = { ...resources, fetchedAt: new Date().toISOString() };
res.json(body);
res.json(await metrics.getResources(providerId(server)));
} catch (error) {
next(error);
}
@@ -192,6 +259,21 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
}
});
// ---------- configuration ----------
router.get('/:slug/configuration', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.configSync) {
throw ApiError.notConfigured('Configuration requires a configured game server backend.');
}
const { config, revision } = await deps.configSync.getLiveConfig(server);
res.json({ config, revision, fetchedAt: new Date().toISOString() });
} catch (error) {
next(error);
}
});
router.get('/:slug/config/performance', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
@@ -206,7 +288,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.put(
'/:slug/config/performance',
syncRateLimit,
writeRateLimit,
requireCapability('config.edit', 'You do not have permission to edit the configuration.'),
async (req, res, next) => {
try {
@@ -221,14 +303,10 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
issue ? `${issue.path.join('.')}: ${issue.message}` : 'Invalid settings.',
);
}
const result = await deps.performance.update(server, body.data);
// Many Reforger eggs template config.json from startup variables at
// boot; mirror the mission there too so switching sticks either way.
if (result.changedFields.includes('scenarioId') && body.data.scenarioId) {
await provider
.updateStartupVariable(providerId(server), 'SCENARIO_ID', body.data.scenarioId)
.catch(() => undefined); // variable may not exist on this egg
}
const result = await deps.performance.update(server, body.data.settings, {
expectedRevision: body.data.expectedRevision,
writeStartupVars: body.data.writeStartupVars,
});
if (result.changedFields.length > 0) {
const user = req.user!;
await service.recordActivity({
@@ -246,6 +324,117 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
},
);
/** Every key config.json actually contains, for the searchable editor. */
router.get(
'/:slug/config/tree',
requireCapability('config.edit', 'Configuration editing is restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
res.json(await requireConfigEditor().getTree(server));
} catch (error) {
next(error);
}
},
);
router.patch(
'/:slug/config',
writeRateLimit,
requireCapability('config.edit', 'You do not have permission to edit the configuration.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const body = configPatchBodySchema.safeParse(req.body);
if (!body.success) {
const issue = body.error.issues[0];
throw ApiError.validation(issue?.message ?? 'Invalid configuration patch.');
}
const ops: ConfigPatchOp[] = body.data.ops;
const result = await requireConfigEditor().patch(server, ops, {
expectedRevision: body.data.expectedRevision,
writeStartupVars: body.data.writeStartupVars,
});
if (result.changedPaths.length > 0) {
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'config.updated',
// Paths only — values can be passwords.
summary: `config.json updated by ${user.displayName ?? user.username}: ${result.changedPaths.join(', ')} (applies on restart)`,
metadata: { changedPaths: result.changedPaths },
});
}
res.json(result);
} catch (error) {
next(error);
}
},
);
router.get(
'/:slug/config/raw',
requireCapability('config.edit', 'Configuration editing is restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
res.json(await requireConfigEditor().getRaw(server));
} catch (error) {
next(error);
}
},
);
router.put(
'/:slug/config/raw',
writeRateLimit,
requireCapability('config.edit', 'You do not have permission to edit the configuration.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const body = configRawBodySchema.safeParse(req.body);
if (!body.success) throw ApiError.validation('Invalid config.json payload.');
const result = await requireConfigEditor().putRaw(
server,
body.data.content,
body.data.expectedRevision,
);
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'config.raw.updated',
summary: `config.json replaced by ${user.displayName ?? user.username} (applies on restart)`,
metadata: { revision: result.revision },
});
res.json(result);
} catch (error) {
next(error);
}
},
);
router.post(
'/:slug/config/sync',
syncRateLimit,
requireCapability('ops.health.view', 'Config sync is restricted to owner and server admins.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.configSync) {
throw ApiError.notConfigured('Config import requires a configured game server backend.');
}
const result = await deps.configSync.sync(server);
res.json({ ok: true, serverName: result.serverName, maxPlayers: result.maxPlayers });
} catch (error) {
next(error);
}
},
);
// ---------- players, activity, killfeed ----------
router.get('/:slug/players', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
@@ -284,137 +473,108 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
}
});
router.get('/:slug/configuration', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.configSync) {
throw ApiError.notConfigured('Configuration requires a configured game server backend.');
}
const config = await deps.configSync.getLiveConfig(server);
res.json({ config, fetchedAt: new Date().toISOString() });
} catch (error) {
next(error);
}
});
// ---------- missions ----------
router.get('/:slug/missions', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
// Resolve the installed mod list from config.json.
// Prefer the mods service (already owns that parse); fall back to configSync.
let installedModIds: string[] = [];
let installed: ReforgerConfigMod[] = [];
if (deps.mods) {
const modsData = await deps.mods.getMods(server);
installedModIds = modsData.mods.map((m) => m.modId);
installed = (await deps.mods.getMods(server)).mods;
} else if (deps.configSync) {
const config = await deps.configSync.getLiveConfig(server).catch(() => null);
installedModIds = (config?.mods ?? []).map((m) => m.modId);
const live = await deps.configSync.getLiveConfig(server).catch(() => null);
installed = live?.config.mods ?? [];
}
// Workshop API -> scenarios from installed scenario-tagged mods.
const modMissions: MissionInfo[] = [];
let scenarioLookupComplete = true;
if (installedModIds.length > 0) {
const details = await Promise.allSettled(
installedModIds.map((modId) => deps.workshop.getMod(modId)),
);
scenarioLookupComplete = details.every((result) => result.status === 'fulfilled');
for (const result of details) {
if (result.status !== 'fulfilled') continue;
const mod = result.value;
if (hasScenarioTag(mod.tags)) {
modMissions.push(...scenariosFromWorkshopMod(mod));
}
}
}
res.json({
missions: mergeMissions([DEFAULT_MISSION], modMissions),
fetchedAt: scenarioLookupComplete ? new Date().toISOString() : null,
});
res.json(await deps.missions.list(installed));
} catch (error) {
next(error);
}
});
// ---------- live console ----------
/**
* Server-Sent Events relay of the Pterodactyl/Wings feed.
*
* This is what makes install, update and mod-download output visible: it
* carries whatever the hosting backend emits, rather than tailing the game's
* own log file, which does not exist until the game has already started.
*/
router.get(
'/:slug/logs/stream',
'/:slug/console/stream',
requireCapability('ops.health.view', 'Live console stream is restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.resolveLogPath) {
throw ApiError.notConfigured('Log streaming requires a configured game server backend.');
await loadServer(req.params.slug);
const hub = deps.consoleHub;
if (!hub) {
throw ApiError.notConfigured(
'Live console requires a configured game server backend with the websocket enabled.',
);
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
const controller = new AbortController();
req.on('close', () => controller.abort());
const send = (event: string, data: unknown) => {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
// Poll the log file every 2 seconds and push only new lines via SSE.
// Tracks total file size to derive the new-content offset on each poll,
// so we never re-send lines and handle log rotation gracefully.
let lastTotalBytes = 0;
send('backlog', hub.backlog());
const stats = hub.latestStats();
if (stats) send('stats', stats);
const sleep = (ms: number) =>
new Promise<void>((resolve) => {
const t = setTimeout(resolve, ms);
controller.signal.addEventListener(
'abort',
() => {
clearTimeout(t);
resolve();
},
{ once: true },
);
});
while (!controller.signal.aborted) {
try {
const logPath = await deps.resolveLogPath();
if (logPath) {
const file = await provider.downloadTextFile(providerId(server), logPath, 512 * 1024);
const totalBytes =
file.totalSizeBytes ?? file.contentStartOffset + file.content.length;
let newContent: string;
if (lastTotalBytes === 0 || totalBytes < lastTotalBytes) {
// First poll or log rotated — send all available content.
newContent = file.content;
} else {
const skip = Math.max(0, lastTotalBytes - file.contentStartOffset);
newContent = file.content.slice(skip);
}
lastTotalBytes = totalBytes;
if (newContent) {
for (const line of newContent.split('\n')) {
if (controller.signal.aborted) break;
if (line) res.write(`data: ${JSON.stringify(line)}\n\n`);
}
}
}
} catch {
// Provider unreachable or no log yet — keep the connection alive.
const unsubscribe = hub.subscribe((event) => {
switch (event.type) {
case 'line':
send('line', event.line);
break;
case 'status':
send('status', { status: event.status });
break;
case 'stats':
send('stats', event.stats);
break;
}
await sleep(2000);
}
});
res.end();
// Proxies drop idle connections; a comment frame keeps them open.
const heartbeat = setInterval(() => res.write(': ping\n\n'), 25_000);
heartbeat.unref?.();
req.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
res.end();
});
} catch (error) {
next(error);
}
},
);
router.get(
'/:slug/console/backlog',
requireCapability('ops.health.view', 'Live console is restricted.'),
async (req, res, next) => {
try {
await loadServer(req.params.slug);
if (!deps.consoleHub) {
throw ApiError.notConfigured('Live console requires a configured game server backend.');
}
res.json(deps.consoleHub.backlog());
} catch (error) {
next(error);
}
},
);
/** The game's own log file — kept as a diagnostic alongside the live feed. */
router.get(
'/:slug/logs/raw',
requireCapability('ops.health.view', 'Raw logs are restricted.'),
@@ -447,6 +607,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
},
);
// ---------- startup variables ----------
router.get(
'/:slug/startup',
requireCapability('config.edit', 'Startup variables are restricted.'),
@@ -473,7 +635,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.put(
'/:slug/startup/variable',
syncRateLimit,
writeRateLimit,
requireCapability('config.edit', 'Startup variables are restricted.'),
async (req, res, next) => {
try {
@@ -497,6 +659,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
},
);
// ---------- schedules ----------
router.get(
'/:slug/schedules',
requireCapability('config.edit', 'Schedule management is restricted.'),
@@ -515,7 +679,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.post(
'/:slug/schedules/restarts',
syncRateLimit,
writeRateLimit,
requireCapability('config.edit', 'Schedule management is restricted.'),
async (req, res, next) => {
try {
@@ -540,7 +704,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.put(
'/:slug/schedules/:scheduleId/restart',
syncRateLimit,
writeRateLimit,
requireCapability('config.edit', 'Schedule management is restricted.'),
async (req, res, next) => {
try {
@@ -571,7 +735,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.delete(
'/:slug/schedules/:scheduleId',
syncRateLimit,
writeRateLimit,
requireCapability('config.edit', 'Schedule management is restricted.'),
async (req, res, next) => {
try {
@@ -594,84 +758,39 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
},
);
// ---------- mods ----------
router.get('/:slug/mods', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.mods) {
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
}
res.json(await deps.mods.getMods(server));
res.json(await requireMods().getMods(await loadServer(req.params.slug)));
} catch (error) {
next(error);
}
});
router.get('/:slug/mods/check', async (req, res, next) => {
/**
* Everything the Mods page renders, in one request: installed mods joined
* with cached Workshop metadata, latest versions, dependency gaps and
* removal blockers. Replaces the old fan-out of one browser request per mod.
*/
router.get('/:slug/mods/overview', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.mods) {
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
res.json(await requireMods().getOverview(await loadServer(req.params.slug)));
} catch (error) {
next(error);
}
});
/** Expands a staged mod list into its full dependency closure, with sizes. */
router.post('/:slug/mods/resolve', async (req, res, next) => {
try {
await loadServer(req.params.slug);
const body = modsResolveBodySchema.safeParse(req.body);
if (!body.success) {
throw ApiError.validation(body.error.issues[0]?.message ?? 'Invalid mod list.');
}
const { mods } = await deps.mods.getMods(server);
const installedIds = new Set(mods.map((m) => m.modId.toUpperCase()));
// Fetch workshop details for all installed mods in parallel.
const details = await Promise.allSettled(mods.map((mod) => deps.workshop.getMod(mod.modId)));
const modsWithMissingVersions: string[] = [];
const modsWithMissingDeps: ModDependencyIssue[] = [];
for (let i = 0; i < mods.length; i++) {
const mod = mods[i]!;
if (!mod.version) modsWithMissingVersions.push(mod.modId);
const result = details[i]!;
if (result.status === 'fulfilled') {
const missing = result.value.dependencies.filter(
(dep) => dep.id && !installedIds.has(dep.id.toUpperCase()),
);
if (missing.length > 0) {
modsWithMissingDeps.push({
modId: mod.modId,
modName: mod.name ?? result.value.name ?? null,
missing,
});
}
}
}
// Detect orphaned mission: configured scenarioId no longer available.
let orphanedMission: { scenarioId: string; name: string | null } | null = null;
const configData = deps.configSync
? await deps.configSync.getLiveConfig(server).catch(() => null)
: null;
const scenarioId = configData?.scenarioId ?? null;
if (scenarioId) {
const knownScenarioIds = new Set([DEFAULT_SCENARIO_ID]);
const scenarioLookupComplete = details.every((result) => result.status === 'fulfilled');
for (const result of details) {
if (result.status === 'fulfilled') {
const mod = result.value;
if (!hasScenarioTag(mod.tags)) continue;
for (const s of mod.scenarios) {
knownScenarioIds.add(s.scenarioId);
}
}
}
if (scenarioLookupComplete && !knownScenarioIds.has(scenarioId)) {
orphanedMission = {
scenarioId,
name: null,
};
}
}
res.json({
modsWithMissingVersions,
modsWithMissingDeps,
orphanedMission,
checkedAt: new Date().toISOString(),
});
assertNoDuplicates(body.data.mods);
res.json(await requireMods().resolve(body.data.mods));
} catch (error) {
next(error);
}
@@ -679,46 +798,41 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.put(
'/:slug/mods',
syncRateLimit,
writeRateLimit,
requireCapability('mods.manage', 'You do not have permission to manage mods.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.mods) {
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
}
const mods = requireMods();
const body = modsBodySchema.safeParse(req.body);
if (!body.success) {
throw ApiError.validation(body.error.issues[0]?.message ?? 'Invalid mod list.');
}
// Reject duplicate mod ids up front instead of silently collapsing.
const ids = body.data.mods.map((mod) => mod.modId.toUpperCase());
if (new Set(ids).size !== ids.length) {
throw ApiError.validation('Duplicate mod ids in the list.');
}
assertNoDuplicates(body.data.mods);
// Reforger requires a version in config.json for each mod to load.
// Fetch it from the Workshop for any mod the caller didn't supply one for.
const enrichedMods = await Promise.all(
// Reforger needs a version in config.json for each mod to load. Fill in
// the latest known version for any entry the caller left unpinned.
const enriched = await Promise.all(
body.data.mods.map(async (mod) => {
if (mod.version) return mod;
try {
const detail = await deps.workshop.getMod(mod.modId);
return { ...mod, ...(detail.version ? { version: detail.version } : {}) };
} catch {
return mod;
}
const detail = await deps.workshop.tryGetMod(mod.modId);
return detail?.version ? { ...mod, version: detail.version } : mod;
}),
);
const result = await deps.mods.setMods(server, enrichedMods);
const result = await mods.setMods(server, enriched, body.data.expectedRevision);
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'mods.updated',
summary: `Mods updated by ${user.displayName ?? user.username}: ${result.added} added, ${result.removed} removed (${result.mods.length} total, applies on restart)`,
metadata: { added: result.added, removed: result.removed, total: result.mods.length },
summary: `Mods updated by ${user.displayName ?? user.username}: ${result.added} added, ${result.removed} removed, ${result.changed} re-versioned (${result.mods.length} total, applies on restart)`,
metadata: {
added: result.added,
removed: result.removed,
changed: result.changed,
total: result.mods.length,
},
});
res.json(result);
} catch (error) {
@@ -736,6 +850,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
}
});
// ---------- power ----------
const powerActions = [
{
action: 'start' as const,
@@ -784,6 +900,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
);
}
// ---------- log ingestion ----------
router.post(
'/:slug/logs/sync',
syncRateLimit,
@@ -817,25 +935,6 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
},
);
router.post(
'/:slug/config/sync',
syncRateLimit,
requireCapability('ops.health.view', 'Config sync is restricted to owner and server admins.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.configSync) {
throw ApiError.notConfigured('Config import requires a configured game server backend.');
}
// Config is served live; this just refreshes the stored name/capacity.
const result = await deps.configSync.sync(server);
res.json({ ok: true, serverName: result.serverName, maxPlayers: result.maxPlayers });
} catch (error) {
next(error);
}
},
);
router.get(
'/:slug/logs/health',
requireCapability('ops.health.view', 'Operational diagnostics are restricted.'),
@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from 'vitest';
import type { WorkshopModDetail } from '@reforger-panel/shared';
import { createLogger } from '../../lib/logger.js';
import { ApiError } from '../../lib/errors.js';
import { WorkshopCache } from './workshop-cache.js';
import type { WorkshopClient } from './workshop-client.js';
function detail(id: string, overrides: Partial<WorkshopModDetail> = {}): WorkshopModDetail {
return {
id,
name: `Mod ${id}`,
author: 'Author',
summary: null,
imageUrl: null,
workshopUrl: null,
version: '1.0.0',
gameVersion: null,
sizeBytes: 1024,
sizeText: '1 KiB',
rating: null,
ratingCount: null,
subscriberCount: null,
createdAt: null,
updatedAt: null,
tags: [],
obsolete: false,
description: null,
license: null,
downloadCount: null,
previewImages: [],
screenshots: [],
versionCount: 1,
dependencyCount: 0,
scenarioCount: 0,
dependencySizeBytes: null,
totalSizeBytes: null,
dependencies: [],
scenarios: [],
...overrides,
};
}
function cacheWith(getMod: (id: string) => Promise<WorkshopModDetail>) {
const client = { getMod: vi.fn(getMod) } as unknown as WorkshopClient;
return { cache: new WorkshopCache(client, createLogger('silent')), client };
}
describe('WorkshopCache', () => {
it('serves repeated reads of the same mod from cache', async () => {
const { cache, client } = cacheWith(async (id) => detail(id));
await cache.getMod('AAAA000000000001');
await cache.getMod('AAAA000000000001');
expect(client.getMod).toHaveBeenCalledTimes(1);
});
it('collapses concurrent reads into a single upstream request', async () => {
// This is what stops the mods overview, the dependency check and the
// mission list from each fanning out over the same ids.
let resolveFetch: (value: WorkshopModDetail) => void = () => {};
const { cache, client } = cacheWith(
() =>
new Promise<WorkshopModDetail>((resolve) => {
resolveFetch = resolve;
}),
);
const pending = Promise.all([
cache.getMod('AAAA000000000002'),
cache.getMod('AAAA000000000002'),
cache.getMod('AAAA000000000002'),
]);
resolveFetch(detail('AAAA000000000002'));
const results = await pending;
expect(client.getMod).toHaveBeenCalledTimes(1);
expect(results.map((mod) => mod.id)).toEqual([
'AAAA000000000002',
'AAAA000000000002',
'AAAA000000000002',
]);
});
it('normalises ids so casing differences share one entry', async () => {
const { cache, client } = cacheWith(async (id) => detail(id));
await cache.getMod('aaaa000000000003');
await cache.getMod('AAAA000000000003');
expect(client.getMod).toHaveBeenCalledTimes(1);
});
it('remembers a missing mod instead of asking again', async () => {
const { cache, client } = cacheWith(async () => {
throw ApiError.notFound('Workshop mod not found.');
});
await expect(cache.getMod('AAAA000000000004')).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(cache.getMod('AAAA000000000004')).rejects.toMatchObject({ code: 'NOT_FOUND' });
expect(client.getMod).toHaveBeenCalledTimes(1);
expect(cache.peekMod('AAAA000000000004')).toBeNull();
});
it('backs off after an upstream failure rather than hammering it', async () => {
const { cache, client } = cacheWith(async () => {
throw ApiError.upstream('boom');
});
await expect(cache.tryGetMod('AAAA000000000005')).resolves.toBeNull();
await expect(cache.tryGetMod('AAAA000000000005')).resolves.toBeNull();
expect(client.getMod).toHaveBeenCalledTimes(1);
});
it('reports uncached ids as unknown, not missing', async () => {
const { cache } = cacheWith(async (id) => detail(id));
expect(cache.peekMod('AAAA000000000006')).toBeUndefined();
await cache.getMod('AAAA000000000006');
expect(cache.peekMod('AAAA000000000006')?.name).toBe('Mod AAAA000000000006');
});
it('reuses the detail payload for dependencies instead of a second request', async () => {
const { cache, client } = cacheWith(async (id) =>
detail(id, {
dependencyCount: 1,
dependencies: [
{
id: 'BBBB000000000001',
name: 'Dep',
version: null,
sizeBytes: 10,
published: true,
private: false,
},
],
}),
);
const dependencies = await cache.getDependencies('AAAA000000000007');
expect(dependencies).toHaveLength(1);
expect(client.getMod).toHaveBeenCalledTimes(1);
});
it('skips the scenario request for mods that ship none', async () => {
const { cache } = cacheWith(async (id) => detail(id, { scenarioCount: 0 }));
await expect(cache.getScenarios('AAAA000000000008')).resolves.toEqual([]);
});
it('drops cached entries on invalidate', async () => {
const { cache, client } = cacheWith(async (id) => detail(id));
await cache.getMod('AAAA000000000009');
cache.invalidate(['AAAA000000000009']);
await cache.getMod('AAAA000000000009');
expect(client.getMod).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,339 @@
import type {
WorkshopDependency,
WorkshopModDetail,
WorkshopModVersionsResponse,
WorkshopScenario,
WorkshopSearchResponse,
WorkshopServerModsResponse,
WorkshopServerSearchResponse,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import { sanitizeErrorMessage, type Logger } from '../../lib/logger.js';
import type { WorkshopClient, WorkshopSearchParams } from './workshop-client.js';
/**
* TTLs mirror what reforgermods.net caches upstream, so we never ask more
* often than the answer can change. `stale` is how long a value stays usable
* while a refresh runs in the background.
*/
const TTL = {
detail: { fresh: 60 * 60_000, stale: 24 * 60 * 60_000 },
versions: { fresh: 6 * 60 * 60_000, stale: 7 * 24 * 60 * 60_000 },
search: { fresh: 10 * 60_000, stale: 60 * 60_000 },
servers: { fresh: 60_000, stale: 10 * 60_000 },
/** How long a "this mod does not exist" answer is trusted. */
negative: 10 * 60_000,
} as const;
/** After an upstream failure, fail fast for this long instead of retrying. */
const FAILURE_COOLDOWN_MS = 30_000;
const MAX_ENTRIES = 2_000;
type Entry = {
value: unknown;
/** True for a cached "not found" — value is null and must not be retried yet. */
missing: boolean;
freshUntil: number;
staleUntil: number;
};
/**
* Concurrency gate plus a token bucket. The free upstream tier allows 60
* requests/minute per IP with a burst of 20; we deliberately sit under that so
* the panel never trips a 429 and never has to throttle in the browser (which
* is what the old client-side limiter in mods.tsx was doing).
*/
class RequestPacer {
private active = 0;
private tokens: number;
private lastRefillAt = Date.now();
private waiters: (() => void)[] = [];
constructor(
private readonly maxConcurrent: number,
private readonly perMinute: number,
private readonly burst: number,
) {
this.tokens = burst;
}
private refill(): void {
const now = Date.now();
const elapsed = now - this.lastRefillAt;
if (elapsed <= 0) return;
this.lastRefillAt = now;
this.tokens = Math.min(this.burst, this.tokens + (elapsed / 60_000) * this.perMinute);
}
private tryTake(): boolean {
this.refill();
if (this.active >= this.maxConcurrent || this.tokens < 1) return false;
this.tokens -= 1;
this.active += 1;
return true;
}
private pump(): void {
while (this.waiters.length > 0) {
if (!this.tryTake()) {
// Nothing available now; re-check when a token could have accrued.
setTimeout(() => this.pump(), Math.ceil(60_000 / this.perMinute)).unref?.();
return;
}
this.waiters.shift()!();
}
}
async run<T>(task: () => Promise<T>): Promise<T> {
if (!this.tryTake()) {
await new Promise<void>((resolve) => {
this.waiters.push(resolve);
setTimeout(() => this.pump(), 0).unref?.();
});
}
try {
return await task();
} finally {
this.active -= 1;
this.pump();
}
}
}
/**
* Read-through cache over the Workshop API with stale-while-revalidate,
* single-flight de-duplication and request pacing.
*
* This is what makes the Mods page fast: the installed-mod overview, the
* mission list and the dependency check all hit the same warm entries instead
* of each fanning out over the network, and a stale entry is served instantly
* while it refreshes behind the request.
*/
export class WorkshopCache {
private readonly store = new Map<string, Entry>();
private readonly inflight = new Map<string, Promise<unknown>>();
private readonly failures = new Map<string, number>();
private readonly pacer = new RequestPacer(6, 50, 15);
constructor(
private readonly client: WorkshopClient,
private readonly logger: Logger,
) {}
// ---------- cache primitives ----------
private read(key: string): Entry | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (entry.staleUntil <= Date.now()) {
this.store.delete(key);
return undefined;
}
// Refresh LRU position.
this.store.delete(key);
this.store.set(key, entry);
return entry;
}
private write(key: string, value: unknown, ttl: { fresh: number; stale: number }): void {
const now = Date.now();
this.store.delete(key);
this.store.set(key, {
value,
missing: false,
freshUntil: now + ttl.fresh,
staleUntil: now + ttl.stale,
});
this.evict();
}
private writeMissing(key: string): void {
const now = Date.now();
this.store.delete(key);
this.store.set(key, {
value: null,
missing: true,
freshUntil: now + TTL.negative,
staleUntil: now + TTL.negative,
});
this.evict();
}
private evict(): void {
while (this.store.size > MAX_ENTRIES) {
const oldest = this.store.keys().next();
if (oldest.done) break;
this.store.delete(oldest.value);
}
}
/**
* Runs `loader` once per key even if called concurrently, paced against the
* upstream rate limit. A 404 is remembered as a negative entry; any other
* failure starts a short cooldown so a flapping upstream cannot be hammered.
*/
private async load<T>(
key: string,
ttl: { fresh: number; stale: number },
loader: () => Promise<T>,
): Promise<T> {
const existing = this.inflight.get(key);
if (existing) return (await existing) as T;
const cooldownUntil = this.failures.get(key);
if (cooldownUntil && cooldownUntil > Date.now()) {
throw ApiError.upstream('Workshop API is temporarily unavailable.');
}
const promise = this.pacer
.run(loader)
.then((value) => {
this.failures.delete(key);
this.write(key, value, ttl);
return value;
})
.catch((error: unknown) => {
if (error instanceof ApiError && error.code === 'NOT_FOUND') {
this.failures.delete(key);
this.writeMissing(key);
throw error;
}
this.failures.set(key, Date.now() + FAILURE_COOLDOWN_MS);
throw error;
})
.finally(() => {
this.inflight.delete(key);
});
this.inflight.set(key, promise);
return (await promise) as T;
}
private async cached<T>(
key: string,
ttl: { fresh: number; stale: number },
loader: () => Promise<T>,
): Promise<T> {
const entry = this.read(key);
if (entry?.missing) throw ApiError.notFound('Workshop mod not found.');
if (entry) {
if (entry.freshUntil > Date.now()) return entry.value as T;
// Stale but usable: serve it now, refresh behind the caller's back.
void this.load(key, ttl, loader).catch(() => undefined);
return entry.value as T;
}
return this.load(key, ttl, loader);
}
// ---------- public API ----------
/** Synchronous peek. `undefined` means "not cached", `null` means "known missing". */
peekMod(modId: string): WorkshopModDetail | null | undefined {
const entry = this.read(`mod:${modId.toUpperCase()}`);
if (!entry) return undefined;
return entry.missing ? null : (entry.value as WorkshopModDetail);
}
async getMod(modId: string): Promise<WorkshopModDetail> {
const id = modId.toUpperCase();
return this.cached(`mod:${id}`, TTL.detail, () => this.client.getMod(id));
}
/** Non-throwing variant for bulk paths that must tolerate missing mods. */
async tryGetMod(modId: string): Promise<WorkshopModDetail | null> {
return this.getMod(modId).catch(() => null);
}
async getVersions(modId: string): Promise<WorkshopModVersionsResponse> {
const id = modId.toUpperCase();
return this.cached(`versions:${id}`, TTL.versions, () => this.client.getVersions(id));
}
async getDependencies(modId: string): Promise<WorkshopDependency[]> {
// The detail payload already carries dependencies, so reuse that entry
// instead of spending a second upstream request on the same information.
const detail = await this.tryGetMod(modId);
if (detail) return detail.dependencies;
const id = modId.toUpperCase();
return this.cached(`deps:${id}`, TTL.detail, () => this.client.getDependencies(id));
}
async getScenarios(modId: string): Promise<WorkshopScenario[]> {
const detail = await this.tryGetMod(modId);
if (!detail) return [];
// scenarioCount is authoritative; skip the request entirely for mods that
// ship none (the old code used a tag heuristic that both over- and
// under-matched).
if (detail.scenarioCount === 0) return [];
if (detail.scenarios.length > 0) return detail.scenarios;
const id = modId.toUpperCase();
return this.cached(`scenarios:${id}`, TTL.detail, () => this.client.getScenarios(id)).catch(
() => [],
);
}
async search(params: WorkshopSearchParams): Promise<WorkshopSearchResponse> {
const key = `search:${params.query ?? ''}|${params.page ?? 1}|${params.sort ?? ''}|${
params.tag ?? ''
}|${params.category ?? ''}`;
return this.cached(key, TTL.search, () => this.client.search(params));
}
async searchServers(query: string, page: number): Promise<WorkshopServerSearchResponse> {
return this.cached(`servers:${query}|${page}`, TTL.servers, () =>
this.client.searchServers(query, page),
);
}
async getServerMods(serverId: string): Promise<WorkshopServerModsResponse> {
return this.cached(`server-mods:${serverId}`, TTL.servers, () =>
this.client.getServerMods(serverId),
);
}
/**
* Populates details for a set of mod ids in the background. Called at boot
* with the installed mod list so the first Mods page open is already warm,
* and after a mod list change so the new entries are ready.
*/
warm(modIds: string[]): void {
const cold = modIds.filter((id) => this.peekMod(id) === undefined);
if (cold.length === 0) return;
this.logger.debug({ count: cold.length }, 'warming workshop cache');
void Promise.allSettled(cold.map((id) => this.tryGetMod(id))).then((results) => {
const failed = results.filter((r) => r.status === 'fulfilled' && r.value === null).length;
if (failed > 0) {
this.logger.debug({ failed, total: cold.length }, 'workshop cache warm partially failed');
}
});
}
/** Drops cached entries so the next read goes upstream (explicit Refresh). */
invalidate(modIds?: string[]): void {
if (!modIds) {
this.store.clear();
this.failures.clear();
return;
}
for (const modId of modIds) {
const id = modId.toUpperCase();
for (const prefix of ['mod', 'versions', 'deps', 'scenarios']) {
this.store.delete(`${prefix}:${id}`);
this.failures.delete(`${prefix}:${id}`);
}
}
}
/** Diagnostics for the settings page. */
stats(): { entries: number; inflight: number; cooldowns: number } {
return {
entries: this.store.size,
inflight: this.inflight.size,
cooldowns: [...this.failures.values()].filter((until) => until > Date.now()).length,
};
}
}
export function describeWorkshopError(error: unknown): string {
return sanitizeErrorMessage(error);
}
@@ -1,131 +1,234 @@
import { describe, expect, it, vi } from 'vitest';
import { WorkshopClient, normalizeImageUrl } from './workshop-client.js';
import { WorkshopClient, localizedLabel, normalizeImageUrl } from './workshop-client.js';
const REAL_IMAGE = 'https://ar-gcp-cdn.bistudio.com/image/abcd/1234';
const MOD_ID = '595F2BF2F44836FB';
/** Shape taken from a real GET /v2/mods response. */
function listResponse() {
return {
status: 'success',
meta: { totalPages: 1, currentPage: 1, totalMods: 2, shownMods: 2 },
meta: { totalPages: 3, currentPage: 1, totalMods: 42, shownMods: 1 },
data: [
{
id: MOD_ID,
name: 'Mod A',
author: 'Author',
imageURL: 'https://via.placeholder.com/640x360',
originalModURL: 'https://reforger.armaplatform.com/workshop/AAAAAAAAAAAAAAA1',
apiModURL: 'https://api.reforgermods.net/v1/mod/AAAAAAAAAAAAAAA1',
size: '1 MB',
rating: '99%',
ID: 'AAAAAAAAAAAAAAA1',
summary: 'Summary',
version: '1.2.0',
gameVersion: '1.8.0.10',
size: 204219382,
sizeFormatted: '195 MiB',
rating: 0.88,
ratingCount: 9323,
subscriberCount: 31997,
updatedAt: '2026-08-14T06:07:52Z',
tags: ['WEAPONS'],
// Upstream still serves placeholder stubs on some rows.
imageUrl: 'https://via.placeholder.com/640x360',
workshopUrl: `https://reforger.armaplatform.com/workshop/${MOD_ID}`,
},
],
};
}
function detailResponse(id: string) {
function detailResponse() {
return {
status: 'success',
mod: {
id: MOD_ID,
name: 'Mod A',
author: 'Author',
originalModURL: `https://reforger.armaplatform.com/workshop/${id}`,
apiModURL: `https://api.reforgermods.net/v1/mod/${id}`,
// Upstream bug: two URLs concatenated.
imageURL: `https://reforger.armaplatform.com${REAL_IMAGE}`,
rating: '99%',
version: '1.2.0',
size: '1 MB',
id,
tags: [],
dependencies: [],
scenarios: [],
// Upstream bug: two URLs concatenated.
imageUrl: `https://reforger.armaplatform.com${REAL_IMAGE}`,
size: 0,
previewImages: [REAL_IMAGE],
screenshots: [],
scenarioCount: 2,
dependencyCount: 1,
totalSize: 8940841288,
tags: ['SCENARIOS_MP'],
dependencies: [
{
id: '1337c0de5dabbeef',
name: 'Content Pack',
version: '0.16.5150',
size: 6343204665,
published: true,
private: false,
},
],
scenarios: [
{
name: 'Conflict - Everon (RHS)',
gameId: '{AAD43C10045857C1}Missions/RHS_Conflict.conf',
gameMode: '#AR-Scenario_GameMode_Campaign',
author: '#AR-Author_BI',
description: '#AR-Campaign_GamemodeDesc',
playerCount: 64,
},
// No gameId: unusable as a mission, so it must be dropped.
{ name: 'Broken', gameId: '', gameMode: null, playerCount: 0 },
],
},
};
}
function client(handler: (path: string) => unknown) {
const fetchImpl = vi.fn(async (url: string | URL) => {
const path = String(url).replace('https://workshop.test', '');
return new Response(JSON.stringify(handler(path)), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as unknown as typeof fetch;
return {
client: new WorkshopClient({ baseUrl: 'https://workshop.test', fetchImpl }),
fetchImpl: fetchImpl as unknown as ReturnType<typeof vi.fn>,
};
}
describe('normalizeImageUrl', () => {
it('drops dead placeholder URLs', () => {
it('drops placeholder stubs', () => {
expect(normalizeImageUrl('https://via.placeholder.com/640x360')).toBeNull();
});
it('repairs concatenated double URLs', () => {
it('recovers the real URL from a concatenated pair', () => {
expect(normalizeImageUrl(`https://reforger.armaplatform.com${REAL_IMAGE}`)).toBe(REAL_IMAGE);
});
it('passes through well-formed URLs and rejects junk', () => {
expect(normalizeImageUrl(REAL_IMAGE)).toBe(REAL_IMAGE);
it('returns null for empty or non-http values', () => {
expect(normalizeImageUrl('')).toBeNull();
expect(normalizeImageUrl('not a url')).toBeNull();
expect(normalizeImageUrl('not-a-url')).toBeNull();
});
});
describe('WorkshopClient preview cache', () => {
it('does not fan out detail requests during search', async () => {
const fetchImpl = vi.fn(async (url: string | URL) => {
const path = String(url);
if (path.includes('/v1/mod/')) {
const id = path.slice(path.lastIndexOf('/') + 1);
return new Response(JSON.stringify(detailResponse(id)), { status: 200 });
}
return new Response(JSON.stringify(listResponse()), { status: 200 });
});
const client = new WorkshopClient({
baseUrl: 'https://workshop.test',
fetchImpl: fetchImpl as unknown as typeof fetch,
});
const first = await client.search('', 1);
expect(first.mods[0]!.imageUrl).toBeNull();
expect(first.mods[0]!.version).toBeNull();
const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
expect(detailCalls).toHaveLength(0);
await client.getMod('AAAAAAAAAAAAAAA1');
// Second search can use the cached detail without another detail request.
const second = await client.search('', 1);
expect(second.mods[0]!.imageUrl).toBe(REAL_IMAGE);
expect(second.mods[0]!.version).toBe('1.2.0');
const detailCallsAfter = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
expect(detailCallsAfter).toHaveLength(1);
describe('localizedLabel', () => {
it('maps known Enfusion localization keys', () => {
expect(localizedLabel('#AR-Scenario_GameMode_Campaign')).toBe('Campaign');
});
it('leaves the image empty when there is no cached detail', async () => {
const fetchImpl = vi.fn(async (url: string | URL) => {
return new Response(JSON.stringify(listResponse()), { status: 200 });
});
const client = new WorkshopClient({
baseUrl: 'https://workshop.test',
fetchImpl: fetchImpl as unknown as typeof fetch,
});
const result = await client.search('', 1);
expect(result.mods[0]!.imageUrl).toBeNull();
it('humanises unknown keys instead of leaking them', () => {
expect(localizedLabel('#AR-Scenario_GameMode_KingOfTheHill')).toBe('King Of The Hill');
});
it('extracts scenario IDs from malformed scenario metadata', async () => {
const fetchImpl = vi.fn(async () => {
const detail = detailResponse('AAAAAAAAAAAAAAA1');
detail.mod.scenarios = [
{
name: '[OG] Udachne',
description: '',
scenarioID: '',
gamemode: 'Scenario ID{39AB5D9094E502AA}Missions/OG_Conflict.conf',
playerCount: 0,
imageURL: '',
},
];
return new Response(JSON.stringify(detail), { status: 200 });
it('passes plain text through', () => {
expect(localizedLabel('Sandbox')).toBe('Sandbox');
expect(localizedLabel(null)).toBeNull();
});
});
describe('WorkshopClient (v2)', () => {
it('maps search results with typed sizes and ratings', async () => {
const { client: workshop, fetchImpl } = client(() => listResponse());
const result = await workshop.search({
query: 'mod a',
page: 2,
sort: 'newest',
tag: 'WEAPONS',
});
const client = new WorkshopClient({
baseUrl: 'https://workshop.test',
fetchImpl: fetchImpl as unknown as typeof fetch,
expect(String(fetchImpl.mock.calls[0]![0])).toContain('/v2/mods?');
expect(String(fetchImpl.mock.calls[0]![0])).toContain('tags=WEAPONS');
expect(result.meta.totalMods).toBe(42);
const mod = result.mods[0]!;
expect(mod.sizeBytes).toBe(204219382);
expect(mod.rating).toBe(0.88);
expect(mod.subscriberCount).toBe(31997);
expect(mod.imageUrl).toBeNull(); // placeholder stripped
});
it('maps mod details, uppercasing dependency ids and keeping real scenario ids', async () => {
const { client: workshop } = client(() => detailResponse());
const detail = await workshop.getMod(MOD_ID);
expect(detail.dependencies).toEqual([
{
id: '1337C0DE5DABBEEF',
name: 'Content Pack',
version: '0.16.5150',
sizeBytes: 6343204665,
published: true,
private: false,
},
]);
expect(detail.scenarios).toHaveLength(1);
expect(detail.scenarios[0]).toEqual({
scenarioId: '{AAD43C10045857C1}Missions/RHS_Conflict.conf',
name: 'Conflict - Everon (RHS)',
gameMode: 'Campaign',
author: 'BI',
description: 'Campaign',
playerCount: 64,
});
expect(detail.imageUrl).toBe(REAL_IMAGE);
// Upstream reports 0 for "unknown", which must not read as "0 bytes".
expect(detail.sizeBytes).toBeNull();
expect(detail.totalSizeBytes).toBe(8940841288);
});
const mod = await client.getMod('AAAAAAAAAAAAAAA1');
expect(mod.scenarios[0]).toMatchObject({
scenarioId: '{39AB5D9094E502AA}Missions/OG_Conflict.conf',
gamemode: null,
it('maps the version history used by the version picker', async () => {
const { client: workshop } = client(() => ({
status: 'success',
data: {
modId: MOD_ID,
count: 1,
versions: [
{
version: '0.16.5150',
gameVersion: '1.8.0.10',
size: 204219382,
sizeFormatted: '195 MiB',
approved: true,
published: true,
createdAt: '2026-08-14T06:05:43Z',
scenarioCount: 11,
dependencyCount: 2,
},
],
},
}));
const result = await workshop.getVersions(MOD_ID);
expect(result.modId).toBe(MOD_ID);
expect(result.versions[0]).toMatchObject({
version: '0.16.5150',
gameVersion: '1.8.0.10',
sizeBytes: 204219382,
approved: true,
});
});
it('maps a server mod list for the import-from-server flow', async () => {
const { client: workshop } = client(() => ({
status: 'success',
serverId: 'room-1',
summary: { count: 2, knownSize: 4096, unresolvedCount: 1 },
data: [
{ id: '69f40d8f38530936', name: 'Hogs Scenario Core', version: '1.0.20', size: 2011590 },
{ id: '64610AFB74AA9842', name: 'WCS_Core', version: null, size: 0 },
],
}));
const result = await workshop.getServerMods('room-1');
expect(result.mods).toEqual([
{
id: '69F40D8F38530936',
name: 'Hogs Scenario Core',
version: '1.0.20',
sizeBytes: 2011590,
},
{ id: '64610AFB74AA9842', name: 'WCS_Core', version: null, sizeBytes: null },
]);
expect(result.unresolvedCount).toBe(1);
});
it('translates upstream failures into ApiError codes', async () => {
const fetchImpl = vi.fn(
async () => new Response('nope', { status: 429 }),
) as unknown as typeof fetch;
const workshop = new WorkshopClient({ baseUrl: 'https://workshop.test', fetchImpl });
await expect(workshop.getMod(MOD_ID)).rejects.toMatchObject({ code: 'RATE_LIMITED' });
});
});
+370 -193
View File
@@ -1,108 +1,190 @@
import { z } from 'zod';
import type {
WorkshopHealth,
WorkshopDependency,
WorkshopModDetail,
WorkshopModPreview,
WorkshopModVersion,
WorkshopModVersionsResponse,
WorkshopScenario,
WorkshopSearchResponse,
WorkshopServerModsResponse,
WorkshopServerSearchResponse,
WorkshopServerSummary,
WorkshopSort,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import { sanitizeErrorMessage } from '../../lib/logger.js';
/**
* Client for the public reforgermods.net Workshop metadata API.
* Endpoint shapes follow https://reforgermods.net/?page=documentation/api:
* GET /v1/health
* GET /v1/mods/{page}?search={q}&sort={sort}
* GET /v1/mod/{mod_id}
* Backend-only — the browser never talks to this host directly.
* Client for the reforgermods.net Workshop metadata API, v2.
* Endpoint shapes follow https://reforgermods.net/arma-reforger-mods-api/v2/:
* GET /v2/mods?page&search&sort&tags&category
* GET /v2/mods/{id}
* GET /v2/mods/{id}/versions
* GET /v2/mods/{id}/dependencies
* GET /v2/mods/{id}/scenarios
* GET /v2/servers?search&hasMods&perPage
* GET /v2/servers/{id}/mods
*
* v2 returns typed values (byte counts, numeric ratings) and real scenario ids,
* so none of v1's string parsing is needed here. Backend-only — the browser
* never talks to this host directly. Caching and request pacing live in
* WorkshopCache; this class is a thin typed transport.
*/
/** Identifies the panel to the upstream, as its docs request. */
const CLIENT_NAME = 'reforger-panel';
// ---------- upstream schemas ----------
const modPreviewSchema = z.object({
name: z.string(),
author: z.string().catch('Unknown'),
imageURL: z.string().catch(''),
originalModURL: z.string().catch(''),
size: z.string().catch(''),
rating: z.string().catch(''),
ID: z.string(),
version: z.string().nullish(),
id: z.string(),
name: z.string().catch('Unknown mod'),
summary: z.string().nullish(),
author: z.string().catch('Unknown'),
version: z.string().nullish(),
gameVersion: z.string().nullish(),
size: z.number().nullish(),
sizeFormatted: z.string().nullish(),
rating: z.number().nullish(),
ratingCount: z.number().nullish(),
subscriberCount: z.number().nullish(),
createdAt: z.string().nullish(),
updatedAt: z.string().nullish(),
obsolete: z.boolean().nullish(),
tags: z.array(z.string()).catch([]),
imageUrl: z.string().nullish(),
workshopUrl: z.string().nullish(),
});
const searchResponseSchema = z.object({
status: z.string(),
meta: z.object({
totalPages: z.number().catch(1),
currentPage: z.number().catch(1),
totalMods: z.number().catch(0),
}),
meta: z
.object({
totalPages: z.number().catch(1),
currentPage: z.number().catch(1),
totalMods: z.number().catch(0),
})
.catch({ totalPages: 1, currentPage: 1, totalMods: 0 }),
data: z.array(modPreviewSchema).catch([]),
});
const modDetailSchema = z.object({
name: z.string(),
author: z.string().catch('Unknown'),
originalModURL: z.string().catch(''),
imageURL: z.string().catch(''),
rating: z.string().catch(''),
version: z.string().nullish(),
gameVersion: z.string().nullish(),
size: z.string().catch(''),
subscribers: z.number().nullish(),
downloads: z.number().nullish(),
created: z.string().nullish(),
lastModified: z.string().nullish(),
const dependencySchema = z.object({
id: z.string(),
summary: z.string().nullish(),
name: z.string().catch('Unknown mod'),
version: z.string().nullish(),
size: z.number().nullish(),
published: z.boolean().nullish(),
private: z.boolean().nullish(),
});
const scenarioSchema = z.object({
name: z.string().catch('Unnamed scenario'),
gameId: z.string().nullish(),
gameMode: z.string().nullish(),
author: z.string().nullish(),
description: z.string().nullish(),
playerCount: z.number().nullish(),
});
const modDetailSchema = modPreviewSchema.extend({
description: z.string().nullish(),
license: z.string().nullish(),
tags: z.array(z.string()).catch([]),
dependencies: z.array(z.object({ name: z.string(), apiModURL: z.string().catch('') })).catch([]),
scenarios: z
downloadCount: z.number().nullish(),
previewImages: z.array(z.string()).catch([]),
screenshots: z.array(z.string()).catch([]),
versionCount: z.number().nullish(),
dependencyCount: z.number().nullish(),
scenarioCount: z.number().nullish(),
dependencySize: z.number().nullish(),
totalSize: z.number().nullish(),
dependencies: z.array(dependencySchema).catch([]),
scenarios: z.array(scenarioSchema).catch([]),
});
const modDetailEnvelopeSchema = z.object({ mod: modDetailSchema });
const versionSchema = z.object({
version: z.string(),
gameVersion: z.string().nullish(),
size: z.number().nullish(),
sizeFormatted: z.string().nullish(),
approved: z.boolean().nullish(),
published: z.boolean().nullish(),
createdAt: z.string().nullish(),
scenarioCount: z.number().nullish(),
dependencyCount: z.number().nullish(),
});
const versionsEnvelopeSchema = z.object({
data: z.object({
modId: z.string().catch(''),
versions: z.array(versionSchema).catch([]),
}),
});
const dependenciesEnvelopeSchema = z.object({
data: z.object({
dependencies: z.array(dependencySchema).catch([]),
}),
});
const scenariosEnvelopeSchema = z.object({
data: z.object({
scenarios: z.array(scenarioSchema).catch([]),
}),
});
const serverSummarySchema = z.object({
id: z.string(),
name: z.string().catch('Unnamed server'),
scenarioId: z.string().nullish(),
scenarioName: z.string().nullish(),
gameVersion: z.string().nullish(),
players: z.number().catch(0),
maxPlayers: z.number().catch(0),
region: z.string().nullish(),
platform: z.string().nullish(),
modCount: z.number().catch(0),
official: z.boolean().nullish(),
online: z.boolean().nullish(),
});
const serverSearchEnvelopeSchema = z.object({
meta: z
.object({
totalPages: z.number().catch(1),
currentPage: z.number().catch(1),
totalServers: z.number().catch(0),
})
.catch({ totalPages: 1, currentPage: 1, totalServers: 0 }),
data: z.array(serverSummarySchema).catch([]),
});
const serverModsEnvelopeSchema = z.object({
serverId: z.string().catch(''),
summary: z
.object({
knownSize: z.number().nullish(),
unresolvedCount: z.number().nullish(),
})
.nullish(),
data: z
.array(
z.object({
name: z.string(),
description: z.string().catch(''),
scenarioID: z.string().catch(''),
gamemode: z.string().catch(''),
playerCount: z.number().catch(0),
imageURL: z.string().catch(''),
id: z.string(),
name: z.string().catch('Unknown mod'),
version: z.string().nullish(),
size: z.number().nullish(),
}),
)
.catch([]),
});
const modDetailEnvelopeSchema = z.object({ status: z.string(), mod: modDetailSchema });
export type WorkshopSort = 'popularity' | 'newest' | 'subscribers' | 'version_size';
function extractModId(apiModUrl: string): string | null {
const match = /\/v1\/mod\/([^/?#]+)/.exec(apiModUrl);
return match?.[1] ?? null;
}
const SCENARIO_ID_PATTERN = /(\{[0-9a-fA-F]{16}\}[^\s,;)]*?\.conf)/;
const SCENARIO_ID_WITH_LABEL_PATTERN =
/scenario\s*id\s*:?\s*\{[0-9a-fA-F]{16}\}[^\s,;)]*?\.conf/i;
function extractScenarioId(...values: Array<string | null | undefined>): string {
for (const value of values) {
const match = value?.match(SCENARIO_ID_PATTERN);
if (match?.[1]) return match[1];
}
return '';
}
function cleanScenarioText(value: string | null | undefined): string | null {
const cleaned = value?.replace(SCENARIO_ID_WITH_LABEL_PATTERN, '').trim();
return cleaned || null;
}
// ---------- normalisation helpers ----------
/**
* Upstream image URLs need repair: list endpoints return dead
* via.placeholder.com stubs, and detail endpoints sometimes concatenate two
* URLs ("https://reforger.armaplatform.comhttps://ar-gcp-cdn...").
* Upstream image URLs still need repair: list endpoints occasionally return
* dead via.placeholder.com stubs, and some rows concatenate two URLs
* ("https://reforger.armaplatform.comhttps://ar-gcp-cdn...").
*/
export function normalizeImageUrl(raw: string | null | undefined): string | null {
if (!raw) return null;
@@ -112,50 +194,162 @@ export function normalizeImageUrl(raw: string | null | undefined): string | null
return candidate.startsWith('http') ? candidate : null;
}
/** Upstream reports 0 for "size unknown"; keep that distinct from "0 bytes". */
function sizeOrNull(size: number | null | undefined): number | null {
return typeof size === 'number' && size > 0 ? size : null;
}
function text(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function toPreview(mod: z.infer<typeof modPreviewSchema>): WorkshopModPreview {
return {
id: mod.ID,
id: mod.id,
name: mod.name,
author: mod.author,
imageUrl: normalizeImageUrl(mod.imageURL),
size: mod.size || null,
rating: mod.rating || null,
workshopUrl: mod.originalModURL || null,
version: mod.version ?? null,
summary: mod.summary ?? null,
summary: text(mod.summary),
imageUrl: normalizeImageUrl(mod.imageUrl),
workshopUrl: text(mod.workshopUrl),
version: text(mod.version),
gameVersion: text(mod.gameVersion),
sizeBytes: sizeOrNull(mod.size),
sizeText: text(mod.sizeFormatted),
rating: typeof mod.rating === 'number' ? mod.rating : null,
ratingCount: mod.ratingCount ?? null,
subscriberCount: mod.subscriberCount ?? null,
createdAt: text(mod.createdAt),
updatedAt: text(mod.updatedAt),
tags: mod.tags,
obsolete: mod.obsolete ?? false,
};
}
const PREVIEW_CACHE_TTL_MS = 60 * 60 * 1000; // matches upstream's 1 h detail cache
function toDependency(dep: z.infer<typeof dependencySchema>): WorkshopDependency {
return {
id: dep.id.toUpperCase(),
name: dep.name,
version: text(dep.version),
sizeBytes: sizeOrNull(dep.size),
published: dep.published ?? true,
private: dep.private ?? false,
};
}
/**
* Scenario `gameMode` / `description` are Enfusion localization keys such as
* `#AR-Scenario_GameMode_Campaign`. Map the ones that show up in practice and
* fall back to a readable form of the key rather than leaking `#AR-` at users.
*/
const GAME_MODE_LABELS: Record<string, string> = {
'#AR-Scenario_GameMode_Campaign': 'Campaign',
'#AR-Scenario_GameMode_Conflict': 'Conflict',
'#AR-Scenario_GameMode_CombatOps': 'Combat Ops',
'#AR-Scenario_GameMode_GameMaster': 'Game Master',
'#AR-Scenario_GameMode_Tutorial': 'Tutorial',
'#AR-ServerBrowser_ServerScenario': 'Scenario',
'#AR-Campaign_GamemodeDesc': 'Campaign',
'#AR-CombatScenario_Description': 'Combat Ops',
};
export function localizedLabel(value: string | null | undefined): string | null {
const raw = text(value);
if (!raw) return null;
if (!raw.startsWith('#')) return raw;
const mapped = GAME_MODE_LABELS[raw];
if (mapped) return mapped;
// "#AR-Scenario_GameMode_FooBar" -> "Foo Bar"
const tail =
raw
.replace(/^#[A-Za-z]+-/, '')
.split('_')
.pop() ?? raw;
const spaced = tail.replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim();
return spaced || null;
}
function toScenario(scenario: z.infer<typeof scenarioSchema>): WorkshopScenario | null {
const scenarioId = text(scenario.gameId);
if (!scenarioId) return null;
return {
scenarioId,
name: scenario.name,
gameMode: localizedLabel(scenario.gameMode),
author: localizedLabel(scenario.author),
description: localizedLabel(scenario.description),
playerCount: scenario.playerCount && scenario.playerCount > 0 ? scenario.playerCount : null,
};
}
function toVersion(version: z.infer<typeof versionSchema>): WorkshopModVersion {
return {
version: version.version,
gameVersion: text(version.gameVersion),
sizeBytes: sizeOrNull(version.size),
sizeText: text(version.sizeFormatted),
approved: version.approved ?? true,
published: version.published ?? true,
createdAt: text(version.createdAt),
scenarioCount: version.scenarioCount ?? null,
dependencyCount: version.dependencyCount ?? null,
};
}
function toServerSummary(server: z.infer<typeof serverSummarySchema>): WorkshopServerSummary {
return {
id: server.id,
name: server.name,
scenarioId: text(server.scenarioId),
scenarioName: text(server.scenarioName),
gameVersion: text(server.gameVersion),
players: server.players,
maxPlayers: server.maxPlayers,
region: text(server.region),
platform: text(server.platform),
modCount: server.modCount,
official: server.official ?? false,
online: server.online ?? true,
};
}
export type WorkshopSearchParams = {
query?: string;
page?: number;
sort?: WorkshopSort;
/** Upstream accepts a single tag only; comma-separated values are rejected. */
tag?: string;
category?: string;
};
export class WorkshopClient {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly timeoutMs: number;
/** modId -> detail fields used to make browse cards useful. */
private previewCache = new Map<
string,
{
imageUrl: string | null;
version: string | null;
summary: string | null;
tags: string[];
expiresAt: number;
}
>();
private readonly apiKey: string;
constructor(options: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number }) {
constructor(options: {
baseUrl: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
apiKey?: string;
}) {
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
this.timeoutMs = options.timeoutMs ?? 10_000;
this.timeoutMs = options.timeoutMs ?? 12_000;
this.apiKey = options.apiKey ?? '';
}
private async get(path: string): Promise<unknown> {
let response: Response;
try {
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
headers: { Accept: 'application/json' },
headers: {
Accept: 'application/json',
'X-API-Client': CLIENT_NAME,
'User-Agent': CLIENT_NAME,
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
},
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (error) {
@@ -175,113 +369,96 @@ export class WorkshopClient {
return response.json();
}
async health(): Promise<WorkshopHealth> {
const startedAt = Date.now();
try {
await this.get('/v1/health');
return {
ok: true,
latencyMs: Date.now() - startedAt,
checkedAt: new Date().toISOString(),
message: null,
};
} catch (error) {
return {
ok: false,
latencyMs: null,
checkedAt: new Date().toISOString(),
message: sanitizeErrorMessage(error),
};
}
}
async search(query: string, page = 1, sort?: WorkshopSort): Promise<WorkshopSearchResponse> {
const params = new URLSearchParams();
if (query) params.set('search', query);
if (sort) params.set('sort', sort);
const qs = params.size > 0 ? `?${params.toString()}` : '';
const raw = await this.get(`/v1/mods/${Math.max(1, page)}${qs}`);
const parsed = searchResponseSchema.safeParse(raw);
private parse<T extends z.ZodTypeAny>(schema: T, raw: unknown): z.infer<T> {
const parsed = schema.safeParse(raw);
if (!parsed.success) {
throw ApiError.upstream('Workshop API returned an unexpected response shape.');
}
const mods = parsed.data.data.map(toPreview);
this.applyCachedPreviews(mods);
return {
mods,
meta: parsed.data.meta,
};
return parsed.data;
}
private applyCachedPreviews(mods: WorkshopModPreview[]): void {
const now = Date.now();
for (const mod of mods) {
const cached = this.previewCache.get(mod.id);
if (cached && cached.expiresAt > now) {
mod.imageUrl = mod.imageUrl ?? cached.imageUrl;
mod.version = mod.version ?? cached.version;
mod.summary = mod.summary ?? cached.summary;
mod.tags = mod.tags.length > 0 ? mod.tags : cached.tags;
}
}
async search(params: WorkshopSearchParams = {}): Promise<WorkshopSearchResponse> {
const search = new URLSearchParams();
search.set('page', String(Math.max(1, params.page ?? 1)));
if (params.query) search.set('search', params.query);
if (params.sort) search.set('sort', params.sort);
if (params.tag) search.set('tags', params.tag);
if (params.category) search.set('category', params.category);
const parsed = this.parse(searchResponseSchema, await this.get(`/v2/mods?${search}`));
return { mods: parsed.data.map(toPreview), meta: parsed.meta };
}
async getMod(modId: string): Promise<WorkshopModDetail> {
const raw = await this.get(`/v1/mod/${encodeURIComponent(modId)}`);
const parsed = modDetailEnvelopeSchema.safeParse(raw);
if (!parsed.success) {
throw ApiError.upstream('Workshop API returned an unexpected response shape.');
}
const mod = parsed.data.mod;
const detail = {
id: mod.id,
name: mod.name,
author: mod.author,
imageUrl: normalizeImageUrl(mod.imageURL),
size: mod.size || null,
rating: mod.rating || null,
workshopUrl: mod.originalModURL || null,
version: mod.version ?? null,
gameVersion: mod.gameVersion ?? null,
subscribers: mod.subscribers ?? null,
downloads: mod.downloads ?? null,
createdAtText: mod.created ?? null,
lastModifiedText: mod.lastModified ?? null,
summary: mod.summary ?? null,
description: mod.description ?? null,
license: mod.license ?? null,
tags: mod.tags,
dependencies: mod.dependencies.map((dep) => ({
name: dep.name,
id: extractModId(dep.apiModURL),
})),
scenarios: mod.scenarios.map((scenario) => ({
name: scenario.name,
description: scenario.description || null,
scenarioId: extractScenarioId(
scenario.scenarioID,
scenario.gamemode,
scenario.description,
scenario.name,
),
gamemode: cleanScenarioText(scenario.gamemode),
playerCount: scenario.playerCount || null,
imageUrl: normalizeImageUrl(scenario.imageURL),
})),
const raw = await this.get(`/v2/mods/${encodeURIComponent(modId)}`);
const { mod } = this.parse(modDetailEnvelopeSchema, raw);
return {
...toPreview(mod),
description: text(mod.description),
license: text(mod.license),
downloadCount: mod.downloadCount ?? null,
previewImages: mod.previewImages
.map(normalizeImageUrl)
.filter((url): url is string => url !== null),
screenshots: mod.screenshots
.map(normalizeImageUrl)
.filter((url): url is string => url !== null),
versionCount: mod.versionCount ?? null,
dependencyCount: mod.dependencyCount ?? mod.dependencies.length,
scenarioCount: mod.scenarioCount ?? mod.scenarios.length,
dependencySizeBytes: sizeOrNull(mod.dependencySize),
totalSizeBytes: sizeOrNull(mod.totalSize),
dependencies: mod.dependencies.map(toDependency),
scenarios: mod.scenarios
.map(toScenario)
.filter((scenario): scenario is WorkshopScenario => scenario !== null),
};
this.previewCache.set(detail.id, {
imageUrl: detail.imageUrl,
version: detail.version,
summary: detail.summary ?? detail.description,
tags: detail.tags,
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS,
}
async getVersions(modId: string): Promise<WorkshopModVersionsResponse> {
const raw = await this.get(`/v2/mods/${encodeURIComponent(modId)}/versions`);
const { data } = this.parse(versionsEnvelopeSchema, raw);
return { modId: data.modId || modId, versions: data.versions.map(toVersion) };
}
async getDependencies(modId: string): Promise<WorkshopDependency[]> {
const raw = await this.get(`/v2/mods/${encodeURIComponent(modId)}/dependencies`);
const { data } = this.parse(dependenciesEnvelopeSchema, raw);
return data.dependencies.map(toDependency);
}
async getScenarios(modId: string): Promise<WorkshopScenario[]> {
const raw = await this.get(`/v2/mods/${encodeURIComponent(modId)}/scenarios`);
const { data } = this.parse(scenariosEnvelopeSchema, raw);
return data.scenarios
.map(toScenario)
.filter((scenario): scenario is WorkshopScenario => scenario !== null);
}
async searchServers(query: string, page = 1): Promise<WorkshopServerSearchResponse> {
const search = new URLSearchParams({
page: String(Math.max(1, page)),
perPage: '25',
hasMods: 'true',
sort: 'players',
});
if (this.previewCache.size > 5_000) {
const now = Date.now();
for (const [key, value] of this.previewCache) {
if (value.expiresAt <= now) this.previewCache.delete(key);
}
}
return detail;
if (query) search.set('search', query);
const parsed = this.parse(serverSearchEnvelopeSchema, await this.get(`/v2/servers?${search}`));
return { servers: parsed.data.map(toServerSummary), meta: parsed.meta };
}
async getServerMods(serverId: string): Promise<WorkshopServerModsResponse> {
const raw = await this.get(`/v2/servers/${encodeURIComponent(serverId)}/mods?sizes=true`);
const parsed = this.parse(serverModsEnvelopeSchema, raw);
return {
serverId: parsed.serverId || serverId,
mods: parsed.data.map((mod) => ({
id: mod.id.toUpperCase(),
name: mod.name,
version: text(mod.version),
sizeBytes: sizeOrNull(mod.size),
})),
knownSizeBytes: sizeOrNull(parsed.summary?.knownSize),
unresolvedCount: parsed.summary?.unresolvedCount ?? 0,
};
}
}
@@ -1,41 +1,53 @@
import { Router } from 'express';
import { z } from 'zod';
import { WORKSHOP_SORTS } from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import { rateLimit } from '../../lib/rate-limit.js';
import { requireAuth } from '../auth/auth-middleware.js';
import type { WorkshopClient } from './workshop-client.js';
import type { WorkshopCache } from './workshop-cache.js';
const searchQuerySchema = z.object({
q: z.string().trim().max(100).default(''),
page: z.coerce.number().int().min(1).max(10_000).default(1),
sort: z.enum(['popularity', 'newest', 'subscribers', 'version_size']).optional(),
sort: z.enum(WORKSHOP_SORTS).optional(),
// Upstream rejects comma-separated tags, so this is deliberately singular.
tag: z
.string()
.trim()
.max(40)
.regex(/^[A-Za-z0-9 _-]*$/, 'Invalid tag.')
.optional(),
category: z
.string()
.trim()
.max(40)
.regex(/^[a-z0-9-]*$/, 'Invalid category.')
.optional(),
});
const modIdSchema = z.string().regex(/^[A-Za-z0-9]{1,32}$/, 'Invalid mod id.');
const serverSearchQuerySchema = z.object({
q: z.string().trim().max(100).default(''),
page: z.coerce.number().int().min(1).max(1_000).default(1),
});
export function createWorkshopRouter(client: WorkshopClient): Router {
const modIdSchema = z.string().regex(/^[A-Fa-f0-9]{16}$/, 'Invalid mod id.');
// reforgermods.net identifies servers by room UUID.
const serverIdSchema = z.string().regex(/^[A-Za-z0-9-]{8,64}$/, 'Invalid server id.');
export function createWorkshopRouter(workshop: WorkshopCache): Router {
const router = Router();
// The upstream allows 60 req/min per IP; stay well under it.
const workshopRateLimit = rateLimit({ windowMs: 60_000, max: 30, keyPrefix: 'workshop' });
// The cache does the real upstream pacing; this only guards against a
// runaway browser loop hammering our own API.
const workshopRateLimit = rateLimit({ windowMs: 60_000, max: 240, keyPrefix: 'workshop' });
router.use(requireAuth, workshopRateLimit);
router.get('/health', async (_req, res, next) => {
try {
res.json(await client.health());
} catch (error) {
next(error);
}
});
router.get('/search', async (req, res, next) => {
try {
const parsed = searchQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw ApiError.validation('Invalid search parameters.');
}
const { q, page, sort } = parsed.data;
res.json(await client.search(q, page, sort));
if (!parsed.success) throw ApiError.validation('Invalid search parameters.');
const { q, page, sort, tag, category } = parsed.data;
res.json(await workshop.search({ query: q, page, sort, tag, category }));
} catch (error) {
next(error);
}
@@ -44,10 +56,39 @@ export function createWorkshopRouter(client: WorkshopClient): Router {
router.get('/mods/:id', async (req, res, next) => {
try {
const parsed = modIdSchema.safeParse(req.params.id);
if (!parsed.success) {
throw ApiError.validation('Invalid mod id.');
}
res.json(await client.getMod(parsed.data));
if (!parsed.success) throw ApiError.validation('Invalid mod id.');
res.json(await workshop.getMod(parsed.data));
} catch (error) {
next(error);
}
});
router.get('/mods/:id/versions', async (req, res, next) => {
try {
const parsed = modIdSchema.safeParse(req.params.id);
if (!parsed.success) throw ApiError.validation('Invalid mod id.');
res.json(await workshop.getVersions(parsed.data));
} catch (error) {
next(error);
}
});
/** Live server browser — backs "add a modlist based off another server". */
router.get('/servers', async (req, res, next) => {
try {
const parsed = serverSearchQuerySchema.safeParse(req.query);
if (!parsed.success) throw ApiError.validation('Invalid server search parameters.');
res.json(await workshop.searchServers(parsed.data.q, parsed.data.page));
} catch (error) {
next(error);
}
});
router.get('/servers/:id/mods', async (req, res, next) => {
try {
const parsed = serverIdSchema.safeParse(req.params.id);
if (!parsed.success) throw ApiError.validation('Invalid server id.');
res.json(await workshop.getServerMods(parsed.data));
} catch (error) {
next(error);
}
+32 -7
View File
@@ -16,7 +16,9 @@ import type { ResourceHistoryService } from '../src/modules/servers/resource-his
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import type { IngestionScheduler } from '../src/modules/reforger-logs/ingestion/scheduler.js';
import type { ServerRecord, ServerService } from '../src/modules/servers/server-service.js';
import { WorkshopClient } from '../src/modules/workshop/workshop-client.js';
import type { WorkshopCache } from '../src/modules/workshop/workshop-cache.js';
import type { MissionsService } from '../src/modules/reforger-logs/missions-catalog.js';
import { ServerMetricsService } from '../src/modules/servers/metrics-service.js';
const OWNER_ID = '111111111111111111';
@@ -115,24 +117,42 @@ function buildApp() {
sessions,
servers,
provider,
workshop: new WorkshopClient({ baseUrl: 'https://workshop.invalid' }),
workshop: {
warm: () => undefined,
peekMod: () => undefined,
tryGetMod: async () => null,
} as unknown as WorkshopCache,
metrics: new ServerMetricsService(provider, null),
consoleHub: null,
scheduler,
resolveLogPath: async () => '/profile/logs/console.log',
configSync: null,
configEditor: null,
mods: {
getMods: async () => ({ mods: [], fetchedAt: new Date().toISOString() }),
getMods: async () => ({
mods: [],
revision: 'a1b2c3d4',
fetchedAt: new Date().toISOString(),
}),
setMods: async () => ({
mods: [],
revision: 'a1b2c3d4',
fetchedAt: new Date().toISOString(),
added: 0,
removed: 0,
changed: 0,
requiresRestart: true as const,
}),
} as unknown as ServerModsService,
performance: {
get: async () => ({ settings: {}, fetchedAt: new Date().toISOString() }),
get: async () => ({
settings: {},
revision: 'a1b2c3d4',
fetchedAt: new Date().toISOString(),
}),
update: async (_server: unknown, settings: unknown) => ({
settings,
revision: 'a1b2c3d4',
fetchedAt: new Date().toISOString(),
changedFields: [],
requiresRestart: true as const,
@@ -141,7 +161,9 @@ function buildApp() {
resourceHistory: {
history: () => ({ samples: [], intervalSeconds: 15 }),
} as unknown as ResourceHistoryService,
missions: null,
missions: {
list: async () => ({ groups: [], incompleteModIds: [], fetchedAt: null }),
} as unknown as MissionsService,
});
return { app, provider, activity };
}
@@ -335,7 +357,7 @@ describe('schedule management by role', () => {
});
describe('performance config by role', () => {
const validBody = {
const validSettings = {
maxPlayers: 32,
serverMaxViewDistance: null,
networkViewDistance: null,
@@ -349,6 +371,9 @@ describe('performance config by role', () => {
slotReservationTimeout: null,
lobbyPlayerSynchronise: null,
};
// The endpoint takes a settings envelope so callers can also pass the
// revision their edits were based on.
const validBody = { settings: validSettings };
it('allows owner and server_admin, forbids mission_lead and viewer', async () => {
const { app } = buildApp();
@@ -373,7 +398,7 @@ describe('performance config by role', () => {
const response = await request(app)
.put('/api/servers/training-server/config/performance')
.set(asUser('owner-token'))
.send({ ...validBody, serverMaxViewDistance: 99999 });
.send({ settings: { ...validSettings, serverMaxViewDistance: 99999 } });
expect(response.status).toBe(400);
expect(response.body.error.message).toContain('serverMaxViewDistance');
});
+225 -43
View File
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { WorkshopModDetail } from '@reforger-panel/shared';
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js';
import { ServerModsService } from '../src/modules/config/mods-service.js';
import type { ConfigSyncService } from '../src/modules/config/config-sync.js';
import type { WorkshopCache } from '../src/modules/workshop/workshop-cache.js';
import type { ServerRecord } from '../src/modules/servers/server-service.js';
import { createLogger } from '../src/lib/logger.js';
import { ApiError } from '../src/lib/errors.js';
@@ -19,38 +20,81 @@ const server: ServerRecord = {
updatedAt: new Date(),
};
const MOCK_MOD = '591AF5BDA9F7CE8B';
const ADMIN_TOOLS = '5AAF0CCE3F001FB5';
const DEPENDENCY = 'BBBB000000000001';
function workshopDetail(overrides: Partial<WorkshopModDetail> & { id: string }) {
return {
name: `Mod ${overrides.id}`,
author: 'Author',
summary: null,
imageUrl: null,
workshopUrl: null,
version: '1.0.2',
gameVersion: null,
sizeBytes: 1024,
sizeText: '1 KiB',
rating: null,
ratingCount: null,
subscriberCount: null,
createdAt: null,
updatedAt: null,
tags: [],
obsolete: false,
description: null,
license: null,
downloadCount: null,
previewImages: [],
screenshots: [],
versionCount: 1,
dependencyCount: 0,
scenarioCount: 0,
dependencySizeBytes: null,
totalSizeBytes: null,
dependencies: [],
scenarios: [],
...overrides,
} as WorkshopModDetail;
}
function fakeWorkshop(catalog: Record<string, WorkshopModDetail> = {}) {
const resolved = new Map(Object.entries(catalog));
return {
warm: vi.fn(),
peekMod: (id: string) => resolved.get(id.toUpperCase()) ?? null,
tryGetMod: async (id: string) => resolved.get(id.toUpperCase()) ?? null,
getMod: async (id: string) => {
const mod = resolved.get(id.toUpperCase());
if (!mod) throw ApiError.notFound('Workshop mod not found.');
return mod;
},
} as unknown as WorkshopCache;
}
describe('ServerModsService', () => {
let provider: MockGameServerProvider;
let service: ServerModsService;
let configSyncCalled: number;
let gateway: ConfigFileGateway;
function build(catalog: Record<string, WorkshopModDetail> = {}) {
return new ServerModsService(gateway, fakeWorkshop(catalog), createLogger('silent'));
}
beforeEach(() => {
provider = new MockGameServerProvider();
configSyncCalled = 0;
const configSync = {
sync: async () => {
configSyncCalled += 1;
return { changed: true, revisionVersion: 2, serverName: 'x', maxPlayers: 16 };
},
} as unknown as ConfigSyncService;
service = new ServerModsService(
new ConfigFileGateway(provider, '/config.json'),
configSync,
createLogger('silent'),
);
gateway = new ConfigFileGateway(provider, '/config.json');
});
it('reads the current mods from config.json', async () => {
const result = await service.getMods(server);
expect(result.mods).toEqual([
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
]);
it('reads the current mods from config.json with a revision to write back against', async () => {
const result = await build().getMods(server);
expect(result.mods).toEqual([{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '1.0.2' }]);
expect(result.revision).toMatch(/^[a-f0-9]{16}$/);
});
it('writes the new mod list while preserving every other config field', async () => {
const result = await service.setMods(server, [
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' },
const result = await build().setMods(server, [
{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '1.0.2' },
{ modId: ADMIN_TOOLS, name: 'Server Admin Tools' },
]);
expect(result.added).toBe(1);
@@ -58,14 +102,11 @@ describe('ServerModsService', () => {
expect(result.requiresRestart).toBe(true);
expect(result.mods).toHaveLength(2);
const written = provider.writtenFiles.get('/config.json')!;
const parsed = JSON.parse(written);
// game.mods replaced…
const parsed = JSON.parse(provider.writtenFiles.get('/config.json')!);
expect(parsed.game.mods).toEqual([
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' },
{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '1.0.2' },
{ modId: ADMIN_TOOLS, name: 'Server Admin Tools' },
]);
// …everything else untouched.
expect(parsed.bindPort).toBe(2001);
expect(parsed.game.name).toBe('Mock Reforger Server');
expect(parsed.game.maxPlayers).toBe(16);
@@ -73,37 +114,178 @@ describe('ServerModsService', () => {
expect(parsed.operating.aiLimit).toBe(40);
});
it('writes a rollback backup of the previous file before modifying it', async () => {
const before = (await provider.downloadTextFile('abc123', '/config.json')).content;
await service.setMods(server, []);
expect(provider.writtenFiles.get('/config.json.bak')).toBe(before);
// Removal reflected in the live file.
const after = JSON.parse(provider.writtenFiles.get('/config.json')!);
expect(after.game.mods).toEqual([]);
it('counts a version change as changed rather than add plus remove', async () => {
const result = await build().setMods(server, [
{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '2.0.0' },
]);
expect(result).toMatchObject({ added: 0, removed: 0, changed: 1 });
});
it('imports a config revision after a successful write', async () => {
await service.setMods(server, []);
expect(configSyncCalled).toBe(1);
it('writes a rollback backup of the previous file before modifying it', async () => {
const before = (await provider.downloadTextFile('abc123', '/config.json')).content;
await build().setMods(server, []);
expect(provider.writtenFiles.get('/config.json.bak')).toBe(before);
expect(JSON.parse(provider.writtenFiles.get('/config.json')!).game.mods).toEqual([]);
});
it('normalizes mod ids to uppercase and drops empty name/version', async () => {
const result = await service.setMods(server, [{ modId: '69c566706abd5a3c', name: '' }]);
const result = await build().setMods(server, [{ modId: '69c566706abd5a3c', name: '' }]);
expect(result.mods).toEqual([{ modId: '69C566706ABD5A3C' }]);
});
it('rejects a write based on a stale revision instead of clobbering it', async () => {
const service = build();
const stale = (await service.getMods(server)).revision;
// Somebody else edits the file in between.
await service.setMods(server, [{ modId: ADMIN_TOOLS }]);
await expect(service.setMods(server, [], stale)).rejects.toMatchObject({ code: 'CONFLICT' });
});
it('fails the write when read-back verification does not match', async () => {
// Simulate a server that ignores writes to config.json.
const originalWrite = provider.writeTextFile.bind(provider);
vi.spyOn(provider, 'writeTextFile').mockImplementation(async (sid, path, content) => {
if (path === '/config.json') return; // swallow the write
await originalWrite(sid, path, content);
});
await expect(service.setMods(server, [])).rejects.toThrow(/verification failed/);
await expect(build().setMods(server, [])).rejects.toThrow(/verification failed/);
});
it('refuses to modify a config without a game section', async () => {
await provider.writeTextFile('abc123', '/config.json', '{"something": true}');
await expect(service.setMods(server, [])).rejects.toThrow(ApiError);
await expect(build().setMods(server, [])).rejects.toThrow(ApiError);
});
describe('overview', () => {
it('joins installed mods with workshop metadata and flags updates', async () => {
const service = build({
[MOCK_MOD]: workshopDetail({ id: MOCK_MOD, name: 'Mock Sample Mod', version: '1.4.0' }),
});
const overview = await service.getOverview(server);
expect(overview.mods).toHaveLength(1);
const entry = overview.mods[0]!;
expect(entry.pinnedVersion).toBe('1.0.2');
expect(entry.workshop?.latestVersion).toBe('1.4.0');
expect(entry.updateAvailable).toBe(true);
expect(overview.updatesAvailable).toBe(1);
expect(overview.warming).toBe(false);
});
it('lists missing dependencies and removal blockers', async () => {
await build().setMods(server, [{ modId: MOCK_MOD }, { modId: ADMIN_TOOLS }]);
const service = build({
[MOCK_MOD]: workshopDetail({
id: MOCK_MOD,
dependencyCount: 1,
dependencies: [
{
id: DEPENDENCY,
name: 'Required Pack',
version: null,
sizeBytes: 10,
published: true,
private: false,
},
],
}),
[ADMIN_TOOLS]: workshopDetail({
id: ADMIN_TOOLS,
dependencyCount: 1,
dependencies: [
{
id: MOCK_MOD,
name: 'Mock Sample Mod',
version: null,
sizeBytes: 10,
published: true,
private: false,
},
],
}),
});
const overview = await service.getOverview(server);
const sample = overview.mods.find((mod) => mod.modId === MOCK_MOD)!;
expect(sample.missingDependencies.map((dep) => dep.id)).toEqual([DEPENDENCY]);
// Admin Tools depends on the sample mod, so removing it is unsafe.
expect(sample.requiredBy).toEqual([ADMIN_TOOLS]);
});
it('reports mods the workshop could not resolve', async () => {
const overview = await build().getOverview(server);
expect(overview.unresolvedIds).toEqual([MOCK_MOD]);
expect(overview.mods[0]!.workshop).toBeNull();
});
});
describe('resolve', () => {
it('pulls in transitive dependencies with their sizes', async () => {
const service = build({
[MOCK_MOD]: workshopDetail({
id: MOCK_MOD,
sizeBytes: 100,
dependencyCount: 1,
dependencies: [
{
id: DEPENDENCY,
name: 'Required Pack',
version: null,
sizeBytes: 900,
published: true,
private: false,
},
],
}),
[DEPENDENCY]: workshopDetail({ id: DEPENDENCY, name: 'Required Pack', sizeBytes: 900 }),
});
const result = await service.resolve([{ modId: MOCK_MOD, version: '1.0.2' }]);
expect(result.mods.map((mod) => mod.modId).sort()).toEqual([DEPENDENCY, MOCK_MOD].sort());
expect(result.addedDependencies).toHaveLength(1);
expect(result.addedDependencies[0]).toMatchObject({
modId: DEPENDENCY,
viaDependency: true,
requiredBy: [MOCK_MOD],
});
expect(result.totalSizeBytes).toBe(1000);
});
it('does not loop forever on a circular dependency graph', async () => {
const service = build({
[MOCK_MOD]: workshopDetail({
id: MOCK_MOD,
dependencies: [
{
id: DEPENDENCY,
name: 'B',
version: null,
sizeBytes: 1,
published: true,
private: false,
},
],
}),
[DEPENDENCY]: workshopDetail({
id: DEPENDENCY,
dependencies: [
{
id: MOCK_MOD,
name: 'A',
version: null,
sizeBytes: 1,
published: true,
private: false,
},
],
}),
});
const result = await service.resolve([{ modId: MOCK_MOD }]);
expect(result.mods).toHaveLength(2);
});
it('names ids the workshop does not know instead of dropping them', async () => {
const result = await build().resolve([{ modId: 'CCCC000000000009' }]);
expect(result.unresolvedIds).toEqual(['CCCC000000000009']);
});
});
});
+102 -11
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import { ConfigEditorService } from '../src/modules/config/config-editor-service.js';
import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js';
import { PerformanceSettingsService } from '../src/modules/config/performance-service.js';
import type { ConfigSyncService } from '../src/modules/config/config-sync.js';
@@ -20,16 +21,16 @@ const server: ServerRecord = {
describe('PerformanceSettingsService', () => {
let provider: MockGameServerProvider;
let gateway: ConfigFileGateway;
let editor: ConfigEditorService;
let service: PerformanceSettingsService;
beforeEach(() => {
provider = new MockGameServerProvider();
gateway = new ConfigFileGateway(provider, '/config.json');
const configSync = { sync: async () => ({}) } as unknown as ConfigSyncService;
service = new PerformanceSettingsService(
new ConfigFileGateway(provider, '/config.json'),
configSync,
createLogger('silent'),
);
editor = new ConfigEditorService(gateway, provider, configSync, createLogger('silent'));
service = new PerformanceSettingsService(gateway, editor, createLogger('silent'));
});
it('reads current values, reporting absent keys as null', async () => {
@@ -46,16 +47,19 @@ describe('PerformanceSettingsService', () => {
});
it('sets changed values and removes nulled keys, preserving everything else', async () => {
const { settings } = await service.get(server);
const result = await service.update(server, {
...settings,
maxPlayers: 32,
playerSaveTime: 180, // new key
disableAI: null,
aiLimit: null, // remove key game default
aiLimit: null, // remove key -> game default
});
expect(result.changedFields.sort()).toEqual(['aiLimit', 'disableAI', 'maxPlayers', 'playerSaveTime']);
expect(result.changedFields.sort()).toEqual([
'aiLimit',
'disableAI',
'maxPlayers',
'playerSaveTime',
]);
expect(result.requiresRestart).toBe(true);
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
@@ -66,14 +70,101 @@ describe('PerformanceSettingsService', () => {
expect(written.bindPort).toBe(2001);
expect(written.game.scenarioId).toContain('Missions');
expect(written.game.mods).toHaveLength(1);
// Backup written:
expect(provider.writtenFiles.get('/config.json.bak')).toBeTruthy();
});
/**
* The old form posted every field on every save, so a form loaded before
* somebody else's change silently reverted it. Only the submitted keys may
* ever be written.
*/
it('leaves fields the caller did not submit alone', async () => {
await service.update(server, { maxPlayers: 48 });
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
expect(written.game.maxPlayers).toBe(48);
expect(written.game.gameProperties.serverMaxViewDistance).toBe(2500);
expect(written.operating.aiLimit).toBe(40);
});
it('does not write the file at all when nothing changed', async () => {
const { settings } = await service.get(server);
const result = await service.update(server, settings);
const result = await service.update(server, {
maxPlayers: settings.maxPlayers,
aiLimit: settings.aiLimit,
});
expect(result.changedFields).toEqual([]);
expect(provider.writtenFiles.has('/config.json')).toBe(false);
});
it('rejects a save based on a revision that has since moved', async () => {
const stale = (await service.get(server)).revision;
await service.update(server, { maxPlayers: 24 });
await expect(
service.update(server, { maxPlayers: 48 }, { expectedRevision: stale }),
).rejects.toMatchObject({ code: 'CONFLICT' });
});
});
describe('ConfigEditorService', () => {
let provider: MockGameServerProvider;
let editor: ConfigEditorService;
beforeEach(() => {
provider = new MockGameServerProvider();
const gateway = new ConfigFileGateway(provider, '/config.json');
const configSync = { sync: async () => ({}) } as unknown as ConfigSyncService;
editor = new ConfigEditorService(gateway, provider, configSync, createLogger('silent'));
});
it('exposes every key in the file, not just the ones the panel knows', async () => {
const tree = await editor.getTree(server);
const paths = tree.entries.map((entry) => entry.path);
expect(paths).toContain('bindAddress');
expect(paths).toContain('game.crossPlatform');
expect(paths).toContain('game.gameProperties.networkViewDistance');
expect(tree.revision).toMatch(/^[a-f0-9]{16}$/);
});
/**
* Reforger eggs often re-template config.json from startup variables at
* boot, which is why edits could appear to save and then vanish.
*/
it('flags config keys that a startup variable also controls', async () => {
const tree = await editor.getTree(server);
const mirror = tree.mirrors.find((entry) => entry.envVariable === 'MAX_PLAYERS');
expect(mirror).toBeDefined();
expect(mirror!.configPath).toBe('game.maxPlayers');
});
it('patches an arbitrary path and reports what changed', async () => {
const result = await editor.patch(server, [
{ path: 'operating.slotReservationTimeout', value: 90 },
]);
expect(result.changedPaths).toEqual(['operating.slotReservationTimeout']);
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
expect(written.operating.slotReservationTimeout).toBe(90);
});
it('mirrors a changed value into its startup variable when asked', async () => {
const result = await editor.patch(server, [{ path: 'game.maxPlayers', value: 40 }], {
writeStartupVars: true,
});
expect(result.startupVarsWritten).toContain('MAX_PLAYERS');
const variables = await provider.listStartupVariables();
expect(variables.find((v) => v.envVariable === 'MAX_PLAYERS')?.serverValue).toBe('40');
});
it('round-trips the raw editor and rejects invalid JSON', async () => {
const raw = await editor.getRaw(server);
expect(JSON.parse(raw.content).game.name).toBe('Mock Reforger Server');
await expect(editor.putRaw(server, '{ nope')).rejects.toMatchObject({
code: 'VALIDATION_ERROR',
});
});
it('refuses a raw write that would drop the game section', async () => {
await expect(editor.putRaw(server, '{"bindPort":2001}')).rejects.toMatchObject({
code: 'VALIDATION_ERROR',
});
});
});