@smoregg/sdk 1.3.0 → 2.0.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.
@@ -1,7 +1,7 @@
1
1
  import { PostMessageTransport } from './transport/PostMessageTransport.js';
2
2
  import { isBridgeMessage, validateInitPayload } from './transport/protocol.js';
3
3
  import { SmoreSDKError } from './errors.js';
4
- import { validateEventName, SMORE_EVENTS } from './events.js';
4
+ import { SMORE_EVENTS, validateEventName } from './events.js';
5
5
  import { DebugLogger } from './logger.js';
6
6
  import { getGlobalConfig } from './config.js';
7
7
 
@@ -14,22 +14,38 @@ class ControllerImpl {
14
14
  _myIndex = -1;
15
15
  _isReady = false;
16
16
  _isDestroyed = false;
17
+ _initTimeoutId = null;
17
18
  boundMessageHandler = null;
18
19
  registeredHandlers = [];
19
20
  eventListeners = /* @__PURE__ */ new Map();
20
21
  // Maps user-facing handler -> transport wrappedHandler for proper cleanup in on()/off()
21
22
  handlerToTransport = /* @__PURE__ */ new Map();
22
23
  _controllers = [];
23
- // Tracks event names registered via config.listeners so off(event) without handler won't remove them
24
- _configListenerEvents = /* @__PURE__ */ new Set();
24
+ // Pending handlers registered via on() before transport is ready
25
+ _pendingHandlers = [];
26
+ // Lifecycle callback arrays
27
+ _onAllReadyCallbacks = /* @__PURE__ */ new Set();
28
+ _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
29
+ _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
30
+ _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
31
+ _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
32
+ _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
33
+ _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
34
+ _onErrorCallbacks = /* @__PURE__ */ new Set();
35
+ // Whether all-ready has fired
36
+ _allReadyFired = false;
37
+ // Ready promise
38
+ _readyResolve;
39
+ _readyReject;
40
+ ready;
25
41
  constructor(config = {}) {
26
42
  this.config = config;
27
43
  this.logger = new DebugLogger(config.debug, "[SmoreController]");
28
- if (config.listeners) {
29
- for (const event of Object.keys(config.listeners)) {
30
- validateEventName(event);
31
- }
32
- }
44
+ this.ready = new Promise((resolve, reject) => {
45
+ this._readyResolve = resolve;
46
+ this._readyReject = reject;
47
+ });
48
+ this.startInitialization();
33
49
  }
34
50
  // ---------------------------------------------------------------------------
35
51
  // Properties (readonly)
@@ -65,38 +81,36 @@ class ControllerImpl {
65
81
  // ---------------------------------------------------------------------------
66
82
  // Initialization
67
83
  // ---------------------------------------------------------------------------
68
- async initialize() {
84
+ startInitialization() {
69
85
  const parentOrigin = this.config.parentOrigin ?? "*";
70
86
  const timeout = this.config.timeout ?? DEFAULT_TIMEOUT;
71
87
  this.logger.lifecycle("Initializing controller...", { parentOrigin, timeout });
72
- return new Promise((resolve, reject) => {
73
- const timeoutId = setTimeout(() => {
74
- this.cleanup();
75
- const error = new SmoreSDKError(
76
- "TIMEOUT",
77
- `Controller initialization timed out after ${timeout}ms. Make sure the parent window sends _bridge:init message. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Controller instance to retry (this instance has been cleaned up).`,
78
- { details: { timeout } }
79
- );
80
- this.handleError(error);
81
- reject(error);
82
- }, timeout);
83
- this.boundMessageHandler = (e) => {
84
- if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
85
- const msg = e.data;
86
- if (!isBridgeMessage(msg)) return;
87
- if (msg.type === "_bridge:init") {
88
- clearTimeout(timeoutId);
89
- this.handleInit(msg, parentOrigin, resolve, reject);
90
- } else if (msg.type === "_bridge:update") {
91
- this.handleUpdate(msg);
92
- }
93
- };
94
- window.addEventListener("message", this.boundMessageHandler);
95
- this.logger.lifecycle("Sending _bridge:ready to parent");
96
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
97
- });
88
+ this._initTimeoutId = setTimeout(() => {
89
+ this.cleanup();
90
+ const error = new SmoreSDKError(
91
+ "TIMEOUT",
92
+ `Controller initialization timed out after ${timeout}ms. Make sure the parent window sends _bridge:init message. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Controller instance to retry (this instance has been cleaned up).`,
93
+ { details: { timeout } }
94
+ );
95
+ this.handleError(error);
96
+ this._readyReject(error);
97
+ }, timeout);
98
+ this.boundMessageHandler = (e) => {
99
+ if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
100
+ const msg = e.data;
101
+ if (!isBridgeMessage(msg)) return;
102
+ if (msg.type === "_bridge:init") {
103
+ clearTimeout(this._initTimeoutId);
104
+ this.handleInit(msg, parentOrigin);
105
+ } else if (msg.type === "_bridge:update") {
106
+ this.handleUpdate(msg);
107
+ }
108
+ };
109
+ window.addEventListener("message", this.boundMessageHandler);
110
+ this.logger.lifecycle("Sending _bridge:ready to parent");
111
+ window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
98
112
  }
99
- handleInit(msg, parentOrigin, resolve, reject) {
113
+ handleInit(msg, parentOrigin) {
100
114
  const initPayload = msg.payload;
101
115
  this.logger.debug("Received _bridge:init", initPayload);
102
116
  try {
@@ -109,7 +123,7 @@ class ControllerImpl {
109
123
  );
110
124
  this.logger.warn("_bridge:init validation failed", error);
111
125
  this.handleError(error);
112
- reject(error);
126
+ this._readyReject(error);
113
127
  return;
114
128
  }
115
129
  const initData = initPayload;
@@ -120,7 +134,7 @@ class ControllerImpl {
120
134
  { details: { side: initData.side } }
121
135
  );
122
136
  this.handleError(error);
123
- reject(error);
137
+ this._readyReject(error);
124
138
  return;
125
139
  }
126
140
  if (initData.myIndex === void 0) {
@@ -130,7 +144,7 @@ class ControllerImpl {
130
144
  { details: initData }
131
145
  );
132
146
  this.handleError(error);
133
- reject(error);
147
+ this._readyReject(error);
134
148
  return;
135
149
  }
136
150
  this.transport = new PostMessageTransport(parentOrigin);
@@ -144,6 +158,10 @@ class ControllerImpl {
144
158
  appearance: p.appearance ?? p.character
145
159
  }));
146
160
  this.setupEventHandlers();
161
+ for (const { event, handler } of this._pendingHandlers) {
162
+ this.setupUserEventHandler(event, handler);
163
+ }
164
+ this._pendingHandlers = [];
147
165
  this._isReady = true;
148
166
  this.logger.lifecycle("Controller ready", {
149
167
  roomCode: this._roomCode,
@@ -154,7 +172,7 @@ class ControllerImpl {
154
172
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
155
173
  this.signalReady();
156
174
  }
157
- resolve();
175
+ this._readyResolve();
158
176
  }
159
177
  handleUpdate(msg) {
160
178
  if (!this._isReady) {
@@ -174,12 +192,12 @@ class ControllerImpl {
174
192
  const oldControllers = this._controllers;
175
193
  for (const nc of newControllers) {
176
194
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
177
- this.config.onControllerJoin?.(nc.playerIndex, nc);
195
+ this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
178
196
  }
179
197
  }
180
198
  for (const oc of oldControllers) {
181
199
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
182
- this.config.onControllerLeave?.(oc.playerIndex);
200
+ this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
183
201
  }
184
202
  }
185
203
  for (const nc of newControllers) {
@@ -187,11 +205,11 @@ class ControllerImpl {
187
205
  if (oc) {
188
206
  if (oc.connected && !nc.connected) {
189
207
  this.logger.debug("Player disconnected (via update)", { playerIndex: nc.playerIndex });
190
- this.config.onControllerDisconnect?.(nc.playerIndex);
208
+ this._onControllerDisconnectCallbacks.forEach((cb) => cb(nc.playerIndex));
191
209
  }
192
210
  if (!oc.connected && nc.connected) {
193
211
  this.logger.debug("Player reconnected (via update)", { playerIndex: nc.playerIndex });
194
- this.config.onControllerReconnect?.(nc.playerIndex, nc);
212
+ this._onControllerReconnectCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
195
213
  }
196
214
  }
197
215
  }
@@ -218,7 +236,7 @@ class ControllerImpl {
218
236
  };
219
237
  this._controllers = [...this._controllers, controllerInfo];
220
238
  this.logger.debug("Player joined", { playerIndex });
221
- this.config.onControllerJoin?.(playerIndex, controllerInfo);
239
+ this._onControllerJoinCallbacks.forEach((cb) => cb(playerIndex, controllerInfo));
222
240
  }
223
241
  });
224
242
  this.registerHandler(SMORE_EVENTS.PLAYER_LEFT, (raw) => {
@@ -228,7 +246,7 @@ class ControllerImpl {
228
246
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
229
247
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
230
248
  this.logger.debug("Player left", { playerIndex });
231
- this.config.onControllerLeave?.(playerIndex);
249
+ this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
232
250
  }
233
251
  });
234
252
  this.registerHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (raw) => {
@@ -240,7 +258,7 @@ class ControllerImpl {
240
258
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
241
259
  );
242
260
  this.logger.debug("Player disconnected", { playerIndex });
243
- this.config.onControllerDisconnect?.(playerIndex);
261
+ this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
244
262
  }
245
263
  });
