@smoregg/sdk 2.0.0 → 2.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.
Files changed (70) hide show
  1. package/dist/cjs/controller.cjs +193 -115
  2. package/dist/cjs/controller.cjs.map +1 -1
  3. package/dist/cjs/errors.cjs +1 -0
  4. package/dist/cjs/errors.cjs.map +1 -1
  5. package/dist/cjs/events.cjs +19 -2
  6. package/dist/cjs/events.cjs.map +1 -1
  7. package/dist/cjs/index.cjs +2 -7
  8. package/dist/cjs/index.cjs.map +1 -1
  9. package/dist/cjs/screen.cjs +185 -130
  10. package/dist/cjs/screen.cjs.map +1 -1
  11. package/dist/cjs/shared.cjs +34 -0
  12. package/dist/cjs/shared.cjs.map +1 -0
  13. package/dist/cjs/testing.cjs +125 -74
  14. package/dist/cjs/testing.cjs.map +1 -1
  15. package/dist/cjs/transport/PostMessageTransport.cjs +12 -0
  16. package/dist/cjs/transport/PostMessageTransport.cjs.map +1 -1
  17. package/dist/cjs/transport/protocol.cjs +2 -0
  18. package/dist/cjs/transport/protocol.cjs.map +1 -1
  19. package/dist/cjs/types.cjs +16 -0
  20. package/dist/cjs/types.cjs.map +1 -0
  21. package/dist/esm/controller.js +195 -117
  22. package/dist/esm/controller.js.map +1 -1
  23. package/dist/esm/errors.js +1 -0
  24. package/dist/esm/errors.js.map +1 -1
  25. package/dist/esm/events.js +18 -3
  26. package/dist/esm/events.js.map +1 -1
  27. package/dist/esm/index.js +1 -3
  28. package/dist/esm/index.js.map +1 -1
  29. package/dist/esm/screen.js +187 -132
  30. package/dist/esm/screen.js.map +1 -1
  31. package/dist/esm/shared.js +30 -0
  32. package/dist/esm/shared.js.map +1 -0
  33. package/dist/esm/testing.js +125 -74
  34. package/dist/esm/testing.js.map +1 -1
  35. package/dist/esm/transport/PostMessageTransport.js +12 -0
  36. package/dist/esm/transport/PostMessageTransport.js.map +1 -1
  37. package/dist/esm/transport/protocol.js +2 -1
  38. package/dist/esm/transport/protocol.js.map +1 -1
  39. package/dist/esm/types.js +14 -0
  40. package/dist/esm/types.js.map +1 -0
  41. package/dist/types/controller.d.ts +1 -1
  42. package/dist/types/controller.d.ts.map +1 -1
  43. package/dist/types/errors.d.ts.map +1 -1
  44. package/dist/types/events.d.ts +10 -1
  45. package/dist/types/events.d.ts.map +1 -1
  46. package/dist/types/index.d.ts +4 -8
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/screen.d.ts +3 -3
  49. package/dist/types/screen.d.ts.map +1 -1
  50. package/dist/types/shared.d.ts +21 -0
  51. package/dist/types/shared.d.ts.map +1 -0
  52. package/dist/types/testing.d.ts +63 -4
  53. package/dist/types/testing.d.ts.map +1 -1
  54. package/dist/types/transport/PostMessageTransport.d.ts +1 -0
  55. package/dist/types/transport/PostMessageTransport.d.ts.map +1 -1
  56. package/dist/types/transport/protocol.d.ts +4 -0
  57. package/dist/types/transport/protocol.d.ts.map +1 -1
  58. package/dist/types/types.d.ts +215 -347
  59. package/dist/types/types.d.ts.map +1 -1
  60. package/dist/umd/smore-sdk.umd.js +442 -787
  61. package/dist/umd/smore-sdk.umd.js.map +1 -1
  62. package/dist/umd/smore-sdk.umd.min.js +1 -1
  63. package/dist/umd/smore-sdk.umd.min.js.map +1 -1
  64. package/package.json +7 -1
  65. package/dist/cjs/config.cjs +0 -13
  66. package/dist/cjs/config.cjs.map +0 -1
  67. package/dist/esm/config.js +0 -10
  68. package/dist/esm/config.js.map +0 -1
  69. package/dist/types/config.d.ts +0 -35
  70. package/dist/types/config.d.ts.map +0 -1
