@vpmedia/simplify 1.74.0 → 1.76.0

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 (167) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/dist/const/http_status.d.ts +66 -0
  3. package/dist/const/http_status.d.ts.map +1 -0
  4. package/dist/index.d.ts +34 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +1119 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/logging/AbstractLogHandler.d.ts +17 -0
  9. package/dist/logging/AbstractLogHandler.d.ts.map +1 -0
  10. package/dist/logging/ConsoleLogHandler.d.ts +13 -0
  11. package/dist/logging/ConsoleLogHandler.d.ts.map +1 -0
  12. package/dist/logging/Logger.d.ts +19 -0
  13. package/dist/logging/Logger.d.ts.map +1 -0
  14. package/dist/logging/OpenTelemetryLogHandler.d.ts +15 -0
  15. package/dist/logging/OpenTelemetryLogHandler.d.ts.map +1 -0
  16. package/dist/logging/SentryLogHandler.d.ts +13 -0
  17. package/dist/logging/SentryLogHandler.d.ts.map +1 -0
  18. package/dist/logging/const.d.ts +14 -0
  19. package/dist/logging/const.d.ts.map +1 -0
  20. package/dist/logging/util.d.ts +14 -0
  21. package/dist/logging/util.d.ts.map +1 -0
  22. package/dist/pagelifecycle/const.d.ts +15 -0
  23. package/dist/pagelifecycle/const.d.ts.map +1 -0
  24. package/dist/pagelifecycle/typedef.d.ts +4 -0
  25. package/dist/pagelifecycle/typedef.d.ts.map +1 -0
  26. package/dist/pagelifecycle/util.d.ts +31 -0
  27. package/dist/pagelifecycle/util.d.ts.map +1 -0
  28. package/dist/typecheck/TypeCheckError.d.ts +11 -0
  29. package/dist/typecheck/TypeCheckError.d.ts.map +1 -0
  30. package/dist/typecheck/TypeChecker.d.ts +27 -0
  31. package/dist/typecheck/TypeChecker.d.ts.map +1 -0
  32. package/dist/typecheck/util.d.ts +17 -0
  33. package/dist/typecheck/util.d.ts.map +1 -0
  34. package/dist/util/async.d.ts +13 -0
  35. package/dist/util/async.d.ts.map +1 -0
  36. package/dist/util/error.d.ts +16 -0
  37. package/dist/util/error.d.ts.map +1 -0
  38. package/dist/util/event_emitter.d.ts +42 -0
  39. package/dist/util/event_emitter.d.ts.map +1 -0
  40. package/dist/util/fetch.d.ts +22 -0
  41. package/dist/util/fetch.d.ts.map +1 -0
  42. package/dist/util/number.d.ts +23 -0
  43. package/dist/util/number.d.ts.map +1 -0
  44. package/dist/util/object.d.ts +24 -0
  45. package/dist/util/object.d.ts.map +1 -0
  46. package/dist/util/query.d.ts +11 -0
  47. package/dist/util/query.d.ts.map +1 -0
  48. package/dist/util/state.d.ts +5 -0
  49. package/dist/util/state.d.ts.map +1 -0
  50. package/dist/util/string.d.ts +25 -0
  51. package/dist/util/string.d.ts.map +1 -0
  52. package/dist/util/uuid.d.ts +13 -0
  53. package/dist/util/uuid.d.ts.map +1 -0
  54. package/dist/util/validate.d.ts +106 -0
  55. package/dist/util/validate.d.ts.map +1 -0
  56. package/package.json +32 -16
  57. package/src/const/http_status.test.ts +7 -0
  58. package/src/const/{http_status.js → http_status.ts} +1 -1
  59. package/src/{index.js → index.ts} +8 -0
  60. package/src/logging/AbstractLogHandler.ts +31 -0
  61. package/src/logging/{ConsoleLogHandler.js → ConsoleLogHandler.ts} +15 -13
  62. package/src/logging/Logger.test.ts +69 -0
  63. package/src/logging/Logger.ts +77 -0
  64. package/src/logging/OpenTelemetryLogHandler.ts +40 -0
  65. package/src/logging/SentryLogHandler.ts +44 -0
  66. package/src/logging/{const.js → const.ts} +1 -1
  67. package/src/logging/util.test.ts +33 -0
  68. package/src/logging/util.ts +36 -0
  69. package/src/pagelifecycle/{const.js → const.ts} +2 -2
  70. package/src/pagelifecycle/typedef.ts +5 -0
  71. package/src/pagelifecycle/util.test.ts +99 -0
  72. package/src/pagelifecycle/{util.js → util.ts} +14 -27
  73. package/src/typecheck/{TypeCheckError.js → TypeCheckError.ts} +7 -3
  74. package/src/typecheck/TypeChecker.test.ts +70 -0
  75. package/src/typecheck/{TypeChecker.js → TypeChecker.ts} +10 -27
  76. package/src/typecheck/util.test.ts +36 -0
  77. package/src/typecheck/util.ts +50 -0
  78. package/src/util/async.test.ts +74 -0
  79. package/src/util/{async.js → async.ts} +3 -12
  80. package/src/util/error.test.ts +32 -0
  81. package/src/util/error.ts +37 -0
  82. package/src/util/event_emitter.test.ts +228 -0
  83. package/src/util/event_emitter.ts +147 -0
  84. package/src/util/fetch.test.ts +62 -0
  85. package/src/util/{fetch.js → fetch.ts} +40 -31
  86. package/src/util/number.test.ts +124 -0
  87. package/src/util/number.ts +58 -0
  88. package/src/util/object.test.ts +203 -0
  89. package/src/util/{object.js → object.ts} +14 -21
  90. package/src/util/query.test.ts +71 -0
  91. package/src/util/query.ts +35 -0
  92. package/src/util/state.test.ts +47 -0
  93. package/src/util/{state.js → state.ts} +3 -6
  94. package/src/util/string.test.ts +64 -0
  95. package/src/util/string.ts +65 -0
  96. package/src/util/uuid.test.ts +53 -0
  97. package/src/util/uuid.ts +31 -0
  98. package/src/util/validate.test.ts +309 -0
  99. package/src/util/validate.ts +230 -0
  100. package/.vscode/extensions.json +0 -6
  101. package/.vscode/settings.json +0 -27
  102. package/src/logging/AbstractLogHandler.js +0 -23
  103. package/src/logging/Logger.js +0 -115
  104. package/src/logging/OpenTelemetryLogHandler.js +0 -30
  105. package/src/logging/SentryLogHandler.js +0 -46
  106. package/src/logging/util.js +0 -41
  107. package/src/pagelifecycle/typedef.js +0 -9
  108. package/src/typecheck/util.js +0 -60
  109. package/src/util/error.js +0 -33
  110. package/src/util/event_emitter.js +0 -196
  111. package/src/util/number.js +0 -118
  112. package/src/util/query.js +0 -32
  113. package/src/util/string.js +0 -76
  114. package/src/util/uuid.js +0 -35
  115. package/src/util/validate.js +0 -247
  116. package/types/const/http_status.d.ts +0 -131
  117. package/types/const/http_status.d.ts.map +0 -1
  118. package/types/index.d.ts +0 -26
  119. package/types/index.d.ts.map +0 -1
  120. package/types/logging/AbstractLogHandler.d.ts +0 -20
  121. package/types/logging/AbstractLogHandler.d.ts.map +0 -1
  122. package/types/logging/ConsoleLogHandler.d.ts +0 -9
  123. package/types/logging/ConsoleLogHandler.d.ts.map +0 -1
  124. package/types/logging/Logger.d.ts +0 -66
  125. package/types/logging/Logger.d.ts.map +0 -1
  126. package/types/logging/OpenTelemetryLogHandler.d.ts +0 -11
  127. package/types/logging/OpenTelemetryLogHandler.d.ts.map +0 -1
  128. package/types/logging/SentryLogHandler.d.ts +0 -9
  129. package/types/logging/SentryLogHandler.d.ts.map +0 -1
  130. package/types/logging/const.d.ts +0 -14
  131. package/types/logging/const.d.ts.map +0 -1
  132. package/types/logging/util.d.ts +0 -4
  133. package/types/logging/util.d.ts.map +0 -1
  134. package/types/pagelifecycle/const.d.ts +0 -15
  135. package/types/pagelifecycle/const.d.ts.map +0 -1
  136. package/types/pagelifecycle/typedef.d.ts +0 -4
  137. package/types/pagelifecycle/typedef.d.ts.map +0 -1
  138. package/types/pagelifecycle/util.d.ts +0 -8
  139. package/types/pagelifecycle/util.d.ts.map +0 -1
  140. package/types/typecheck/TypeCheckError.d.ts +0 -13
  141. package/types/typecheck/TypeCheckError.d.ts.map +0 -1
  142. package/types/typecheck/TypeChecker.d.ts +0 -40
  143. package/types/typecheck/TypeChecker.d.ts.map +0 -1
  144. package/types/typecheck/util.d.ts +0 -4
  145. package/types/typecheck/util.d.ts.map +0 -1
  146. package/types/util/async.d.ts +0 -4
  147. package/types/util/async.d.ts.map +0 -1
  148. package/types/util/error.d.ts +0 -3
  149. package/types/util/error.d.ts.map +0 -1
  150. package/types/util/event_emitter.d.ts +0 -69
  151. package/types/util/event_emitter.d.ts.map +0 -1
  152. package/types/util/fetch.d.ts +0 -22
  153. package/types/util/fetch.d.ts.map +0 -1
  154. package/types/util/number.d.ts +0 -11
  155. package/types/util/number.d.ts.map +0 -1
  156. package/types/util/object.d.ts +0 -6
  157. package/types/util/object.d.ts.map +0 -1
  158. package/types/util/query.d.ts +0 -3
  159. package/types/util/query.d.ts.map +0 -1
  160. package/types/util/state.d.ts +0 -2
  161. package/types/util/state.d.ts.map +0 -1
  162. package/types/util/string.d.ts +0 -7
  163. package/types/util/string.d.ts.map +0 -1
  164. package/types/util/uuid.d.ts +0 -4
  165. package/types/util/uuid.d.ts.map +0 -1
  166. package/types/util/validate.d.ts +0 -45
  167. package/types/util/validate.d.ts.map +0 -1
