@myned-ai/gsplat-flame-avatar-renderer 1.0.4 → 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.
@@ -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;
@@ -28,7 +28,8 @@ export class LoaderUtils {
28
28
  try {
29
29
  // merges multi-byte utf-8 characters.
30
30
  return decodeURIComponent(escape(s));
31
- } catch (e) { // see #16358
31
+ } catch {
32
+ // see #16358 - ignore decoding errors
32
33
  return s;
33
34
  }
34
35
  }