@@ -5,7 +5,7 @@ var protocol = require('./transport/protocol.cjs');
5
5
  var errors = require('./errors.cjs');
6
6
  var events = require('./events.cjs');
7
7
  var logger = require('./logger.cjs');
8
- var config = require('./config.cjs');
8
+ var shared = require('./shared.cjs');
9
9
 
10
10
  const DEFAULT_TIMEOUT = 1e4;
11
11
  function validatePlayerIndex(playerIndex, controllers) {
@@ -36,17 +36,16 @@ class ScreenImpl {
36
36
  handlerToTransport = /* @__PURE__ */ new Map();
37
37
  // Pending handlers registered via on() before transport is ready
38
38
  _pendingHandlers = [];
39
- // Lifecycle callback arrays
40
- _onAllReadyCallbacks = /* @__PURE__ */ new Set();
41
- _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
42
- _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
43
- _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
44
- _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
45
- _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
46
- _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
47
- _onErrorCallbacks = /* @__PURE__ */ new Set();
39
+ // Unified lifecycle listener map (supports both onXxx() and on('$xxx') patterns)
40
+ _lifecycleListeners = /* @__PURE__ */ new Map();
41
+ // Outbound message buffer (messages sent before ready)
42
+ _outboundBuffer = [];
48
43
  // Whether all-ready has fired
49
44
  _allReadyFired = false;
45
+ // Self-connection awareness
46
+ _isConnected = false;
47
+ // Protocol versioning
48
+ _protocolVersion = protocol.PROTOCOL_VERSION;
50
49
  // Ready promise
51
50
  _readyResolve;
52
51
  _readyReject;
@@ -108,8 +107,17 @@ class ScreenImpl {
108
107
  this._readyReject(error);
109
108
  return;
110
109
  }
111
- this.transport = new PostMessageTransport.PostMessageTransport(parentOrigin);
110
+ this.transport = this.config.transport ?? new PostMessageTransport.PostMessageTransport(parentOrigin);
112
111
  this._roomCode = initData.roomCode;
112
+ const serverProtocolVersion = initData.protocolVersion;
113
+ if (serverProtocolVersion !== void 0) {
114
+ this._protocolVersion = serverProtocolVersion;
115
+ if (serverProtocolVersion !== protocol.PROTOCOL_VERSION) {
116
+ this.logger.warn(
117
+ `Protocol version mismatch: SDK v${protocol.PROTOCOL_VERSION}, server v${serverProtocolVersion}. Some features may not work correctly.`
118
+ );
119
+ }
120
+ }
113
121
  this._controllers = this.mapControllersFromInit(initData.players);
114
122
  if (this._controllers.length === 0) {
115
123
  this.logger.warn("Screen initialized with zero controllers");
@@ -119,13 +127,28 @@ class ScreenImpl {
119
127
  this.setupUserEventHandler(event, handler);
120
128
  }
121
129
  this._pendingHandlers = [];
130
+ this._isConnected = true;
122
131
  this._isReady = true;
132
+ for (const buffered of this._outboundBuffer) {
133
+ try {
134
+ switch (buffered.method) {
135
+ case "broadcast":
136
+ this.broadcast(buffered.args[0], buffered.args[1]);
137
+ break;
138
+ case "sendToController":
139
+ this.sendToController(buffered.args[0], buffered.args[1], buffered.args[2]);
140
+ break;
141
+ }
142
+ } catch (err) {
143
+ this.handleError(err instanceof errors.SmoreSDKError ? err : new errors.SmoreSDKError("UNKNOWN", "Failed to flush buffered message"));
144
+ }
145
+ }
146
+ this._outboundBuffer = [];
123
147
  this.logger.lifecycle("Screen ready", {
124
148
  roomCode: this._roomCode,
125
149
  controllers: this._controllers.length
126
150
  });
127
- const autoReady = config.getGlobalConfig().autoReady ?? true;
128
- if (autoReady) {
151
+ if (this.config.autoReady !== false) {
129
152
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
130
153
  this.signalReady();
131
154
  }
@@ -143,13 +166,13 @@ class ScreenImpl {
143
166
  for (const nc of newControllers) {
144
167
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
145
168
  this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
146
- this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
169
+ this._emitLifecycle("$controller-join", nc.playerIndex, nc);
147
170
  }
148
171
  }
149
172
  for (const oc of oldControllers) {
150
173
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
151
174
  this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
152
- this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
175
+ this._emitLifecycle("$controller-leave", oc.playerIndex);
153
176
  }
154
177
  }
155
178
  }
@@ -159,18 +182,11 @@ class ScreenImpl {
159
182
  }
160
183
  };
161
184
  window.addEventListener("message", this.boundMessageHandler);
162
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
185
+ window.parent.postMessage({ type: "_bridge:ready", protocolVersion: protocol.PROTOCOL_VERSION }, parentOrigin);
163
186
  this.logger.lifecycle("Sent _bridge:ready to parent");
164
187
  }