@@ -2,23 +2,16 @@ import { getTypedError } from './error.js';
2
2
 
3
3
  /**
4
4
  * Returns a promise with delayed resolve.
5
- * @param {number} delayMS - Promise resolve delay in milliseconds.
6
- * @returns {Promise<void>} Delayed resolve promise.
7
5
  */
8
- export const delayPromise = (delayMS) =>
6
+ export const delayPromise = (delayMS: number): Promise<void> =>
9
7
  new Promise((resolve) => {
10
8
  setTimeout(resolve, delayMS);
11
9
  });
12
10
 
13
11
  /**
14
12
  * Async method call retry helper.
15
- * @template T
16
- * @param {() => Promise<T>} method - Async function to call.
17
- * @param {number} numTries - Max retries.
18
- * @param {number} delayMs - Delay between attempts in ms.
19
- * @returns {Promise<T>} Async function result.
20
13
  */
21
- export const retryAsync = async (method, numTries = 1, delayMs = 100) => {
14
+ export const retryAsync = async <T>(method: () => Promise<T>, numTries = 1, delayMs = 100): Promise<T> => {
22
15
  for (let attempt = 0; attempt <= numTries; attempt += 1) {
23
16
  try {
24
17
  // oxlint-disable-next-line no-await-in-loop
@@ -38,10 +31,8 @@ export const retryAsync = async (method, numTries = 1, delayMs = 100) => {
38
31
 
39
32
  /**
40
33
  * Load JSON file using a fetch GET request.
41
- * @param {string} url - URL to load.
42
- * @returns {Promise<unknown>} The parsed JSON data.
43
34
  */
44
- export const loadJSON = async (url) => {
35
+ export const loadJSON = async (url: string): Promise<unknown> => {
45
36
  const response = await fetch(url);
46
37
  if (!response.ok) {
47
38
  throw new DOMException(`Fetch error ${response.status}`, 'FetchError');
@@ -0,0 +1,32 @@
1
+ import { getErrorDetails, getTypedError } from './error.js';
2
+
3
+ describe('error', () => {
4
+ test('getErrorDetails', () => {
5
+ const error = new Error('Test error', { cause: 'Test cause' });
6
+ const errorDetails = getErrorDetails(error);
7
+ expect(errorDetails.type).toBe('Error');
8
+ expect(errorDetails.message).toBe('Test error');
9
+ expect(errorDetails.cause).toBe('Test cause');
10
+ expect(errorDetails['stack']).toBe(undefined);
11
+ });
12
+
13
+ test('getErrorDetails with Error cause', () => {
14
+ const error = new SyntaxError('Test error', { cause: new TypeError('Cause error') });
15
+ const errorDetails = getErrorDetails(error);
16
+ expect(errorDetails.type).toBe('SyntaxError');
17
+ expect(errorDetails.message).toBe('Test error');
18
+ expect(errorDetails.cause instanceof Error).toBe(true);
19
+ if (errorDetails.cause instanceof Error) {
20
+ expect(errorDetails.cause.message).toBe('Cause error');
21
+ }
22
+ });
23
+
24
+ test('getTypedError', () => {
25
+ expect(getTypedError(new Error('Error message')).message).toBe('Error message');
26
+ expect(getTypedError('Error message').message).toBe('Error message');
27
+ expect(getTypedError(1).message).toBe('1');
28
+ expect(getTypedError(true).message).toBe('true');
29
+ expect(getTypedError(null).message).toBe('null');
30
+ expect(getTypedError(undefined).message).toBe('undefined');
31
+ });
32
+ });
@@ -0,0 +1,37 @@
1
+ const DEFAULT_EXCLUDES = new Set(['stack']);
2
+
3
+ export interface ErrorDetails {
4
+ name: string;
5
+ type: string;
6
+ message?: string;
7
+ cause?: unknown;
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ /**
12
+ * Retrieves detailed information from an error object.
13
+ */
14
+ export const getErrorDetails = (error: Error, excludes?: string[] | null): ErrorDetails => {
15
+ const errorDetails: ErrorDetails = {
16
+ name: error.name,
17
+ type: error.constructor?.name ?? typeof error,
18
+ };
19
+ if (error.message) {
20
+ errorDetails.message = error.message;
21
+ }
22
+ if (error.cause) {
23
+ errorDetails.cause = error.cause;
24
+ }
25
+ for (const key of Object.getOwnPropertyNames(error)) {
26
+ if (!excludes?.includes(key) && !DEFAULT_EXCLUDES.has(key)) {
27
+ errorDetails[key] = (error as unknown as Record<string, unknown>)[key];
28
+ }
29
+ }
30
+ return errorDetails;
31
+ };
32
+
33
+ /**
34
+ * Get typed error from an unknown type.
35
+ */
36
+ export const getTypedError = (error: unknown): Error =>
37
+ error instanceof Error ? error : new Error(String(error));
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { EventEmitter } from './event_emitter.js';
3
+
4
+ describe('EventEmitter3 basics', () => {
5
+ it('can be instantiated', () => {
6
+ const e = new EventEmitter();
7
+ expect(e).toBeInstanceOf(EventEmitter);
8
+ });
9
+
10
+ it('supports subclassing', () => {
11
+ class Beast extends EventEmitter {}
12
+ const beast = new Beast();
13
+
14
+ expect(beast).toBeInstanceOf(Beast);
15
+ expect(beast).toBeInstanceOf(EventEmitter);
16
+ });
17
+ });
18
+
19
+ describe('emit()', () => {
20
+ it('returns false when no listeners exist', () => {
21
+ const e = new EventEmitter();
22
+ expect(e.emit('foo')).toBe(false);
23
+ });
24
+
25
+ it('returns true when listeners exist', () => {
26
+ const e = new EventEmitter();
27
+ e.on('foo', () => {});
28
+ expect(e.emit('foo')).toBe(true);
29
+ });
30
+
31
+ it('invokes listeners with provided arguments', async () => {
32
+ const e = new EventEmitter();
33
+
34
+ await new Promise<void>((resolve) => {
35
+ e.on('foo', (a: number, b: number) => {
36
+ expect(a).toBe(1);
37
+ expect(b).toBe(2);
38
+ resolve();
39
+ });
40
+
41
+ e.emit('foo', 1, 2);
42
+ });
43
+ });
44
+
45
+ it('binds the correct context', async () => {
46
+ const e = new EventEmitter();
47
+ const ctx = { value: 42 };
48
+
49
+ await new Promise<void>((resolve) => {
50
+ e.on(
51
+ 'foo',
52
+ function (this: typeof ctx) {
53
+ expect(this).toBe(ctx);
54
+ resolve();
55
+ },
56
+ ctx
57
+ );
58
+
59
+ e.emit('foo');
60
+ });
61
+ });
62
+
63
+ it('supports many listeners for the same event', () => {
64
+ const e = new EventEmitter();
65
+ const calls: number[] = [];
66
+
67
+ e.on('foo', () => calls.push(1));
68
+ e.on('foo', () => calls.push(2));
69
+
70
+ e.emit('foo');
71
+
72
+ expect(calls).toEqual([1, 2]);
73
+ });
74
+ });
75
+
76
+ describe('once()', () => {
77
+ it('fires the listener only once', () => {
78
+ const e = new EventEmitter();
79
+ let calls = 0;
80
+
81
+ e.once('foo', () => {
82
+ calls += 1;
83
+ });
84
+
85
+ e.emit('foo');
86
+ e.emit('foo');
87
+
88
+ expect(calls).toBe(1);
89
+ expect(e.listenerCount('foo')).toBe(0);
90
+ });
91
+
92
+ it('passes arguments correctly', async () => {
93
+ const e = new EventEmitter();
94
+
95
+ await new Promise<void>((resolve) => {
96
+ e.once('foo', (...args: unknown[]) => {
97
+ expect(args).toEqual([1, 2, 3]);
98
+ resolve();
99
+ });
100
+
101
+ e.emit('foo', 1, 2, 3);
102
+ });
103
+ });
104
+ });
105
+
106
+ describe('listeners() and listenerCount()', () => {
107
+ it('returns an empty array when no listeners exist', () => {
108
+ const e = new EventEmitter();
109
+ expect(e.listeners('foo')).toEqual([]);
110
+ expect(e.listenerCount('foo')).toBe(0);
111
+ });
112
+
113
+ it('returns only listener functions (not internals)', () => {
114
+ const e = new EventEmitter();
115
+ function fn() {}
116
+
117
+ e.on('foo', fn);
118
+
119
+ expect(e.listeners('foo')).toEqual([fn]);
120
+ });
121
+
122
+ it('does not expose internal listener storage', () => {
123
+ const e = new EventEmitter();
124
+ function fn() {}
125
+
126
+ e.on('foo', fn);
127
+ const listeners = e.listeners('foo');
128
+ listeners.push(() => {});
129
+
130
+ expect(e.listeners('foo')).toEqual([fn]);
131
+ });
132
+ });
133
+
134
+ describe('off()', () => {
135
+ it('removes all listeners for an event when fn is omitted', () => {
136
+ const e = new EventEmitter();
137
+
138
+ e.on('foo', () => {});
139
+ e.on('foo', () => {});
140
+
141
+ e.off('foo');
142
+
143
+ expect(e.listenerCount('foo')).toBe(0);
144
+ });
145
+
146
+ it('removes a specific listener', () => {
147
+ const e = new EventEmitter();
148
+ function fn() {}
149
+
150
+ e.on('foo', fn);
151
+ e.off('foo', fn);
152
+
153
+ expect(e.listeners('foo')).toEqual([]);
154
+ });
155
+
156
+ it('removes listeners matching both function and context', () => {
157
+ const e = new EventEmitter();
158
+ const ctx1 = {};
159
+ const ctx2 = {};
160
+ function fn() {}
161
+
162
+ e.on('foo', fn, ctx1);
163
+ e.on('foo', fn, ctx2);
164
+
165
+ e.off('foo', fn, ctx1);
166
+
167
+ expect(e.listenerCount('foo')).toBe(1);
168
+ });
169
+ });
170
+
171
+ describe('removeAllListeners()', () => {
172
+ it('removes listeners for a single event', () => {
173
+ const e = new EventEmitter();
174
+
175
+ e.on('foo', () => {});
176
+ e.on('bar', () => {});
177
+
178
+ e.removeAllListeners('foo');
179
+
180
+ expect(e.listenerCount('foo')).toBe(0);
181
+ expect(e.listenerCount('bar')).toBe(1);
182
+ });
183
+
184
+ it('removes all listeners when no event is specified', () => {
185
+ const e = new EventEmitter();
186
+
187
+ e.on('foo', () => {});
188
+ e.on('bar', () => {});
189
+
190
+ e.removeAllListeners();
191
+
192
+ expect(e.eventNames()).toEqual([]);
193
+ });
194
+ });
195
+
196
+ describe('eventNames()', () => {
197
+ it('returns an empty array when no events exist', () => {
198
+ const e = new EventEmitter();
199
+ expect(e.eventNames()).toEqual([]);
200
+ });
201
+
202
+ it('returns all registered event names', () => {
203
+ const e = new EventEmitter();
204
+
205
+ e.on('foo', () => {});
206
+ e.on('bar', () => {});
207
+
208
+ expect(e.eventNames()).toContain('foo');
209
+ expect(e.eventNames()).toContain('bar');
210
+ });
211
+
212
+ it('supports symbol event names', () => {
213
+ const e = new EventEmitter();
214
+ const sym = Symbol('test');
215
+
216
+ e.on(sym, () => {});
217
+ expect(e.eventNames()).toContain(sym);
218
+ });
219
+
220
+ it('events map is instance field', () => {
221
+ const e1 = new EventEmitter();
222
+ const e2 = new EventEmitter();
223
+ e1.on('event', () => {});
224
+ expect(e1.listenerCount('event')).toBe(1);
225
+ expect(e1.listenerCount('no-event')).toBe(0);
226
+ expect(e2.listenerCount('event')).toBe(0);
227
+ });
228
+ });
@@ -0,0 +1,147 @@
1
+ export type EventListener = (...args: any[]) => void;
2
+
3
+ /**
4
+ * Internal listener wrapper that stores metadata
5
+ * about a registered event listener.
6
+ */
7
+ class Listener {
8
+ fn: EventListener;
9
+ context: unknown;
10
+ once: boolean;
11
+
12
+ constructor(fn: EventListener, context: unknown, once = false) {
13
+ this.fn = fn;
14
+ this.context = context;
15
+ this.once = once;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Event emitter implementation inspired by Node.js/EventEmitter3.
21
+ */
22
+ export class EventEmitter {
23
+ #events: Map<string | symbol, Listener[]>;
24
+
25
+ constructor() {
26
+ this.#events = new Map();
27
+ }
28
+
29
+ /**
30
+ * Get all registered event names.
31
+ */
32
+ eventNames(): (string | symbol)[] {
33
+ return [...this.#events.keys()];
34
+ }
35
+
36
+ /**
37
+ * Get all listener functions registered for an event.
38
+ */
39
+ listeners(event: string | symbol): EventListener[] {
40
+ const listeners = this.#events.get(event);
41
+ return listeners ? listeners.map((l) => l.fn) : [];
42
+ }
43
+
44
+ /**
45
+ * Get the number of listeners registered for an event.
46
+ */
47
+ listenerCount(event: string | symbol): number {
48
+ const listeners = this.#events.get(event);
49
+ return listeners ? listeners.length : 0;
50
+ }
51
+
52
+ /**
53
+ * Emit an event, invoking all registered listeners.
54
+ */
55
+ emit(event: string | symbol, ...args: unknown[]): boolean {
56
+ const listeners = this.#events.get(event);
57
+ if (!listeners || listeners.length === 0) {
58
+ return false;
59
+ }
60
+
61
+ for (const listener of [...listeners]) {
62
+ listener.fn.apply(listener.context, args);
63
+ if (listener.once) {
64
+ this.off(event, listener.fn, listener.context);
65
+ }
66
+ }
67
+
68
+ return true;
69
+ }
70
+
71
+ #addListener(event: string | symbol, fn: EventListener, context: unknown, once: boolean): this {
72
+ if (typeof fn !== 'function') {
73
+ throw new TypeError('Listener must be a function');
74
+ }
75
+
76
+ const listener = new Listener(fn, context ?? this, once);
77
+ const listeners = this.#events.get(event);
78
+
79
+ if (listeners) {
80
+ listeners.push(listener);
81
+ } else {
82
+ this.#events.set(event, [listener]);
83
+ }
84
+
85
+ return this;
86
+ }
87
+
88
+ /**
89
+ * Register a persistent listener for an event.
90
+ */
91
+ on(event: string | symbol, fn: EventListener, context?: unknown): this {
92
+ return this.#addListener(event, fn, context, false);
93
+ }
94
+
95
+ /**
96
+ * Register a one-time listener for an event.
97
+ */
98
+ once(event: string | symbol, fn: EventListener, context?: unknown): this {
99
+ return this.#addListener(event, fn, context, true);
100
+ }
101
+
102
+ /**
103
+ * Remove a specific listener, or all listeners for an event.
104
+ */
105
+ off(event: string | symbol, fn?: EventListener, context?: unknown): this {
106
+ if (!this.#events.has(event)) {
107
+ return this;
108
+ }
109
+
110
+ if (!fn) {
111
+ this.#events.delete(event);
112
+ return this;
113
+ }
114
+
115
+ const filtered = this.#events.get(event)!.filter((listener) => {
116
+ if (listener.fn !== fn) {
117
+ return true;
118
+ }
119
+ if (context !== undefined && listener.context !== context) {
120
+ return true;
121
+ }
122
+ return false;
123
+ });
124
+
125
+ if (filtered.length > 0) {
126
+ this.#events.set(event, filtered);
127
+ } else {
128
+ this.#events.delete(event);
129
+ }
130
+
131
+ return this;
132
+ }
133
+
134
+ /**
135
+ * Remove all listeners from the emitter,
136
+ * or all listeners for a specific event.
137
+ */
138
+ removeAllListeners(event?: string | symbol): this {
139
+ if (event === undefined) {
140
+ this.#events.clear();
141
+ } else {
142
+ this.#events.delete(event);
143
+ }
144
+
145
+ return this;
146
+ }
147
+ }
@@ -0,0 +1,62 @@
1
+ import { HTTP_404_NOT_FOUND } from '../const/http_status.js';
2
+ import { fetchRetry, FetchError } from './fetch.js';
3
+
4
+ describe('FetchError', () => {
5
+ test('constructor', () => {
6
+ const error = new FetchError('message', 'url', { method: 'GET' }, null);
7
+ expect(error.message).toEqual('message');
8
+ expect(error.resource).toEqual('url');
9
+ expect(error.fetchOptions).toMatchObject({ method: 'GET' });
10
+ expect(error.response).toBe(null);
11
+ });
12
+ });
13
+
14
+ describe('fetchRetry', () => {
15
+ test('fetch OK', async () => {
16
+ const response = await fetchRetry('/test.json', {
17
+ cache: 'no-cache',
18
+ keepalive: false,
19
+ method: 'GET',
20
+ redirect: 'error',
21
+ });
22
+ const json = await response.json();
23
+ const expectedJSON = {
24
+ success: true,
25
+ method: 'GET',
26
+ };
27
+ expect(json).toEqual(expectedJSON);
28
+ });
29
+ test('fetch unknown scheme', async () => {
30
+ try {
31
+ await fetchRetry('htps://', {});
32
+ } catch (error) {
33
+ const typedError = error instanceof Error ? error : new Error(String(error));
34
+ expect(typedError.message).toEqual('fetch failed');
35
+ const typedErrorCause =
36
+ typedError.cause instanceof Error ? typedError.cause : new Error(String(typedError.cause));
37
+ expect(typedErrorCause.message).toEqual('unknown scheme');
38
+ }
39
+ });
40
+ test('fetch 404 error with retry', async () => {
41
+ try {
42
+ await fetchRetry(
43
+ '/test_error.json',
44
+ {
45
+ cache: 'no-cache',
46
+ keepalive: false,
47
+ method: 'POST',
48
+ redirect: 'error',
49
+ },
50
+ { numTries: 2, statusExcludes: [], delay: 1 }
51
+ );
52
+ } catch (error) {
53
+ const typedError = error instanceof Error ? error : new Error(String(error));
54
+ expect(typedError).toBeInstanceOf(FetchError);
55
+ if (typedError instanceof FetchError) {
56
+ expect(typedError.message).toBe('Fetch error 404');
57
+ expect(typedError.response?.status).toBe(HTTP_404_NOT_FOUND);
58
+ expect(typedError.cause).toBe(HTTP_404_NOT_FOUND);
59
+ }
60
+ }
61
+ });
62
+ });
@@ -12,15 +12,23 @@ const logger = new Logger('fetch');
12
12
 
13
13
  export const HTTP_0_ANY = 0;
14
14
 
15
+ export interface FetchRetryOptions {
16
+ delay?: number;
17
+ numTries?: number;
18
+ statusExcludes?: number[];
19
+ timeout?: number;
20
+ }
21
+
15
22
  export class FetchError extends Error {
23
+ resource: string | URL | Request;
24
+ fetchOptions: RequestInit;
25
+ response: Response | null;
26
+ override cause: number | null;
27
+
16
28
  /**
17
29
  * Creates a new FetchError instance.
18
- * @param {string} message - Error message.
19
- * @param {string | URL | Request} resource - Fetch URL.
20
- * @param {RequestInit} fetchOptions - Fetch options.
21
- * @param {Response} response - Fetch response.
22
30
  */
23
- constructor(message, resource, fetchOptions, response) {
31
+ constructor(message: string, resource: string | URL | Request, fetchOptions: RequestInit, response: Response | null) {
24
32
  super(message);
25
33
  this.name = 'FetchError';
26
34
  this.resource = resource;
@@ -32,31 +40,32 @@ export class FetchError extends Error {
32
40
 
33
41
  /**
34
42
  * Fetch with retry.
35
- * @param {string | URL | Request} resource - Fetch URL.
36
- * @param {RequestInit} fetchOptions - Fetch options.
37
- * @param {{delay?: number, numTries?: number, statusExcludes?: number[], timeout?: number}} [retryOptions] - Retry options.
38
- * @returns {Promise<Response>} Fetch result.
39
43
  */
40
- export const fetchRetry = async (resource, fetchOptions, retryOptions) => {
41
- retryOptions ??= {};
42
- retryOptions.timeout = Math.max(retryOptions.timeout ?? 30000, 1);
43
- retryOptions.delay = Math.max(retryOptions.delay ?? 1000, 1);
44
- retryOptions.numTries = Math.max(retryOptions.numTries ?? 1, 1);
45
- retryOptions.statusExcludes ??= [
46
- HTTP_401_UNAUTHORIZED,
47
- HTTP_403_FORBIDDEN,
48
- HTTP_405_METHOD_NOT_ALLOWED,
49
- HTTP_422_UNPROCESSABLE_CONTENT,
50
- ];
51
- while (retryOptions.numTries > 0) {
44
+ export const fetchRetry = async (
45
+ resource: string | URL | Request,
46
+ fetchOptions: RequestInit,
47
+ retryOptions?: FetchRetryOptions
48
+ ): Promise<Response> => {
49
+ const opts: Required<FetchRetryOptions> = {
50
+ timeout: Math.max(retryOptions?.timeout ?? 30_000, 1),
51
+ delay: Math.max(retryOptions?.delay ?? 1000, 1),
52
+ numTries: Math.max(retryOptions?.numTries ?? 1, 1),
53
+ statusExcludes: retryOptions?.statusExcludes ?? [
54
+ HTTP_401_UNAUTHORIZED,
55
+ HTTP_403_FORBIDDEN,
56
+ HTTP_405_METHOD_NOT_ALLOWED,
57
+ HTTP_422_UNPROCESSABLE_CONTENT,
58
+ ],
59
+ };
60
+ while (opts.numTries > 0) {
52
61
  const isOnline = globalThis.navigator?.onLine;
53
- logger.info('request', { resource, fetchOptions, retryOptions, isOnline });
62
+ logger.info('request', { resource: String(resource), fetchOptions, retryOptions: { ...opts }, isOnline });
54
63
  const controller = new AbortController();
55
64
  const timeoutId = setTimeout(
56
65
  () => controller.abort(new DOMException('Fetch timed out', 'AbortError')),
57
- retryOptions.timeout
66
+ opts.timeout
58
67
  );
59
- const options = {
68
+ const options: RequestInit = {
60
69
  ...fetchOptions,
61
70
  signal: controller.signal,
62
71
  };
@@ -65,22 +74,22 @@ export const fetchRetry = async (resource, fetchOptions, retryOptions) => {
65
74
  if (!response.ok) {
66
75
  throw new FetchError(`Fetch error ${response.status}`, resource, options, response);
67
76
  }
68
- logger.info('response', response);
77
+ logger.info('response', { status: response.status });
69
78
  return response;
70
79
  } catch (error) {
71
80
  const typedError = error instanceof Error ? error : new Error(String(error));
72
81
  logger.debug('error', getErrorDetails(typedError));
73
- retryOptions.numTries -= 1;
82
+ opts.numTries -= 1;
74
83
  if (
75
- retryOptions.numTries === 0 ||
84
+ opts.numTries === 0 ||
76
85
  (typedError instanceof FetchError &&
77
- (retryOptions.statusExcludes.includes(typedError.response.status) ||
78
- retryOptions.statusExcludes.includes(HTTP_0_ANY)))
86
+ (opts.statusExcludes.includes(typedError.response?.status ?? -1) ||
87
+ opts.statusExcludes.includes(HTTP_0_ANY)))
79
88
  ) {
80
89
  throw error;
81
90
  }
82
- await delayPromise(retryOptions.delay);
83
- retryOptions.delay *= 2;
91
+ await delayPromise(opts.delay);
92
+ opts.delay *= 2;
84
93
  } finally {
85
94
  clearTimeout(timeoutId);
86
95
  }