import fs from 'node:fs/promises'; import http, { Agent } from 'node:http'; import rl from 'node:readline/promises'; import timers from 'node:timers/promises'; import type { ManifestContext } from '@travetto/manifest'; import { CommonUtil } from '../common.ts'; import type { LogShape } from '../log.ts'; import type { CompilerEventPayload, CompilerEventType, CompilerServerInfo, CompilerStateType } from '../types.ts'; import { ProcessHandle } from './process-handle.ts'; type FetchEventsConfig = { 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>; #ctx: ManifestContext; constructor(ctx: ManifestContext, log: LogShape) { this.#ctx = ctx; 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 { return this.#fetch('/info', { timeout: 200 }, false).then( response => JSON.parse(response.text), () => undefined ); } async isWatching(): Promise { return (await this.info())?.state === 'watch-start'; } /** Clean the server */ async clean(forceOnFailure?: boolean): Promise { const result = await this.#fetch('/clean', { timeout: 300 }).then( response => response.ok, () => false ); if (!result && forceOnFailure) { this.#log.warn('Clean request failed, forcing cleanup'); try { await Promise.all( [this.#ctx.build.outputFolder, this.#ctx.build.typesFolder].map(file => fs.rm(CommonUtil.resolveWorkspace(this.#ctx, file), { force: true, recursive: true }) ) ); } catch {} } return result; } /** Stop server and wait for shutdown */ async stop(): Promise { 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>( type: V, config: FetchEventsConfig = {} ): AsyncIterable { 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((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 { 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); } } }