@wexample/js-helpers 0.0.26 → 0.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +1 -128
  2. package/package.json +14 -6
  3. package/src/Common/AsyncConstructor.ts +47 -0
  4. package/src/Common/RetryBackoffScheduler.ts +64 -0
  5. package/src/Helper/AbstractMixin.ts +18 -0
  6. package/src/Helper/Animation.ts +47 -0
  7. package/src/Helper/Array.ts +30 -0
  8. package/src/Helper/Bytes.ts +12 -0
  9. package/src/Helper/Dom.ts +142 -0
  10. package/src/Helper/ElementSize.ts +87 -0
  11. package/src/Helper/Event.ts +30 -0
  12. package/src/Helper/Function.ts +5 -0
  13. package/src/Helper/Height.ts +20 -0
  14. package/src/Helper/Id.ts +3 -0
  15. package/src/Helper/KeyCode.ts +5 -0
  16. package/src/Helper/Location.ts +65 -0
  17. package/src/Helper/Log.ts +41 -0
  18. package/src/Helper/Mixin.ts +33 -0
  19. package/src/Helper/NodeEnv.ts +46 -0
  20. package/src/Helper/NodeFs.ts +37 -0
  21. package/src/Helper/NodePath.ts +23 -0
  22. package/src/Helper/Object.ts +124 -0
  23. package/src/Helper/Pointer.ts +3 -0
  24. package/src/Helper/Queue.ts +112 -0
  25. package/src/Helper/Reconnect.ts +170 -0
  26. package/src/Helper/Serialize.ts +69 -0
  27. package/src/Helper/String.ts +316 -0
  28. package/src/Helper/Time.ts +5 -0
  29. package/src/Helper/Transition.ts +50 -0
  30. package/src/Helper/Url.ts +23 -0
  31. package/src/Helper/Variables.ts +13 -0
  32. package/dist/Common/AsyncConstructor.js +0 -42
  33. package/dist/Common/AsyncConstructor.js.map +0 -1
  34. package/dist/Helper/Array.js +0 -25
  35. package/dist/Helper/Array.js.map +0 -1
  36. package/dist/Helper/Bytes.js +0 -11
  37. package/dist/Helper/Bytes.js.map +0 -1
  38. package/dist/Helper/Dom.js +0 -74
  39. package/dist/Helper/Dom.js.map +0 -1
  40. package/dist/Helper/Event.js +0 -29
  41. package/dist/Helper/Event.js.map +0 -1
  42. package/dist/Helper/Function.js +0 -4
  43. package/dist/Helper/Function.js.map +0 -1
  44. package/dist/Helper/KeyCode.js +0 -4
  45. package/dist/Helper/KeyCode.js.map +0 -1
  46. package/dist/Helper/Location.js +0 -49
  47. package/dist/Helper/Location.js.map +0 -1
  48. package/dist/Helper/Log.js +0 -27
  49. package/dist/Helper/Log.js.map +0 -1
  50. package/dist/Helper/Mixin.js +0 -29
  51. package/dist/Helper/Mixin.js.map +0 -1
  52. package/dist/Helper/Object.js +0 -90
  53. package/dist/Helper/Object.js.map +0 -1
  54. package/dist/Helper/Pointer.js +0 -4
  55. package/dist/Helper/Pointer.js.map +0 -1
  56. package/dist/Helper/Queue.js +0 -80
  57. package/dist/Helper/Queue.js.map +0 -1
  58. package/dist/Helper/String.js +0 -261
  59. package/dist/Helper/String.js.map +0 -1
  60. package/dist/Helper/Time.js +0 -6
  61. package/dist/Helper/Time.js.map +0 -1
  62. package/dist/Helper/Url.js +0 -17
  63. package/dist/Helper/Url.js.map +0 -1
  64. package/dist/Helper/Variables.js +0 -11
  65. package/dist/Helper/Variables.js.map +0 -1
