@xiboplayer/utils 0.1.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.
- package/docs/README.md +61 -0
- package/package.json +36 -0
- package/src/cms-api.js +764 -0
- package/src/cms-api.test.js +803 -0
- package/src/config.js +288 -0
- package/src/config.test.js +473 -0
- package/src/event-emitter.js +77 -0
- package/src/event-emitter.test.js +432 -0
- package/src/fetch-retry.js +61 -0
- package/src/fetch-retry.test.js +108 -0
- package/src/index.js +6 -0
- package/src/logger.js +237 -0
- package/src/logger.test.js +477 -0
- package/vitest.config.js +8 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for configuration management with localStorage persistence
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
8
|
+
import { Config } from './config.js';
|
|
9
|
+
|
|
10
|
+
describe('Config', () => {
|
|
11
|
+
let config;
|
|
12
|
+
let mockLocalStorage;
|
|
13
|
+
let mockRandomUUID;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// Mock localStorage
|
|
17
|
+
mockLocalStorage = {
|
|
18
|
+
data: {},
|
|
19
|
+
getItem(key) {
|
|
20
|
+
return this.data[key] || null;
|
|
21
|
+
},
|
|
22
|
+
setItem(key, value) {
|
|
23
|
+
this.data[key] = value;
|
|
24
|
+
},
|
|
25
|
+
removeItem(key) {
|
|
26
|
+
delete this.data[key];
|
|
27
|
+
},
|
|
28
|
+
clear() {
|
|
29
|
+
this.data = {};
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
vi.stubGlobal('localStorage', mockLocalStorage);
|
|
34
|
+
|
|
35
|
+
// Mock crypto.randomUUID using vi.stubGlobal (jsdom makes crypto read-only)
|
|
36
|
+
mockRandomUUID = vi.fn(() => '12345678-1234-4567-8901-234567890abc');
|
|
37
|
+
vi.stubGlobal('crypto', {
|
|
38
|
+
randomUUID: mockRandomUUID
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Ensure no env vars interfere with localStorage path
|
|
42
|
+
delete process.env.CMS_ADDRESS;
|
|
43
|
+
delete process.env.CMS_URL;
|
|
44
|
+
delete process.env.CMS_KEY;
|
|
45
|
+
delete process.env.DISPLAY_NAME;
|
|
46
|
+
delete process.env.HARDWARE_KEY;
|
|
47
|
+
delete process.env.XMR_CHANNEL;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
vi.restoreAllMocks();
|
|
52
|
+
vi.unstubAllGlobals();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('Initialization', () => {
|
|
56
|
+
it('should create new config when localStorage is empty', () => {
|
|
57
|
+
config = new Config();
|
|
58
|
+
|
|
59
|
+
expect(config.data).toBeDefined();
|
|
60
|
+
expect(config.data.cmsAddress).toBe('');
|
|
61
|
+
expect(config.data.cmsKey).toBe('');
|
|
62
|
+
expect(config.data.displayName).toBe('');
|
|
63
|
+
expect(config.data.hardwareKey).toMatch(/^pwa-/);
|
|
64
|
+
expect(config.data.xmrChannel).toMatch(/^[0-9a-f-]{36}$/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should generate stable hardware key on first load', () => {
|
|
68
|
+
config = new Config();
|
|
69
|
+
|
|
70
|
+
const hwKey = config.data.hardwareKey;
|
|
71
|
+
expect(hwKey).toMatch(/^pwa-[0-9a-f]{28}$/);
|
|
72
|
+
expect(hwKey).toBe('pwa-1234567812344567890123456789');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should save config to localStorage on creation', () => {
|
|
76
|
+
config = new Config();
|
|
77
|
+
|
|
78
|
+
const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
|
|
79
|
+
expect(stored).toEqual(config.data);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should load existing config from localStorage', () => {
|
|
83
|
+
const existingConfig = {
|
|
84
|
+
cmsAddress: 'https://test.cms.com',
|
|
85
|
+
cmsKey: 'test-key',
|
|
86
|
+
displayName: 'Test Display',
|
|
87
|
+
hardwareKey: 'pwa-existinghardwarekey1234567',
|
|
88
|
+
xmrChannel: '12345678-1234-4567-8901-234567890abc'
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
mockLocalStorage.setItem('xibo_config', JSON.stringify(existingConfig));
|
|
92
|
+
|
|
93
|
+
config = new Config();
|
|
94
|
+
|
|
95
|
+
expect(config.data).toEqual(existingConfig);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should regenerate hardware key if invalid in stored config', () => {
|
|
99
|
+
const invalidConfig = {
|
|
100
|
+
cmsAddress: 'https://test.cms.com',
|
|
101
|
+
cmsKey: 'test-key',
|
|
102
|
+
displayName: 'Test Display',
|
|
103
|
+
hardwareKey: 'short', // Invalid: too short
|
|
104
|
+
xmrChannel: '12345678-1234-4567-8901-234567890abc'
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
mockLocalStorage.setItem('xibo_config', JSON.stringify(invalidConfig));
|
|
108
|
+
|
|
109
|
+
config = new Config();
|
|
110
|
+
|
|
111
|
+
expect(config.data.hardwareKey).not.toBe('short');
|
|
112
|
+
expect(config.data.hardwareKey).toMatch(/^pwa-[0-9a-f]{28}$/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should handle corrupted JSON in localStorage', () => {
|
|
116
|
+
mockLocalStorage.setItem('xibo_config', 'invalid-json{');
|
|
117
|
+
|
|
118
|
+
config = new Config();
|
|
119
|
+
|
|
120
|
+
// Should create new config
|
|
121
|
+
expect(config.data.hardwareKey).toMatch(/^pwa-/);
|
|
122
|
+
expect(config.isConfigured()).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('Hardware Key Generation', () => {
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
config = new Config();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should generate UUID-based hardware key when crypto.randomUUID available', () => {
|
|
132
|
+
const hwKey = config.generateStableHardwareKey();
|
|
133
|
+
|
|
134
|
+
expect(hwKey).toBe('pwa-1234567812344567890123456789');
|
|
135
|
+
expect(mockRandomUUID).toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should fallback to random hex when crypto.randomUUID unavailable', () => {
|
|
139
|
+
vi.stubGlobal('crypto', {});
|
|
140
|
+
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
|
141
|
+
|
|
142
|
+
const hwKey = config.generateStableHardwareKey();
|
|
143
|
+
|
|
144
|
+
expect(hwKey).toMatch(/^pwa-[0-9a-f]{28}$/);
|
|
145
|
+
expect(hwKey.length).toBe(32); // 'pwa-' + 28 chars
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should ensure hardware key never becomes undefined', () => {
|
|
149
|
+
config.data.hardwareKey = undefined;
|
|
150
|
+
|
|
151
|
+
const hwKey = config.hardwareKey; // Getter auto-repairs
|
|
152
|
+
|
|
153
|
+
expect(hwKey).toMatch(/^pwa-/);
|
|
154
|
+
expect(config.data.hardwareKey).toBe(hwKey);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('XMR Channel Generation', () => {
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
config = new Config();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should generate valid UUID v4', () => {
|
|
164
|
+
const channel = config.generateXmrChannel();
|
|
165
|
+
|
|
166
|
+
expect(channel).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should generate different UUIDs each time', () => {
|
|
170
|
+
const channel1 = config.generateXmrChannel();
|
|
171
|
+
const channel2 = config.generateXmrChannel();
|
|
172
|
+
|
|
173
|
+
expect(channel1).not.toBe(channel2);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('Hash Function (FNV-1a)', () => {
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
config = new Config();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should generate 32-character hex hash', () => {
|
|
183
|
+
const hash = config.hash('test string');
|
|
184
|
+
|
|
185
|
+
expect(hash).toMatch(/^[0-9a-f]{32}$/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should be deterministic for same input', () => {
|
|
189
|
+
const hash1 = config.hash('test');
|
|
190
|
+
const hash2 = config.hash('test');
|
|
191
|
+
|
|
192
|
+
expect(hash1).toBe(hash2);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should produce different hashes for different inputs', () => {
|
|
196
|
+
const hash1 = config.hash('test1');
|
|
197
|
+
const hash2 = config.hash('test2');
|
|
198
|
+
|
|
199
|
+
expect(hash1).not.toBe(hash2);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should handle empty string', () => {
|
|
203
|
+
const hash = config.hash('');
|
|
204
|
+
|
|
205
|
+
expect(hash).toMatch(/^[0-9a-f]{32}$/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should produce good entropy for similar inputs', () => {
|
|
209
|
+
const hash1 = config.hash('a');
|
|
210
|
+
const hash2 = config.hash('b');
|
|
211
|
+
|
|
212
|
+
// Hashes should be completely different (not just 1 character difference)
|
|
213
|
+
let differences = 0;
|
|
214
|
+
for (let i = 0; i < hash1.length; i++) {
|
|
215
|
+
if (hash1[i] !== hash2[i]) differences++;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
expect(differences).toBeGreaterThan(15); // At least half different
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('Canvas Fingerprint', () => {
|
|
223
|
+
let createElementSpy;
|
|
224
|
+
|
|
225
|
+
beforeEach(() => {
|
|
226
|
+
config = new Config();
|
|
227
|
+
|
|
228
|
+
// Mock canvas via spying on document.createElement
|
|
229
|
+
const mockCanvas = {
|
|
230
|
+
getContext: vi.fn(() => ({
|
|
231
|
+
textBaseline: '',
|
|
232
|
+
font: '',
|
|
233
|
+
fillStyle: '',
|
|
234
|
+
fillRect: vi.fn(),
|
|
235
|
+
fillText: vi.fn()
|
|
236
|
+
})),
|
|
237
|
+
toDataURL: vi.fn(() => 'data:image/png;base64,mockdata')
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
afterEach(() => {
|
|
244
|
+
createElementSpy.mockRestore();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should generate canvas fingerprint', () => {
|
|
248
|
+
const fingerprint = config.getCanvasFingerprint();
|
|
249
|
+
|
|
250
|
+
expect(fingerprint).toBe('data:image/png;base64,mockdata');
|
|
251
|
+
expect(createElementSpy).toHaveBeenCalledWith('canvas');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should return "no-canvas" when canvas context unavailable', () => {
|
|
255
|
+
const mockCanvas = {
|
|
256
|
+
getContext: vi.fn(() => null)
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
createElementSpy.mockReturnValue(mockCanvas);
|
|
260
|
+
|
|
261
|
+
const fingerprint = config.getCanvasFingerprint();
|
|
262
|
+
|
|
263
|
+
expect(fingerprint).toBe('no-canvas');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should return "canvas-error" on exception', () => {
|
|
267
|
+
createElementSpy.mockImplementation(() => {
|
|
268
|
+
throw new Error('Canvas not supported');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const fingerprint = config.getCanvasFingerprint();
|
|
272
|
+
|
|
273
|
+
expect(fingerprint).toBe('canvas-error');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('Configuration Getters/Setters', () => {
|
|
278
|
+
beforeEach(() => {
|
|
279
|
+
config = new Config();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should get/set cmsAddress', () => {
|
|
283
|
+
expect(config.cmsAddress).toBe('');
|
|
284
|
+
|
|
285
|
+
config.cmsAddress = 'https://new.cms.com';
|
|
286
|
+
|
|
287
|
+
expect(config.cmsAddress).toBe('https://new.cms.com');
|
|
288
|
+
expect(config.data.cmsAddress).toBe('https://new.cms.com');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should save to localStorage when cmsAddress set', () => {
|
|
292
|
+
config.cmsAddress = 'https://test.com';
|
|
293
|
+
|
|
294
|
+
const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
|
|
295
|
+
expect(stored.cmsAddress).toBe('https://test.com');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should get/set cmsKey', () => {
|
|
299
|
+
config.cmsKey = 'new-key';
|
|
300
|
+
|
|
301
|
+
expect(config.cmsKey).toBe('new-key');
|
|
302
|
+
expect(config.data.cmsKey).toBe('new-key');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should get/set displayName', () => {
|
|
306
|
+
config.displayName = 'New Display';
|
|
307
|
+
|
|
308
|
+
expect(config.displayName).toBe('New Display');
|
|
309
|
+
expect(config.data.displayName).toBe('New Display');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should get hardwareKey (read-only via data)', () => {
|
|
313
|
+
const originalKey = config.hardwareKey;
|
|
314
|
+
|
|
315
|
+
expect(config.hardwareKey).toBe(originalKey);
|
|
316
|
+
expect(config.hardwareKey).toMatch(/^pwa-/);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should get xmrChannel (read-only)', () => {
|
|
320
|
+
const channel = config.xmrChannel;
|
|
321
|
+
|
|
322
|
+
expect(channel).toMatch(/^[0-9a-f-]{36}$/);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('isConfigured()', () => {
|
|
327
|
+
beforeEach(() => {
|
|
328
|
+
config = new Config();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should return false when config incomplete', () => {
|
|
332
|
+
expect(config.isConfigured()).toBe(false);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('should return false when only cmsAddress set', () => {
|
|
336
|
+
config.cmsAddress = 'https://test.com';
|
|
337
|
+
|
|
338
|
+
expect(config.isConfigured()).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should return false when only cmsKey set', () => {
|
|
342
|
+
config.cmsKey = 'test-key';
|
|
343
|
+
|
|
344
|
+
expect(config.isConfigured()).toBe(false);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should return false when only displayName set', () => {
|
|
348
|
+
config.displayName = 'Test Display';
|
|
349
|
+
|
|
350
|
+
expect(config.isConfigured()).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should return true when all required fields set', () => {
|
|
354
|
+
config.cmsAddress = 'https://test.com';
|
|
355
|
+
config.cmsKey = 'test-key';
|
|
356
|
+
config.displayName = 'Test Display';
|
|
357
|
+
|
|
358
|
+
expect(config.isConfigured()).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe('save()', () => {
|
|
363
|
+
beforeEach(() => {
|
|
364
|
+
config = new Config();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('should save current config to localStorage', () => {
|
|
368
|
+
config.data.cmsAddress = 'https://manual.com';
|
|
369
|
+
config.data.cmsKey = 'manual-key';
|
|
370
|
+
|
|
371
|
+
config.save();
|
|
372
|
+
|
|
373
|
+
const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
|
|
374
|
+
expect(stored.cmsAddress).toBe('https://manual.com');
|
|
375
|
+
expect(stored.cmsKey).toBe('manual-key');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should auto-save when setters used', () => {
|
|
379
|
+
config.cmsAddress = 'https://auto.com';
|
|
380
|
+
|
|
381
|
+
const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
|
|
382
|
+
expect(stored.cmsAddress).toBe('https://auto.com');
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe('Backwards Compatibility', () => {
|
|
387
|
+
beforeEach(() => {
|
|
388
|
+
config = new Config();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('should support generateHardwareKey() alias', () => {
|
|
392
|
+
const key1 = config.generateHardwareKey();
|
|
393
|
+
const key2 = config.generateStableHardwareKey();
|
|
394
|
+
|
|
395
|
+
// Both should generate valid keys
|
|
396
|
+
expect(key1).toMatch(/^pwa-[0-9a-f]{28}$/);
|
|
397
|
+
expect(key2).toMatch(/^pwa-[0-9a-f]{28}$/);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe('Edge Cases', () => {
|
|
402
|
+
it('should handle missing hardwareKey in loaded config', () => {
|
|
403
|
+
mockLocalStorage.setItem('xibo_config', JSON.stringify({
|
|
404
|
+
cmsAddress: 'https://test.com',
|
|
405
|
+
cmsKey: 'test-key',
|
|
406
|
+
displayName: 'Test'
|
|
407
|
+
// hardwareKey missing
|
|
408
|
+
}));
|
|
409
|
+
|
|
410
|
+
config = new Config();
|
|
411
|
+
|
|
412
|
+
// Should auto-generate
|
|
413
|
+
expect(config.hardwareKey).toMatch(/^pwa-/);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('should handle null values in config', () => {
|
|
417
|
+
mockLocalStorage.setItem('xibo_config', JSON.stringify({
|
|
418
|
+
cmsAddress: null,
|
|
419
|
+
cmsKey: null,
|
|
420
|
+
displayName: null,
|
|
421
|
+
hardwareKey: 'pwa-1234567812344567890123456789',
|
|
422
|
+
xmrChannel: '12345678-1234-4567-8901-234567890abc'
|
|
423
|
+
}));
|
|
424
|
+
|
|
425
|
+
config = new Config();
|
|
426
|
+
|
|
427
|
+
expect(config.isConfigured()).toBe(false);
|
|
428
|
+
expect(config.cmsAddress).toBeNull();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('should handle very long strings', () => {
|
|
432
|
+
config = new Config();
|
|
433
|
+
|
|
434
|
+
const longString = 'a'.repeat(10000);
|
|
435
|
+
const hash = config.hash(longString);
|
|
436
|
+
|
|
437
|
+
expect(hash).toMatch(/^[0-9a-f]{32}$/);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('should handle unicode in hash', () => {
|
|
441
|
+
config = new Config();
|
|
442
|
+
|
|
443
|
+
const hash = config.hash('测试中文🎉');
|
|
444
|
+
|
|
445
|
+
expect(hash).toMatch(/^[0-9a-f]{32}$/);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe('Persistence', () => {
|
|
450
|
+
it('should persist hardware key across multiple instances', () => {
|
|
451
|
+
const config1 = new Config();
|
|
452
|
+
const key1 = config1.hardwareKey;
|
|
453
|
+
|
|
454
|
+
const config2 = new Config();
|
|
455
|
+
const key2 = config2.hardwareKey;
|
|
456
|
+
|
|
457
|
+
expect(key1).toBe(key2);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should persist configuration changes', () => {
|
|
461
|
+
const config1 = new Config();
|
|
462
|
+
config1.cmsAddress = 'https://persist.com';
|
|
463
|
+
config1.cmsKey = 'persist-key';
|
|
464
|
+
config1.displayName = 'Persist Display';
|
|
465
|
+
|
|
466
|
+
const config2 = new Config();
|
|
467
|
+
|
|
468
|
+
expect(config2.cmsAddress).toBe('https://persist.com');
|
|
469
|
+
expect(config2.cmsKey).toBe('persist-key');
|
|
470
|
+
expect(config2.displayName).toBe('Persist Display');
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple EventEmitter implementation
|
|
3
|
+
* Compatible with both browser and Node.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class EventEmitter {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.events = new Map();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register event listener
|
|
13
|
+
* @param {string} event - Event name
|
|
14
|
+
* @param {Function} callback - Callback function
|
|
15
|
+
*/
|
|
16
|
+
on(event, callback) {
|
|
17
|
+
if (!this.events.has(event)) {
|
|
18
|
+
this.events.set(event, []);
|
|
19
|
+
}
|
|
20
|
+
this.events.get(event).push(callback);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Register one-time event listener
|
|
25
|
+
* @param {string} event - Event name
|
|
26
|
+
* @param {Function} callback - Callback function
|
|
27
|
+
*/
|
|
28
|
+
once(event, callback) {
|
|
29
|
+
const wrapper = (...args) => {
|
|
30
|
+
callback(...args);
|
|
31
|
+
this.off(event, wrapper);
|
|
32
|
+
};
|
|
33
|
+
this.on(event, wrapper);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Remove event listener
|
|
38
|
+
* @param {string} event - Event name
|
|
39
|
+
* @param {Function} callback - Callback function
|
|
40
|
+
*/
|
|
41
|
+
off(event, callback) {
|
|
42
|
+
if (!this.events.has(event)) return;
|
|
43
|
+
|
|
44
|
+
const listeners = this.events.get(event);
|
|
45
|
+
const index = listeners.indexOf(callback);
|
|
46
|
+
if (index !== -1) {
|
|
47
|
+
listeners.splice(index, 1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Emit event
|
|
53
|
+
* @param {string} event - Event name
|
|
54
|
+
* @param {...any} args - Arguments to pass to listeners
|
|
55
|
+
*/
|
|
56
|
+
emit(event, ...args) {
|
|
57
|
+
if (!this.events.has(event)) return;
|
|
58
|
+
|
|
59
|
+
// Make a copy to handle listeners that remove themselves during emission
|
|
60
|
+
const listeners = this.events.get(event).slice();
|
|
61
|
+
for (const listener of listeners) {
|
|
62
|
+
listener(...args);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Remove all listeners for an event
|
|
68
|
+
* @param {string} event - Event name (optional, removes all if not specified)
|
|
69
|
+
*/
|
|
70
|
+
removeAllListeners(event) {
|
|
71
|
+
if (event) {
|
|
72
|
+
this.events.delete(event);
|
|
73
|
+
} else {
|
|
74
|
+
this.events.clear();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|