Files
factorio-clusterio/packages/lib/src/helpers.ts
T
Hornwitser 5dcaca4d0b Migrate to ECMAScript Modules
Migrate the usage of CommonJS specific code and imports to ESM.  Roughly
summarised this:
- Changes all require() calls to import statement or expressions.
- Changes usage of module.exports to export statements.
- Replaces require.resolve with import.meta.resolve.
- Replaces conditional imports with conditional subpath imports.
- Replaces module === require.main with import.meta.main.
- Adds the full filename with extention to all relative imports.
- Changes all usage of __dirname and __filename to their import.meta
  equivalents.
- Sets "type": "module" in package.json files.
- Removes usage of "use strict".
- Adds .ts an an alias of .js for the webpack build.

Due to differences in how require and import resolves files the plugin
entrypoints will have to include the filename and extension after this
migration in order to work.
2026-09-11 22:43:57 +02:00

606 lines
17 KiB
TypeScript

/**
* Collection of small utilites that are useful in multiple places.
* @module lib/helpers
*/
import type { Logger } from "./logging.js";
import setBlocking from "set-blocking";
/**
* Return a string describing the type of the value passed
*
* Works the same as typeof, excpet that null and array types get their
* own string.
*
* @param value - value to return the type of.
* @returns basic type of the value passed.
*/
export function basicType(value: unknown) {
if (value === null) { return "null"; }
if (value instanceof Array) { return "array"; }
return typeof value;
}
/**
* Asynchronously wait for the given duration
*
* @param durationMs - Time to wait for in milliseconds.
*/
export async function wait(durationMs: number) {
await new Promise(resolve => { setTimeout(resolve, durationMs); });
}
/**
* Resolve a promise with a timeout.
*
* @param {Promise} promise - Promise to wait for.
* @param {number} limitMs - Maximum time im milliseconds to wait for.
* @param {*=} timeoutResult - Value to return if the operation timed out.
*/
export async function timeout<T>(promise: Promise<T>, limitMs: number, timeoutResult: T) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>(resolve => {
timer = setTimeout(() => resolve(timeoutResult), limitMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
/**
* Catch process signals, force exit on second signal
*/
export function createShutdownGuard(logger: Logger, signalName: string, callback: () => Promise<unknown>): () => void {
let called = false;
return () => {
if (called) {
setBlocking(true);
logger.fatal(`Caught second ${signalName}, terminating immediately`);
process.exit(1);
}
called = true;
logger.info(`Caught ${signalName}, shutting down`);
callback().catch(err => {
setBlocking(true);
logger.fatal("Error during shutdown, terminating immediately");
logger.fatal(err.stack);
process.exit(1);
});
};
}
/**
* Helper to serialise and merge waiting calls to an async callback.
*
* Provides a convenient interface to serialise the calls made to an async
* callback such that no overlapping invocations of the callback is made.
* If the callback is already running when another invocation is requested
* the request will be queued until the callback returns and then an the
* callback called again.
*
* If multiple calls are made while the callback is running then these will
* be merged into one call of the callback.
*/
export class AsyncSerialMergingCallback {
private _currentlyRunning = false;
private _currentlyWaiting: (() => void)[] = [];
/**
* @param callback - Async function to serialise access to
*/
constructor(
public callback: () => Promise<void>,
) { }
/**
* Invoke the assosiated callback.
*
* If the callback is currently running then this will wait until the
* existing invocation finishes and then invoke it again. If called
* multiple times while an invocation is running the calls will be
* merged into one call.
*/
async invoke() {
if (this._currentlyRunning) {
const waitForCompletion = () => new Promise<void>(resolve => {
this._currentlyWaiting.push(resolve);
});
await waitForCompletion();
if (this._currentlyRunning) {
await waitForCompletion();
return;
}
}
this._currentlyRunning = true;
try {
await this.callback();
} finally {
this._currentlyRunning = false;
for (const waiter of this._currentlyWaiting) {
waiter();
}
this._currentlyWaiting.length = 0;
}
}
}
/**
* Helper to serialise calls to an async callback.
*
* Provides a convenient interface to serialise the calls made to an async
* callback such that no overlapping invocations of the callback is made,
* and successive calls are served on a first in first out basis. If the
* callback is already running when another invocation is requested the
* request will be queued until the callback returns and then the callback
* called again.
*/
export class AsyncSerialCallback<
Callback extends (...args: Parameters<Callback>) => Promise<Awaited<ReturnType<Callback>>>,
> {
private _currentlyRunning = false;
private _currentlyWaiting: (() => void)[] = [];
/**
* @param callback - Async function to serialise access to
*/
constructor(
public callback: Callback,
) { }
/**
* Invoke the assosiated callback.
*
* If the callback is currently running then this will wait until the
* existing invocation finishes and then invoke it again. If called
* multiple times while an invocation is running the calls will be
* merged into one call.
*/
async invoke(...args: Parameters<Callback>) {
if (this._currentlyRunning) {
await new Promise<void>(resolve => {
this._currentlyWaiting.push(resolve);
});
}
this._currentlyRunning = true;
try {
return await this.callback(...args);
} finally {
this._currentlyRunning = false;
this._currentlyWaiting.shift()?.();
}
}
}
/**
* Read stream to the end and return its content
*
* Reads the stream given asynchronously until the end is reached and
* returns all the data which was read from the stream.
*
* @param stream - byte stream to read to the end.
* @returns content of the stream.
*/
export async function readStream(stream: NodeJS.ReadableStream & { isTTY?: boolean }) {
let chunks: Buffer[] = [];
for await (let chunk of stream) {
// Support using ^Z to end input on Windows
if (process.platform === "win32" && stream.isTTY && chunk.toString() === "\x1a\r\n") {
break;
}
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks);
}
/**
* Split the given string on the first instance of separator
*
* Splits `string` on the first instance of `separator` and returns an
* array consisting of the string up to the separator and the string
* after the separator. Returns an array with the string and an empty
* string if the separator is not present.
*
* @param separator - Separator to split string by.
* @param string - String to split
* @returns string split on separator.
*/
export function splitOn(separator: string, string: string) {
let index = string.indexOf(separator);
if (index === -1) {
return [string, ""];
}
return [string.slice(0, index), string.slice(index + separator.length)];
}
/**
* Escapes text for inclusion in a RegExp
*
* Adds \ character in front of special meta characters in the passsed in
* text so that it can be embedded into a RegExp and only match the text.
*
* See https://stackoverflow.com/a/9310752
*
* @param text - Text to escape RegExp meta chars in.
* @returns escaped text.
*/
export function escapeRegExp(text: string) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
}
/**
* Format byte count for human readable display
*
* Shortens a large number of bytes using the kB/MB/GB/TB prefixes.
* @param bytes - Count of bytes to format.
* @param prefixes - Whethere to use SI powers (1000) or the binary powers (1024).
* @returns formatted text.
*/
export function formatBytes(bytes: number, prefixes: "si" | "binary" = "si") {
if (bytes === 0) {
return "0\u{A0}Bytes"; // No-break space
}
let base, units;
if (prefixes === "si") {
base = 1000;
units = ["\u{A0}Bytes", "\u{A0}kB", "\u{A0}MB", "\u{A0}GB", "\u{A0}TB"];
} else {
base = 1024;
units = ["\u{A0}Bytes", "\u{A0}kiB", "\u{A0}MiB", "\u{A0}GiB", "\u{A0}TiB"];
}
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(base)), units.length);
const significant = bytes / base ** exponent;
const fractionDigits = Number(significant < 99.95) + Number(significant < 9.995);
return significant.toFixed(fractionDigits) + units[exponent];
}
/**
* Query parameters whose values are credentials and must never be logged.
*
* Matched case insensitively against the full parameter name.
*/
const sensitiveSearchParams = new Set([
"access_token",
"api_key",
"apikey",
"password",
"secret",
"token",
]);
/**
* Replace credentials in a URL with a placeholder
*
* Returns the URL as a string with the values of any query parameter
* carrying a credential replaced by `REDACTED`, so that the URL can
* safely be included in log output and error messages. The parameter
* names are kept intact to preserve the diagnostic value of the URL.
*
* Returns the input unchanged if it is a string which does not parse as
* a URL, as there are then no query parameters to redact.
*
* @param url - URL to redact credentials from.
* @returns url with credentials replaced by `REDACTED`.
*/
export function redactUrl(url: URL | string) {
let parsed;
try {
parsed = new URL(url);
} catch {
return String(url);
}
const searchParams = new URLSearchParams();
for (const [name, value] of parsed.searchParams) {
searchParams.append(name, sensitiveSearchParams.has(name.toLowerCase()) ? "REDACTED" : value);
}
parsed.search = searchParams.toString();
return parsed.toString();
}
function skipWhitespace(pos: number, input: string) {
// whitespace = 1*" "
while (pos < input.length && input.charAt(pos) === " ") {
pos += 1;
}
return pos;
}
function parseSearchIdentifier(pos: number, input: string): [number, string] {
// character = ? all characters excluding space : " ?
// identifier = 1*character
let startPos = pos;
while (pos < input.length && ![" ", '"', ":"].includes(input.charAt(pos))) {
pos += 1;
}
return [pos, input.slice(startPos, pos)];
}
function parseSearchWord(pos: number, input: string): [number, ParsedWord] {
// non quote character = ? all characters excluding " ?
// word op = "-"
// word = [ word op ], ( identifier | '"', *non quote character, '"' )
let exclude = false;
let value: string;
if (input.charAt(pos) === "-") {
exclude = true;
pos += 1;
}
if (input.charAt(pos) === '"') {
pos += 1;
let startPos = pos;
let endPos = pos;
while (pos < input.length && input.charAt(pos) !== '"') {
pos += 1;
endPos += 1;
}
if (input.charAt(pos) === '"') {
pos += 1;
}
value = input.slice(startPos, endPos);
} else {
([pos, value] = parseSearchIdentifier(pos, input));
}
let word: ParsedWord = {
type: "word",
value,
};
if (exclude) {
word.exclude = true;
}
return [pos, word];
}
function parseSearchTerm(
pos: number,
input: string,
attributes: Record<string, string>,
issues: Array<string>
): [number, ParsedTerm | undefined] {
// attribute = identifier, ':', word
// term = word | attribute
let term: ParsedTerm | undefined;
if (["-", '"'].includes(input.charAt(pos))) {
([pos, term] = parseSearchWord(pos, input));
} else {
let identifier: string;
([pos, identifier] = parseSearchIdentifier(pos, input));
if (input.charAt(pos) === ":") {
pos += 1;
identifier = identifier.toLowerCase();
if (!Object.prototype.hasOwnProperty.call(attributes, identifier)) {
issues.push(`Unregonized attribute "${identifier}", use quotes to escape colons`);
} else if (attributes[identifier] === "word") {
let value: ParsedWord;
([pos, value] = parseSearchWord(pos, input));
term = { type: "attribute", name: identifier, value };
} else {
throw new Error(`Bad attribute format ${attributes[identifier]} for ${identifier}`);
}
} else {
term = { type: "word", value: identifier };
}
}
return [pos, term];
}
/**
* Match a parsed word term with the given texts.
*
* Returns true if the passed word term in the search matches at least one
* of the passed text snippets. If the word mode is exclude then returns
* true if none of the passed text snippets match.
*
* @param word - Word to match.
* @param texts - Text to match word in.
* @returns true if the word matches.
*/
export function wordMatches(word: ParsedTerm, ...texts: string[]) {
if (word.type !== "word") {
throw Error("wordMatches: parameter is not a word");
}
let matches = false;
for (let text of texts) {
if (text.includes(word.value as string)) {
matches = true;
break;
}
}
if (word.exclude) {
matches = !matches;
}
return matches;
}
export interface ParsedAttribute {
/** Type of term, either attribute or word. */
type: "attribute"
/** Name of attribute. */
name: string;
/** Parsed value of this attribute. */
value: ParsedWord;
}
export interface ParsedWord {
/** Type of term, either attribute or word. */
type: "word";
/** Exclude results with this word. */
exclude?: boolean;
/** Parsed text of this word. */
value: string;
}
export type ParsedTerm = ParsedAttribute | ParsedWord;
/**
* Result from {@link parseSearchString}.
*/
export interface ParsedSearch {
/** Parsed result of search terms. */
terms: Array<ParsedTerm>;
/** Issues detected while parsing the seach string. */
issues: Array<string>;
};
/**
* Parse a search string with optional attributes
*
* Parses the given input as a search expression consisting of space
* delimited words and attributes:word pairs. For aattributes to be
* recognized they need to be passed as name: "word" in the attributes
* parameter.
*
* Words in the search expression can optionally be prefixed by a - to
* search for results that does not contain that word. E.g. -author contains
* all results not matching author.
*
* If parsing fails an string describing the problem will be added to the
* issues array returned and parsing will resume at some arbitrary point.
* The issues should be shown to the end user so that they can correct their
* search.
*
* @param input - Search expression to parse.
* @param attributes -
* Recognized attributes and their format. Currently only word is
* supported.
* @returns parsed terms of the search.
*/
export function parseSearchString(
input: string,
attributes: Record<string, string> = {}
): ParsedSearch {
// search = [ term, *( [ whitespace ], term ) ]
input = input.trim();
let parsed = {
terms: [] as ParsedTerm[],
issues: [] as string[],
};
let pos = 0;
while (pos < input.length) {
let term: ParsedTerm | undefined;
([pos, term] = parseSearchTerm(pos, input, attributes, parsed.issues));
if (term) {
parsed.terms.push(term);
}
pos = skipWhitespace(pos, input);
}
// istanbul ignore if (should not be possible)
if (pos !== input.length) {
throw new Error(`parse search ended at ${pos} which is not the end of the input (${input.length})`);
}
return parsed;
}
function isDigit(pos: number, input: string) {
const code = input.charCodeAt(pos);
return "0".charCodeAt(0) <= code && code <= "9".charCodeAt(0);
}
function parseNumber(pos: number, input: string): [number, number] {
// number = 1*('0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9')
let startPos = pos;
let endPos = pos;
while (pos < input.length && isDigit(pos, input)) {
pos += 1;
endPos += 1;
}
return [pos, Number.parseInt(input.slice(startPos, endPos), 10)];
}
function parseRange(pos: number, input: string, min: number, max: number): [number, Set<number>] {
// range = number, [ whitespace ], [ '-', [ whitespace ], number ]
const range = new Set<number>();
if (!isDigit(pos, input)) {
throw new Error(`Expected digit but got '${input[pos]}' at pos ${pos} while parsing "${input}"`);
}
let start: number;
([pos, start] = parseNumber(pos, input));
pos = skipWhitespace(pos, input);
if (pos < input.length && input[pos] === "-") {
pos += 1; // Skip dash
pos = skipWhitespace(pos, input);
if (pos === input.length) {
throw new Error(`Expected digit but got end of input while parsing "${input}"`);
}
if (!isDigit(pos, input)) {
throw new Error(`Expected digit but got '${input[pos]}' at pos ${pos} while parsing "${input}"`);
}
let end: number;
([pos, end] = parseNumber(pos, input));
if (start > end) {
([start, end] = [end, start]);
}
if (start < min) {
throw new Error(`start of range ${start}-${end} is below the minimum value ${min}`);
}
if (end > max) {
throw new Error(`end of range ${start}-${end} is above the maximum value ${max}`);
}
for (let i = start; i <= end; i++) {
range.add(i);
}
} else { // Single value range
if (start < min) {
throw new Error(`value ${start} is below the minimum value ${min}`);
}
if (start > max) {
throw new Error(`value ${start} is above the maximum value ${max}`);
}
range.add(start);
}
return [pos, range];
}
/**
* Parse a comma separated range expression
*
* Parses the given input as a series of comma sepparated number ranges
* where each range can consist of either a whole number or a whole number
* of where the range starts, a dash and then a whole number of where the
* range ends inclusively.
*
* @param input - Range expression to parse
* @param min - Minimum accepted input value.
* @param max - Maximum accepted input value.
* @returns Set of all numbers in the parsed range.
*/
export function parseRanges(
input: string,
min: number,
max: number,
) {
// ranges = [ range, *( [ whitespace ], [ ',', [ whitespace ] ], range ) ]
input = input.trim();
const parsed = new Set<number>();
let pos = 0;
while (pos < input.length) {
let range: Set<number>;
([pos, range] = parseRange(pos, input, min, max));
for (const i of range) {
parsed.add(i);
}
pos = skipWhitespace(pos, input);
if (pos < input.length && input[pos] === ",") {
pos += 1;
pos = skipWhitespace(pos, input);
}
}
return parsed;
}