Files
electric-cherry/src/lib/server/provisioning/launch-preparation.ts
T

80 lines
3.2 KiB
TypeScript

import { Worker } from 'node:worker_threads';
import { resolve } from 'node:path';
import { config } from '../config.js';
import { db } from '../db/index.js';
import type { NativeInstance } from '../backends/native.js';
import type { LaunchRecipePlan } from '../fleet/launch-recipes.js';
import type { PlacementOptions } from '../hosts/capacity.js';
export interface NativeLaunchInput {
game: 't5' | 't6';
mapSlug: string;
native?: LaunchRecipePlan['native'];
instance: NativeInstance;
serverKey: string;
rconPassword: string;
rebuild: boolean;
safetyOverride?: boolean;
}
export interface PreparedNativeLaunch {
recipe: LaunchRecipePlan;
recipeSummary: string;
blobs?: Array<{ sha256: string; content: Uint8Array }>;
recipeJson: Uint8Array;
payloadJson: Uint8Array;
}
let worker: Worker | null = null;
let sequence = 0;
const pending = new Map<number, {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}>();
function stop(error: Error, current = worker): Promise<number> | undefined {
if (current !== worker) return;
worker = null;
for (const request of pending.values()) { clearTimeout(request.timer); request.reject(error); }
pending.clear();
return current?.terminate();
}
/** The reader and JSON encoder run off the controller's request thread. */
function run<T>(kind: 'launch' | 'placement', input: NativeLaunchInput | PlacementOptions): Promise<T> {
db(); // Migrations must finish before the independent read-only connection opens.
if (!worker) {
const current = worker = new Worker(resolve(process.env.NODE_ENV === 'production'
? 'build/launch/worker.mjs' : '.svelte-kit/launch/worker.mjs'), {
workerData: { dbPath: resolve(config.dbPath) }
});
current.on('message', ({ id, value, error }: { id: number; value: unknown; error?: string }) => {
if (current !== worker) return;
const request = pending.get(id);
if (!request) return;
pending.delete(id); clearTimeout(request.timer);
if (error) request.reject(new Error(error)); else request.resolve(value);
if (!pending.size) current.unref();
});
current.on('error', error => { void stop(error, current); });
current.on('exit', () => { void stop(new Error('Launch preparation worker stopped; preparation will retry.'), current); });
}
worker.ref();
const id = ++sequence;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => { void stop(new Error('Launch preparation timed out; preparation will retry.')); }, 60_000);
pending.set(id, { resolve: value => resolve(value as T), reject, timer });
worker!.postMessage({ id, kind, input });
});
}
export function prepareNativeLaunch(input: NativeLaunchInput): Promise<PreparedNativeLaunch> { return run('launch', input); }
export function evaluateNativePlacement(input: PlacementOptions): Promise<{ hostId: string; lastHeartbeat: string | null }> {
return run('placement', input);
}
export async function closeLaunchPreparation(): Promise<void> {
await stop(new Error('Launch preparation stopped.'));
}