@smoregg/sdk 2.0.0 → 2.2.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 +260 -113
  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 +26 -3
  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 +244 -128
  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 +181 -73
  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 +262 -115
  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 +25 -4
  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 +246 -130
  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 +181 -73
  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 +14 -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 +65 -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 +5 -0
  57. package/dist/types/transport/protocol.d.ts.map +1 -1
  58. package/dist/types/types.d.ts +254 -345
  59. package/dist/types/types.d.ts.map +1 -1
  60. package/dist/umd/smore-sdk.umd.js +575 -784
  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
@@ -1,9 +1,9 @@
1
1
  import { PostMessageTransport } from './transport/PostMessageTransport.js';
2
- import { isBridgeMessage, validateInitPayload } from './transport/protocol.js';
2
+ import { PROTOCOL_VERSION, isBridgeMessage, validateInitPayload } from './transport/protocol.js';
3
3
  import { SmoreSDKError } from './errors.js';
4
- import { SMORE_EVENTS, validateEventName } from './events.js';
4
+ import { SMORE_EVENTS, validateEventName, SCREEN_LIFECYCLE_EVENTS } from './events.js';
5
5
  import { DebugLogger } from './logger.js';
6
- import { getGlobalConfig } from './config.js';
6
+ import { mapPlayerDTO, validatePayloadSize } from './shared.js';
7
7
 
8
8
  const DEFAULT_TIMEOUT = 1e4;
9
9
  function validatePlayerIndex(playerIndex, controllers) {
@@ -34,17 +34,19 @@ class ScreenImpl {
34
34
  handlerToTransport = /* @__PURE__ */ new Map();
35
35
  // Pending handlers registered via on() before transport is ready
36
36
  _pendingHandlers = [];
37
- // Lifecycle callback arrays
38
- _onAllReadyCallbacks = /* @__PURE__ */ new Set();
39
- _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
40
- _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
41
- _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
42
- _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
43
- _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
44
- _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
45
- _onErrorCallbacks = /* @__PURE__ */ new Set();
37
+ // Unified lifecycle listener map (supports both onXxx() and on('$xxx') patterns)
38
+ _lifecycleListeners = /* @__PURE__ */ new Map();
39
+ // Outbound message buffer (messages sent before ready)
40
+ _outboundBuffer = [];
46
41
  // Whether all-ready has fired
47
42
  _allReadyFired = false;
43
+ // Self-connection awareness
44
+ _isConnected = false;
45
+ // Custom state management
46
+ _customStates = /* @__PURE__ */ new Map();
47
+ _stateChangeListeners = /* @__PURE__ */ new Set();
48
+ // Protocol versioning
49
+ _protocolVersion = PROTOCOL_VERSION;
48
50
  // Ready promise
49
51
  _readyResolve;
50
52
  _readyReject;
@@ -106,8 +108,17 @@ class ScreenImpl {
106
108
  this._readyReject(error);
107
109
  return;
108
110
  }
109
- this.transport = new PostMessageTransport(parentOrigin);
111
+ this.transport = this.config.transport ?? new PostMessageTransport(parentOrigin);
110
112
  this._roomCode = initData.roomCode;
113
+ const serverProtocolVersion = initData.protocolVersion;
114
+ if (serverProtocolVersion !== void 0) {
115
+ this._protocolVersion = serverProtocolVersion;
116
+ if (serverProtocolVersion !== PROTOCOL_VERSION) {
117
+ this.logger.warn(
118
+ `Protocol version mismatch: SDK v${PROTOCOL_VERSION}, server v${serverProtocolVersion}. Some features may not work correctly.`
119
+ );
120
+ }
121
+ }
111
122
  this._controllers = this.mapControllersFromInit(initData.players);
112
123
  if (this._controllers.length === 0) {
113
124
  this.logger.warn("Screen initialized with zero controllers");
@@ -117,13 +128,28 @@ class ScreenImpl {
117
128
  this.setupUserEventHandler(event, handler);
118
129
  }
119
130
  this._pendingHandlers = [];
131
+ this._isConnected = true;
120
132
  this._isReady = true;
133
+ for (const buffered of this._outboundBuffer) {
134
+ try {
135
+ switch (buffered.method) {
136
+ case "broadcast":
137
+ this.broadcast(buffered.args[0], buffered.args[1]);
138
+ break;
139
+ case "sendToController":
140
+ this.sendToController(buffered.args[0], buffered.args[1], buffered.args[2]);
141
+ break;
142
+ }
143
+ } catch (err) {
144
+ this.handleError(err instanceof SmoreSDKError ? err : new SmoreSDKError("UNKNOWN", "Failed to flush buffered message"));
145
+ }
146
+ }
147
+ this._outboundBuffer = [];
121
148
  this.logger.lifecycle("Screen ready", {
122
149
  roomCode: this._roomCode,
123
150
  controllers: this._controllers.length
124
151
  });
125
- const autoReady = getGlobalConfig().autoReady ?? true;
126
- if (autoReady) {
152
+ if (this.config.autoReady !== false) {
127
153
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
128
154
  this.signalReady();
129
155
  }
@@ -141,13 +167,13 @@ class ScreenImpl {
141
167
  for (const nc of newControllers) {
142
168
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
143
169
  this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
144
- this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
170
+ this._emitLifecycle("$controller-join", nc.playerIndex, nc);
145
171
  }
146
172
  }
147
173
  for (const oc of oldControllers) {
148
174
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
149
175
  this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
150
- this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
176
+ this._emitLifecycle("$controller-leave", oc.playerIndex);
151
177
  }
152
178
  }
153
179
  }
@@ -157,18 +183,11 @@ class ScreenImpl {
157
183
  }
158
184
  };
159
185
  window.addEventListener("message", this.boundMessageHandler);
160
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
186
+ window.parent.postMessage({ type: "_bridge:ready", protocolVersion: PROTOCOL_VERSION }, parentOrigin);
161
187
  this.logger.lifecycle("Sent _bridge:ready to parent");
162
188
  }
