@myned-ai/gsplat-flame-avatar-renderer 1.0.5 → 1.0.6
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/README.md +130 -32
- package/dist/gsplat-flame-avatar-renderer.cjs.js +3478 -722
- package/dist/gsplat-flame-avatar-renderer.cjs.min.js +2 -0
- package/dist/gsplat-flame-avatar-renderer.cjs.min.js.map +1 -0
- package/dist/gsplat-flame-avatar-renderer.esm.js +3439 -724
- package/dist/gsplat-flame-avatar-renderer.esm.min.js +2 -0
- package/dist/gsplat-flame-avatar-renderer.esm.min.js.map +1 -0
- package/package.json +10 -6
- package/src/core/SplatMesh.js +53 -46
- package/src/core/Viewer.js +47 -196
- package/src/errors/ApplicationError.js +185 -0
- package/src/errors/index.js +17 -0
- package/src/flame/FlameAnimator.js +282 -57
- package/src/loaders/PlyLoader.js +302 -44
- package/src/materials/SplatMaterial.js +13 -10
- package/src/materials/SplatMaterial3D.js +72 -27
- package/src/renderer/AnimationManager.js +8 -5
- package/src/renderer/GaussianSplatRenderer.js +668 -217
- package/src/utils/BlobUrlManager.js +294 -0
- package/src/utils/EventEmitter.js +349 -0
- package/src/utils/LoaderUtils.js +2 -1
- package/src/utils/Logger.js +171 -0
- package/src/utils/ObjectPool.js +248 -0
- package/src/utils/RenderLoop.js +306 -0
- package/src/utils/Util.js +59 -18
- package/src/utils/ValidationUtils.js +331 -0
- package/src/utils/index.js +10 -1
- package/dist/gsplat-flame-avatar-renderer.cjs.js.map +0 -1
- package/dist/gsplat-flame-avatar-renderer.esm.js.map +0 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlobUrlManager - Secure blob URL lifecycle management
|
|
3
|
+
*
|
|
4
|
+
* Tracks blob URLs and ensures they are revoked to prevent memory leaks
|
|
5
|
+
* and unauthorized access. Critical for security and resource management.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getLogger } from './Logger.js';
|
|
9
|
+
import { ValidationError } from '../errors/index.js';
|
|
10
|
+
|
|
11
|
+
const logger = getLogger('BlobUrlManager');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* BlobUrlManager - Manages blob URL lifecycle
|
|
15
|
+
*/
|
|
16
|
+
export class BlobUrlManager {
|
|
17
|
+
constructor() {
|
|
18
|
+
/**
|
|
19
|
+
* Map of blob URL to metadata
|
|
20
|
+
* @private
|
|
21
|
+
*/
|
|
22
|
+
this._urls = new Map();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether manager has been disposed
|
|
26
|
+
* @private
|
|
27
|
+
*/
|
|
28
|
+
this._disposed = false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Assert manager is not disposed
|
|
33
|
+
* @private
|
|
34
|
+
* @throws {Error} If manager is disposed
|
|
35
|
+
*/
|
|
36
|
+
_assertNotDisposed() {
|
|
37
|
+
if (this._disposed) {
|
|
38
|
+
throw new Error('BlobUrlManager has been disposed');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a blob URL from data
|
|
44
|
+
*
|
|
45
|
+
* @param {Blob|ArrayBuffer|Uint8Array} data - Data to create blob from
|
|
46
|
+
* @param {string} mimeType - MIME type (e.g., 'model/gltf-binary')
|
|
47
|
+
* @param {string} [label] - Optional label for debugging
|
|
48
|
+
* @returns {string} Blob URL
|
|
49
|
+
* @throws {ValidationError} If data or mimeType is invalid
|
|
50
|
+
*/
|
|
51
|
+
createBlobUrl(data, mimeType, label = '') {
|
|
52
|
+
this._assertNotDisposed();
|
|
53
|
+
|
|
54
|
+
// Validate mimeType
|
|
55
|
+
if (typeof mimeType !== 'string' || mimeType.length === 0) {
|
|
56
|
+
throw new ValidationError(
|
|
57
|
+
'mimeType must be a non-empty string',
|
|
58
|
+
'mimeType'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Convert data to Blob if needed
|
|
63
|
+
let blob;
|
|
64
|
+
if (data instanceof Blob) {
|
|
65
|
+
blob = data;
|
|
66
|
+
} else if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
|
67
|
+
blob = new Blob([data], { type: mimeType });
|
|
68
|
+
} else {
|
|
69
|
+
throw new ValidationError(
|
|
70
|
+
'data must be Blob, ArrayBuffer, or Uint8Array',
|
|
71
|
+
'data'
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Create blob URL
|
|
76
|
+
const url = URL.createObjectURL(blob);
|
|
77
|
+
|
|
78
|
+
// Track metadata
|
|
79
|
+
this._urls.set(url, {
|
|
80
|
+
createdAt: Date.now(),
|
|
81
|
+
mimeType,
|
|
82
|
+
label: label || 'unlabeled',
|
|
83
|
+
size: blob.size
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
logger.debug(`Created blob URL: ${label || url.substring(0, 50)}, size: ${blob.size} bytes`);
|
|
87
|
+
|
|
88
|
+
return url;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Register an externally created blob URL for tracking
|
|
93
|
+
*
|
|
94
|
+
* @param {string} url - Blob URL to track
|
|
95
|
+
* @param {string} [label] - Optional label for debugging
|
|
96
|
+
* @throws {ValidationError} If URL is not a blob URL
|
|
97
|
+
*/
|
|
98
|
+
registerBlobUrl(url, label = '') {
|
|
99
|
+
this._assertNotDisposed();
|
|
100
|
+
|
|
101
|
+
if (typeof url !== 'string' || !url.startsWith('blob:')) {
|
|
102
|
+
throw new ValidationError(
|
|
103
|
+
'url must be a valid blob URL',
|
|
104
|
+
'url'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!this._urls.has(url)) {
|
|
109
|
+
this._urls.set(url, {
|
|
110
|
+
createdAt: Date.now(),
|
|
111
|
+
mimeType: 'unknown',
|
|
112
|
+
label: label || 'registered-external',
|
|
113
|
+
size: 0
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
logger.debug(`Registered external blob URL: ${label || url.substring(0, 50)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Revoke a blob URL and remove from tracking
|
|
122
|
+
*
|
|
123
|
+
* @param {string} url - Blob URL to revoke
|
|
124
|
+
* @returns {boolean} True if URL was tracked and revoked
|
|
125
|
+
*/
|
|
126
|
+
revokeBlobUrl(url) {
|
|
127
|
+
this._assertNotDisposed();
|
|
128
|
+
|
|
129
|
+
if (this._urls.has(url)) {
|
|
130
|
+
const metadata = this._urls.get(url);
|
|
131
|
+
URL.revokeObjectURL(url);
|
|
132
|
+
this._urls.delete(url);
|
|
133
|
+
|
|
134
|
+
logger.debug(`Revoked blob URL: ${metadata.label}, age: ${Date.now() - metadata.createdAt}ms`);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Revoke all blob URLs and clear tracking
|
|
143
|
+
*/
|
|
144
|
+
revokeAll() {
|
|
145
|
+
this._assertNotDisposed();
|
|
146
|
+
|
|
147
|
+
logger.debug(`Revoking ${this._urls.size} blob URLs`);
|
|
148
|
+
|
|
149
|
+
for (const url of this._urls.keys()) {
|
|
150
|
+
URL.revokeObjectURL(url);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this._urls.clear();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get tracked blob URL metadata
|
|
158
|
+
*
|
|
159
|
+
* @param {string} url - Blob URL
|
|
160
|
+
* @returns {object|null} Metadata or null if not tracked
|
|
161
|
+
*/
|
|
162
|
+
getMetadata(url) {
|
|
163
|
+
return this._urls.get(url) || null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get all tracked blob URLs
|
|
168
|
+
*
|
|
169
|
+
* @returns {Array<{url: string, metadata: object}>} Array of URL info
|
|
170
|
+
*/
|
|
171
|
+
getAllTrackedUrls() {
|
|
172
|
+
const urls = [];
|
|
173
|
+
for (const [url, metadata] of this._urls.entries()) {
|
|
174
|
+
urls.push({ url, metadata });
|
|
175
|
+
}
|
|
176
|
+
return urls;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get statistics about tracked URLs
|
|
181
|
+
*
|
|
182
|
+
* @returns {object} Statistics
|
|
183
|
+
*/
|
|
184
|
+
getStats() {
|
|
185
|
+
let totalSize = 0;
|
|
186
|
+
let oldestAge = 0;
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
|
|
189
|
+
for (const metadata of this._urls.values()) {
|
|
190
|
+
totalSize += metadata.size;
|
|
191
|
+
const age = now - metadata.createdAt;
|
|
192
|
+
if (age > oldestAge) {
|
|
193
|
+
oldestAge = age;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
count: this._urls.size,
|
|
199
|
+
totalSize,
|
|
200
|
+
oldestAge
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Revoke blob URLs older than specified age
|
|
206
|
+
*
|
|
207
|
+
* @param {number} maxAgeMs - Maximum age in milliseconds
|
|
208
|
+
* @returns {number} Number of URLs revoked
|
|
209
|
+
*/
|
|
210
|
+
revokeOlderThan(maxAgeMs) {
|
|
211
|
+
this._assertNotDisposed();
|
|
212
|
+
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
const toRevoke = [];
|
|
215
|
+
|
|
216
|
+
for (const [url, metadata] of this._urls.entries()) {
|
|
217
|
+
const age = now - metadata.createdAt;
|
|
218
|
+
if (age > maxAgeMs) {
|
|
219
|
+
toRevoke.push(url);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const url of toRevoke) {
|
|
224
|
+
this.revokeBlobUrl(url);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (toRevoke.length > 0) {
|
|
228
|
+
logger.info(`Revoked ${toRevoke.length} blob URLs older than ${maxAgeMs}ms`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return toRevoke.length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check if a URL is being tracked
|
|
236
|
+
*
|
|
237
|
+
* @param {string} url - URL to check
|
|
238
|
+
* @returns {boolean} True if URL is tracked
|
|
239
|
+
*/
|
|
240
|
+
isTracked(url) {
|
|
241
|
+
return this._urls.has(url);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Dispose manager and revoke all URLs
|
|
246
|
+
*/
|
|
247
|
+
dispose() {
|
|
248
|
+
if (this._disposed) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
logger.debug('Disposing BlobUrlManager');
|
|
253
|
+
this.revokeAll();
|
|
254
|
+
this._disposed = true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Global blob URL manager instance
|
|
260
|
+
* @type {BlobUrlManager}
|
|
261
|
+
*/
|
|
262
|
+
const globalBlobUrlManager = new BlobUrlManager();
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get the global blob URL manager
|
|
266
|
+
* @returns {BlobUrlManager} Global manager instance
|
|
267
|
+
*/
|
|
268
|
+
export function getGlobalBlobUrlManager() {
|
|
269
|
+
return globalBlobUrlManager;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Helper function to create a blob URL with automatic tracking
|
|
274
|
+
*
|
|
275
|
+
* @param {Blob|ArrayBuffer|Uint8Array} data - Data to create blob from
|
|
276
|
+
* @param {string} mimeType - MIME type
|
|
277
|
+
* @param {string} [label] - Optional label for debugging
|
|
278
|
+
* @returns {string} Blob URL
|
|
279
|
+
*/
|
|
280
|
+
export function createTrackedBlobUrl(data, mimeType, label) {
|
|
281
|
+
return globalBlobUrlManager.createBlobUrl(data, mimeType, label);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Helper function to revoke a blob URL
|
|
286
|
+
*
|
|
287
|
+
* @param {string} url - Blob URL to revoke
|
|
288
|
+
* @returns {boolean} True if URL was tracked and revoked
|
|
289
|
+
*/
|
|
290
|
+
export function revokeTrackedBlobUrl(url) {
|
|
291
|
+
return globalBlobUrlManager.revokeBlobUrl(url);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export default BlobUrlManager;
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventEmitter - Observer pattern implementation for event-driven communication
|
|
3
|
+
*
|
|
4
|
+
* Provides decoupled event handling with automatic cleanup to prevent memory leaks.
|
|
5
|
+
* Critical for managing state changes and component communication.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getLogger } from './Logger.js';
|
|
9
|
+
import { ValidationError } from '../errors/index.js';
|
|
10
|
+
|
|
11
|
+
const logger = getLogger('EventEmitter');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* EventEmitter - Pub/sub event system
|
|
15
|
+
*/
|
|
16
|
+
export class EventEmitter {
|
|
17
|
+
constructor() {
|
|
18
|
+
/**
|
|
19
|
+
* Map of event name to Set of listeners
|
|
20
|
+
* @private
|
|
21
|
+
*/
|
|
22
|
+
this._listeners = new Map();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether emitter has been disposed
|
|
26
|
+
* @private
|
|
27
|
+
*/
|
|
28
|
+
this._disposed = false;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Event emission history for debugging (last N events)
|
|
32
|
+
* @private
|
|
33
|
+
*/
|
|
34
|
+
this._eventHistory = [];
|
|
35
|
+
this._maxHistorySize = 50;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Assert emitter is not disposed
|
|
40
|
+
* @private
|
|
41
|
+
* @throws {Error} If emitter is disposed
|
|
42
|
+
*/
|
|
43
|
+
_assertNotDisposed() {
|
|
44
|
+
if (this._disposed) {
|
|
45
|
+
throw new Error('EventEmitter has been disposed');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Subscribe to an event
|
|
51
|
+
*
|
|
52
|
+
* @param {string} event - Event name
|
|
53
|
+
* @param {Function} callback - Event handler
|
|
54
|
+
* @param {object} [options] - Subscription options
|
|
55
|
+
* @param {boolean} [options.once=false] - Auto-unsubscribe after first call
|
|
56
|
+
* @returns {Function} Unsubscribe function
|
|
57
|
+
* @throws {ValidationError} If parameters are invalid
|
|
58
|
+
*/
|
|
59
|
+
on(event, callback, options = {}) {
|
|
60
|
+
this._assertNotDisposed();
|
|
61
|
+
|
|
62
|
+
if (typeof event !== 'string' || event.length === 0) {
|
|
63
|
+
throw new ValidationError(
|
|
64
|
+
'event must be a non-empty string',
|
|
65
|
+
'event'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof callback !== 'function') {
|
|
70
|
+
throw new ValidationError(
|
|
71
|
+
'callback must be a function',
|
|
72
|
+
'callback'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Wrap callback if once option is set
|
|
77
|
+
const wrappedCallback = options.once
|
|
78
|
+
? (...args) => {
|
|
79
|
+
this.off(event, wrappedCallback);
|
|
80
|
+
callback(...args);
|
|
81
|
+
}
|
|
82
|
+
: callback;
|
|
83
|
+
|
|
84
|
+
// Store original callback reference for removal
|
|
85
|
+
if (options.once) {
|
|
86
|
+
wrappedCallback._originalCallback = callback;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Add listener
|
|
90
|
+
if (!this._listeners.has(event)) {
|
|
91
|
+
this._listeners.set(event, new Set());
|
|
92
|
+
}
|
|
93
|
+
this._listeners.get(event).add(wrappedCallback);
|
|
94
|
+
|
|
95
|
+
logger.debug(`Subscribed to event: ${event}`);
|
|
96
|
+
|
|
97
|
+
// Return unsubscribe function
|
|
98
|
+
return () => this.off(event, wrappedCallback);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Subscribe to an event (fires once then auto-unsubscribes)
|
|
103
|
+
*
|
|
104
|
+
* @param {string} event - Event name
|
|
105
|
+
* @param {Function} callback - Event handler
|
|
106
|
+
* @returns {Function} Unsubscribe function
|
|
107
|
+
*/
|
|
108
|
+
once(event, callback) {
|
|
109
|
+
return this.on(event, callback, { once: true });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Unsubscribe from an event
|
|
114
|
+
*
|
|
115
|
+
* @param {string} event - Event name
|
|
116
|
+
* @param {Function} callback - Event handler to remove
|
|
117
|
+
* @returns {boolean} True if callback was found and removed
|
|
118
|
+
*/
|
|
119
|
+
off(event, callback) {
|
|
120
|
+
this._assertNotDisposed();
|
|
121
|
+
|
|
122
|
+
const listeners = this._listeners.get(event);
|
|
123
|
+
if (!listeners) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Try to remove directly
|
|
128
|
+
if (listeners.delete(callback)) {
|
|
129
|
+
logger.debug(`Unsubscribed from event: ${event}`);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Try to find by original callback (for once listeners)
|
|
134
|
+
for (const listener of listeners) {
|
|
135
|
+
if (listener._originalCallback === callback) {
|
|
136
|
+
listeners.delete(listener);
|
|
137
|
+
logger.debug(`Unsubscribed from event: ${event} (once listener)`);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Remove all listeners for an event (or all events if no event specified)
|
|
147
|
+
*
|
|
148
|
+
* @param {string} [event] - Event name (optional)
|
|
149
|
+
*/
|
|
150
|
+
removeAllListeners(event = null) {
|
|
151
|
+
this._assertNotDisposed();
|
|
152
|
+
|
|
153
|
+
if (event) {
|
|
154
|
+
const count = this._listeners.get(event)?.size || 0;
|
|
155
|
+
this._listeners.delete(event);
|
|
156
|
+
logger.debug(`Removed ${count} listeners for event: ${event}`);
|
|
157
|
+
} else {
|
|
158
|
+
const totalCount = Array.from(this._listeners.values())
|
|
159
|
+
.reduce((sum, set) => sum + set.size, 0);
|
|
160
|
+
this._listeners.clear();
|
|
161
|
+
logger.debug(`Removed all ${totalCount} listeners`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Emit an event to all subscribers
|
|
167
|
+
*
|
|
168
|
+
* @param {string} event - Event name
|
|
169
|
+
* @param {...*} args - Arguments to pass to listeners
|
|
170
|
+
* @returns {boolean} True if event had listeners
|
|
171
|
+
*/
|
|
172
|
+
emit(event, ...args) {
|
|
173
|
+
this._assertNotDisposed();
|
|
174
|
+
|
|
175
|
+
const listeners = this._listeners.get(event);
|
|
176
|
+
if (!listeners || listeners.size === 0) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Record in history for debugging
|
|
181
|
+
this._recordEvent(event, args);
|
|
182
|
+
|
|
183
|
+
// Call all listeners
|
|
184
|
+
let callCount = 0;
|
|
185
|
+
for (const callback of listeners) {
|
|
186
|
+
try {
|
|
187
|
+
callback(...args);
|
|
188
|
+
callCount++;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
logger.error(`Error in event listener for '${event}':`, error);
|
|
191
|
+
// Continue calling other listeners
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
logger.debug(`Emitted event: ${event} to ${callCount} listeners`);
|
|
196
|
+
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Record event in history for debugging
|
|
202
|
+
* @private
|
|
203
|
+
* @param {string} event - Event name
|
|
204
|
+
* @param {Array} args - Event arguments
|
|
205
|
+
*/
|
|
206
|
+
_recordEvent(event, args) {
|
|
207
|
+
this._eventHistory.push({
|
|
208
|
+
event,
|
|
209
|
+
timestamp: Date.now(),
|
|
210
|
+
argCount: args.length
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Keep history bounded
|
|
214
|
+
if (this._eventHistory.length > this._maxHistorySize) {
|
|
215
|
+
this._eventHistory.shift();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get event emission history
|
|
221
|
+
* @returns {Array} Array of event records
|
|
222
|
+
*/
|
|
223
|
+
getEventHistory() {
|
|
224
|
+
return [...this._eventHistory];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Check if event has listeners
|
|
229
|
+
*
|
|
230
|
+
* @param {string} event - Event name
|
|
231
|
+
* @returns {boolean} True if event has listeners
|
|
232
|
+
*/
|
|
233
|
+
hasListeners(event) {
|
|
234
|
+
const listeners = this._listeners.get(event);
|
|
235
|
+
return listeners ? listeners.size > 0 : false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get listener count for an event
|
|
240
|
+
*
|
|
241
|
+
* @param {string} event - Event name
|
|
242
|
+
* @returns {number} Number of listeners
|
|
243
|
+
*/
|
|
244
|
+
listenerCount(event) {
|
|
245
|
+
const listeners = this._listeners.get(event);
|
|
246
|
+
return listeners ? listeners.size : 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get all event names with listeners
|
|
251
|
+
*
|
|
252
|
+
* @returns {string[]} Array of event names
|
|
253
|
+
*/
|
|
254
|
+
eventNames() {
|
|
255
|
+
return Array.from(this._listeners.keys());
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Get statistics about listeners
|
|
260
|
+
*
|
|
261
|
+
* @returns {object} Statistics
|
|
262
|
+
*/
|
|
263
|
+
getStats() {
|
|
264
|
+
const stats = {
|
|
265
|
+
totalEvents: this._listeners.size,
|
|
266
|
+
totalListeners: 0,
|
|
267
|
+
eventBreakdown: {}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
for (const [event, listeners] of this._listeners.entries()) {
|
|
271
|
+
const count = listeners.size;
|
|
272
|
+
stats.totalListeners += count;
|
|
273
|
+
stats.eventBreakdown[event] = count;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return stats;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Dispose emitter and remove all listeners
|
|
281
|
+
*/
|
|
282
|
+
dispose() {
|
|
283
|
+
if (this._disposed) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
logger.debug('Disposing EventEmitter');
|
|
288
|
+
this.removeAllListeners();
|
|
289
|
+
this._eventHistory.length = 0;
|
|
290
|
+
this._disposed = true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* TypedEventEmitter - Type-safe event emitter with event registry
|
|
296
|
+
*
|
|
297
|
+
* Ensures events are pre-registered, preventing typos and providing
|
|
298
|
+
* better developer experience.
|
|
299
|
+
*/
|
|
300
|
+
export class TypedEventEmitter extends EventEmitter {
|
|
301
|
+
/**
|
|
302
|
+
* Create a TypedEventEmitter
|
|
303
|
+
* @param {string[]} allowedEvents - Array of allowed event names
|
|
304
|
+
*/
|
|
305
|
+
constructor(allowedEvents) {
|
|
306
|
+
super();
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Set of allowed event names
|
|
310
|
+
* @private
|
|
311
|
+
*/
|
|
312
|
+
this._allowedEvents = new Set(allowedEvents);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Validate event name is allowed
|
|
317
|
+
* @private
|
|
318
|
+
* @param {string} event - Event name
|
|
319
|
+
* @throws {ValidationError} If event is not allowed
|
|
320
|
+
*/
|
|
321
|
+
_validateEvent(event) {
|
|
322
|
+
if (!this._allowedEvents.has(event)) {
|
|
323
|
+
throw new ValidationError(
|
|
324
|
+
`Event '${event}' is not registered. Allowed events: ${Array.from(this._allowedEvents).join(', ')}`,
|
|
325
|
+
'event'
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Subscribe to an event (override to add validation)
|
|
332
|
+
* @override
|
|
333
|
+
*/
|
|
334
|
+
on(event, callback, options) {
|
|
335
|
+
this._validateEvent(event);
|
|
336
|
+
return super.on(event, callback, options);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Emit an event (override to add validation)
|
|
341
|
+
* @override
|
|
342
|
+
*/
|
|
343
|
+
emit(event, ...args) {
|
|
344
|
+
this._validateEvent(event);
|
|
345
|
+
return super.emit(event, ...args);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export default EventEmitter;
|
package/src/utils/LoaderUtils.js
CHANGED