@@ -0,0 +1,5 @@
1
+ export const KEY_CODE = {
2
+ ESCAPE: 'Escape',
3
+ } as const;
4
+
5
+ export type KeyCode = (typeof KEY_CODE)[keyof typeof KEY_CODE];
@@ -0,0 +1,65 @@
1
+ let locationParamsHash: URLSearchParams | null = null;
2
+
3
+ export function locationParamReload(): URLSearchParams {
4
+ locationParamsHash = new URLSearchParams(window.location.hash.slice(1));
5
+ return locationParamsHash;
6
+ }
7
+
8
+ export function locationHashParamGet(name: string, defaultValue = ''): string {
9
+ const params = locationParamReload();
10
+ const value = params.get(name);
11
+ return value !== null ? value : defaultValue;
12
+ }
13
+
14
+ export function locationHashParamSet(name: string, value: string, ignoreHistory = false): void {
15
+ const params = locationParamReload();
16
+ params.set(name, value);
17
+
18
+ const { pathname, search } = window.location;
19
+ locationUpdate(`${pathname}${search}#${params.toString()}`, ignoreHistory);
20
+ }
21
+
22
+ export function locationUpdate(href: string, ignoreHistory = false): void {
23
+ let nextHref = href;
24
+
25
+ if (nextHref.endsWith('#')) {
26
+ nextHref = nextHref.slice(0, -1);
27
+ }
28
+
29
+ const method: 'pushState' | 'replaceState' = ignoreHistory ? 'replaceState' : 'pushState';
30
+ window.history[method]({ manualState: true }, document.title, nextHref);
31
+ }
32
+
33
+ export function locationDetectLanguageAndRedirect(
34
+ config: Record<string, string> & { _default: string }
35
+ ): void {
36
+ const userLanguage = (
37
+ navigator.language ||
38
+ (navigator as unknown as { userLanguage?: string })?.userLanguage ||
39
+ ''
40
+ ).toLowerCase();
41
+ const currentPath = window.location.pathname;
42
+
43
+ const redirectUrls = Object.values(config);
44
+ const escaped = redirectUrls.map((url) => url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
45
+ const redirectPattern = new RegExp(`^(${escaped.join('|')})`);
46
+
47
+ if (redirectPattern.test(currentPath)) {
48
+ return;
49
+ }
50
+
51
+ let redirectUrl = config._default;
52
+
53
+ for (const [lang, url] of Object.entries(config)) {
54
+ if (lang === '_default') {
55
+ continue;
56
+ }
57
+ if (userLanguage.startsWith(lang.toLowerCase())) {
58
+ redirectUrl = url;
59
+ break;
60
+ }
61
+ }
62
+
63
+ const restOfPath = currentPath.replace(/^\//, '');
64
+ window.location.href = `${redirectUrl}${restOfPath}`;
65
+ }
@@ -0,0 +1,41 @@
1
+ export const COLORS = {
2
+ blue: '34',
3
+ cyan: '36',
4
+ gray: '90',
5
+ green: '32',
6
+ magenta: '35',
7
+ yellow: '33',
8
+ red: '31',
9
+ } as const;
10
+
11
+ type ColorValue = keyof typeof COLORS | string;
12
+
13
+ export function logColor(text: string, colorCode: ColorValue = COLORS.gray): string {
14
+ const resolved =
15
+ typeof colorCode === 'string' && colorCode in COLORS
16
+ ? COLORS[colorCode as keyof typeof COLORS]
17
+ : colorCode || COLORS.gray;
18
+
19
+ return `\x1b[${resolved}m${text}\x1b[0m`;
20
+ }
21
+
22
+ export function logTitle(title: string, colorCode: ColorValue = COLORS.cyan): void {
23
+ console.log('');
24
+ console.log(logColor(`# ${String(title).toUpperCase()}`, colorCode));
25
+ }
26
+
27
+ export function logPath(
28
+ label: string,
29
+ value: string,
30
+ labelColor: ColorValue = COLORS.gray,
31
+ valueColor: ColorValue = COLORS.yellow
32
+ ): void {
33
+ console.log(`${logColor(label, labelColor)} ${logColor(value, valueColor)}`);
34
+ }
35
+
36
+ export function logEntry(action: string, entry: { output: string; source: string }): void {
37
+ console.log(
38
+ `${logColor('•', COLORS.green)} ${logColor(action, COLORS.blue)} ${logColor(entry.output, COLORS.yellow)}`
39
+ );
40
+ console.log(` ${logColor('from', COLORS.gray)} ${logColor(entry.source, COLORS.gray)}`);
41
+ }
@@ -0,0 +1,33 @@
1
+ type Constructor<T = any> = abstract new (...args: any[]) => T;
2
+
3
+ /**
4
+ * Apply mixin classes to a target class (TypeScript-compatible).
5
+ * Copy prototype properties (except constructor) and static props.
6
+ */
7
+ export function mixinApply(targetCtor: Constructor, mixins: Constructor[]): void {
8
+ mixins.forEach((mixinCtor) => {
9
+ // Copy instance members (prototype).
10
+ Object.getOwnPropertyNames(mixinCtor.prototype).forEach((name) => {
11
+ if (name === 'constructor') {
12
+ return;
13
+ }
14
+
15
+ const descriptor = Object.getOwnPropertyDescriptor(mixinCtor.prototype, name);
16
+ if (descriptor) {
17
+ Object.defineProperty(targetCtor.prototype, name, descriptor);
18
+ }
19
+ });
20
+
21
+ // Copy static members if any (excluding length/name/prototype).
22
+ Object.getOwnPropertyNames(mixinCtor).forEach((name) => {
23
+ if (['length', 'name', 'prototype'].includes(name)) {
24
+ return;
25
+ }
26
+
27
+ const descriptor = Object.getOwnPropertyDescriptor(mixinCtor, name);
28
+ if (descriptor) {
29
+ Object.defineProperty(targetCtor, name, descriptor);
30
+ }
31
+ });
32
+ });
33
+ }
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function nodeEnvReadVarFromDotEnvFiles(
5
+ variableName: string,
6
+ envFiles: string[] = ['.env.local', '.env.build', '.env']
7
+ ): string | null {
8
+ for (const envFile of envFiles) {
9
+ const envPath = path.resolve(process.cwd(), envFile);
10
+ if (!fs.existsSync(envPath)) {
11
+ continue;
12
+ }
13
+
14
+ const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/);
15
+ for (const line of lines) {
16
+ const value = nodeEnvParseDotEnvLine(line, variableName);
17
+ if (value !== null) {
18
+ return value;
19
+ }
20
+ }
21
+ }
22
+
23
+ return null;
24
+ }
25
+
26
+ export function nodeEnvParseDotEnvLine(line: string, variableName: string): string | null {
27
+ const trimmed = line.trim();
28
+ if (!trimmed || trimmed.startsWith('#')) {
29
+ return null;
30
+ }
31
+
32
+ const match = trimmed.match(new RegExp(`^(?:export\\s+)?${variableName}\\s*=\\s*(.*)$`));
33
+ if (!match) {
34
+ return null;
35
+ }
36
+
37
+ let value = match[1].trim();
38
+ if (
39
+ (value.startsWith('"') && value.endsWith('"')) ||
40
+ (value.startsWith("'") && value.endsWith("'"))
41
+ ) {
42
+ value = value.slice(1, -1);
43
+ }
44
+
45
+ return value;
46
+ }
@@ -0,0 +1,37 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function nodeFsListFilesRecursively(
5
+ rootPath: string,
6
+ ignoredDirectoryNames: string[] = ['.git', 'node_modules']
7
+ ): string[] {
8
+ const files: string[] = [];
9
+ const directories = [rootPath];
10
+
11
+ while (directories.length) {
12
+ const currentPath = directories.pop();
13
+ if (!currentPath) {
14
+ continue;
15
+ }
16
+
17
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true });
18
+
19
+ entries.forEach((entry) => {
20
+ if (ignoredDirectoryNames.includes(entry.name)) {
21
+ return;
22
+ }
23
+
24
+ const absolutePath = path.join(currentPath, entry.name);
25
+ if (entry.isDirectory()) {
26
+ directories.push(absolutePath);
27
+ return;
28
+ }
29
+
30
+ if (entry.isFile()) {
31
+ files.push(absolutePath);
32
+ }
33
+ });
34
+ }
35
+
36
+ return files;
37
+ }
@@ -0,0 +1,23 @@
1
+ import path from 'node:path';
2
+
3
+ export function nodePathNormalizeToAbsolute(value: unknown): string | null {
4
+ if (typeof value !== 'string') {
5
+ return null;
6
+ }
7
+
8
+ const trimmed = value.trim();
9
+ if (!trimmed.length) {
10
+ return null;
11
+ }
12
+
13
+ return path.resolve(trimmed);
14
+ }
15
+
16
+ export function nodePathToPosix(filePath: string): string {
17
+ return filePath.split(path.sep).join('/');
18
+ }
19
+
20
+ export function nodePathStripExtension(filePath: string): string {
21
+ const extension = path.extname(filePath);
22
+ return filePath.slice(0, -extension.length);
23
+ }
@@ -0,0 +1,124 @@
1
+ export function objectMergeDeep<T extends Record<string, any>, U extends Record<string, any>>(
2
+ target: T = {} as T,
3
+ source: U = {} as U
4
+ ): T & U {
5
+ const output: Record<string, any> = { ...target };
6
+
7
+ Object.entries(source || {}).forEach(([key, value]) => {
8
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
9
+ output[key] = objectMergeDeep(
10
+ typeof output[key] === 'object' && output[key] !== null ? output[key] : {},
11
+ value as Record<string, any>
12
+ );
13
+ } else {
14
+ output[key] = value;
15
+ }
16
+ });
17
+
18
+ return output as T & U;
19
+ }
20
+
21
+ export function objectToType(value: unknown): string {
22
+ return {}.toString.call(value).match(/([a-z]+)(:?\])/i)?.[1] ?? typeof value;
23
+ }
24
+
25
+ export function objectIsPlainObject(value: unknown): value is Record<PropertyKey, unknown> {
26
+ return objectToType(value) === 'Object';
27
+ }
28
+
29
+ export function objectDeepAssignWithOptions(options: {
30
+ nonEnum?: boolean;
31
+ symbols?: boolean;
32
+ descriptors?: boolean;
33
+ proto?: boolean;
34
+ }) {
35
+ const mergedOptions = {
36
+ nonEnum: true,
37
+ symbols: true,
38
+ descriptors: true,
39
+ proto: true,
40
+ ...options,
41
+ };
42
+
43
+ return (target: any, ...sources: any[]) => {
44
+ sources.forEach((source) => {
45
+ if (!objectIsPlainObject(source) || !objectIsPlainObject(target)) return;
46
+
47
+ const copyProperty = (property: string | symbol) => {
48
+ const descriptor = Object.getOwnPropertyDescriptor(source, property);
49
+ if (!descriptor) return;
50
+
51
+ if (descriptor.enumerable || mergedOptions.nonEnum) {
52
+ if (objectIsPlainObject(source[property]) && objectIsPlainObject(target[property])) {
53
+ descriptor.value = objectDeepAssignWithOptions(mergedOptions)(
54
+ target[property],
55
+ source[property]
56
+ );
57
+ }
58
+
59
+ if (mergedOptions.descriptors) {
60
+ Object.defineProperty(target, property, descriptor);
61
+ } else {
62
+ target[property] = descriptor.value;
63
+ }
64
+ }
65
+ };
66
+
67
+ Object.getOwnPropertyNames(source).forEach(copyProperty);
68
+
69
+ if (mergedOptions.symbols) {
70
+ Object.getOwnPropertySymbols(source).forEach(copyProperty);
71
+ }
72
+
73
+ if (mergedOptions.proto) {
74
+ const targetProto = Object.getPrototypeOf(target);
75
+ const sourceProto = Object.getPrototypeOf(source);
76
+ if (targetProto && sourceProto) {
77
+ objectDeepAssignWithOptions({ ...mergedOptions, proto: false })(targetProto, sourceProto);
78
+ }
79
+ }
80
+ });
81
+ return target;
82
+ };
83
+ }
84
+
85
+ export function objectDeepAssign(...args: any[]): any {
86
+ return objectDeepAssignWithOptions({
87
+ nonEnum: true,
88
+ symbols: true,
89
+ descriptors: true,
90
+ proto: true,
91
+ }).apply(undefined, args as [any, ...any[]]);
92
+ }
93
+
94
+ export function objectCallPrototypeMethodIfExists<T extends object, R>(
95
+ self: T,
96
+ methodName: string,
97
+ args: unknown[] = []
98
+ ): R | undefined {
99
+ const method = Object.getPrototypeOf(self)?.[methodName];
100
+ if (typeof method === 'function') {
101
+ return method.apply(self, args);
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ export function objectGetItemByPath(
107
+ data: any,
108
+ key: string | string[],
109
+ defaultValue: any = null,
110
+ separator = '.'
111
+ ): any {
112
+ const keys = Array.isArray(key) ? key : key.split(separator);
113
+ let cursor: any = data;
114
+
115
+ for (const k of keys) {
116
+ if (cursor !== null && typeof cursor === 'object' && k in cursor) {
117
+ cursor = cursor[k];
118
+ } else {
119
+ return defaultValue;
120
+ }
121
+ }
122
+
123
+ return cursor;
124
+ }
@@ -0,0 +1,3 @@
1
+ export const POINTER = {
2
+ CLICK_DURATION: 500,
3
+ } as const;
@@ -0,0 +1,112 @@
1
+ export type QueueWorker<TItem, TResult = unknown> = (item: TItem) => Promise<TResult> | TResult;
2
+
3
+ export type QueueCallbacks<TItem, TResult = unknown> = {
4
+ onItemStart?: (item: TItem) => void;
5
+ onItemSuccess?: (item: TItem, result: TResult) => void;
6
+ onItemError?: (item: TItem, error: unknown) => void;
7
+ onItemDone?: (item: TItem) => void;
8
+ onDrain?: () => void;
9
+ };
10
+
11
+ export type QueueOptions<TItem, TResult = unknown> = QueueCallbacks<TItem, TResult> & {
12
+ worker: QueueWorker<TItem, TResult>;
13
+ concurrency?: number;
14
+ autoStart?: boolean;
15
+ };
16
+
17
+ export default class Queue<TItem, TResult = unknown> {
18
+ private queue: TItem[] = [];
19
+ private activeCount = 0;
20
+ private paused = false;
21
+ private readonly worker: QueueWorker<TItem, TResult>;
22
+ private readonly concurrency: number;
23
+ private readonly autoStart: boolean;
24
+ private readonly callbacks: QueueCallbacks<TItem, TResult>;
25
+
26
+ constructor(options: QueueOptions<TItem, TResult>) {
27
+ this.worker = options.worker;
28
+ this.concurrency = Math.max(1, options.concurrency ?? 1);
29
+ this.autoStart = options.autoStart ?? true;
30
+ this.callbacks = {
31
+ onItemStart: options.onItemStart,
32
+ onItemSuccess: options.onItemSuccess,
33
+ onItemError: options.onItemError,
34
+ onItemDone: options.onItemDone,
35
+ onDrain: options.onDrain,
36
+ };
37
+ }
38
+
39
+ enqueue(item: TItem): void {
40
+ this.queue.push(item);
41
+ if (this.autoStart) {
42
+ this.pump();
43
+ }
44
+ }
45
+
46
+ enqueueMany(items: TItem[]): void {
47
+ this.queue.push(...items);
48
+ if (this.autoStart) {
49
+ this.pump();
50
+ }
51
+ }
52
+
53
+ pause(): void {
54
+ this.paused = true;
55
+ }
56
+
57
+ resume(): void {
58
+ if (!this.paused) {
59
+ return;
60
+ }
61
+
62
+ this.paused = false;
63
+ this.pump();
64
+ }
65
+
66
+ clear(): void {
67
+ this.queue = [];
68
+ }
69
+
70
+ size(): number {
71
+ return this.queue.length;
72
+ }
73
+
74
+ active(): number {
75
+ return this.activeCount;
76
+ }
77
+
78
+ isIdle(): boolean {
79
+ return this.queue.length === 0 && this.activeCount === 0;
80
+ }
81
+
82
+ private pump(): void {
83
+ if (this.paused) {
84
+ return;
85
+ }
86
+
87
+ while (this.activeCount < this.concurrency && this.queue.length > 0) {
88
+ const item = this.queue.shift() as TItem;
89
+ this.runItem(item);
90
+ }
91
+
92
+ if (this.isIdle()) {
93
+ this.callbacks.onDrain?.();
94
+ }
95
+ }
96
+
97
+ private async runItem(item: TItem): Promise<void> {
98
+ this.activeCount += 1;
99
+ this.callbacks.onItemStart?.(item);
100
+
101
+ try {
102
+ const result = await this.worker(item);
103
+ this.callbacks.onItemSuccess?.(item, result);
104
+ } catch (error) {
105
+ this.callbacks.onItemError?.(item, error);
106
+ } finally {
107
+ this.callbacks.onItemDone?.(item);
108
+ this.activeCount -= 1;
109
+ this.pump();
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,170 @@
1
+ import { timeSleep } from './Time';
2
+
3
+ export type ReconnectBackoffOptions = {
4
+ initialDelayMs?: number;
5
+ maxDelayMs?: number;
6
+ factor?: number;
7
+ jitterRatio?: number;
8
+ maxAttempts?: number;
9
+ random?: () => number;
10
+ };
11
+
12
+ export type ReconnectBackoffResolvedOptions = {
13
+ initialDelayMs: number;
14
+ maxDelayMs: number;
15
+ factor: number;
16
+ jitterRatio: number;
17
+ maxAttempts: number;
18
+ random: () => number;
19
+ };
20
+
21
+ export type ReconnectAttemptContext = {
22
+ attempt: number;
23
+ delayMs: number;
24
+ error: unknown;
25
+ };
26
+
27
+ export type ReconnectAttemptCallbacks = {
28
+ onRetry?: (context: ReconnectAttemptContext) => void | Promise<void>;
29
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
30
+ };
31
+
32
+ const DEFAULT_RECONNECT_BACKOFF_OPTIONS: ReconnectBackoffResolvedOptions = {
33
+ initialDelayMs: 1000,
34
+ maxDelayMs: 30000,
35
+ factor: 2,
36
+ jitterRatio: 0.2,
37
+ maxAttempts: Number.POSITIVE_INFINITY,
38
+ random: Math.random,
39
+ };
40
+
41
+ export function reconnectBackoffResolveOptions(
42
+ options: ReconnectBackoffOptions = {}
43
+ ): ReconnectBackoffResolvedOptions {
44
+ const resolved: ReconnectBackoffResolvedOptions = {
45
+ ...DEFAULT_RECONNECT_BACKOFF_OPTIONS,
46
+ ...options,
47
+ };
48
+
49
+ if (resolved.initialDelayMs < 0) {
50
+ throw new Error('initialDelayMs must be >= 0.');
51
+ }
52
+
53
+ if (resolved.maxDelayMs < 0) {
54
+ throw new Error('maxDelayMs must be >= 0.');
55
+ }
56
+
57
+ if (resolved.factor < 1) {
58
+ throw new Error('factor must be >= 1.');
59
+ }
60
+
61
+ if (resolved.jitterRatio < 0 || resolved.jitterRatio > 1) {
62
+ throw new Error('jitterRatio must be between 0 and 1.');
63
+ }
64
+
65
+ if (resolved.maxAttempts < 1 && Number.isFinite(resolved.maxAttempts)) {
66
+ throw new Error('maxAttempts must be >= 1.');
67
+ }
68
+
69
+ return resolved;
70
+ }
71
+
72
+ export function reconnectBackoffBaseDelay(
73
+ attempt: number,
74
+ options: ReconnectBackoffOptions = {}
75
+ ): number {
76
+ const resolved = reconnectBackoffResolveOptions(options);
77
+ const safeAttempt = Math.max(1, attempt);
78
+ const baseDelay = resolved.initialDelayMs * resolved.factor ** (safeAttempt - 1);
79
+
80
+ return Math.min(resolved.maxDelayMs, baseDelay);
81
+ }
82
+
83
+ export function reconnectBackoffApplyJitter(
84
+ delayMs: number,
85
+ options: ReconnectBackoffOptions = {}
86
+ ): number {
87
+ const resolved = reconnectBackoffResolveOptions(options);
88
+ if (delayMs <= 0 || resolved.jitterRatio === 0) {
89
+ return Math.max(0, delayMs);
90
+ }
91
+
92
+ const amplitude = delayMs * resolved.jitterRatio;
93
+ const offset = (resolved.random() * 2 - 1) * amplitude;
94
+ const jitteredDelay = delayMs + offset;
95
+
96
+ return Math.max(0, Math.min(resolved.maxDelayMs, jitteredDelay));
97
+ }
98
+
99
+ export function reconnectBackoffDelay(
100
+ attempt: number,
101
+ options: ReconnectBackoffOptions = {}
102
+ ): number {
103
+ const baseDelay = reconnectBackoffBaseDelay(attempt, options);
104
+ return reconnectBackoffApplyJitter(baseDelay, options);
105
+ }
106
+
107
+ export type ReconnectBackoffController = {
108
+ getAttempt: () => number;
109
+ nextDelay: () => number;
110
+ canRetry: () => boolean;
111
+ reset: () => void;
112
+ };
113
+
114
+ export function reconnectBackoffCreateController(
115
+ options: ReconnectBackoffOptions = {}
116
+ ): ReconnectBackoffController {
117
+ const resolved = reconnectBackoffResolveOptions(options);
118
+ let attempt = 0;
119
+
120
+ return {
121
+ getAttempt: () => attempt,
122
+
123
+ canRetry: () => {
124
+ if (!Number.isFinite(resolved.maxAttempts)) {
125
+ return true;
126
+ }
127
+
128
+ return attempt < resolved.maxAttempts;
129
+ },
130
+
131
+ nextDelay: () => {
132
+ attempt += 1;
133
+ return reconnectBackoffDelay(attempt, resolved);
134
+ },
135
+
136
+ reset: () => {
137
+ attempt = 0;
138
+ },
139
+ };
140
+ }
141
+
142
+ export async function reconnectBackoffAttempt<T>(
143
+ operation: () => Promise<T> | T,
144
+ options: ReconnectBackoffOptions = {},
145
+ callbacks: ReconnectAttemptCallbacks = {}
146
+ ): Promise<T> {
147
+ const controller = reconnectBackoffCreateController(options);
148
+
149
+ while (true) {
150
+ try {
151
+ const result = await operation();
152
+ controller.reset();
153
+ return result;
154
+ } catch (error) {
155
+ const nextAttempt = controller.getAttempt() + 1;
156
+ const shouldRetry = callbacks.shouldRetry?.(error, nextAttempt) ?? true;
157
+ if (!shouldRetry || !controller.canRetry()) {
158
+ throw error;
159
+ }
160
+
161
+ const delayMs = controller.nextDelay();
162
+ await callbacks.onRetry?.({
163
+ attempt: controller.getAttempt(),
164
+ delayMs,
165
+ error,
166
+ });
167
+ await timeSleep(delayMs);
168
+ }
169
+ }
170
+ }