Assessor_2.0/scripts/dev-local.mjs

211 lines
7.6 KiB
JavaScript

import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build, createServer } from 'vite';
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const backendHealthUrl = 'http://127.0.0.1:4173/api/v1/health';
const backendEntry = resolve(projectRoot, 'dist-server/server.mjs');
const serverConfig = resolve(projectRoot, 'vite.server.config.ts');
const clientConfig = resolve(projectRoot, 'vite.client.config.ts');
const tscCli = resolve(projectRoot, 'node_modules/typescript/bin/tsc');
const runtimeStateFile = resolve(projectRoot, 'node_modules/.assessor-dev-runtime.json');
const developmentInstanceId = randomUUID();
let backendProcess;
let clientServer;
let serverBuildWatcher;
let restartChain = Promise.resolve();
let shuttingDown = false;
const delay = milliseconds => new Promise(resolveDelay => setTimeout(resolveDelay, milliseconds));
async function backendHealth() {
try {
const response = await fetch(backendHealthUrl, { method: 'GET' });
if (!response.ok) return undefined;
const health = await response.json();
return health?.status === 'ok' && health?.application === 'ASSESSOR' ? health : undefined;
} catch {
return undefined;
}
}
async function readRuntimeState() {
try { return JSON.parse(await readFile(runtimeStateFile, 'utf8')); }
catch { return undefined; }
}
async function writeRuntimeState(releaseId, processId) {
await mkdir(dirname(runtimeStateFile), { recursive: true });
await writeFile(runtimeStateFile, JSON.stringify({ developmentInstanceId, releaseId, processId }), 'utf8');
}
async function removeOwnedRuntimeState() {
const state = await readRuntimeState();
if (state?.developmentInstanceId !== developmentInstanceId) return;
try { await unlink(runtimeStateFile); }
catch (error) { if (error?.code !== 'ENOENT') throw error; }
}
function runNode(args) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(process.execPath, args, { cwd: projectRoot, stdio: 'inherit' });
child.once('error', rejectRun);
child.once('exit', (code, signal) => {
if (code === 0) resolveRun();
else rejectRun(new Error('Command failed (' + (signal ?? code) + '): node ' + args.join(' ')));
});
});
}
async function releaseIdForBackend() {
return createHash('sha256').update(await readFile(backendEntry)).digest('hex');
}
async function waitForBackend(releaseId) {
for (let attempt = 0; attempt < 80; attempt += 1) {
const health = await backendHealth();
if (health?.releaseId === releaseId && health?.developmentInstanceId === developmentInstanceId) return;
if (backendProcess && backendProcess.exitCode !== null) {
throw new Error('Local backend exited before becoming healthy (code ' + backendProcess.exitCode + ').');
}
await delay(250);
}
throw new Error('Local backend did not report the expected development release at ' + backendHealthUrl + '.');
}
async function waitForBackendToStop() {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (!await backendHealth()) return;
await delay(100);
}
throw new Error('The previous local backend did not release port 4173 in time.');
}
async function reclaimManagedBackend() {
const health = await backendHealth();
if (!health) return;
const state = await readRuntimeState();
const managed = health.developmentInstanceId
&& state?.developmentInstanceId === health.developmentInstanceId
&& state?.releaseId === health.releaseId
&& Number.isSafeInteger(state.processId) && state.processId > 0;
if (!managed) {
throw new Error('Port 4173 is occupied by an unmanaged or incompatible backend. Stop that one process once; subsequent development restarts are managed automatically.');
}
console.log('[assessor] Reclaiming the previously managed development backend...');
try { process.kill(state.processId, 'SIGTERM'); }
catch (error) { if (error?.code !== 'ESRCH') throw error; }
await waitForBackendToStop();
}
async function stopBackend() {
const child = backendProcess;
backendProcess = undefined;
if (!child || child.exitCode !== null) return;
const exited = new Promise(resolveExit => child.once('exit', resolveExit));
child.kill('SIGTERM');
await Promise.race([exited, delay(5_000)]);
if (child.exitCode === null) {
child.kill('SIGKILL');
await exited;
}
}
async function startBackend(releaseId) {
backendProcess = spawn(process.execPath, ['--env-file-if-exists=.env.postgres', backendEntry], {
cwd: projectRoot,
env: {
...process.env,
ASSESSOR_HOST: '127.0.0.1', ASSESSOR_PORT: '4173',
ASSESSOR_RELEASE_ID: releaseId,
ASSESSOR_DEVELOPMENT_INSTANCE_ID: developmentInstanceId
},
stdio: 'inherit'
});
const child = backendProcess;
child.once('error', error => {
console.error('[assessor] Backend start failed:', error);
void shutdown(1);
});
child.once('exit', code => {
if (backendProcess === child && !shuttingDown) {
console.error('[assessor] Backend stopped unexpectedly (code ' + code + ').');
void shutdown(code ?? 1);
}
});
await writeRuntimeState(releaseId, child.pid);
await waitForBackend(releaseId);
}
async function activateBuiltBackend() {
const releaseId = await releaseIdForBackend();
const hadBackend = Boolean(await backendHealth());
await stopBackend();
if (hadBackend) await waitForBackendToStop();
await startBackend(releaseId);
console.log('[assessor] Backend ready: ' + backendHealthUrl + ' · release ' + releaseId.slice(0, 12));
}
async function startServerBuildWatcher() {
return new Promise(async (resolveInitial, rejectInitial) => {
let initialSettled = false;
try {
serverBuildWatcher = await build({ configFile: serverConfig, build: { watch: {} } });
serverBuildWatcher.on('event', event => {
if (event.code === 'ERROR') {
console.error('[assessor] Backend rebuild failed; the last healthy backend remains active.', event.error);
if (!initialSettled) { initialSettled = true; rejectInitial(event.error); }
return;
}
if (event.code !== 'END') return;
restartChain = restartChain.then(activateBuiltBackend);
restartChain.then(() => {
if (!initialSettled) { initialSettled = true; resolveInitial(); }
}).catch(error => {
console.error('[assessor] Built backend could not be activated:', error);
if (!initialSettled) { initialSettled = true; rejectInitial(error); }
else void shutdown(1);
});
});
} catch (error) {
rejectInitial(error);
}
});
}
async function shutdown(exitCode = 0) {
if (shuttingDown) return;
shuttingDown = true;
await clientServer?.close();
await serverBuildWatcher?.close();
await stopBackend();
await removeOwnedRuntimeState();
process.exitCode = exitCode;
}
async function start() {
await reclaimManagedBackend();
console.log('[assessor] Type-checking and watching the local backend...');
await runNode([tscCli, '-p', 'tsconfig.server.json', '--noEmit']);
await startServerBuildWatcher();
clientServer = await createServer({
configFile: clientConfig,
server: { host: '127.0.0.1', port: 5173, strictPort: true }
});
await clientServer.listen();
clientServer.printUrls();
}
process.once('SIGINT', () => void shutdown());
process.once('SIGTERM', () => void shutdown());
start().catch(error => {
console.error('[assessor] Local development stack failed:', error);
void shutdown(1);
});