165
188
  mapControllersFromInit(players) {
166
- return players.map((p, index) => ({
167
- playerIndex: p.playerIndex ?? index,
168
- // Fallback to `nickname` for defensive compatibility (server currently always sends `name`)
169
- nickname: p.nickname || p.name || `Player ${index + 1}`,
170
- connected: p.connected !== false,
171
- // Fallback to `appearance` for defensive compatibility (server currently always sends `character`)
172
- appearance: p.appearance ?? p.character
173
- }));
189
+ return players.map((p, index) => shared.mapPlayerDTO(p, index));
174
190
  }
175
191
  setupEventHandlers() {
176
192
  if (!this.transport) return;
@@ -178,16 +194,11 @@ class ScreenImpl {
178
194
  const payload = data;
179
195
  const playerData = payload?.player;
180
196
  if (playerData && typeof playerData.playerIndex === "number") {
181
- const controllerInfo = {
182
- playerIndex: playerData.playerIndex,
183
- nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
184
- connected: playerData.connected !== false,
185
- appearance: playerData.appearance ?? playerData.character
186
- };
197
+ const controllerInfo = shared.mapPlayerDTO(playerData, playerData.playerIndex);
187
198
  if (this._controllers.some((c) => c.playerIndex === controllerInfo.playerIndex)) return;
188
199
  this._controllers = [...this._controllers, controllerInfo];
189
200
  this.logger.lifecycle("Controller joined", { playerIndex: controllerInfo.playerIndex });
190
- this._onControllerJoinCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
201
+ this._emitLifecycle("$controller-join", controllerInfo.playerIndex, controllerInfo);
191
202
  }
192
203
  });
193
204
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_LEFT, (data) => {
@@ -197,7 +208,7 @@ class ScreenImpl {
197
208
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
198
209
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
199
210
  this.logger.lifecycle("Controller left", { playerIndex });
200
- this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
211
+ this._emitLifecycle("$controller-leave", playerIndex);
201
212
  }
202
213
  });
203
214
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_DISCONNECTED, (data) => {
@@ -208,24 +219,19 @@ class ScreenImpl {
208
219
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
209
220
  );
210
221
  this.logger.lifecycle("Controller disconnected", { playerIndex });
211
- this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
222
+ this._emitLifecycle("$controller-disconnect", playerIndex);
212
223
  }
213
224
  });