246
264
  this.registerHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (raw) => {
@@ -262,56 +280,63 @@ class ControllerImpl {
262
280
  (c) => c.playerIndex === playerIndex ? controllerInfo : c
263
281
  );
264
282
  this.logger.debug("Player reconnected", { playerIndex });
265
- this.config.onControllerReconnect?.(playerIndex, controllerInfo);
283
+ this._onControllerReconnectCallbacks.forEach((cb) => cb(playerIndex, controllerInfo));
266
284
  }
267
285
  });
268
286
  this.registerHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (raw) => {
269
287
  const payload = raw;
270
288
  const playerData = payload?.player;
271
289
  if (playerData && typeof playerData.playerIndex === "number") {
290
+ const pi = playerData.playerIndex;
272
291
  const appearance = playerData.character ?? null;
273
292
  this._controllers = this._controllers.map(
274
- (c) => c.playerIndex === playerData.playerIndex ? { ...c, appearance } : c
293
+ (c) => c.playerIndex === pi ? { ...c, appearance } : c
275
294
  );
276
- this.logger.debug("Player character updated", { playerIndex: playerData.playerIndex });
277
- this.config.onCharacterUpdated?.(playerData.playerIndex, appearance ?? null);
295
+ this.logger.debug("Player character updated", { playerIndex: pi });
296
+ this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
278
297
  }
279
298
  });
