]>
Commit | Line | Data |
---|---|---|
0749233f | 1 | import type { CircularBuffer } from 'mnemonist' |
8114d10e | 2 | |
f0c6601c JB |
3 | import { |
4 | formatDuration, | |
be4c6702 JB |
5 | hoursToMinutes, |
6 | hoursToSeconds, | |
b5c19509 | 7 | isDate, |
f0c6601c JB |
8 | millisecondsToHours, |
9 | millisecondsToMinutes, | |
10 | millisecondsToSeconds, | |
be4c6702 | 11 | minutesToSeconds, |
d1f5bfd8 | 12 | secondsToMilliseconds, |
66a7748d | 13 | } from 'date-fns' |
0749233f | 14 | import { getRandomValues, randomBytes, randomUUID } from 'node:crypto' |
95e17f67 | 15 | import { env } from 'node:process' |
088ee3c1 | 16 | |
4ccf551d | 17 | import { |
276e05ae JB |
18 | type JsonType, |
19 | MapStringifyFormat, | |
4ccf551d | 20 | type TimestampedData, |
d1f5bfd8 | 21 | WebSocketCloseEventStatusString, |
4ccf551d | 22 | } from '../types/index.js' |
5e3cb728 | 23 | |
ff685bbb JB |
24 | type NonEmptyArray<T> = [T, ...T[]] |
25 | type ReadonlyNonEmptyArray<T> = readonly [T, ...(readonly T[])] | |
26 | ||
9bf0ef23 | 27 | export const logPrefix = (prefixString = ''): string => { |
66a7748d JB |
28 | return `${new Date().toLocaleString()}${prefixString}` |
29 | } | |
d5bd1c00 | 30 | |
ff685bbb JB |
31 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
32 | export const once = <T extends (...args: any[]) => any>(fn: T): T => { | |
33 | let hasBeenCalled = false | |
13b0cc5c | 34 | let result: ReturnType<T> |
ff685bbb JB |
35 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
36 | return function (this: any, ...args: Parameters<T>): ReturnType<T> { | |
37 | if (!hasBeenCalled) { | |
38 | hasBeenCalled = true | |
39 | result = fn.apply(this, args) as ReturnType<T> | |
40 | } | |
41 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return | |
42 | return result | |
43 | } as T | |
44 | } | |
45 | ||
06ca76ae | 46 | export const has = (property: PropertyKey, object: null | object | undefined): boolean => { |
ff685bbb JB |
47 | if (object == null) { |
48 | return false | |
49 | } | |
0c58e0c2 | 50 | return Object.hasOwn(object, property) |
ff685bbb JB |
51 | } |
52 | ||
53 | const type = (value: unknown): string => { | |
54 | if (value === null) return 'Null' | |
55 | if (value === undefined) return 'Undefined' | |
56 | if (Number.isNaN(value)) return 'NaN' | |
57 | if (Array.isArray(value)) return 'Array' | |
58 | return Object.prototype.toString.call(value).slice(8, -1) | |
59 | } | |
60 | ||
61 | export const isEmpty = (value: unknown): boolean => { | |
13b0cc5c JB |
62 | const valueType = type(value) |
63 | if (['NaN', 'Null', 'Number', 'Undefined'].includes(valueType)) { | |
ff685bbb JB |
64 | return false |
65 | } | |
66 | if (!value) return true | |
67 | ||
13b0cc5c | 68 | if (valueType === 'Object') { |
ff685bbb JB |
69 | return Object.keys(value as Record<string, unknown>).length === 0 |
70 | } | |
71 | ||
13b0cc5c | 72 | if (valueType === 'Array') { |
ff685bbb JB |
73 | return (value as unknown[]).length === 0 |
74 | } | |
75 | ||
06ca76ae JB |
76 | if (valueType === 'Map') { |
77 | return (value as Map<unknown, unknown>).size === 0 | |
78 | } | |
79 | ||
80 | if (valueType === 'Set') { | |
81 | return (value as Set<unknown>).size === 0 | |
82 | } | |
83 | ||
ff685bbb JB |
84 | return false |
85 | } | |
86 | ||
87 | const isObject = (value: unknown): value is object => { | |
88 | return type(value) === 'Object' | |
89 | } | |
90 | ||
13b0cc5c JB |
91 | export const mergeDeepRight = <T extends Record<string, unknown>>( |
92 | target: T, | |
93 | source: Partial<T> | |
94 | ): T => { | |
ff685bbb JB |
95 | const output = { ...target } |
96 | ||
97 | if (isObject(target) && isObject(source)) { | |
98 | Object.keys(source).forEach(key => { | |
99 | if (isObject(source[key])) { | |
100 | if (!(key in target)) { | |
101 | Object.assign(output, { [key]: source[key] }) | |
102 | } else { | |
ff685bbb JB |
103 | output[key] = mergeDeepRight(target[key], source[key]) |
104 | } | |
105 | } else { | |
ff685bbb JB |
106 | Object.assign(output, { [key]: source[key] }) |
107 | } | |
108 | }) | |
109 | } | |
110 | ||
111 | return output | |
112 | } | |
113 | ||
2c5c7443 | 114 | export const generateUUID = (): `${string}-${string}-${string}-${string}-${string}` => { |
66a7748d JB |
115 | return randomUUID() |
116 | } | |
147d0e0f | 117 | |
2c5c7443 JB |
118 | export const validateUUID = ( |
119 | uuid: `${string}-${string}-${string}-${string}-${string}` | |
120 | ): uuid is `${string}-${string}-${string}-${string}-${string}` => { | |
66a7748d JB |
121 | return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(uuid) |
122 | } | |
03eacbe5 | 123 | |
9bf0ef23 | 124 | export const sleep = async (milliSeconds: number): Promise<NodeJS.Timeout> => { |
a974c8e4 | 125 | return await new Promise<NodeJS.Timeout>(resolve => |
66a7748d JB |
126 | setTimeout(resolve as () => void, milliSeconds) |
127 | ) | |
128 | } | |
7dde0b73 | 129 | |
9bf0ef23 | 130 | export const formatDurationMilliSeconds = (duration: number): string => { |
66a7748d | 131 | duration = convertToInt(duration) |
17b07e47 | 132 | if (duration < 0) { |
66a7748d | 133 | throw new RangeError('Duration cannot be negative') |
17b07e47 | 134 | } |
66a7748d JB |
135 | const days = Math.floor(duration / (24 * 3600 * 1000)) |
136 | const hours = Math.floor(millisecondsToHours(duration) - days * 24) | |
be4c6702 | 137 | const minutes = Math.floor( |
66a7748d JB |
138 | millisecondsToMinutes(duration) - days * 24 * 60 - hoursToMinutes(hours) |
139 | ) | |
f0c6601c | 140 | const seconds = Math.floor( |
be4c6702 JB |
141 | millisecondsToSeconds(duration) - |
142 | days * 24 * 3600 - | |
143 | hoursToSeconds(hours) - | |
66a7748d JB |
144 | minutesToSeconds(minutes) |
145 | ) | |
d7ceb0f0 | 146 | if (days === 0 && hours === 0 && minutes === 0 && seconds === 0) { |
66a7748d | 147 | return formatDuration({ seconds }, { zero: true }) |
d7ceb0f0 JB |
148 | } |
149 | return formatDuration({ | |
150 | days, | |
151 | hours, | |
152 | minutes, | |
d1f5bfd8 | 153 | seconds, |
66a7748d JB |
154 | }) |
155 | } | |
7dde0b73 | 156 | |
9bf0ef23 | 157 | export const formatDurationSeconds = (duration: number): string => { |
66a7748d JB |
158 | return formatDurationMilliSeconds(secondsToMilliseconds(duration)) |
159 | } | |
7dde0b73 | 160 | |
0bd926c1 | 161 | // More efficient time validation function than the one provided by date-fns |
5dc7c990 | 162 | export const isValidDate = (date: Date | number | undefined): date is Date | number => { |
b5c19509 | 163 | if (typeof date === 'number') { |
1ea7f1df | 164 | return !Number.isNaN(date) |
b5c19509 | 165 | } else if (isDate(date)) { |
1ea7f1df | 166 | return !Number.isNaN(date.getTime()) |
b5c19509 | 167 | } |
66a7748d JB |
168 | return false |
169 | } | |
b5c19509 | 170 | |
a78c196b | 171 | export const convertToDate = ( |
0749233f | 172 | value: Date | null | number | string | undefined |
79fd697f | 173 | ): Date | undefined => { |
66a7748d | 174 | if (value == null) { |
79fd697f | 175 | return undefined |
7dde0b73 | 176 | } |
0bd926c1 | 177 | if (isDate(value)) { |
66a7748d | 178 | return value |
a6e68f34 | 179 | } |
87bcd3b4 | 180 | if (typeof value === 'string' || typeof value === 'number') { |
66a7748d | 181 | const valueToDate = new Date(value) |
1ea7f1df | 182 | if (Number.isNaN(valueToDate.getTime())) { |
d1f5bfd8 | 183 | throw new Error(`Cannot convert to date: '${value.toString()}'`) |
43ff25b8 | 184 | } |
66a7748d | 185 | return valueToDate |
560bcf5b | 186 | } |
66a7748d | 187 | } |
560bcf5b | 188 | |
9bf0ef23 | 189 | export const convertToInt = (value: unknown): number => { |
66a7748d JB |
190 | if (value == null) { |
191 | return 0 | |
560bcf5b | 192 | } |
9bf0ef23 | 193 | if (Number.isSafeInteger(value)) { |
66a7748d | 194 | return value as number |
6d3a11a0 | 195 | } |
9bf0ef23 | 196 | if (typeof value === 'number') { |
66a7748d | 197 | return Math.trunc(value) |
7dde0b73 | 198 | } |
f5ee1403 | 199 | let changedValue: number = value as number |
87bcd3b4 | 200 | if (typeof value === 'string') { |
48847bc0 | 201 | changedValue = Number.parseInt(value) |
9ccca265 | 202 | } |
1ea7f1df | 203 | if (Number.isNaN(changedValue)) { |
408b4e6d JB |
204 | // eslint-disable-next-line @typescript-eslint/no-base-to-string |
205 | throw new Error(`Cannot convert to integer: '${value.toString()}'`) | |
dada83ec | 206 | } |
66a7748d JB |
207 | return changedValue |
208 | } | |
dada83ec | 209 | |
9bf0ef23 | 210 | export const convertToFloat = (value: unknown): number => { |
66a7748d JB |
211 | if (value == null) { |
212 | return 0 | |
dada83ec | 213 | } |
66a7748d | 214 | let changedValue: number = value as number |
87bcd3b4 | 215 | if (typeof value === 'string') { |
48847bc0 | 216 | changedValue = Number.parseFloat(value) |
560bcf5b | 217 | } |
1ea7f1df | 218 | if (Number.isNaN(changedValue)) { |
408b4e6d JB |
219 | // eslint-disable-next-line @typescript-eslint/no-base-to-string |
220 | throw new Error(`Cannot convert to float: '${value.toString()}'`) | |
5a2a53cf | 221 | } |
66a7748d JB |
222 | return changedValue |
223 | } | |
5a2a53cf | 224 | |
9bf0ef23 | 225 | export const convertToBoolean = (value: unknown): boolean => { |
66a7748d | 226 | let result = false |
a78c196b | 227 | if (value != null) { |
9bf0ef23 JB |
228 | // Check the type |
229 | if (typeof value === 'boolean') { | |
66a7748d | 230 | return value |
87bcd3b4 | 231 | } else if (typeof value === 'string' && (value.toLowerCase() === 'true' || value === '1')) { |
66a7748d | 232 | result = true |
9bf0ef23 | 233 | } else if (typeof value === 'number' && value === 1) { |
66a7748d | 234 | result = true |
e7aeea18 | 235 | } |
c37528f1 | 236 | } |
66a7748d JB |
237 | return result |
238 | } | |
9bf0ef23 JB |
239 | |
240 | export const getRandomFloat = (max = Number.MAX_VALUE, min = 0): number => { | |
241 | if (max < min) { | |
66a7748d | 242 | throw new RangeError('Invalid interval') |
9bf0ef23 | 243 | } |
cffc32b7 | 244 | if (max - min === Number.POSITIVE_INFINITY) { |
66a7748d | 245 | throw new RangeError('Invalid interval') |
9bf0ef23 | 246 | } |
66a7748d JB |
247 | return (randomBytes(4).readUInt32LE() / 0xffffffff) * (max - min) + min |
248 | } | |
9bf0ef23 | 249 | |
9bf0ef23 JB |
250 | /** |
251 | * Rounds the given number to the given scale. | |
252 | * The rounding is done using the "round half away from zero" method. | |
9bf0ef23 JB |
253 | * @param numberValue - The number to round. |
254 | * @param scale - The scale to round to. | |
255 | * @returns The rounded number. | |
256 | */ | |
257 | export const roundTo = (numberValue: number, scale: number): number => { | |
0c58e0c2 | 258 | const roundPower = 10 ** scale |
66a7748d JB |
259 | return Math.round(numberValue * roundPower * (1 + Number.EPSILON)) / roundPower |
260 | } | |
9bf0ef23 JB |
261 | |
262 | export const getRandomFloatRounded = (max = Number.MAX_VALUE, min = 0, scale = 2): number => { | |
5199f9fd | 263 | if (min !== 0) { |
66a7748d | 264 | return roundTo(getRandomFloat(max, min), scale) |
9bf0ef23 | 265 | } |
66a7748d JB |
266 | return roundTo(getRandomFloat(max), scale) |
267 | } | |
9bf0ef23 JB |
268 | |
269 | export const getRandomFloatFluctuatedRounded = ( | |
270 | staticValue: number, | |
271 | fluctuationPercent: number, | |
66a7748d | 272 | scale = 2 |
9bf0ef23 JB |
273 | ): number => { |
274 | if (fluctuationPercent < 0 || fluctuationPercent > 100) { | |
275 | throw new RangeError( | |
d1f5bfd8 | 276 | `Fluctuation percent must be between 0 and 100. Actual value: ${fluctuationPercent.toString()}` |
66a7748d | 277 | ) |
fe791818 | 278 | } |
9bf0ef23 | 279 | if (fluctuationPercent === 0) { |
66a7748d | 280 | return roundTo(staticValue, scale) |
9bf0ef23 | 281 | } |
66a7748d | 282 | const fluctuationRatio = fluctuationPercent / 100 |
9bf0ef23 JB |
283 | return getRandomFloatRounded( |
284 | staticValue * (1 + fluctuationRatio), | |
285 | staticValue * (1 - fluctuationRatio), | |
66a7748d JB |
286 | scale |
287 | ) | |
288 | } | |
9bf0ef23 | 289 | |
840ca85d JB |
290 | export const extractTimeSeriesValues = (timeSeries: CircularBuffer<TimestampedData>): number[] => { |
291 | return (timeSeries.toArray() as TimestampedData[]).map(timeSeriesItem => timeSeriesItem.value) | |
66a7748d | 292 | } |
da55bd34 | 293 | |
40615072 | 294 | export const clone = <T>(object: T): T => { |
3fad0dec | 295 | return structuredClone<T>(object) |
66a7748d | 296 | } |
9bf0ef23 | 297 | |
f8d74e92 JB |
298 | type AsyncFunctionType<A extends unknown[], R> = (...args: A) => PromiseLike<R> |
299 | ||
be0a4d4d JB |
300 | /** |
301 | * Detects whether the given value is an asynchronous function or not. | |
be0a4d4d JB |
302 | * @param fn - Unknown value. |
303 | * @returns `true` if `fn` was an asynchronous function, otherwise `false`. | |
304 | * @internal | |
305 | */ | |
f8d74e92 JB |
306 | export const isAsyncFunction = (fn: unknown): fn is AsyncFunctionType<unknown[], unknown> => { |
307 | // eslint-disable-next-line @typescript-eslint/no-empty-function | |
308 | return fn?.constructor === (async () => {}).constructor | |
be0a4d4d JB |
309 | } |
310 | ||
9bf0ef23 | 311 | export const isCFEnvironment = (): boolean => { |
aa63c9b7 | 312 | return env.VCAP_APPLICATION != null |
66a7748d | 313 | } |
9bf0ef23 | 314 | |
665864ca | 315 | declare const nonEmptyString: unique symbol |
c4a89082 | 316 | type NonEmptyString = string & { [nonEmptyString]: true } |
665864ca | 317 | export const isNotEmptyString = (value: unknown): value is NonEmptyString => { |
87bcd3b4 | 318 | return typeof value === 'string' && value.trim().length > 0 |
66a7748d | 319 | } |
9bf0ef23 | 320 | |
665864ca JB |
321 | export const isNotEmptyArray = <T>( |
322 | value: unknown | |
323 | ): value is NonEmptyArray<T> | ReadonlyNonEmptyArray<T> => { | |
ff685bbb | 324 | return Array.isArray(value) && value.length > 0 |
66a7748d | 325 | } |
9bf0ef23 JB |
326 | |
327 | export const insertAt = (str: string, subStr: string, pos: number): string => | |
66a7748d | 328 | `${str.slice(0, pos)}${subStr}${str.slice(pos)}` |
9bf0ef23 JB |
329 | |
330 | /** | |
331 | * Computes the retry delay in milliseconds using an exponential backoff algorithm. | |
9bf0ef23 | 332 | * @param retryNumber - the number of retries that have already been attempted |
45abd3c6 | 333 | * @param delayFactor - the base delay factor in milliseconds |
9bf0ef23 JB |
334 | * @returns delay in milliseconds |
335 | */ | |
45abd3c6 | 336 | export const exponentialDelay = (retryNumber = 0, delayFactor = 100): number => { |
0c58e0c2 | 337 | const delay = 2 ** retryNumber * delayFactor |
66a7748d JB |
338 | const randomSum = delay * 0.2 * secureRandom() // 0-20% of the delay |
339 | return delay + randomSum | |
340 | } | |
9bf0ef23 | 341 | |
9bf0ef23 JB |
342 | /** |
343 | * Generates a cryptographically secure random number in the [0,1[ range | |
ab93b184 | 344 | * @returns A number in the [0,1[ range |
9bf0ef23 JB |
345 | */ |
346 | export const secureRandom = (): number => { | |
66a7748d JB |
347 | return getRandomValues(new Uint32Array(1))[0] / 0x100000000 |
348 | } | |
9bf0ef23 | 349 | |
276e05ae | 350 | export const JSONStringify = < |
01841aad | 351 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters |
28f384aa | 352 | T extends |
d1f5bfd8 | 353 | | JsonType |
0749233f | 354 | | Map<string, Record<string, unknown>> |
d1f5bfd8 JB |
355 | | Record<string, unknown>[] |
356 | | Set<Record<string, unknown>> | |
276e05ae JB |
357 | >( |
358 | object: T, | |
0749233f | 359 | space?: number | string, |
276e05ae JB |
360 | mapFormat?: MapStringifyFormat |
361 | ): string => { | |
9bf0ef23 | 362 | return JSON.stringify( |
bfcd3a87 | 363 | object, |
58ddf341 | 364 | (_, value: Record<string, unknown>) => { |
ff685bbb | 365 | if (value instanceof Map) { |
276e05ae JB |
366 | switch (mapFormat) { |
367 | case MapStringifyFormat.object: | |
48847bc0 | 368 | return { |
d1f5bfd8 | 369 | ...Object.fromEntries<Map<string, Record<string, unknown>>>(value.entries()), |
48847bc0 | 370 | } |
276e05ae JB |
371 | case MapStringifyFormat.array: |
372 | default: | |
373 | return [...value] | |
66a7748d | 374 | } |
ff685bbb JB |
375 | } else if (value instanceof Set) { |
376 | return [...value] as Record<string, unknown>[] | |
9bf0ef23 | 377 | } |
66a7748d | 378 | return value |
9bf0ef23 | 379 | }, |
66a7748d JB |
380 | space |
381 | ) | |
382 | } | |
9bf0ef23 JB |
383 | |
384 | /** | |
385 | * Converts websocket error code to human readable string message | |
9bf0ef23 JB |
386 | * @param code - websocket error code |
387 | * @returns human readable string message | |
388 | */ | |
389 | export const getWebSocketCloseEventStatusString = (code: number): string => { | |
390 | if (code >= 0 && code <= 999) { | |
66a7748d | 391 | return '(Unused)' |
9bf0ef23 JB |
392 | } else if (code >= 1016) { |
393 | if (code <= 1999) { | |
66a7748d | 394 | return '(For WebSocket standard)' |
9bf0ef23 | 395 | } else if (code <= 2999) { |
66a7748d | 396 | return '(For WebSocket extensions)' |
9bf0ef23 | 397 | } else if (code <= 3999) { |
66a7748d | 398 | return '(For libraries and frameworks)' |
9bf0ef23 | 399 | } else if (code <= 4999) { |
66a7748d | 400 | return '(For applications)' |
5e3cb728 | 401 | } |
5e3cb728 | 402 | } |
a37fc6dc | 403 | if ( |
5199f9fd JB |
404 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition |
405 | WebSocketCloseEventStatusString[code as keyof typeof WebSocketCloseEventStatusString] != null | |
a37fc6dc | 406 | ) { |
66a7748d | 407 | return WebSocketCloseEventStatusString[code as keyof typeof WebSocketCloseEventStatusString] |
9bf0ef23 | 408 | } |
66a7748d JB |
409 | return '(Unknown)' |
410 | } | |
80c58041 | 411 | |
991fb26b | 412 | export const isArraySorted = <T>(array: T[], compareFn: (a: T, b: T) => number): boolean => { |
8bfa4d2b JB |
413 | if (array.length <= 1) { |
414 | return true | |
415 | } | |
991fb26b JB |
416 | for (let index = 0; index < array.length - 1; ++index) { |
417 | if (compareFn(array[index], array[index + 1]) > 0) { | |
66a7748d | 418 | return false |
80c58041 JB |
419 | } |
420 | } | |
66a7748d JB |
421 | return true |
422 | } | |
5f742aac | 423 | |
95e17f67 JB |
424 | export const queueMicrotaskErrorThrowing = (error: Error): void => { |
425 | queueMicrotask(() => { | |
66a7748d JB |
426 | throw error |
427 | }) | |
428 | } |