@travetto/compiler
Version:
The compiler infrastructure for the Travetto framework
262 lines (228 loc) • 8.46 kB
text/typescript
import http from 'node:http';
import fs from 'node:fs/promises';
import { setMaxListeners } from 'node:events';
import type { ManifestContext } from '@travetto/manifest';
import type { CompilerMode, CompilerProgressEvent, CompilerEvent, CompilerEventType, CompilerServerInfo } from '../types.ts';
import { Log } from '../log.ts';
import { CommonUtil } from '../util.ts';
import { CompilerClient } from './client.ts';
import { ProcessHandle } from './process-handle.ts';
const log = Log.scoped('server');
/**
* Compiler Server Class
*/
export class CompilerServer {
#ctx: ManifestContext;
#server: http.Server;
#listenersAll = new Set<http.ServerResponse>();
#listeners: Partial<Record<CompilerEventType | 'all', Record<string, http.ServerResponse>>> = {};
#shutdown = new AbortController();
signal = this.#shutdown.signal;
info: CompilerServerInfo;
#client: CompilerClient;
#url: string;
#handle: Record<'compiler' | 'server', ProcessHandle>;
constructor(ctx: ManifestContext, mode: CompilerMode) {
this.#ctx = ctx;
this.#client = new CompilerClient(ctx, Log.scoped('server.client'));
this.#url = this.#client.url;
this.#handle = { server: new ProcessHandle(ctx, 'server'), compiler: new ProcessHandle(ctx, 'compiler') };
this.info = {
state: 'startup',
iteration: Date.now(),
mode,
serverPid: process.pid,
compilerPid: -1,
path: ctx.workspace.path,
url: this.#url
};
this.#server = http.createServer({
keepAlive: true,
requestTimeout: 1000 * 60 * 60,
keepAliveTimeout: 1000 * 60 * 60,
}, (req, res) => this.#onRequest(req, res));
setMaxListeners(1000, this.signal);
}
get mode(): CompilerMode {
return this.info.mode;
}
isResetEvent(ev: CompilerEvent): boolean {
return ev.type === 'state' && ev.payload.state === 'reset';
}
async #tryListen(attempt = 0): Promise<'ok' | 'running'> {
const output = await new Promise<'ok' | 'running' | 'retry'>((resolve, reject) => {
this.#server
.on('listening', () => resolve('ok'))
.on('error', async err => {
if ('code' in err && err.code === 'EADDRINUSE') {
const info = await this.#client.info();
resolve((info && info.mode === 'build' && this.mode === 'watch') ? 'retry' : 'running');
} else {
log.warn('Failed in running server', err);
reject(err);
}
})
.on('close', () => log.debug('Server close event'));
const url = new URL(this.#url);
CommonUtil.queueMacroTask().then(() => this.#server.listen(+url.port, url.hostname)); // Run async
});
if (output === 'retry') {
if (attempt >= 5) {
throw new Error('Unable to verify compilation server');
}
log.info('Waiting for build to finish, before retrying');
// Let the server finish
await this.#client.waitForState(['closed'], 'Server closed', this.signal);
return this.#tryListen(attempt + 1);
} else if (output === 'ok') {
await this.#handle.server.writePid(this.info.serverPid);
}
return output;
}
#addListener(type: CompilerEventType | 'all', res: http.ServerResponse): void {
res.writeHead(200);
const id = `id_${Date.now()}_${Math.random()}`.replace('.', '1');
(this.#listeners[type] ??= {})[id] = res;
this.#listenersAll.add(res);
if (type === 'state' || type === 'all') { // Send on initial connect
this.#emitEvent({ type: 'state', payload: { state: this.info.state } }, id);
} else {
res.write('\n'); // Send at least one byte on listen
}
// Do not wait on it
res.on('close', () => {
delete this.#listeners[type]?.[id];
this.#listenersAll.delete(res);
});
}
#emitEvent(ev: CompilerEvent, to?: string): void {
if (this.#listeners.all) {
const msg = JSON.stringify(ev);
for (const [id, item] of Object.entries(this.#listeners.all)) {
if (item.closed || (to && id !== to)) {
continue;
}
item.write(msg);
item.write('\n');
}
}
if (this.#listeners[ev.type]) {
const msg = JSON.stringify(ev.payload);
for (const [id, item] of Object.entries(this.#listeners[ev.type]!)) {
if (item.closed || (to && id !== to)) {
continue;
}
item.write(msg);
item.write('\n');
}
}
}
async #disconnectActive(): Promise<void> {
log.info('Server disconnect requested');
this.info.iteration = Date.now();
await CommonUtil.blockingTimeout(20);
for (const el of this.#listenersAll) {
try { el.end(); } catch { }
}
this.#listeners = {}; // Ensure its empty
this.#listenersAll.clear();
}
async #clean(): Promise<{ clean: boolean }> {
await Promise.all([this.#ctx.build.compilerFolder, this.#ctx.build.outputFolder]
.map(f => fs.rm(CommonUtil.resolveWorkspace(this.#ctx, f), { recursive: true, force: true })));
return { clean: true };
}
/**
* Request handler
*/
async #onRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
res.setHeader('Content-Type', 'application/json');
const [, action, subAction] = new URL(`${this.#url}${req.url}`).pathname.split('/');
let out: unknown;
let close = false;
switch (action) {
case 'event': {
switch (subAction) {
case 'change': case 'log': case 'progress': case 'state': case 'all':
return this.#addListener(subAction, res);
default: return;
}
}
case 'clean': out = await this.#clean(); break;
case 'stop': out = JSON.stringify({ closing: true }); close = true; break;
case 'info':
default: out = this.info ?? {}; break;
}
res.end(JSON.stringify(out));
if (close) {
await this.close();
}
}
/**
* Process events
*/
async processEvents(src: (signal: AbortSignal) => AsyncIterable<CompilerEvent>): Promise<void> {
for await (const ev of CommonUtil.restartableEvents(src, this.signal, this.isResetEvent)) {
if (ev.type === 'progress') {
await Log.onProgressEvent(ev.payload);
}
this.#emitEvent(ev);
if (ev.type === 'state') {
this.info.state = ev.payload.state;
if (ev.payload.state === 'init' && ev.payload.extra && 'pid' in ev.payload.extra && typeof ev.payload.extra.pid === 'number') {
if (this.info.mode === 'watch' && !this.info.compilerPid) {
// Ensure we are killing in watch mode on first set
await this.#handle.compiler.kill();
}
this.info.compilerPid = ev.payload.extra.pid;
await this.#handle.compiler.writePid(this.info.compilerPid);
}
log.info(`State changed: ${this.info.state}`);
} else if (ev.type === 'log') {
log.render(ev.payload);
}
if (this.isResetEvent(ev)) {
await this.#disconnectActive();
}
}
// Terminate, after letting all remaining events emit
await this.close();
log.debug('Finished processing events');
}
/**
* Close server
*/
async close(): Promise<void> {
log.info('Closing down server');
// If we are in a place where progress exists
if (this.info.state === 'compile-start') {
const cancel: CompilerProgressEvent = { complete: true, idx: 0, total: 0, message: 'Complete', operation: 'compile' };
await Log.onProgressEvent(cancel);
this.#emitEvent({ type: 'progress', payload: cancel });
}
try {
await new Promise((resolve, reject) => {
CommonUtil.nonBlockingTimeout(2000).then(reject); // Wait 2s max
this.#server.close(resolve);
this.#emitEvent({ type: 'state', payload: { state: 'closed' } });
CommonUtil.queueMacroTask().then(() => {
this.#server.closeAllConnections();
this.#shutdown.abort();
});
});
} catch { // Timeout or other error
// Force shutdown
this.#server.closeAllConnections();
await this.#handle.compiler.kill();
}
log.info('Closed down server');
}
/**
* Start the server listening
*/
async listen(): Promise<CompilerServer | undefined> {
const running = await this.#tryListen() === 'ok';
log.info(running ? 'Starting server' : 'Server already running under a different process', this.#url);
return running ? this : undefined;
}
}