280
299
  this.registerHandler(SMORE_EVENTS.RATE_LIMITED, (raw) => {
281
300
  const data = raw;
282
301
  const event = data?.event ?? "unknown";
283
302
  this.logger.warn(`Rate limited: ${event}`);
284
- this.config.onRateLimited?.(event);
303
+ this._onRateLimitedCallbacks.forEach((cb) => cb(event));
285
304
  });
286
305
  this.registerHandler(SMORE_EVENTS.ALL_READY, () => {
287
306
  this.logger.lifecycle("All participants ready");
288
- this.config.onReady?.();
307
+ this._allReadyFired = true;
308
+ this._onAllReadyCallbacks.forEach((cb) => cb());
289
309
  });
290
- if (this.config.onHostDisconnect) {
291
- this.logger.warn("onHostDisconnect is reserved for future use and currently non-functional");
292
- }
293
- if (this.config.onHostReconnect) {
294
- this.logger.warn("onHostReconnect is reserved for future use and currently non-functional");
295
- }
296
- if (this.config.listeners) {
297
- for (const [event, handler] of Object.entries(this.config.listeners)) {
298
- if (!handler) continue;
299
- this._configListenerEvents.add(event);
300
- this.registerHandler(event, (data) => {
301
- this.logReceive(event, data);
302
- try {
303
- handler(data);
304
- } catch (err) {
305
- this.handleError(
306
- new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
307
- cause: err instanceof Error ? err : void 0,
308
- details: { event }
309
- })
310
- );
311
- }
312
- });
310
+ }
311
+ /**
312
+ * Sets up a user event handler for controller events.
313
+ * Used for registering pending handlers after transport becomes available.
314
+ */
315
+ setupUserEventHandler(event, handler) {
316
+ const transportHandler = (data) => {
317
+ this.logReceive(event, data);
318
+ try {
319
+ handler(data);
320
+ } catch (err) {
321
+ this.handleError(
322
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
323
+ cause: err instanceof Error ? err : void 0,
324
+ details: { event }
325
+ })
326
+ );
313
327
  }