163
189
  mapControllersFromInit(players) {
164
- return players.map((p, index) => ({
165
- playerIndex: p.playerIndex ?? index,
166
- // Fallback to `nickname` for defensive compatibility (server currently always sends `name`)
167
- nickname: p.nickname || p.name || `Player ${index + 1}`,
168
- connected: p.connected !== false,
169
- // Fallback to `appearance` for defensive compatibility (server currently always sends `character`)
170
- appearance: p.appearance ?? p.character
171
- }));
190
+ return players.map((p, index) => mapPlayerDTO(p, index));
172
191
  }
173
192
  setupEventHandlers() {
174
193
  if (!this.transport) return;
@@ -176,16 +195,11 @@ class ScreenImpl {
176
195
  const payload = data;
177
196
  const playerData = payload?.player;
178
197
  if (playerData && typeof playerData.playerIndex === "number") {
179
- const controllerInfo = {
180
- playerIndex: playerData.playerIndex,
181
- nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
182
- connected: playerData.connected !== false,
183
- appearance: playerData.appearance ?? playerData.character
184
- };
198
+ const controllerInfo = mapPlayerDTO(playerData, playerData.playerIndex);
185
199
  if (this._controllers.some((c) => c.playerIndex === controllerInfo.playerIndex)) return;
186
200
  this._controllers = [...this._controllers, controllerInfo];
187
201
  this.logger.lifecycle("Controller joined", { playerIndex: controllerInfo.playerIndex });
188
- this._onControllerJoinCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
202
+ this._emitLifecycle("$controller-join", controllerInfo.playerIndex, controllerInfo);
189
203
  }
190
204
  });
191
205
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_LEFT, (data) => {
@@ -195,7 +209,7 @@ class ScreenImpl {
195
209
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
196
210
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
197
211
  this.logger.lifecycle("Controller left", { playerIndex });
198
- this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
212
+ this._emitLifecycle("$controller-leave", playerIndex);
199
213
  }
200
214
  });
201
215
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (data) => {
@@ -206,24 +220,19 @@ class ScreenImpl {
206
220
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
207
221
  );
208
222
  this.logger.lifecycle("Controller disconnected", { playerIndex });
209
- this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
223
+ this._emitLifecycle("$controller-disconnect", playerIndex);
210
224
  }
211
225
  });
