UNPKG

@jsenv/core

Version:

Tool to develop, test and build js projects

1,551 lines (1,393 loc) 40.4 kB
import { createSupportsColor, isUnicodeSupported, emojiRegex, eastAsianWidth, clearTerminal, eraseLines } from "./jsenv_core_node_modules.js"; import { stripVTControlCharacters } from "node:util"; import { pathToFileURL } from "node:url"; const createCallbackListNotifiedOnce = () => { let callbacks = []; let status = "waiting"; let currentCallbackIndex = -1; const callbackListOnce = {}; const add = (callback) => { if (status !== "waiting") { emitUnexpectedActionWarning({ action: "add", status }); return removeNoop; } if (typeof callback !== "function") { throw new Error(`callback must be a function, got ${callback}`); } // don't register twice const existingCallback = callbacks.find((callbackCandidate) => { return callbackCandidate === callback; }); if (existingCallback) { emitCallbackDuplicationWarning(); return removeNoop; } callbacks.push(callback); return () => { if (status === "notified") { // once called removing does nothing // as the callbacks array is frozen to null return; } const index = callbacks.indexOf(callback); if (index === -1) { return; } if (status === "looping") { if (index <= currentCallbackIndex) { // The callback was already called (or is the current callback) // We don't want to mutate the callbacks array // or it would alter the looping done in "call" and the next callback // would be skipped return; } // Callback is part of the next callback to call, // we mutate the callbacks array to prevent this callback to be called } callbacks.splice(index, 1); }; }; const notify = (param) => { if (status !== "waiting") { emitUnexpectedActionWarning({ action: "call", status }); return []; } status = "looping"; const values = callbacks.map((callback, index) => { currentCallbackIndex = index; return callback(param); }); callbackListOnce.notified = true; status = "notified"; // we reset callbacks to null after looping // so that it's possible to remove during the loop callbacks = null; currentCallbackIndex = -1; return values; }; callbackListOnce.notified = false; callbackListOnce.add = add; callbackListOnce.notify = notify; return callbackListOnce; }; const emitUnexpectedActionWarning = ({ action, status }) => { if (typeof process.emitWarning === "function") { process.emitWarning( `"${action}" should not happen when callback list is ${status}`, { CODE: "UNEXPECTED_ACTION_ON_CALLBACK_LIST", detail: `Code is potentially executed when it should not`, }, ); } else { console.warn( `"${action}" should not happen when callback list is ${status}`, ); } }; const emitCallbackDuplicationWarning = () => { if (typeof process.emitWarning === "function") { process.emitWarning(`Trying to add a callback already in the list`, { CODE: "CALLBACK_DUPLICATION", detail: `Code is potentially executed more than it should`, }); } else { console.warn(`Trying to add same callback twice`); } }; const removeNoop = () => {}; /* * See callback_race.md */ const raceCallbacks = (raceDescription, winnerCallback) => { let cleanCallbacks = []; let status = "racing"; const clean = () => { cleanCallbacks.forEach((clean) => { clean(); }); cleanCallbacks = null; }; const cancel = () => { if (status !== "racing") { return; } status = "cancelled"; clean(); }; Object.keys(raceDescription).forEach((candidateName) => { const register = raceDescription[candidateName]; const returnValue = register((data) => { if (status !== "racing") { return; } status = "done"; clean(); winnerCallback({ name: candidateName, data, }); }); if (typeof returnValue === "function") { cleanCallbacks.push(returnValue); } }); return cancel; }; /* * https://github.com/whatwg/dom/issues/920 */ const Abort = { isAbortError: (error) => { return error && error.name === "AbortError"; }, startOperation: () => { return createOperation(); }, throwIfAborted: (signal) => { if (signal.aborted) { const error = new Error(`The operation was aborted`); error.name = "AbortError"; error.type = "aborted"; throw error; } }, }; const createOperation = () => { const operationAbortController = new AbortController(); // const abortOperation = (value) => abortController.abort(value) const operationSignal = operationAbortController.signal; // abortCallbackList is used to ignore the max listeners warning from Node.js // this warning is useful but becomes problematic when it's expect // (a function doing 20 http call in parallel) // To be 100% sure we don't have memory leak, only Abortable.asyncCallback // uses abortCallbackList to know when something is aborted const abortCallbackList = createCallbackListNotifiedOnce(); const endCallbackList = createCallbackListNotifiedOnce(); let isAbortAfterEnd = false; operationSignal.onabort = () => { operationSignal.onabort = null; const allAbortCallbacksPromise = Promise.all(abortCallbackList.notify()); if (!isAbortAfterEnd) { addEndCallback(async () => { await allAbortCallbacksPromise; }); } }; const throwIfAborted = () => { Abort.throwIfAborted(operationSignal); }; // add a callback called on abort // differences with signal.addEventListener('abort') // - operation.end awaits the return value of this callback // - It won't increase the count of listeners for "abort" that would // trigger max listeners warning when count > 10 const addAbortCallback = (callback) => { // It would be painful and not super redable to check if signal is aborted // before deciding if it's an abort or end callback // with pseudo-code below where we want to stop server either // on abort or when ended because signal is aborted // operation[operation.signal.aborted ? 'addAbortCallback': 'addEndCallback'](async () => { // await server.stop() // }) if (operationSignal.aborted) { return addEndCallback(callback); } return abortCallbackList.add(callback); }; const addEndCallback = (callback) => { return endCallbackList.add(callback); }; const end = async ({ abortAfterEnd = false } = {}) => { await Promise.all(endCallbackList.notify()); // "abortAfterEnd" can be handy to ensure "abort" callbacks // added with { once: true } are removed // It might also help garbage collection because // runtime implementing AbortSignal (Node.js, browsers) can consider abortSignal // as settled and clean up things if (abortAfterEnd) { // because of operationSignal.onabort = null // + abortCallbackList.clear() this won't re-call // callbacks if (!operationSignal.aborted) { isAbortAfterEnd = true; operationAbortController.abort(); } } }; const addAbortSignal = ( signal, { onAbort = callbackNoop, onRemove = callbackNoop } = {}, ) => { const applyAbortEffects = () => { const onAbortCallback = onAbort; onAbort = callbackNoop; onAbortCallback(); }; const applyRemoveEffects = () => { const onRemoveCallback = onRemove; onRemove = callbackNoop; onAbort = callbackNoop; onRemoveCallback(); }; if (operationSignal.aborted) { applyAbortEffects(); applyRemoveEffects(); return callbackNoop; } if (signal.aborted) { operationAbortController.abort(); applyAbortEffects(); applyRemoveEffects(); return callbackNoop; } const cancelRace = raceCallbacks( { operation_abort: (cb) => { return addAbortCallback(cb); }, operation_end: (cb) => { return addEndCallback(cb); }, child_abort: (cb) => { return addEventListener(signal, "abort", cb); }, }, (winner) => { const raceEffects = { // Both "operation_abort" and "operation_end" // means we don't care anymore if the child aborts. // So we can: // - remove "abort" event listener on child (done by raceCallback) // - remove abort callback on operation (done by raceCallback) // - remove end callback on operation (done by raceCallback) // - call any custom cancel function operation_abort: () => { applyAbortEffects(); applyRemoveEffects(); }, operation_end: () => { // Exists to // - remove abort callback on operation // - remove "abort" event listener on child // - call any custom cancel function applyRemoveEffects(); }, child_abort: () => { applyAbortEffects(); operationAbortController.abort(); }, }; raceEffects[winner.name](winner.value); }, ); return () => { cancelRace(); applyRemoveEffects(); }; }; const addAbortSource = (abortSourceCallback) => { const abortSource = { cleaned: false, signal: null, remove: callbackNoop, }; const abortSourceController = new AbortController(); const abortSourceSignal = abortSourceController.signal; abortSource.signal = abortSourceSignal; if (operationSignal.aborted) { return abortSource; } const returnValue = abortSourceCallback((value) => { abortSourceController.abort(value); }); const removeAbortSignal = addAbortSignal(abortSourceSignal, { onRemove: () => { if (typeof returnValue === "function") { returnValue(); } abortSource.cleaned = true; }, }); abortSource.remove = removeAbortSignal; return abortSource; }; const timeout = (ms) => { return addAbortSource((abort) => { const timeoutId = setTimeout(abort, ms); // an abort source return value is called when: // - operation is aborted (by an other source) // - operation ends return () => { clearTimeout(timeoutId); }; }); }; const wait = (ms) => { return new Promise((resolve) => { const timeoutId = setTimeout(() => { removeAbortCallback(); resolve(); }, ms); const removeAbortCallback = addAbortCallback(() => { clearTimeout(timeoutId); }); }); }; const withSignal = async (asyncCallback) => { const abortController = new AbortController(); const signal = abortController.signal; const removeAbortSignal = addAbortSignal(signal, { onAbort: () => { abortController.abort(); }, }); try { const value = await asyncCallback(signal); removeAbortSignal(); return value; } catch (e) { removeAbortSignal(); throw e; } }; const withSignalSync = (callback) => { const abortController = new AbortController(); const signal = abortController.signal; const removeAbortSignal = addAbortSignal(signal, { onAbort: () => { abortController.abort(); }, }); try { const value = callback(signal); removeAbortSignal(); return value; } catch (e) { removeAbortSignal(); throw e; } }; const fork = () => { const forkedOperation = createOperation(); forkedOperation.addAbortSignal(operationSignal); return forkedOperation; }; return { // We could almost hide the operationSignal // But it can be handy for 2 things: // - know if operation is aborted (operation.signal.aborted) // - forward the operation.signal directly (not using "withSignal" or "withSignalSync") signal: operationSignal, throwIfAborted, addAbortCallback, addAbortSignal, addAbortSource, fork, timeout, wait, withSignal, withSignalSync, addEndCallback, end, }; }; const callbackNoop = () => {}; const addEventListener = (target, eventName, cb) => { target.addEventListener(eventName, cb); return () => { target.removeEventListener(eventName, cb); }; }; const raceProcessTeardownEvents = (processTeardownEvents, callback) => { return raceCallbacks( { ...(processTeardownEvents.SIGHUP ? SIGHUP_CALLBACK : {}), ...(processTeardownEvents.SIGTERM ? SIGTERM_CALLBACK : {}), ...(SIGINT_CALLBACK ), ...(processTeardownEvents.beforeExit ? BEFORE_EXIT_CALLBACK : {}), ...(processTeardownEvents.exit ? EXIT_CALLBACK : {}), }, callback, ); }; const SIGHUP_CALLBACK = { SIGHUP: (cb) => { process.on("SIGHUP", cb); return () => { process.removeListener("SIGHUP", cb); }; }, }; const SIGTERM_CALLBACK = { SIGTERM: (cb) => { process.on("SIGTERM", cb); return () => { process.removeListener("SIGTERM", cb); }; }, }; const BEFORE_EXIT_CALLBACK = { beforeExit: (cb) => { process.on("beforeExit", cb); return () => { process.removeListener("beforeExit", cb); }; }, }; const EXIT_CALLBACK = { exit: (cb) => { process.on("exit", cb); return () => { process.removeListener("exit", cb); }; }, }; const SIGINT_CALLBACK = { SIGINT: (cb) => { process.on("SIGINT", cb); return () => { process.removeListener("SIGINT", cb); }; }, }; // https://github.com/Marak/colors.js/blob/master/lib/styles.js // https://stackoverflow.com/a/75985833/2634179 const RESET = "\x1b[0m"; const RED = "red"; const GREEN = "green"; const YELLOW = "yellow"; const BLUE = "blue"; const MAGENTA = "magenta"; const CYAN = "cyan"; const GREY = "grey"; const WHITE = "white"; const BLACK = "black"; const TEXT_COLOR_ANSI_CODES = { [RED]: "\x1b[31m", [GREEN]: "\x1b[32m", [YELLOW]: "\x1b[33m", [BLUE]: "\x1b[34m", [MAGENTA]: "\x1b[35m", [CYAN]: "\x1b[36m", [GREY]: "\x1b[90m", [WHITE]: "\x1b[37m", [BLACK]: "\x1b[30m", }; const BACKGROUND_COLOR_ANSI_CODES = { [RED]: "\x1b[41m", [GREEN]: "\x1b[42m", [YELLOW]: "\x1b[43m", [BLUE]: "\x1b[44m", [MAGENTA]: "\x1b[45m", [CYAN]: "\x1b[46m", [GREY]: "\x1b[100m", [WHITE]: "\x1b[47m", [BLACK]: "\x1b[40m", }; const createAnsi = ({ supported }) => { const ANSI = { supported, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, GREY, WHITE, BLACK, color: (text, color) => { if (!ANSI.supported) { return text; } if (!color) { return text; } if (typeof text === "string" && text.trim() === "") { // cannot set color of blank chars return text; } const ansiEscapeCodeForTextColor = TEXT_COLOR_ANSI_CODES[color]; if (!ansiEscapeCodeForTextColor) { return text; } return `${ansiEscapeCodeForTextColor}${text}${RESET}`; }, backgroundColor: (text, color) => { if (!ANSI.supported) { return text; } if (!color) { return text; } if (typeof text === "string" && text.trim() === "") { // cannot set background color of blank chars return text; } const ansiEscapeCodeForBackgroundColor = BACKGROUND_COLOR_ANSI_CODES[color]; if (!ansiEscapeCodeForBackgroundColor) { return text; } return `${ansiEscapeCodeForBackgroundColor}${text}${RESET}`; }, BOLD: "\x1b[1m", UNDERLINE: "\x1b[4m", STRIKE: "\x1b[9m", effect: (text, effect) => { if (!ANSI.supported) { return text; } if (!effect) { return text; } // cannot add effect to empty string if (text === "") { return text; } const ansiEscapeCodeForEffect = effect; return `${ansiEscapeCodeForEffect}${text}${RESET}`; }, }; return ANSI; }; const processSupportsBasicColor = createSupportsColor(process.stdout).hasBasic; const ANSI = createAnsi({ supported: process.env.FORCE_COLOR === "1" || processSupportsBasicColor || // GitHub workflow does support ANSI but "supports-color" returns false // because stream.isTTY returns false, see https://github.com/actions/runner/issues/241 process.env.GITHUB_WORKFLOW, }); // see also https://github.com/sindresorhus/figures const createUnicode = ({ supported, ANSI }) => { const UNICODE = { supported, get COMMAND_RAW() { return UNICODE.supported ? `❯` : `>`; }, get OK_RAW() { return UNICODE.supported ? `✔` : `√`; }, get FAILURE_RAW() { return UNICODE.supported ? `✖` : `×`; }, get DEBUG_RAW() { return UNICODE.supported ? `◆` : `♦`; }, get INFO_RAW() { return UNICODE.supported ? `ℹ` : `i`; }, get WARNING_RAW() { return UNICODE.supported ? `⚠` : `‼`; }, get CIRCLE_CROSS_RAW() { return UNICODE.supported ? `ⓧ` : `(×)`; }, get CIRCLE_DOTTED_RAW() { return UNICODE.supported ? `◌` : `*`; }, get COMMAND() { return ANSI.color(UNICODE.COMMAND_RAW, ANSI.GREY); // ANSI_MAGENTA) }, get OK() { return ANSI.color(UNICODE.OK_RAW, ANSI.GREEN); }, get FAILURE() { return ANSI.color(UNICODE.FAILURE_RAW, ANSI.RED); }, get DEBUG() { return ANSI.color(UNICODE.DEBUG_RAW, ANSI.GREY); }, get INFO() { return ANSI.color(UNICODE.INFO_RAW, ANSI.BLUE); }, get WARNING() { return ANSI.color(UNICODE.WARNING_RAW, ANSI.YELLOW); }, get CIRCLE_CROSS() { return ANSI.color(UNICODE.CIRCLE_CROSS_RAW, ANSI.RED); }, get ELLIPSIS() { return UNICODE.supported ? `…` : `...`; }, }; return UNICODE; }; const UNICODE = createUnicode({ supported: process.env.FORCE_UNICODE === "1" || isUnicodeSupported(), ANSI, }); const setRoundedPrecision = ( number, { decimals = 1, decimalsWhenSmall = decimals } = {}, ) => { return setDecimalsPrecision(number, { decimals, decimalsWhenSmall, transform: Math.round, }); }; const setPrecision = ( number, { decimals = 1, decimalsWhenSmall = decimals } = {}, ) => { return setDecimalsPrecision(number, { decimals, decimalsWhenSmall, transform: parseInt, }); }; const setDecimalsPrecision = ( number, { transform, decimals, // max decimals for number in [-Infinity, -1[]1, Infinity] decimalsWhenSmall, // max decimals for number in [-1,1] } = {}, ) => { if (number === 0) { return 0; } let numberCandidate = Math.abs(number); if (numberCandidate < 1) { const integerGoal = Math.pow(10, decimalsWhenSmall - 1); let i = 1; while (numberCandidate < integerGoal) { numberCandidate *= 10; i *= 10; } const asInteger = transform(numberCandidate); const asFloat = asInteger / i; return number < 0 ? -asFloat : asFloat; } const coef = Math.pow(10, decimals); const numberMultiplied = (number + Number.EPSILON) * coef; const asInteger = transform(numberMultiplied); const asFloat = asInteger / coef; return number < 0 ? -asFloat : asFloat; }; // https://www.codingem.com/javascript-how-to-limit-decimal-places/ // export const roundNumber = (number, maxDecimals) => { // const decimalsExp = Math.pow(10, maxDecimals) // const numberRoundInt = Math.round(decimalsExp * (number + Number.EPSILON)) // const numberRoundFloat = numberRoundInt / decimalsExp // return numberRoundFloat // } // export const setPrecision = (number, precision) => { // if (Math.floor(number) === number) return number // const [int, decimals] = number.toString().split(".") // if (precision <= 0) return int // const numberTruncated = `${int}.${decimals.slice(0, precision)}` // return numberTruncated // } const unitShort = { year: "y", month: "m", week: "w", day: "d", hour: "h", minute: "m", second: "s", }; const humanizeDuration = ( ms, { short, rounded = true, decimals } = {}, ) => { // ignore ms below meaningfulMs so that: // humanizeDuration(0.5) -> "0 second" // humanizeDuration(1.1) -> "0.001 second" (and not "0.0011 second") // This tool is meant to be read by humans and it would be barely readable to see // "0.0001 second" (stands for 0.1 millisecond) // yes we could return "0.1 millisecond" but we choosed consistency over precision // so that the prefered unit is "second" (and does not become millisecond when ms is super small) if (ms < 1) { return short ? "0s" : "0 second"; } const { primary, remaining } = parseMs(ms); if (!remaining) { return humanizeDurationUnit(primary, { decimals: decimals === undefined ? (primary.name === "second" ? 1 : 0) : decimals, short, rounded, }); } return `${humanizeDurationUnit(primary, { decimals: decimals === undefined ? 0 : decimals, short, rounded, })} and ${humanizeDurationUnit(remaining, { decimals: decimals === undefined ? 0 : decimals, short, rounded, })}`; }; const humanizeDurationUnit = (unit, { decimals, short, rounded }) => { const count = rounded ? setRoundedPrecision(unit.count, { decimals }) : setPrecision(unit.count, { decimals }); let name = unit.name; if (short) { name = unitShort[name]; return `${count}${name}`; } if (count <= 1) { return `${count} ${name}`; } return `${count} ${name}s`; }; const MS_PER_UNITS = { year: 31_557_600_000, month: 2_629_000_000, week: 604_800_000, day: 86_400_000, hour: 3_600_000, minute: 60_000, second: 1000, }; const parseMs = (ms) => { const unitNames = Object.keys(MS_PER_UNITS); const smallestUnitName = unitNames[unitNames.length - 1]; let firstUnitName = smallestUnitName; let firstUnitCount = ms / MS_PER_UNITS[smallestUnitName]; const firstUnitIndex = unitNames.findIndex((unitName) => { if (unitName === smallestUnitName) { return false; } const msPerUnit = MS_PER_UNITS[unitName]; const unitCount = Math.floor(ms / msPerUnit); if (unitCount) { firstUnitName = unitName; firstUnitCount = unitCount; return true; } return false; }); if (firstUnitName === smallestUnitName) { return { primary: { name: firstUnitName, count: firstUnitCount, }, }; } const remainingMs = ms - firstUnitCount * MS_PER_UNITS[firstUnitName]; const remainingUnitName = unitNames[firstUnitIndex + 1]; const remainingUnitCount = remainingMs / MS_PER_UNITS[remainingUnitName]; // - 1 year and 1 second is too much information // so we don't check the remaining units // - 1 year and 0.0001 week is awful // hence the if below if (Math.round(remainingUnitCount) < 1) { return { primary: { name: firstUnitName, count: firstUnitCount, }, }; } // - 1 year and 1 month is great return { primary: { name: firstUnitName, count: firstUnitCount, }, remaining: { name: remainingUnitName, count: remainingUnitCount, }, }; }; const LOG_LEVEL_OFF = "off"; const LOG_LEVEL_DEBUG = "debug"; const LOG_LEVEL_INFO = "info"; const LOG_LEVEL_WARN = "warn"; const LOG_LEVEL_ERROR = "error"; const createLogger = ({ logLevel = LOG_LEVEL_INFO } = {}) => { if (logLevel === LOG_LEVEL_DEBUG) { return { level: "debug", levels: { debug: true, info: true, warn: true, error: true }, debug, info, warn, error, }; } if (logLevel === LOG_LEVEL_INFO) { return { level: "info", levels: { debug: false, info: true, warn: true, error: true }, debug: debugDisabled, info, warn, error, }; } if (logLevel === LOG_LEVEL_WARN) { return { level: "warn", levels: { debug: false, info: false, warn: true, error: true }, debug: debugDisabled, info: infoDisabled, warn, error, }; } if (logLevel === LOG_LEVEL_ERROR) { return { level: "error", levels: { debug: false, info: false, warn: false, error: true }, debug: debugDisabled, info: infoDisabled, warn: warnDisabled, error, }; } if (logLevel === LOG_LEVEL_OFF) { return { level: "off", levels: { debug: false, info: false, warn: false, error: false }, debug: debugDisabled, info: infoDisabled, warn: warnDisabled, error: errorDisabled, }; } throw new Error(`unexpected logLevel. --- logLevel --- ${logLevel} --- allowed log levels --- ${LOG_LEVEL_OFF} ${LOG_LEVEL_ERROR} ${LOG_LEVEL_WARN} ${LOG_LEVEL_INFO} ${LOG_LEVEL_DEBUG}`); }; const debug = (...args) => console.debug(...args); const debugDisabled = () => {}; const info = (...args) => console.info(...args); const infoDisabled = () => {}; const warn = (...args) => console.warn(...args); const warnDisabled = () => {}; const error = (...args) => console.error(...args); const errorDisabled = () => {}; const createMeasureTextWidth = ({ stripAnsi }) => { const segmenter = new Intl.Segmenter(); const defaultIgnorableCodePointRegex = /^\p{Default_Ignorable_Code_Point}$/u; const measureTextWidth = ( string, { ambiguousIsNarrow = true, countAnsiEscapeCodes = false, skipEmojis = false, } = {}, ) => { if (typeof string !== "string" || string.length === 0) { return 0; } if (!countAnsiEscapeCodes) { string = stripAnsi(string); } if (string.length === 0) { return 0; } let width = 0; const eastAsianWidthOptions = { ambiguousAsWide: !ambiguousIsNarrow }; for (const { segment: character } of segmenter.segment(string)) { const codePoint = character.codePointAt(0); // Ignore control characters if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) { continue; } // Ignore zero-width characters if ( (codePoint >= 0x20_0b && codePoint <= 0x20_0f) || // Zero-width space, non-joiner, joiner, left-to-right mark, right-to-left mark codePoint === 0xfe_ff // Zero-width no-break space ) { continue; } // Ignore combining characters if ( (codePoint >= 0x3_00 && codePoint <= 0x3_6f) || // Combining diacritical marks (codePoint >= 0x1a_b0 && codePoint <= 0x1a_ff) || // Combining diacritical marks extended (codePoint >= 0x1d_c0 && codePoint <= 0x1d_ff) || // Combining diacritical marks supplement (codePoint >= 0x20_d0 && codePoint <= 0x20_ff) || // Combining diacritical marks for symbols (codePoint >= 0xfe_20 && codePoint <= 0xfe_2f) // Combining half marks ) { continue; } // Ignore surrogate pairs if (codePoint >= 0xd8_00 && codePoint <= 0xdf_ff) { continue; } // Ignore variation selectors if (codePoint >= 0xfe_00 && codePoint <= 0xfe_0f) { continue; } // This covers some of the above cases, but we still keep them for performance reasons. if (defaultIgnorableCodePointRegex.test(character)) { continue; } if (!skipEmojis && emojiRegex().test(character)) { if (process.env.CAPTURING_SIDE_EFFECTS) { if (character === "✔️") { width += 2; continue; } } width += measureTextWidth(character, { skipEmojis: true, countAnsiEscapeCodes: true, // to skip call to stripAnsi }); continue; } width += eastAsianWidth(codePoint, eastAsianWidthOptions); } return width; }; return measureTextWidth; }; const measureTextWidth = createMeasureTextWidth({ stripAnsi: stripVTControlCharacters, }); /* * see also https://github.com/vadimdemedes/ink */ const createDynamicLog = ({ stream = process.stdout, clearTerminalAllowed, onVerticalOverflow = () => {}, onWriteFromOutside = () => {}, } = {}) => { const { columns = 80, rows = 24 } = stream; const dynamicLog = { destroyed: false, onVerticalOverflow, onWriteFromOutside, }; let lastOutput = ""; let lastOutputFromOutside = ""; let clearAttemptResult; let writing = false; const getErasePreviousOutput = () => { // nothing to clear if (!lastOutput) { return ""; } if (clearAttemptResult !== undefined) { return ""; } const logLines = lastOutput.split(/\r\n|\r|\n/); let visualLineCount = 0; for (const logLine of logLines) { const width = measureTextWidth(logLine); if (width === 0) { visualLineCount++; } else { visualLineCount += Math.ceil(width / columns); } } if (visualLineCount > rows) { if (clearTerminalAllowed) { clearAttemptResult = true; return clearTerminal; } // the whole log cannot be cleared because it's vertically to long // (longer than terminal height) // readline.moveCursor cannot move cursor higher than screen height // it means we would only clear the visible part of the log // better keep the log untouched clearAttemptResult = false; dynamicLog.onVerticalOverflow(); return ""; } clearAttemptResult = true; return eraseLines(visualLineCount); }; const update = (string) => { if (dynamicLog.destroyed) { throw new Error("Cannot write log after destroy"); } let stringToWrite = string; if (lastOutput) { if (lastOutputFromOutside) { // We don't want to clear logs written by other code, // it makes output unreadable and might erase precious information // To detect this we put a spy on the stream. // The spy is required only if we actually wrote something in the stream // something else than this code has written in the stream // so we just write without clearing (append instead of replacing) lastOutput = ""; lastOutputFromOutside = ""; } else { stringToWrite = `${getErasePreviousOutput()}${string}`; } } writing = true; stream.write(stringToWrite); lastOutput = string; writing = false; clearAttemptResult = undefined; }; const clearDuringFunctionCall = ( callback, ouputAfterCallback = lastOutput, ) => { // 1. Erase the current log // 2. Call callback (expect to write something on stdout) // 3. Restore the current log // During step 2. we expect a "write from outside" so we uninstall // the stream spy during function call update(""); writing = true; callback(update); lastOutput = ""; writing = false; update(ouputAfterCallback); }; const writeFromOutsideEffect = (value) => { if (!lastOutput) { // we don't care if the log never wrote anything // or if last update() wrote an empty string return; } if (writing) { return; } lastOutputFromOutside = value; dynamicLog.onWriteFromOutside(value); }; let removeStreamSpy; if (stream === process.stdout) { const removeStdoutSpy = spyStreamOutput( process.stdout, writeFromOutsideEffect, ); const removeStderrSpy = spyStreamOutput( process.stderr, writeFromOutsideEffect, ); removeStreamSpy = () => { removeStdoutSpy(); removeStderrSpy(); }; } else { removeStreamSpy = spyStreamOutput(stream, writeFromOutsideEffect); } const destroy = () => { dynamicLog.destroyed = true; if (removeStreamSpy) { removeStreamSpy(); removeStreamSpy = null; lastOutput = ""; lastOutputFromOutside = ""; } }; Object.assign(dynamicLog, { update, destroy, stream, clearDuringFunctionCall, }); return dynamicLog; }; // maybe https://github.com/gajus/output-interceptor/tree/v3.0.0 ? // the problem with listening data on stdout // is that node.js will later throw error if stream gets closed // while something listening data on it const spyStreamOutput = (stream, callback) => { let output = ""; let installed = true; const originalWrite = stream.write; stream.write = function (...args /* chunk, encoding, callback */) { output += args; callback(output); return originalWrite.call(this, ...args); }; const uninstall = () => { if (!installed) { return; } stream.write = originalWrite; installed = false; }; return () => { uninstall(); return output; }; }; const startSpinner = ({ dynamicLog, frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], fps = 20, keepProcessAlive = false, stopOnWriteFromOutside = true, stopOnVerticalOverflow = true, render = () => "", effect = () => {}, animated = dynamicLog.stream.isTTY, }) => { let frameIndex = 0; let interval; let running = true; const spinner = { message: undefined, }; const update = (message) => { spinner.message = running ? `${frames[frameIndex]} ${message}\n` : `${message}\n`; return spinner.message; }; spinner.update = update; let cleanup; if (animated && ANSI.supported) { running = true; cleanup = effect(); dynamicLog.update(update(render())); interval = setInterval(() => { frameIndex = frameIndex === frames.length - 1 ? 0 : frameIndex + 1; dynamicLog.update(update(render())); }, 1000 / fps); if (!keepProcessAlive) { interval.unref(); } } else { dynamicLog.update(update(render())); } const stop = (message) => { running = false; if (interval) { clearInterval(interval); interval = null; } if (cleanup) { cleanup(); cleanup = null; } if (dynamicLog && message) { dynamicLog.update(update(message)); dynamicLog = null; } }; spinner.stop = stop; if (stopOnVerticalOverflow) { dynamicLog.onVerticalOverflow = stop; } if (stopOnWriteFromOutside) { dynamicLog.onWriteFromOutside = stop; } return spinner; }; const createTaskLog = ( label, { disabled = false, animated = true, stopOnWriteFromOutside } = {}, ) => { if (disabled) { return { setRightText: () => {}, done: () => {}, happen: () => {}, fail: () => {}, }; } if (animated && process.env.CAPTURING_SIDE_EFFECTS) { animated = false; } const startMs = Date.now(); const dynamicLog = createDynamicLog(); let message = label; const taskSpinner = startSpinner({ dynamicLog, render: () => message, stopOnWriteFromOutside, animated, }); return { setRightText: (value) => { message = `${label} ${value}`; }, done: () => { const msEllapsed = Date.now() - startMs; taskSpinner.stop( `${UNICODE.OK} ${label} (done in ${humanizeDuration(msEllapsed)})`, ); }, happen: (message) => { taskSpinner.stop( `${UNICODE.INFO} ${message} (at ${new Date().toLocaleTimeString()})`, ); }, fail: (message = `failed to ${label}`) => { taskSpinner.stop(`${UNICODE.FAILURE} ${message}`); }, }; }; const pathnameToExtension = (pathname) => { const slashLastIndex = pathname.lastIndexOf("/"); const filename = slashLastIndex === -1 ? pathname : pathname.slice(slashLastIndex + 1); if (filename.match(/@([0-9])+(\.[0-9]+)?(\.[0-9]+)?$/)) { return ""; } const dotLastIndex = filename.lastIndexOf("."); if (dotLastIndex === -1) { return ""; } // if (dotLastIndex === pathname.length - 1) return "" const extension = filename.slice(dotLastIndex); return extension; }; const resourceToPathname = (resource) => { const searchSeparatorIndex = resource.indexOf("?"); if (searchSeparatorIndex > -1) { return resource.slice(0, searchSeparatorIndex); } const hashIndex = resource.indexOf("#"); if (hashIndex > -1) { return resource.slice(0, hashIndex); } return resource; }; const urlToScheme = (url) => { const urlString = String(url); const colonIndex = urlString.indexOf(":"); if (colonIndex === -1) { return ""; } const scheme = urlString.slice(0, colonIndex); return scheme; }; const urlToResource = (url) => { const scheme = urlToScheme(url); if (scheme === "file") { const urlAsStringWithoutFileProtocol = String(url).slice("file://".length); return urlAsStringWithoutFileProtocol; } if (scheme === "https" || scheme === "http") { // remove origin const afterProtocol = String(url).slice(scheme.length + "://".length); const pathnameSlashIndex = afterProtocol.indexOf("/", "://".length); const urlAsStringWithoutOrigin = afterProtocol.slice(pathnameSlashIndex); return urlAsStringWithoutOrigin; } const urlAsStringWithoutProtocol = String(url).slice(scheme.length + 1); return urlAsStringWithoutProtocol; }; const urlToPathname = (url) => { const resource = urlToResource(url); const pathname = resourceToPathname(resource); return pathname; }; const urlToExtension = (url) => { const pathname = urlToPathname(url); return pathnameToExtension(pathname); }; const transformUrlPathname = (url, transformer) => { if (typeof url === "string") { const urlObject = new URL(url); const { pathname } = urlObject; const pathnameTransformed = transformer(pathname); if (pathnameTransformed === pathname) { return url; } let { origin } = urlObject; // origin is "null" for "file://" urls with Node.js if (origin === "null" && urlObject.href.startsWith("file:")) { origin = "file://"; } const { search, hash } = urlObject; const urlWithPathnameTransformed = `${origin}${pathnameTransformed}${search}${hash}`; return urlWithPathnameTransformed; } const pathnameTransformed = transformer(url.pathname); url.pathname = pathnameTransformed; return url; }; const ensurePathnameTrailingSlash = (url) => { return transformUrlPathname(url, (pathname) => { return pathname.endsWith("/") ? pathname : `${pathname}/`; }); }; const isFileSystemPath = (value) => { if (typeof value !== "string") { throw new TypeError( `isFileSystemPath first arg must be a string, got ${value}`, ); } if (value[0] === "/") { return true; } return startsWithWindowsDriveLetter(value); }; const startsWithWindowsDriveLetter = (string) => { const firstChar = string[0]; if (!/[a-zA-Z]/.test(firstChar)) return false; const secondChar = string[1]; if (secondChar !== ":") return false; return true; }; const fileSystemPathToUrl = (value) => { if (!isFileSystemPath(value)) { throw new Error(`value must be a filesystem path, got ${value}`); } return String(pathToFileURL(value)); }; const validateDirectoryUrl = (value) => { let urlString; if (value instanceof URL) { urlString = value.href; } else if (typeof value === "string") { if (isFileSystemPath(value)) { urlString = fileSystemPathToUrl(value); } else { try { urlString = String(new URL(value)); } catch { return { valid: false, value, message: `must be a valid url`, }; } } } else if ( value && typeof value === "object" && typeof value.href === "string" ) { value = value.href; } else { return { valid: false, value, message: `must be a string or an url`, }; } if (!urlString.startsWith("file://")) { return { valid: false, value, message: 'must start with "file://"', }; } return { valid: true, value: ensurePathnameTrailingSlash(urlString), }; }; const assertAndNormalizeDirectoryUrl = ( directoryUrl, name = "directoryUrl", ) => { const { valid, message, value } = validateDirectoryUrl(directoryUrl); if (!valid) { throw new TypeError(`${name} ${message}, got ${value}`); } return value; }; export { Abort, assertAndNormalizeDirectoryUrl, createLogger, createTaskLog, raceProcessTeardownEvents, urlToExtension, urlToPathname };