211 lines
7.6 KiB
JavaScript
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);
|
|
});
|