212
226
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (data) => {
213
227
  const payload = data;
214
228
  const playerData = payload?.player;
215
229
  if (playerData && typeof playerData.playerIndex === "number") {
216
- const controllerInfo = {
217
- playerIndex: playerData.playerIndex,
218
- nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
219
- connected: true,
220
- appearance: playerData.appearance ?? playerData.character
221
- };
230
+ const controllerInfo = mapPlayerDTO(playerData, playerData.playerIndex);
222
231
  this._controllers = this._controllers.map(
223
232
  (c) => c.playerIndex === controllerInfo.playerIndex ? controllerInfo : c
224
233
  );
225
234
  this.logger.lifecycle("Controller reconnected", { playerIndex: controllerInfo.playerIndex });
226
- this._onControllerReconnectCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
235
+ this._emitLifecycle("$controller-reconnect", controllerInfo.playerIndex, controllerInfo);
227
236
  }
228
237
  });
229
238
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (data) => {
@@ -236,19 +245,69 @@ class ScreenImpl {
236
245
  (c) => c.playerIndex === pi ? { ...c, appearance } : c
237
246
  );
238
247
  this.logger.lifecycle("Player character updated", { playerIndex: pi });
239
- this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
248
+ this._emitLifecycle("$character-updated", pi, appearance ?? null);
240
249
  }
241
250
  });
242
251
  this.registerTransportHandler(SMORE_EVENTS.RATE_LIMITED, (data) => {
243
252
  const payload = data;
244
- const event = payload?.event ?? "unknown";
245
- this.logger.warn(`Rate limited: ${event}`);
246
- this._onRateLimitedCallbacks.forEach((cb) => cb(event));
253
+ const eventName = payload?.event ?? "unknown";
254
+ this.handleError(
255
+ new SmoreSDKError("RATE_LIMITED", `Server rate-limited event: ${eventName}`, {
256
+ details: { event: eventName }
257
+ })
258
+ );
247
259
  });
248
260
  this.registerTransportHandler(SMORE_EVENTS.ALL_READY, () => {
249
261
  this.logger.lifecycle("All participants ready");
250
262
  this._allReadyFired = true;
251
- this._onAllReadyCallbacks.forEach((cb) => cb());
263
+ this._emitLifecycle("$all-ready");
264
+ });
265
+ this.registerTransportHandler(SMORE_EVENTS.SELF_DISCONNECTED, () => {
266
+ this._isConnected = false;
267
+ this.logger.lifecycle("Connection lost");
268
+ this._emitLifecycle("$connection-change", false);
269
+ });
270
+ this.registerTransportHandler(SMORE_EVENTS.SELF_RECONNECTED, () => {
271
+ this._isConnected = true;
272
+ this.logger.lifecycle("Connection restored");
273
+ this._emitLifecycle("$connection-change", true);
274
+ });
275
+ this.registerTransportHandler(SMORE_EVENTS.STATE_CHANGED, (raw) => {
276
+ const data = raw;
277
+ if (typeof data?.playerIndex === "number" && data.state) {
278
+ this._customStates.set(data.playerIndex, data.state);
279
+ this._stateChangeListeners.forEach((cb) => {
280
+ try {
281
+ cb(data.playerIndex, data.state);
282
+ } catch (err) {
283
+ this.handleError(
284
+ new SmoreSDKError("UNKNOWN", "Error in custom state change listener", {
285
+ cause: err instanceof Error ? err : void 0
286
+ })
287
+ );
288
+ }
289
+ });
290
+ }
291
+ });
292
+ this.registerTransportHandler(SMORE_EVENTS.STATE_ALL, (raw) => {
293
+ const data = raw;
294
+ if (data?.states) {
295
+ for (const [key, value] of Object.entries(data.states)) {
296
+ const pi = Number(key);
297
+ this._customStates.set(pi, value);
298
+ this._stateChangeListeners.forEach((cb) => {
299
+ try {
300
+ cb(pi, value);
301
+ } catch (err) {
302
+ this.handleError(
303
+ new SmoreSDKError("UNKNOWN", "Error in custom state change listener", {
304
+ cause: err instanceof Error ? err : void 0
305
+ })
306
+ );
307
+ }
308
+ });
309
+ }
310
+ }
252
311
  });
253
312
  }