214
225
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_RECONNECTED, (data) => {
215
226
  const payload = data;
216
227
  const playerData = payload?.player;
217
228
  if (playerData && typeof playerData.playerIndex === "number") {
218
- const controllerInfo = {
219
- playerIndex: playerData.playerIndex,
220
- nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
221
- connected: true,
222
- appearance: playerData.appearance ?? playerData.character
223
- };
229
+ const controllerInfo = shared.mapPlayerDTO(playerData, playerData.playerIndex);
224
230
  this._controllers = this._controllers.map(
225
231
  (c) => c.playerIndex === controllerInfo.playerIndex ? controllerInfo : c
226
232
  );
227
233
  this.logger.lifecycle("Controller reconnected", { playerIndex: controllerInfo.playerIndex });
228
- this._onControllerReconnectCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
234
+ this._emitLifecycle("$controller-reconnect", controllerInfo.playerIndex, controllerInfo);
229
235
  }
230
236
  });
231
237
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (data) => {
@@ -238,19 +244,32 @@ class ScreenImpl {
238
244
  (c) => c.playerIndex === pi ? { ...c, appearance } : c
239
245
  );
240
246
  this.logger.lifecycle("Player character updated", { playerIndex: pi });
241
- this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
247
+ this._emitLifecycle("$character-updated", pi, appearance ?? null);
242
248
  }
243
249
  });
244
250
  this.registerTransportHandler(events.SMORE_EVENTS.RATE_LIMITED, (data) => {
245
251
  const payload = data;
246
- const event = payload?.event ?? "unknown";
247
- this.logger.warn(`Rate limited: ${event}`);
248
- this._onRateLimitedCallbacks.forEach((cb) => cb(event));
252
+ const eventName = payload?.event ?? "unknown";
253
+ this.handleError(
254
+ new errors.SmoreSDKError("RATE_LIMITED", `Server rate-limited event: ${eventName}`, {
255
+ details: { event: eventName }
256
+ })
257
+ );
249
258
  });
250
259
  this.registerTransportHandler(events.SMORE_EVENTS.ALL_READY, () => {
251
260
  this.logger.lifecycle("All participants ready");
252
261
  this._allReadyFired = true;
253
- this._onAllReadyCallbacks.forEach((cb) => cb());
262
+ this._emitLifecycle("$all-ready");
263
+ });
264
+ this.registerTransportHandler(events.SMORE_EVENTS.SELF_DISCONNECTED, () => {
265
+ this._isConnected = false;
266
+ this.logger.lifecycle("Connection lost");
267
+ this._emitLifecycle("$connection-change", false);
268
+ });
269
+ this.registerTransportHandler(events.SMORE_EVENTS.SELF_RECONNECTED, () => {
270
+ this._isConnected = true;
271
+ this.logger.lifecycle("Connection restored");
272
+ this._emitLifecycle("$connection-change", true);
254
273
  });
255
274
  }
256
275
  /**
@@ -319,6 +338,45 @@ class ScreenImpl {
319
338
  get isDestroyed() {
320
339
  return this._isDestroyed;
321
340
  }
341
+ get isConnected() {
342
+ return this._isConnected;
343
+ }
344
+ get protocolVersion() {
345
+ return this._protocolVersion;
346
+ }
347
+ // ---------------------------------------------------------------------------
348
+ // Lifecycle Listener Helpers
349
+ // ---------------------------------------------------------------------------
350
+ _addLifecycleListener(event, listener) {
351
+ let set = this._lifecycleListeners.get(event);
352
+ if (!set) {
353
+ set = /* @__PURE__ */ new Set();
354
+ this._lifecycleListeners.set(event, set);
355
+ }
356
+ set.add(listener);
357
+ return () => {
358
+ set.delete(listener);
359
+ if (set.size === 0) this._lifecycleListeners.delete(event);
360
+ };
361
+ }
362
+ _emitLifecycle(event, ...args) {
363
+ this._lifecycleListeners.get(event)?.forEach((cb) => {
364
+ try {
365
+ cb(...args);
366
+ } catch (err) {
367
+ this.handleError(
368
+ new errors.SmoreSDKError("UNKNOWN", `Error in lifecycle handler for "${event}"`, {
369
+ cause: err instanceof Error ? err : void 0,
370
+ details: { event }
371
+ })
372
+ );
373
+ }
374
+ });
375
+ }
376
+ _hasLifecycleListeners(event) {
377
+ const set = this._lifecycleListeners.get(event);
378
+ return set !== void 0 && set.size > 0;
379
+ }
322
380
  // ---------------------------------------------------------------------------