328
+ };
329
+ if (this.transport) {
330
+ this.transport.on(event, transportHandler);
331
+ this.registeredHandlers.push({ event, handler: transportHandler });
332
+ this.handlerToTransport.set(handler, { event, transportHandler });
314
333
  }
334
+ let listeners = this.eventListeners.get(event);
335
+ if (!listeners) {
336
+ listeners = /* @__PURE__ */ new Set();
337
+ this.eventListeners.set(event, listeners);
338
+ }
339
+ listeners.add(handler);
315
340
  }
316
341
  registerHandler(event, handler) {
317
342
  if (!this.transport) return;
@@ -319,13 +344,67 @@ class ControllerImpl {
319
344
  this.registeredHandlers.push({ event, handler });
320
345
  }
321
346
  // ---------------------------------------------------------------------------
347
+ // Lifecycle Methods
348
+ // ---------------------------------------------------------------------------
349
+ onAllReady(callback) {
350
+ if (this._allReadyFired) {
351
+ callback();
352
+ }
353
+ this._onAllReadyCallbacks.add(callback);
354
+ return () => {
355
+ this._onAllReadyCallbacks.delete(callback);
356
+ };
357
+ }
358
+ onControllerJoin(callback) {
359
+ this._onControllerJoinCallbacks.add(callback);
360
+ return () => {
361
+ this._onControllerJoinCallbacks.delete(callback);
362
+ };
363
+ }
364
+ onControllerLeave(callback) {
365
+ this._onControllerLeaveCallbacks.add(callback);
366
+ return () => {
367
+ this._onControllerLeaveCallbacks.delete(callback);
368
+ };
369
+ }
370
+ onControllerDisconnect(callback) {
371
+ this._onControllerDisconnectCallbacks.add(callback);
372
+ return () => {
373
+ this._onControllerDisconnectCallbacks.delete(callback);
374
+ };
375
+ }
376
+ onControllerReconnect(callback) {
377
+ this._onControllerReconnectCallbacks.add(callback);
378
+ return () => {
379
+ this._onControllerReconnectCallbacks.delete(callback);
380
+ };
381
+ }
382
+ onCharacterUpdated(callback) {
383
+ this._onCharacterUpdatedCallbacks.add(callback);
384
+ return () => {
385
+ this._onCharacterUpdatedCallbacks.delete(callback);
386
+ };
387
+ }
388
+ onRateLimited(callback) {
389
+ this._onRateLimitedCallbacks.add(callback);
390
+ return () => {
391
+ this._onRateLimitedCallbacks.delete(callback);
392
+ };
393
+ }
394
+ onError(callback) {
395
+ this._onErrorCallbacks.add(callback);
396
+ return () => {
397
+ this._onErrorCallbacks.delete(callback);
398
+ };
399
+ }
400
+ // ---------------------------------------------------------------------------
322
401
  // Communication Methods
323
402
  // ---------------------------------------------------------------------------
324
403
  /**
325
404
  * Send an event to the Screen. Controller-to-Controller direct communication
326
405
  * is not supported; all messages must go through the Screen.
327
406
  *
328
- * Data is sent to the Screen only (not to other controllers). For ScreenController communication,
407
+ * Data is sent to the Screen only (not to other controllers). For Screen->Controller communication,
329
408
  * Screen uses broadcast() or sendToController().
330
409
  *
331
410
  * @note Fire-and-forget sends (no callback) will silently fail if rate-limited.
@@ -359,24 +438,14 @@ class ControllerImpl {
359
438
  /**
360
439
  * Register a handler for custom events.
361
440
  *
441
+ * Can be called before the Controller is ready. Handlers registered before ready
442
+ * are queued and activated when the transport becomes available.
443
+ *
362
444
  * When receiving events from Screen's `broadcast()`:
363
- * handler receives `(data)` no playerIndex included.
445
+ * handler receives `(data)` -- no playerIndex included.
364
446
  *
365
447
  * When receiving events from Screen's `sendToController()`:
366
- * handler receives `(data)` targeted to this specific controller.
367
- *
368
- * @note Unlike Screen's `on()` which receives `(playerIndex, data)`,
369
- * Controller's `on()` receives only `(data)` since there's only one player per controller.
370
- *
371
- * Controller's on() handler signature: (data) => void
372
- * Unlike Screen's (playerIndex, data) => void, Controller doesn't receive playerIndex
373
- * because Controller only receives events from Screen, not from other controllers.
374
- * The sender is always the Screen, so playerIndex is not applicable.
375
- *
376
- * **Important:** If called before the Controller is ready (i.e., before `await createController()`
377
- * resolves or before the `onReady` callback fires), the handler is stored locally but
378
- * will NOT receive events until the transport is initialized. Always call `on()` after
379
- * initialization completes, or use `config.listeners` for handlers needed from the start.
448
+ * handler receives `(data)` -- targeted to this specific controller.
380
449
  */
381
450
  on(event, handler) {
382
451
  validateEventName(event);
@@ -386,34 +455,42 @@ class ControllerImpl {
386
455
  this.eventListeners.set(event, listeners);
387
456
  }
388
457
  listeners.add(handler);
389
- const transportHandler = (data) => {
390
- this.logReceive(event, data);
391
- try {
392
- handler(data);
393
- } catch (err) {
394
- this.handleError(
395
- new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
396
- cause: err instanceof Error ? err : void 0,
397
- details: { event }
398
- })
399
- );
400
- }
401
- };
402
458
  if (this.transport) {
459
+ const transportHandler = (data) => {
460
+ this.logReceive(event, data);
461
+ try {
462
+ handler(data);
463
+ } catch (err) {
464
+ this.handleError(
465
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
466
+ cause: err instanceof Error ? err : void 0,
467
+ details: { event }
468
+ })
469
+ );
470
+ }
471
+ };
403
472
  this.transport.on(event, transportHandler);
