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