323
381
  // Lifecycle Methods
324
382
  // ---------------------------------------------------------------------------
@@ -326,52 +384,28 @@ class ScreenImpl {
326
384
  if (this._allReadyFired) {
327
385
  callback();
328
386
  }
329
- this._onAllReadyCallbacks.add(callback);
330
- return () => {
331
- this._onAllReadyCallbacks.delete(callback);
332
- };
387
+ return this._addLifecycleListener("$all-ready", callback);
333
388
  }
334
389
  onControllerJoin(callback) {
335
- this._onControllerJoinCallbacks.add(callback);
336
- return () => {
337
- this._onControllerJoinCallbacks.delete(callback);
338
- };
390
+ return this._addLifecycleListener("$controller-join", callback);
339
391
  }
340
392
  onControllerLeave(callback) {
341
- this._onControllerLeaveCallbacks.add(callback);
342
- return () => {
343
- this._onControllerLeaveCallbacks.delete(callback);
344
- };
393
+ return this._addLifecycleListener("$controller-leave", callback);
345
394
  }
346
395
  onControllerDisconnect(callback) {
347
- this._onControllerDisconnectCallbacks.add(callback);
348
- return () => {
349
- this._onControllerDisconnectCallbacks.delete(callback);
350
- };
396
+ return this._addLifecycleListener("$controller-disconnect", callback);
351
397
  }
352
398
  onControllerReconnect(callback) {
353
- this._onControllerReconnectCallbacks.add(callback);
354
- return () => {
355
- this._onControllerReconnectCallbacks.delete(callback);
356
- };
399
+ return this._addLifecycleListener("$controller-reconnect", callback);
357
400
  }
358
401
  onCharacterUpdated(callback) {
359
- this._onCharacterUpdatedCallbacks.add(callback);
360
- return () => {
361
- this._onCharacterUpdatedCallbacks.delete(callback);
362
- };
363
- }
364
- onRateLimited(callback) {
365
- this._onRateLimitedCallbacks.add(callback);
366
- return () => {
367
- this._onRateLimitedCallbacks.delete(callback);
368
- };
402
+ return this._addLifecycleListener("$character-updated", callback);
369
403
  }
370
404
  onError(callback) {
371
- this._onErrorCallbacks.add(callback);
372
- return () => {
373
- this._onErrorCallbacks.delete(callback);
374
- };
405
+ return this._addLifecycleListener("$error", callback);
406
+ }
407
+ onConnectionChange(callback) {
408
+ return this._addLifecycleListener("$connection-change", callback);
375
409
  }
376
410
  // ---------------------------------------------------------------------------
377
411
  // Communication Methods
