@travetto/compiler
Version:
The compiler infrastructure for the Travetto framework
168 lines (140 loc) • 5.74 kB
text/typescript
import rl from 'node:readline/promises';
import timers from 'node:timers/promises';
import http, { Agent } from 'node:http';
import { ManifestContext } from '@travetto/manifest';
import type { CompilerEventPayload, CompilerEventType, CompilerServerInfo, CompilerStateType } from '../types.ts';
import type { LogShape } from '../log.ts';
import { CommonUtil } from '../util.ts';
import { ProcessHandle } from './process-handle.ts';
type FetchEventsConfig<T> = {
signal?: AbortSignal;
until?: (event: T) => boolean;
enforceIteration?: boolean;
};
const streamAgent = new Agent({
keepAlive: true,
keepAliveMsecs: 10000,
timeout: 1000 * 60 * 60 * 24
});
/**
* Compiler Client Operations
*/
export class CompilerClient {
#url: string;
#log: LogShape;
#handle: Record<'compiler' | 'server', ProcessHandle>;
constructor(ctx: ManifestContext, log: LogShape) {
this.#url = ctx.build.compilerUrl.replace('localhost', '127.0.0.1');
this.#log = log;
this.#handle = { compiler: new ProcessHandle(ctx, 'compiler'), server: new ProcessHandle(ctx, 'server') };
}
toString(): string {
return `[${this.constructor.name} url=${this.#url}]`;
}
get url(): string {
return this.#url;
}
async #fetch(urlPath: string, options?: RequestInit & { timeout?: number }, logTimeout = true): Promise<{ ok: boolean, text: string }> {
const controller = new AbortController();
const timeoutController = new AbortController();
options?.signal?.addEventListener('abort', () => controller.abort());
timers.setTimeout(options?.timeout ?? 100, undefined, { ref: false, signal: timeoutController.signal })
.then(() => {
logTimeout && this.#log.error(`Timeout on request to ${this.#url}${urlPath}`);
controller.abort('TIMEOUT');
})
.catch(() => { });
const response = await fetch(`${this.#url}${urlPath}`, { ...options, signal: controller.signal });
const out = { ok: response.ok, text: await response.text() };
timeoutController.abort();
return out;
}
/** Get server information, if server is running */
info(): Promise<CompilerServerInfo | undefined> {
return this.#fetch('/info', { timeout: 200 }, false).then(response => JSON.parse(response.text), () => undefined);
}
async isWatching(): Promise<boolean> {
return (await this.info())?.state === 'watch-start';
}
/** Clean the server */
clean(): Promise<boolean> {
return this.#fetch('/clean', { timeout: 300 }).then(response => response.ok, () => false);
}
/** Stop server and wait for shutdown */
async stop(): Promise<boolean> {
const info = await this.info();
if (!info) {
this.#log.debug('Stopping server, info not found, manual killing');
return Promise.all([this.#handle.server.kill(), this.#handle.compiler.kill()])
.then(results => results.some(result => result));
}
await this.#fetch('/stop').catch(() => { }); // Trigger
this.#log.debug('Waiting for compiler to exit');
await this.#handle.compiler.ensureKilled();
return true;
}
/** Fetch compiler events */
async * fetchEvents<V extends CompilerEventType, T extends CompilerEventPayload<V>>(type: V, config: FetchEventsConfig<T> = {}): AsyncIterable<T> {
let info = await this.info();
if (!info) {
return;
}
this.#log.debug(`Starting watch for events of type "${type}"`);
let signal = config.signal;
// Ensure we capture end of process at least
if (!signal) {
const controller = new AbortController();
process.on('SIGINT', () => controller.abort());
signal = controller.signal;
}
const { iteration } = info;
for (; ;) {
const controller = new AbortController();
const quit = (): void => controller.abort();
try {
signal.addEventListener('abort', quit);
const response = await new Promise<http.IncomingMessage>((resolve, reject) =>
http.get(`${this.#url}/event/${type}`, { agent: streamAgent, signal: controller.signal }, resolve)
.on('error', reject)
);
for await (const line of rl.createInterface(response)) {
if (line.trim().charAt(0) === '{') {
const event: T = JSON.parse(line);
if (config.until?.(event)) {
await CommonUtil.queueMacroTask();
controller.abort();
}
yield event;
}
}
} catch (error) {
const aborted = controller.signal.aborted || (typeof error === 'object' && error && 'code' in error && error.code === 'ECONNRESET');
if (!aborted) { throw error; }
}
signal.removeEventListener('abort', quit);
await CommonUtil.queueMacroTask();
info = await this.info();
if (controller.signal.reason === 'TIMEOUT') {
this.#log.debug('Failed due to timeout');
return;
}
if (controller.signal.aborted || !info || (config.enforceIteration && info.iteration !== iteration)) { // If health check fails, or aborted
this.#log.debug(`Stopping watch for events of type "${type}"`);
return;
} else {
this.#log.debug(`Restarting watch for events of type "${type}"`);
}
}
}
/** Wait for one of N states to be achieved */
async waitForState(states: CompilerStateType[], message?: string, signal?: AbortSignal): Promise<void> {
const set = new Set(states);
// Loop until
this.#log.debug(`Waiting for states, ${states.join(', ')}`);
for await (const _ of this.fetchEvents('state', { signal, until: event => set.has(event.state) })) { }
this.#log.debug(`Found state, one of ${states.join(', ')} `);
if (message) {
this.#log.info(message);
}
}
}