@jsenv/core
Version:
Tool to develop, test and build js projects
1,551 lines (1,393 loc) • 40.4 kB
JavaScript
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 };