@@ -380,7 +414,6 @@ class ScreenImpl {
380
414
  * Send type-safe events to all controllers.
381
415
  *
382
416
  * Uses EventMap generic for compile-time type checking of event names and data payloads.
383
- * Runtime behavior is identical to broadcastRaw - both call validateEventName at runtime.
384
417
  *
385
418
  * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
386
419
  * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
@@ -389,31 +422,18 @@ class ScreenImpl {
389
422
  *
390
423
  * Warning: Avoid sending primitive values directly (string, number, boolean).
391
424
  * Wrap in an object: broadcast('event', { value: 42 }) instead of broadcast('event', 42)
392
- *
393
- * @see broadcastRaw for bypassing TypeScript type checking (runtime behavior identical)
394
425
  */
395
426
  broadcast(event, data) {
396
- this.ensureReady("broadcast");
397
- events.validateEventName(event);
398
- this.logger.send(event, data);
399
- this.transport.emit(event, data);
400
- }
401
- /**
402
- * Send events to all controllers without TypeScript type checking.
403
- *
404
- * Bypasses EventMap generic type checks at compile time.
405
- * Runtime behavior is identical to broadcast - both call validateEventName at runtime.
406
- *
407
- * Use this when you need dynamic event names or when working without a predefined EventMap.
408
- *
409
- * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
410
- * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
411
- *
412
- * @see broadcast for type-safe version using EventMap generic
413
- */
414
- broadcastRaw(event, data) {
415
- this.ensureReady("broadcastRaw");
427
+ if (this._isDestroyed) {
428
+ throw new errors.SmoreSDKError("DESTROYED", "Cannot call broadcast() after destroy()");
429
+ }
430
+ if (!this._isReady || !this.transport) {
431
+ this._outboundBuffer.push({ method: "broadcast", args: [event, data] });
432
+ this.logger.debug(`Buffered broadcast "${event}" (screen not ready yet)`);
433
+ return;
434
+ }
416
435
  events.validateEventName(event);
436
+ shared.validatePayloadSize(data);
417
437
  this.logger.send(event, data);
418
438
  this.transport.emit(event, data);
419
439
  }
@@ -432,24 +452,17 @@ class ScreenImpl {
432
452
  * @param data - Event data payload
433
453
  */
434
454
  sendToController(playerIndex, event, data) {
435
- this.ensureReady("sendToController");
436
- events.validateEventName(event);
437
- validatePlayerIndex(playerIndex, this._controllers);
438
- if (data && typeof data === "object" && "targetPlayerIndex" in data) {
439
- this.logger.warn(
440
- `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
441
- );
455
+ if (this._isDestroyed) {
456
+ throw new errors.SmoreSDKError("DESTROYED", "Cannot call sendToController() after destroy()");
457
+ }
458
+ if (!this._isReady || !this.transport) {
459
+ this._outboundBuffer.push({ method: "sendToController", args: [playerIndex, event, data] });
460
+ this.logger.debug(`Buffered sendToController "${event}" -> Player ${playerIndex} (screen not ready yet)`);
461
+ return;
442
462
  }
443
- this.logger.send(`${event} -> Player ${playerIndex}`, data);
444
- this.transport.emit(event, {
445
- targetPlayerIndex: playerIndex,
446
- ...data && typeof data === "object" ? data : { data }
447
- });
448
- }
449
- sendToControllerRaw(playerIndex, event, data) {
450
- this.ensureReady("sendToControllerRaw");
451
463
  events.validateEventName(event);
452
464
  validatePlayerIndex(playerIndex, this._controllers);
465
+ shared.validatePayloadSize(data);
453
466
  if (data && typeof data === "object" && "targetPlayerIndex" in data) {
454
467
  this.logger.warn(
455
468
  `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
@@ -484,6 +497,16 @@ class ScreenImpl {
484
497
  * are queued and activated when the transport becomes available.
485
498
  */
486
499
  on(event, handler) {
500
+ if (typeof event === "string" && event.startsWith("$")) {
501
+ const validEvents = events.SCREEN_LIFECYCLE_EVENTS;
502
+ if (!validEvents.has(event)) {
503
+ throw new errors.SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}". Valid lifecycle events: ${Array.from(validEvents).join(", ")}`);
504
+ }
505
+ if (event === "$all-ready" && this._allReadyFired) {
506
+ handler();
507
+ }
508
+ return this._addLifecycleListener(event, handler);
509
+ }
487
510
  events.validateEventName(event);
488
511
  let handlers = this.eventHandlers.get(event);
489
512
  if (!handlers) {
@@ -546,6 +569,23 @@ class ScreenImpl {
546
569
  * @returns Unsubscribe function to remove the handler before it fires
547
570
  */
548
571
  once(event, handler) {
572
+ if (typeof event === "string" && event.startsWith("$")) {
573
+ const validEvents = events.SCREEN_LIFECYCLE_EVENTS;
574
+ if (!validEvents.has(event)) {
575
+ throw new errors.SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}"`);
576
+ }
577
+ if (event === "$all-ready" && this._allReadyFired) {
578
+ handler();
579
+ return () => {
580
+ };
581
+ }
582
+ const wrapper = (...args) => {
583
+ unsub();
584
+ handler(...args);
585
+ };
586
+ const unsub = this._addLifecycleListener(event, wrapper);
587
+ return unsub;
588
+ }
549
589
  const wrappedHandler = (playerIndex, data) => {
550
590
  unsubscribe();
551
591
  handler(playerIndex, data);
@@ -554,6 +594,14 @@ class ScreenImpl {
554
594
  return unsubscribe;
555
595
  }
556
596
  off(event, handler) {
597
+ if (typeof event === "string" && event.startsWith("$")) {
598
+ if (!handler) {
599
+ this._lifecycleListeners.delete(event);
600
+ } else {
601
+ this._lifecycleListeners.get(event)?.delete(handler);
602
+ }
603
+ return;
604
+ }
557
605
  if (!handler) {
558
606
  this.eventHandlers.delete(event);
559
607
  this.transport?.off(event);
@@ -581,6 +629,21 @@ class ScreenImpl {
581
629
  );
582
630
  }
583
631
  }
632
+ removeAllListeners(event) {
633
+ if (event) {
634
+ this.eventHandlers.delete(event);
635
+ this.transport?.off(event);
636
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
637
+ for (const [key, val] of this.handlerToTransport) {
638
+ if (val.event === event) this.handlerToTransport.delete(key);
639
+ }
640
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
641
+ } else {
642
+ for (const evt of [...this.eventHandlers.keys()]) {
643
+ this.removeAllListeners(evt);
644
+ }
645
+ }
646
+ }
584
647
  // ---------------------------------------------------------------------------
585
648
  // Utilities
586
649
  // ---------------------------------------------------------------------------
@@ -590,9 +653,6 @@ class ScreenImpl {
590
653
  getControllerCount() {
591
654
  return this._controllers.filter((c) => c.connected).length;
592
655
  }
593
- hasAnyConnectedControllers() {
594
- return this._controllers.some((c) => c.connected);
595
- }
596
656
  // ---------------------------------------------------------------------------
597
657
  // Cleanup
598
658
  // ---------------------------------------------------------------------------
@@ -616,14 +676,9 @@ class ScreenImpl {
616
676
  this.eventHandlers.clear();
617
677
  this.handlerToTransport.clear();
618
678
  this._pendingHandlers = [];
619
- this._onAllReadyCallbacks.clear();
620
- this._onControllerJoinCallbacks.clear();
621
- this._onControllerLeaveCallbacks.clear();
622
- this._onControllerDisconnectCallbacks.clear();
623
- this._onControllerReconnectCallbacks.clear();
624
- this._onCharacterUpdatedCallbacks.clear();
625
- this._onRateLimitedCallbacks.clear();
626
- this._onErrorCallbacks.clear();
679
+ this._lifecycleListeners.clear();
680
+ this._isConnected = false;
681
+ this._outboundBuffer = [];
627
682
  if (this.transport instanceof PostMessageTransport.PostMessageTransport) {
628
683
  this.transport.destroy();
629
684
  }
@@ -639,8 +694,8 @@ class ScreenImpl {
639
694
  handleError(error) {
640
695
  this.logger.warn(`Error in handler: ${error.message}`);
641
696
  const smoreError = error.toSmoreError();
642
- if (this._onErrorCallbacks.size > 0) {
643
- this._onErrorCallbacks.forEach((cb) => cb(smoreError));
697
+ if (this._hasLifecycleListeners("$error")) {
698
+ this._emitLifecycle("$error", smoreError);
644
699
  } else {
645
700
  this.logger.error(error.message, error.details);
646
701
  }