254
313
  /**
@@ -317,6 +376,45 @@ class ScreenImpl {
317
376
  get isDestroyed() {
318
377
  return this._isDestroyed;
319
378
  }
379
+ get isConnected() {
380
+ return this._isConnected;
381
+ }
382
+ get protocolVersion() {
383
+ return this._protocolVersion;
384
+ }
385
+ // ---------------------------------------------------------------------------
386
+ // Lifecycle Listener Helpers
387
+ // ---------------------------------------------------------------------------
388
+ _addLifecycleListener(event, listener) {
389
+ let set = this._lifecycleListeners.get(event);
390
+ if (!set) {
391
+ set = /* @__PURE__ */ new Set();
392
+ this._lifecycleListeners.set(event, set);
393
+ }
394
+ set.add(listener);
395
+ return () => {
396
+ set.delete(listener);
397
+ if (set.size === 0) this._lifecycleListeners.delete(event);
398
+ };
399
+ }
400
+ _emitLifecycle(event, ...args) {
401
+ this._lifecycleListeners.get(event)?.forEach((cb) => {
402
+ try {
403
+ cb(...args);
404
+ } catch (err) {
405
+ this.handleError(
406
+ new SmoreSDKError("UNKNOWN", `Error in lifecycle handler for "${event}"`, {
407
+ cause: err instanceof Error ? err : void 0,
408
+ details: { event }
409
+ })
410
+ );
411
+ }
412
+ });
413
+ }
414
+ _hasLifecycleListeners(event) {
415
+ const set = this._lifecycleListeners.get(event);
416
+ return set !== void 0 && set.size > 0;
417
+ }
320
418
  // ---------------------------------------------------------------------------
321
419
  // Lifecycle Methods
322
420
  // ---------------------------------------------------------------------------
@@ -324,51 +422,46 @@ class ScreenImpl {
324
422
  if (this._allReadyFired) {
325
423
  callback();
326
424
  }
327
- this._onAllReadyCallbacks.add(callback);
328
- return () => {
329
- this._onAllReadyCallbacks.delete(callback);
330
- };
425
+ return this._addLifecycleListener("$all-ready", callback);
331
426
  }
332
427
  onControllerJoin(callback) {
333
- this._onControllerJoinCallbacks.add(callback);
334
- return () => {
335
- this._onControllerJoinCallbacks.delete(callback);
336
- };
428
+ return this._addLifecycleListener("$controller-join", callback);
337
429
  }
338
430
  onControllerLeave(callback) {
339
- this._onControllerLeaveCallbacks.add(callback);
340
- return () => {
341
- this._onControllerLeaveCallbacks.delete(callback);
342
- };
431
+ return this._addLifecycleListener("$controller-leave", callback);
343
432
  }
344
433
  onControllerDisconnect(callback) {
345
- this._onControllerDisconnectCallbacks.add(callback);
346
- return () => {
347
- this._onControllerDisconnectCallbacks.delete(callback);
348
- };
434
+ return this._addLifecycleListener("$controller-disconnect", callback);
349
435
  }
350
436
  onControllerReconnect(callback) {
351
- this._onControllerReconnectCallbacks.add(callback);
352
- return () => {
353
- this._onControllerReconnectCallbacks.delete(callback);
354
- };
437
+ return this._addLifecycleListener("$controller-reconnect", callback);
355
438
  }
356
439
  onCharacterUpdated(callback) {
357
- this._onCharacterUpdatedCallbacks.add(callback);
358
- return () => {
359
- this._onCharacterUpdatedCallbacks.delete(callback);
360
- };
361
- }
362
- onRateLimited(callback) {
363
- this._onRateLimitedCallbacks.add(callback);
364
- return () => {
365
- this._onRateLimitedCallbacks.delete(callback);
366
- };
440
+ return this._addLifecycleListener("$character-updated", callback);
367
441
  }
368
442
  onError(callback) {
369
- this._onErrorCallbacks.add(callback);
443
+ return this._addLifecycleListener("$error", callback);
444
+ }
445
+ onConnectionChange(callback) {
446
+ return this._addLifecycleListener("$connection-change", callback);
447
+ }
448
+ // ---------------------------------------------------------------------------
449
+ // Custom State Methods
450
+ // ---------------------------------------------------------------------------
451
+ getControllerState(playerIndex) {
452
+ return this._customStates.get(playerIndex);
453
+ }
454
+ getAllControllerStates() {
455
+ const result = {};
456
+ for (const [key, value] of this._customStates) {
457
+ result[key] = value;
458
+ }
459
+ return result;
460
+ }
461
+ onCustomStateChange(listener) {
462
+ this._stateChangeListeners.add(listener);
370
463
  return () => {
371
- this._onErrorCallbacks.delete(callback);
464
+ this._stateChangeListeners.delete(listener);
372
465
  };
373
466
  }
