@travetto/compiler
Version:
The compiler infrastructure for the Travetto framework
173 lines (145 loc) • 5.73 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 { CompilerEvent, 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?: (ev: 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(rel: string, opts?: RequestInit & { timeout?: number }, logTimeout = true): Promise<{ ok: boolean, text: string }> {
const ctrl = new AbortController();
const timeoutCtrl = new AbortController();
opts?.signal?.addEventListener('abort', () => ctrl.abort());
timers.setTimeout(opts?.timeout ?? 100, undefined, { ref: false, signal: timeoutCtrl.signal })
.then(() => {
logTimeout && this.#log.error(`Timeout on request to ${this.#url}${rel}`);
ctrl.abort('TIMEOUT');
})
.catch(() => { });
const response = await fetch(`${this.#url}${rel}`, { ...opts, signal: ctrl.signal });
const out = { ok: response.ok, text: await response.text() };
timeoutCtrl.abort();
return out;
}
/** Get server information, if server is running */
info(): Promise<CompilerServerInfo | undefined> {
return this.#fetch('/info', { timeout: 200 }, false).then(v => JSON.parse(v.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(v => v.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(v => v.some(x => x));
}
await this.#fetch('/stop').catch(() => { }); // Trigger
this.#log.debug('Waiting for compiler to exit');
await this.#handle.compiler.ensureKilled();
return true;
}
/** Fetch compiler events */
fetchEvents<
V extends CompilerEventType,
T extends (CompilerEvent & { type: V })['payload']
>(type: V, cfg?: FetchEventsConfig<T>): AsyncIterable<T>;
fetchEvents(type: 'all', cfg?: FetchEventsConfig<CompilerEvent>): AsyncIterable<CompilerEvent>;
async * fetchEvents<T = unknown>(type: string, cfg: FetchEventsConfig<T> = {}): AsyncIterable<T> {
let info = await this.info();
if (!info) {
return;
}
this.#log.debug(`Starting watch for events of type "${type}"`);
let signal = cfg.signal;
// Ensure we capture end of process at least
if (!signal) {
const ctrl = new AbortController();
process.on('SIGINT', () => ctrl.abort());
signal = ctrl.signal;
}
const { iteration } = info;
for (; ;) {
const ctrl = new AbortController();
const quit = (): void => ctrl.abort();
try {
signal.addEventListener('abort', quit);
const response = await new Promise<http.IncomingMessage>((resolve, reject) =>
http.get(`${this.#url}/event/${type}`, { agent: streamAgent, signal: ctrl.signal }, resolve)
.on('error', reject)
);
for await (const line of rl.createInterface(response)) {
if (line.trim().charAt(0) === '{') {
const val: T = JSON.parse(line);
if (cfg.until?.(val)) {
await CommonUtil.queueMacroTask();
ctrl.abort();
}
yield val;
}
}
} catch (err) {
const aborted = ctrl.signal.aborted || (typeof err === 'object' && err && 'code' in err && err.code === 'ECONNRESET');
if (!aborted) { throw err; }
}
signal.removeEventListener('abort', quit);
await CommonUtil.queueMacroTask();
info = await this.info();
if (ctrl.signal.reason === 'TIMEOUT') {
this.#log.debug('Failed due to timeout');
return;
}
if (ctrl.signal.aborted || !info || (cfg.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: s => set.has(s.state) })) { }
this.#log.debug(`Found state, one of ${states.join(', ')} `);
if (message) {
this.#log.info(message);
}
}
}