@boost/core
Version:
Robust pipeline for creating dev tools that separate logic into routines and tasks.
400 lines (399 loc) • 12.2 kB
JavaScript
"use strict";
/* eslint-disable no-console */
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const exit_1 = __importDefault(require("exit"));
const term_size_1 = __importDefault(require("term-size"));
const ansi_escapes_1 = __importDefault(require("ansi-escapes"));
const debug_1 = require("@boost/debug");
const event_1 = require("@boost/event");
const Emitter_1 = __importDefault(require("./Emitter"));
const SignalError_1 = __importDefault(require("./SignalError"));
// 8 FPS (60 FPS is actually too fast as it tears)
exports.FPS_RATE = 125;
// Bind our writable streams for easy access
exports.BOUND_WRITERS = {
stderr: process.stderr.write.bind(process.stderr),
stdout: process.stdout.write.bind(process.stdout),
};
exports.WRAPPED_STREAMS = {
stderr: false,
stdout: false,
};
class Console extends Emitter_1.default {
constructor(tool, /* test only */ testWriters = exports.BOUND_WRITERS) {
super();
this.bufferedStreams = [];
this.errorLogs = [];
this.logs = [];
this.outputQueue = [];
this.renderTimer = null;
this.state = {
disabled: false,
final: false,
started: false,
stopped: false,
};
/**
* Handle uncaught exceptions and unhandled rejections that bubble up.
*/
this.handleFailure = (error) => {
this.start();
this.debug('Uncaught exception or unresolved promise handled');
this.stop(error);
exit_1.default(2);
};
/**
* Handle SIGINT and SIGTERM interruptions.
*/
this.handleSignal = (signal) => {
this.start();
this.debug('SIGINT or SIGTERM handled');
this.stop(new SignalError_1.default(this.tool.msg('errors:processTerminated'), signal));
exit_1.default(2);
};
this.debug = debug_1.createDebugger('console');
this.tool = tool;
this.writers = testWriters;
this.onError = new event_1.Event('error');
this.onRoutine = new event_1.Event('routine');
this.onRoutines = new event_1.Event('routines');
this.onStart = new event_1.Event('start');
this.onStop = new event_1.Event('stop');
this.onTask = new event_1.Event('task');
this.onTasks = new event_1.Event('tasks');
// istanbul ignore next
if (process.env.NODE_ENV !== 'test') {
process
.on('SIGINT', this.handleSignal)
.on('SIGTERM', this.handleSignal)
.on('uncaughtException', this.handleFailure)
// @ts-ignore
.on('unhandledRejection', this.handleFailure);
}
}
/**
* Disable the render loop entirely.
*/
disable() {
this.state.disabled = true;
return this;
}
/**
* Display a footer after all output.
*/
displayFooter() {
const { footer } = this.tool.options;
if (footer) {
this.out(footer, 1);
}
}
/**
* Display a header before all output.
*/
displayHeader() {
const { header } = this.tool.options;
if (header) {
this.out(header, 1);
}
}
/**
* Enable the render loop.
*/
enable() {
this.state.disabled = false;
return this;
}
/**
* Write a message to `stderr` with optional trailing newline(s).
*/
err(message, nl = 0) {
if (!this.isSilent()) {
this.writers.stderr(message + '\n'.repeat(nl));
}
return this;
}
/**
* Flush buffered stream output.
*/
flushBufferedStreams() {
this.bufferedStreams.forEach(buffer => {
buffer();
});
return this;
}
/**
* Flush the top output block in the queue.
*/
flushOutputQueue() {
const outputs = this.outputQueue.filter((out, i) => i === 0 || out.isConcurrent());
// Erase the previous output
outputs.forEach(output => {
output.erasePrevious();
});
// Write buffered output
this.flushBufferedStreams();
// Write the next output
outputs.forEach(output => {
output.render();
});
// Remove completed outputs
this.outputQueue = this.outputQueue.filter(out => !out.isComplete());
// Stop the render loop once the queue is empty
if (this.isEmptyQueue()) {
this.stopRenderLoop();
}
else {
this.startRenderLoop();
}
return this;
}
/**
* Hide the console cursor.
*/
hideCursor() {
console.warn('Console#hideCursor is deprecated. Use Reporter#hideCursor instead.');
this.out(ansi_escapes_1.default.cursorHide);
return this;
}
/**
* Return true if the render loop has been disabled.
*/
isDisabled() {
return this.state.disabled;
}
/**
* Return true if the output queue is empty.
*/
isEmptyQueue() {
return this.outputQueue.length === 0;
}
/**
* Return true if the final render.
*/
isFinalRender() {
return this.state.final;
}
/**
* Return true if the there should be no output.
*/
isSilent() {
// Check all objects since these might not have been loaded yet
return this.tool && this.tool.config && this.tool.config.silent;
}
/**
* Return true if the defined stream has been wrapped by the console layer.
*/
isStreamWrapped(type) {
return !!exports.WRAPPED_STREAMS[type];
}
/**
* Log a message to display on success during the final render.
*/
log(message) {
this.logs.push(message);
return this;
}
/**
* Log a live message to display during the rendering process.
*/
logLive(message) {
console.warn('Console#logLive is deprecated. Use console.log instead.');
// Write to the wrapped buffer
process.stdout.write(message);
return this;
}
/**
* Log an error message to display on failure during the final render.
*/
logError(message) {
this.errorLogs.push(message);
return this;
}
/**
* Write a message to `stdout` with optional trailing newline(s).
*/
out(message, nl = 0) {
if (!this.isSilent()) {
this.writers.stdout(message + '\n'.repeat(nl));
}
return this;
}
/**
* Enqueue a block of output to be rendered.
*/
render(output) {
if (this.isDisabled()) {
throw new Error('Output cannot be enqueued as the render loop has been disabled. This is usually caused by conflicting reporters.');
}
if (!this.outputQueue.includes(output)) {
this.outputQueue.push(output);
}
// Only run the render loop when output is enqueued
this.startRenderLoop();
return this;
}
/**
* Handle the final rendering of all output before stopping.
*/
renderFinalOutput(error) {
this.debug('Rendering final console output');
this.state.final = true;
// Mark all output as final
this.outputQueue.forEach(output => {
output.enqueue(true);
});
// Recursively render the remaining output
while (!this.isEmptyQueue()) {
this.flushOutputQueue();
}
// Stop the render loop
this.stopRenderLoop();
if (error) {
if (this.errorLogs.length > 0) {
this.err(`\n${this.errorLogs.join('\n')}\n`);
}
this.emit('error', [error]);
this.onError.emit([error]);
}
else {
if (this.logs.length > 0) {
this.out(`\n${this.logs.join('\n')}\n`);
}
this.displayFooter();
}
// Flush any stream output that still exists
this.flushBufferedStreams();
// Show the cursor incase it has been hidden
this.out(ansi_escapes_1.default.cursorShow);
}
/**
* Reset the cursor back to the bottom of the console.
*/
resetCursor() {
console.warn('Console#resetCursor is deprecated. Use Reporter#resetCursor instead.');
this.out(ansi_escapes_1.default.cursorTo(0, term_size_1.default().rows));
return this;
}
/**
* Show the console cursor.
*/
showCursor() {
console.warn('Console#showCursor is deprecated. Use Reporter#showCursor instead.');
this.out(ansi_escapes_1.default.cursorShow);
return this;
}
/**
* Start the console by wrapping streams and buffering output.
*/
start(args = []) {
if (this.state.started) {
return this;
}
this.debug('Starting console render loop');
this.emit('start', args);
this.onStart.emit(args);
this.wrapStreams();
this.displayHeader();
this.state.started = true;
return this;
}
/**
* Automatically render the console in a timeout loop at 8 FPS.
*/
startRenderLoop() {
if (this.isSilent() || this.isDisabled() || this.renderTimer) {
return;
}
this.renderTimer = setTimeout(() => {
this.renderTimer = null;
this.flushOutputQueue();
}, exports.FPS_RATE);
}
/**
* Stop the console rendering process.
*/
stop(error = null) {
if (this.state.stopped) {
return;
}
if (error) {
this.debug('Stopping console with an error');
this.errorLogs.push(...this.logs);
this.logs = [];
}
else {
this.debug('Stopping console render loop');
}
this.renderFinalOutput(error);
this.unwrapStreams();
this.emit('stop', [error]);
this.onStop.emit([error]);
this.state.stopped = true;
this.state.started = false;
}
/**
* Stop the background render loop.
*/
stopRenderLoop() {
if (this.renderTimer) {
clearTimeout(this.renderTimer);
this.renderTimer = null;
}
}
/**
* Unwrap the native console and reset it back to normal.
*/
unwrapStreams() {
if (this.isSilent() || process.env.NODE_ENV === 'test') {
return;
}
['stderr', 'stdout'].forEach(key => {
const name = key;
if (!this.isStreamWrapped(name)) {
return;
}
this.debug('Unwrapping `%s` stream', name);
process[name].write = this.writers[name];
exports.WRAPPED_STREAMS[name] = false;
});
}
/**
* Wrap the `stdout` and `stderr` streams and buffer the output as
* to not collide with our render loop.
*/
wrapStreams() {
if (this.isSilent() || this.isDisabled() || process.env.NODE_ENV === 'test') {
return;
}
['stderr', 'stdout'].forEach(key => {
const name = key;
const stream = process[name];
let buffer = '';
if (this.isStreamWrapped(name)) {
return;
}
this.debug('Wrapping `%s` stream', name);
this.bufferedStreams.push(() => {
if (stream.isTTY && buffer) {
this.writers[name](buffer);
}
buffer = '';
});
stream.write = (chunk) => {
// No output, display immediately
if (this.isEmptyQueue()) {
this.writers[name](chunk);
}
else {
buffer += String(chunk);
}
return true;
};
exports.WRAPPED_STREAMS[name] = true;
});
}
}
exports.default = Console;