374
467
  // ---------------------------------------------------------------------------
@@ -378,7 +471,6 @@ class ScreenImpl {
378
471
  * Send type-safe events to all controllers.
379
472
  *
380
473
  * Uses EventMap generic for compile-time type checking of event names and data payloads.
381
- * Runtime behavior is identical to broadcastRaw - both call validateEventName at runtime.
382
474
  *
383
475
  * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
384
476
  * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
@@ -387,31 +479,18 @@ class ScreenImpl {
387
479
  *
388
480
  * Warning: Avoid sending primitive values directly (string, number, boolean).
389
481
  * Wrap in an object: broadcast('event', { value: 42 }) instead of broadcast('event', 42)
390
- *
391
- * @see broadcastRaw for bypassing TypeScript type checking (runtime behavior identical)
392
482
  */
393
483
  broadcast(event, data) {
394
- this.ensureReady("broadcast");
395
- validateEventName(event);
396
- this.logger.send(event, data);
397
- this.transport.emit(event, data);
398
- }
399
- /**
400
- * Send events to all controllers without TypeScript type checking.
401
- *
402
- * Bypasses EventMap generic type checks at compile time.
403
- * Runtime behavior is identical to broadcast - both call validateEventName at runtime.
404
- *
405
- * Use this when you need dynamic event names or when working without a predefined EventMap.
406
- *
407
- * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
408
- * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
409
- *
410
- * @see broadcast for type-safe version using EventMap generic
411
- */
412
- broadcastRaw(event, data) {
413
- this.ensureReady("broadcastRaw");
484
+ if (this._isDestroyed) {
485
+ throw new SmoreSDKError("DESTROYED", "Cannot call broadcast() after destroy()");
486
+ }
487
+ if (!this._isReady || !this.transport) {
488
+ this._outboundBuffer.push({ method: "broadcast", args: [event, data] });
489
+ this.logger.debug(`Buffered broadcast "${event}" (screen not ready yet)`);
490
+ return;
491
+ }
414
492
  validateEventName(event);
493
+ validatePayloadSize(data);
415
494
  this.logger.send(event, data);
416
495
  this.transport.emit(event, data);
417
496
  }
@@ -430,24 +509,17 @@ class ScreenImpl {
430
509
  * @param data - Event data payload
431
510
  */
432
511
  sendToController(playerIndex, event, data) {
433
- this.ensureReady("sendToController");
434
- validateEventName(event);
435
- validatePlayerIndex(playerIndex, this._controllers);
436
- if (data && typeof data === "object" && "targetPlayerIndex" in data) {
437
- this.logger.warn(
438
- `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
439
- );
512
+ if (this._isDestroyed) {
513
+ throw new SmoreSDKError("DESTROYED", "Cannot call sendToController() after destroy()");
514
+ }
515
+ if (!this._isReady || !this.transport) {
516
+ this._outboundBuffer.push({ method: "sendToController", args: [playerIndex, event, data] });
517
+ this.logger.debug(`Buffered sendToController "${event}" -> Player ${playerIndex} (screen not ready yet)`);
518
+ return;
440
519
  }
441
- this.logger.send(`${event} -> Player ${playerIndex}`, data);
442
- this.transport.emit(event, {
443
- targetPlayerIndex: playerIndex,
444
- ...data && typeof data === "object" ? data : { data }
445
- });
446
- }
447
- sendToControllerRaw(playerIndex, event, data) {
448
- this.ensureReady("sendToControllerRaw");
449
520
  validateEventName(event);
450
521
  validatePlayerIndex(playerIndex, this._controllers);
522
+ validatePayloadSize(data);
451
523
  if (data && typeof data === "object" && "targetPlayerIndex" in data) {
452
524
  this.logger.warn(
453
525
  `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
@@ -482,6 +554,16 @@ class ScreenImpl {
482
554
  * are queued and activated when the transport becomes available.
483
555
  */
484
556
  on(event, handler) {
557
+ if (typeof event === "string" && event.startsWith("$")) {
558
+ const validEvents = SCREEN_LIFECYCLE_EVENTS;
559
+ if (!validEvents.has(event)) {
560
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}". Valid lifecycle events: ${Array.from(validEvents).join(", ")}`);
561
+ }
562
+ if (event === "$all-ready" && this._allReadyFired) {
563
+ handler();
564
+ }
565
+ return this._addLifecycleListener(event, handler);
566
+ }
485
567
  validateEventName(event);
486
568
  let handlers = this.eventHandlers.get(event);
487
569
  if (!handlers) {
@@ -544,6 +626,23 @@ class ScreenImpl {
544
626
  * @returns Unsubscribe function to remove the handler before it fires
545
627
  */
546
628
  once(event, handler) {
629
+ if (typeof event === "string" && event.startsWith("$")) {
630
+ const validEvents = SCREEN_LIFECYCLE_EVENTS;
631
+ if (!validEvents.has(event)) {
632
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}"`);
633
+ }
634
+ if (event === "$all-ready" && this._allReadyFired) {
635
+ handler();
636
+ return () => {
637
+ };
638
+ }
639
+ const wrapper = (...args) => {
640
+ unsub();
641
+ handler(...args);
642
+ };
643
+ const unsub = this._addLifecycleListener(event, wrapper);
644
+ return unsub;
645
+ }
547
646
  const wrappedHandler = (playerIndex, data) => {
548
647
  unsubscribe();
549
648
  handler(playerIndex, data);
@@ -552,6 +651,14 @@ class ScreenImpl {
552
651
  return unsubscribe;
553
652
  }
554
653
  off(event, handler) {
654
+ if (typeof event === "string" && event.startsWith("$")) {
655
+ if (!handler) {
656
+ this._lifecycleListeners.delete(event);
657
+ } else {
658
+ this._lifecycleListeners.get(event)?.delete(handler);
659
+ }
660
+ return;
661
+ }
555
662
  if (!handler) {
556
663
  this.eventHandlers.delete(event);
557
664
  this.transport?.off(event);
@@ -579,6 +686,21 @@ class ScreenImpl {
579
686
  );
580
687
  }
581
688
  }
689
+ removeAllListeners(event) {
690
+ if (event) {
691
+ this.eventHandlers.delete(event);
692
+ this.transport?.off(event);
693
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
694
+ for (const [key, val] of this.handlerToTransport) {
695
+ if (val.event === event) this.handlerToTransport.delete(key);
696
+ }
697
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
698
+ } else {
699
+ for (const evt of [...this.eventHandlers.keys()]) {
700
+ this.removeAllListeners(evt);
701
+ }
702
+ }
703
+ }
582
704
  // ---------------------------------------------------------------------------
583
705
  // Utilities
584
706
  // ---------------------------------------------------------------------------
@@ -588,9 +710,6 @@ class ScreenImpl {
588
710
  getControllerCount() {
589
711
  return this._controllers.filter((c) => c.connected).length;
590
712
  }
591
- hasAnyConnectedControllers() {
592
- return this._controllers.some((c) => c.connected);
593
- }
594
713
  // ---------------------------------------------------------------------------
595
714
  // Cleanup
596
715
  // ---------------------------------------------------------------------------
@@ -614,14 +733,11 @@ class ScreenImpl {
614
733
  this.eventHandlers.clear();
615
734
  this.handlerToTransport.clear();
616
735
  this._pendingHandlers = [];
617
- this._onAllReadyCallbacks.clear();
618
- this._onControllerJoinCallbacks.clear();
619
- this._onControllerLeaveCallbacks.clear();
620
- this._onControllerDisconnectCallbacks.clear();
621
- this._onControllerReconnectCallbacks.clear();
622
- this._onCharacterUpdatedCallbacks.clear();
623
- this._onRateLimitedCallbacks.clear();
624
- this._onErrorCallbacks.clear();
736
+ this._lifecycleListeners.clear();
737
+ this._customStates.clear();
738
+ this._stateChangeListeners.clear();
739
+ this._isConnected = false;
740
+ this._outboundBuffer = [];
625
741
  if (this.transport instanceof PostMessageTransport) {
626
742
  this.transport.destroy();
627
743
  }
@@ -637,8 +753,8 @@ class ScreenImpl {
637
753
  handleError(error) {
638
754
  this.logger.warn(`Error in handler: ${error.message}`);
639
755
  const smoreError = error.toSmoreError();
640
- if (this._onErrorCallbacks.size > 0) {
641
- this._onErrorCallbacks.forEach((cb) => cb(smoreError));
756
+ if (this._hasLifecycleListeners("$error")) {
757
+ this._emitLifecycle("$error", smoreError);
642
758
  } else {
643
759
  this.logger.error(error.message, error.details);
644
760
  }