404
473
  this.registeredHandlers.push({ event, handler: transportHandler });
405
474
  this.handlerToTransport.set(handler, { event, transportHandler });
475
+ } else {
476
+ this._pendingHandlers.push({ event, handler });
406
477
  }
407
478
  return () => {
408
479
  listeners?.delete(handler);
409
480
  if (listeners?.size === 0) {
410
481
  this.eventListeners.delete(event);
411
482
  }
412
- this.transport?.off(event, transportHandler);
413
- this.registeredHandlers = this.registeredHandlers.filter(
414
- (h) => h.handler !== transportHandler
483
+ this._pendingHandlers = this._pendingHandlers.filter(
484
+ (p) => !(p.event === event && p.handler === handler)
415
485
  );
416
- this.handlerToTransport.delete(handler);
486
+ const entry = this.handlerToTransport.get(handler);
487
+ if (entry) {
488
+ this.transport?.off(event, entry.transportHandler);
489
+ this.registeredHandlers = this.registeredHandlers.filter(
490
+ (h) => h.handler !== entry.transportHandler
491
+ );
492
+ this.handlerToTransport.delete(handler);
493
+ }
417
494
  };
418
495
  }
419
496
  /**
@@ -421,16 +498,6 @@ class ControllerImpl {
421
498
  *
422
499
  * @note The handler is internally wrapped, so it cannot be removed via
423
500
  * `off(event, originalHandler)`. Use the returned unsubscribe function instead.
424
- *
425
- * @example
426
- * ```ts
427
- * const unsubscribe = controller.once('game-start', (data) => {
428
- * console.log('Game started!', data);
429
- * });
430
- *
431
- * // To cancel before it fires:
432
- * unsubscribe();
433
- * ```
434
501
  */
435
502
  once(event, handler) {
436
503
  const unsubscribe = this.on(event, ((data) => {
@@ -441,24 +508,13 @@ class ControllerImpl {
441
508
  }
442
509
  off(event, handler) {
443
510
  if (!handler) {
444
- if (this._configListenerEvents.has(event)) {
445
- for (const [key, val] of this.handlerToTransport) {
446
- if (val.event === event) {
447
- this.transport?.off(event, val.transportHandler);
448
- this.registeredHandlers = this.registeredHandlers.filter(
449
- (h) => h.handler !== val.transportHandler
450
- );
451
- this.handlerToTransport.delete(key);
452
- }
453
- }
454
- } else {
455
- this.eventListeners.delete(event);
456
- this.transport?.off(event);
457
- this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
458
- for (const [key, val] of this.handlerToTransport) {
459
- if (val.event === event) this.handlerToTransport.delete(key);
460
- }
511
+ this.eventListeners.delete(event);
512
+ this.transport?.off(event);
513
+ this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
514
+ for (const [key, val] of this.handlerToTransport) {
515
+ if (val.event === event) this.handlerToTransport.delete(key);
461
516
  }
517
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
462
518
  } else {
463
519
  const listeners = this.eventListeners.get(event);
464
520
  listeners?.delete(handler);
@@ -473,6 +529,9 @@ class ControllerImpl {
473
529
  );
474
530
  this.handlerToTransport.delete(handler);
475
531
  }
532
+ this._pendingHandlers = this._pendingHandlers.filter(
533
+ (p) => !(p.event === event && p.handler === handler)
534
+ );
476
535
  }
477
536
  }
478
537
  // ---------------------------------------------------------------------------
@@ -481,10 +540,15 @@ class ControllerImpl {
481
540
  destroy() {
482
541
  if (this._isDestroyed) return;
483
542
  this.logger.lifecycle("Destroying controller");
484
- this.cleanup();
485
543
  this._isDestroyed = true;
544
+ this._isReady = false;
545
+ this.cleanup();
486
546
  }
487
547
  cleanup() {
548
+ if (this._initTimeoutId) {
549
+ clearTimeout(this._initTimeoutId);
550
+ this._initTimeoutId = null;
551
+ }
488
552
  this._isReady = false;
489
553
  for (const { event, handler } of this.registeredHandlers) {
490
554
  this.transport?.off(event, handler);
@@ -492,6 +556,15 @@ class ControllerImpl {
492
556
  this.registeredHandlers = [];
493
557
  this.eventListeners.clear();
494
558
  this.handlerToTransport.clear();
559
+ this._pendingHandlers = [];
560
+ this._onAllReadyCallbacks.clear();
561
+ this._onControllerJoinCallbacks.clear();
562
+ this._onControllerLeaveCallbacks.clear();
563
+ this._onControllerDisconnectCallbacks.clear();
564
+ this._onControllerReconnectCallbacks.clear();
565
+ this._onCharacterUpdatedCallbacks.clear();
566
+ this._onRateLimitedCallbacks.clear();
567
+ this._onErrorCallbacks.clear();
495
568
  if (this.transport) {
496
569
  this.transport.destroy();
497
570
  this.transport = null;
@@ -515,15 +588,16 @@ class ControllerImpl {
515
588
  if (!this._isReady || !this.transport) {
516
589
  throw new SmoreSDKError(
517
590
  "NOT_READY",
518
- `Cannot call ${method}() before controller is ready. Use await createController() or wait for onReady callback.`,
591
+ `Cannot call ${method}() before controller is ready. Use await controller.ready.`,
519
592
  { details: { method, isReady: this._isReady } }
520
593
  );
521
594
  }
522
595
  }
523
596
  handleError(error) {
524
597
  this.logger.warn(`Error in handler: ${error.message}`);
525
- if (this.config.onError) {
526
- this.config.onError(error.toSmoreError());
598
+ const smoreError = error.toSmoreError();
599
+ if (this._onErrorCallbacks.size > 0) {
600
+ this._onErrorCallbacks.forEach((cb) => cb(smoreError));
527
601
  } else {
528
602
  this.logger.error(error.message, error.details);
529
603
  }
@@ -536,10 +610,7 @@ class ControllerImpl {
536
610
  }
537
611
  }
538
612
  function createController(config) {
539
- const controller = new ControllerImpl(config ?? {});
540
- const promise = controller.initialize().then(() => controller);
541
- promise.instance = controller;
542
- return promise;
613
+ return new ControllerImpl(config ?? {});
543
614
  }
544
615
 
545
616
  export { createController };