@smoregg/sdk 1.3.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 +342 -193
  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 +336 -238
  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 +269 -197
  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 +344 -195
  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 +338 -240
  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 +269 -197
  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 +22 -43
  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 +15 -19
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/screen.d.ts +26 -37
  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 +78 -3
  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 +391 -540
  59. package/dist/types/types.d.ts.map +1 -1
  60. package/dist/umd/smore-sdk.umd.js +742 -952
  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) {
@@ -28,131 +28,165 @@ class ScreenImpl {
28
28
  _roomCode = "";
29
29
  _isReady = false;
30
30
  _isDestroyed = false;
31
+ _initTimeoutId = null;
31
32
  eventHandlers = /* @__PURE__ */ new Map();
32
33
  registeredTransportHandlers = [];
33
34
  boundMessageHandler = null;
34
- // Maps user-facing handler transport wrappedHandler for proper cleanup in on()/off()
35
+ // Maps user-facing handler -> transport wrappedHandler for proper cleanup in on()/off()
35
36
  handlerToTransport = /* @__PURE__ */ new Map();
36
- // Tracks event names registered via config.listeners so off(event) without handler won't remove them
37
- _configListenerEvents = /* @__PURE__ */ new Set();
37
+ // Pending handlers registered via on() before transport is ready
38
+ _pendingHandlers = [];
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 = [];
43
+ // Whether all-ready has fired
44
+ _allReadyFired = false;
45
+ // Self-connection awareness
46
+ _isConnected = false;
47
+ // Protocol versioning
48
+ _protocolVersion = protocol.PROTOCOL_VERSION;
49
+ // Ready promise
50
+ _readyResolve;
51
+ _readyReject;
52
+ ready;
38
53
  constructor(config = {}) {
39
54
  this.config = config;
40
55
  this.logger = new logger.DebugLogger(config.debug, "[SmoreScreen]");
41
- if (config.listeners) {
42
- for (const event of Object.keys(config.listeners)) {
43
- events.validateEventName(event);
44
- }
45
- }
56
+ this.ready = new Promise((resolve, reject) => {
57
+ this._readyResolve = resolve;
58
+ this._readyReject = reject;
59
+ });
60
+ this.startInitialization();
46
61
  }
47
62
  // ---------------------------------------------------------------------------
48
- // Initialization (called by factory)
63
+ // Initialization (called in constructor)
49
64
  // ---------------------------------------------------------------------------
50
- async initialize() {
65
+ startInitialization() {
51
66
  this.logger.lifecycle("Initializing screen...");
52
67
  const parentOrigin = this.config.parentOrigin ?? "*";
53
68
  const timeout = this.config.timeout ?? DEFAULT_TIMEOUT;
54
- return new Promise((resolve, reject) => {
55
- const timeoutId = setTimeout(() => {
56
- this.cleanup();
57
- const error = new errors.SmoreSDKError(
58
- "TIMEOUT",
59
- `Screen initialization timed out after ${timeout}ms. Make sure the parent frame sends _bridge:init. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Screen instance to retry (this instance has been cleaned up).`,
60
- { details: { timeout } }
61
- );
62
- this.handleError(error);
63
- reject(error);
64
- }, timeout);
65
- this.boundMessageHandler = (e) => {
66
- if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
67
- const msg = e.data;
68
- if (!protocol.isBridgeMessage(msg)) return;
69
- if (msg.type === "_bridge:init") {
70
- clearTimeout(timeoutId);
71
- const initPayload = msg.payload;
72
- try {
73
- protocol.validateInitPayload(initPayload);
74
- } catch (err) {
75
- const error = new errors.SmoreSDKError(
76
- "INIT_FAILED",
77
- `Invalid _bridge:init payload: ${err instanceof Error ? err.message : String(err)}`,
78
- { details: { payload: initPayload } }
79
- );
80
- this.logger.warn("_bridge:init validation failed", error);
81
- this.handleError(error);
82
- reject(error);
83
- return;
84
- }
85
- const initData = initPayload;
86
- if (initData.side !== "host") {
87
- const error = new errors.SmoreSDKError(
88
- "INIT_FAILED",
89
- `Received init for wrong side: ${initData.side}. Expected "host".`,
90
- { details: { side: initData.side } }
69
+ this._initTimeoutId = setTimeout(() => {
70
+ this.cleanup();
71
+ const error = new errors.SmoreSDKError(
72
+ "TIMEOUT",
73
+ `Screen initialization timed out after ${timeout}ms. Make sure the parent frame sends _bridge:init. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Screen instance to retry (this instance has been cleaned up).`,
74
+ { details: { timeout } }
75
+ );
76
+ this.handleError(error);
77
+ this._readyReject(error);
78
+ }, timeout);
79
+ this.boundMessageHandler = (e) => {
80
+ if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
81
+ const msg = e.data;
82
+ if (!protocol.isBridgeMessage(msg)) return;
83
+ if (msg.type === "_bridge:init") {
84
+ clearTimeout(this._initTimeoutId);
85
+ const initPayload = msg.payload;
86
+ try {
87
+ protocol.validateInitPayload(initPayload);
88
+ } catch (err) {
89
+ const error = new errors.SmoreSDKError(
90
+ "INIT_FAILED",
91
+ `Invalid _bridge:init payload: ${err instanceof Error ? err.message : String(err)}`,
92
+ { details: { payload: initPayload } }
93
+ );
94
+ this.logger.warn("_bridge:init validation failed", error);
95
+ this.handleError(error);
96
+ this._readyReject(error);
97
+ return;
98
+ }
99
+ const initData = initPayload;
100
+ if (initData.side !== "host") {
101
+ const error = new errors.SmoreSDKError(
102
+ "INIT_FAILED",
103
+ `Received init for wrong side: ${initData.side}. Expected "host".`,
104
+ { details: { side: initData.side } }
105
+ );
106
+ this.handleError(error);
107
+ this._readyReject(error);
108
+ return;
109
+ }
110
+ this.transport = this.config.transport ?? new PostMessageTransport.PostMessageTransport(parentOrigin);
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.`
91
118
  );
92
- this.handleError(error);
93
- reject(error);
94
- return;
95
- }
96
- this.transport = new PostMessageTransport.PostMessageTransport(parentOrigin);
97
- this._roomCode = initData.roomCode;
98
- this._controllers = this.mapControllersFromInit(initData.players);
99
- if (this._controllers.length === 0) {
100
- this.logger.warn("Screen initialized with zero controllers");
101
- }
102
- this.setupEventHandlers();
103
- this._isReady = true;
104
- this.logger.lifecycle("Screen ready", {
105
- roomCode: this._roomCode,
106
- controllers: this._controllers.length
107
- });
108
- const autoReady = config.getGlobalConfig().autoReady ?? true;
109
- if (autoReady) {
110
- this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
111
- this.signalReady();
112
119
  }
113
- resolve();
114
- } else if (msg.type === "_bridge:update") {
115
- if (!this._isReady) {
116
- this.logger.debug("Ignoring _bridge:update before init completes");
117
- return;
120
+ }
121
+ this._controllers = this.mapControllersFromInit(initData.players);
122
+ if (this._controllers.length === 0) {
123
+ this.logger.warn("Screen initialized with zero controllers");
124
+ }
125
+ this.setupEventHandlers();
126
+ for (const { event, handler } of this._pendingHandlers) {
127
+ this.setupUserEventHandler(event, handler);
128
+ }
129
+ this._pendingHandlers = [];
130
+ this._isConnected = true;
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"));
118
144
  }
119
- const updateData = msg.payload;
120
- if (updateData.players && Array.isArray(updateData.players)) {
121
- const oldControllers = this._controllers;
122
- const newControllers = this.mapControllersFromInit(updateData.players);
123
- this._controllers = newControllers;
124
- for (const nc of newControllers) {
125
- if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
126
- this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
127
- this.config.onControllerJoin?.(nc.playerIndex, nc);
128
- }
145
+ }
146
+ this._outboundBuffer = [];
147
+ this.logger.lifecycle("Screen ready", {
148
+ roomCode: this._roomCode,
149
+ controllers: this._controllers.length
150
+ });
151
+ if (this.config.autoReady !== false) {
152
+ this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
153
+ this.signalReady();
154
+ }
155
+ this._readyResolve();
156
+ } else if (msg.type === "_bridge:update") {
157
+ if (!this._isReady) {
158
+ this.logger.debug("Ignoring _bridge:update before init completes");
159
+ return;
160
+ }
161
+ const updateData = msg.payload;
162
+ if (updateData.players && Array.isArray(updateData.players)) {
163
+ const oldControllers = this._controllers;
164
+ const newControllers = this.mapControllersFromInit(updateData.players);
165
+ this._controllers = newControllers;
166
+ for (const nc of newControllers) {
167
+ if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
168
+ this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
169
+ this._emitLifecycle("$controller-join", nc.playerIndex, nc);
129
170
  }
130
- for (const oc of oldControllers) {
131
- if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
132
- this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
133
- this.config.onControllerLeave?.(oc.playerIndex);
134
- }
171
+ }
172
+ for (const oc of oldControllers) {
173
+ if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
174
+ this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
175
+ this._emitLifecycle("$controller-leave", oc.playerIndex);
135
176
  }
136
177
  }
137
- this.logger.lifecycle("Room updated", {
138
- controllers: this._controllers.length
139
- });
140
178
  }
141
- };
142
- window.addEventListener("message", this.boundMessageHandler);
143
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
144
- this.logger.lifecycle("Sent _bridge:ready to parent");
145
- });
179
+ this.logger.lifecycle("Room updated", {
180
+ controllers: this._controllers.length
181
+ });
182
+ }
183
+ };
184
+ window.addEventListener("message", this.boundMessageHandler);
185
+ window.parent.postMessage({ type: "_bridge:ready", protocolVersion: protocol.PROTOCOL_VERSION }, parentOrigin);
186
+ this.logger.lifecycle("Sent _bridge:ready to parent");
146
187
  }
147
188
  mapControllersFromInit(players) {
148
- return players.map((p, index) => ({
149
- playerIndex: p.playerIndex ?? index,
150
- // Fallback to `nickname` for defensive compatibility (server currently always sends `name`)
151
- nickname: p.nickname || p.name || `Player ${index + 1}`,
152
- connected: p.connected !== false,
153
- // Fallback to `appearance` for defensive compatibility (server currently always sends `character`)
154
- appearance: p.appearance ?? p.character
155
- }));
189
+ return players.map((p, index) => shared.mapPlayerDTO(p, index));
156
190
  }
157
191
  setupEventHandlers() {
158
192
  if (!this.transport) return;
@@ -160,16 +194,11 @@ class ScreenImpl {
160
194
  const payload = data;
161
195
  const playerData = payload?.player;
162
196
  if (playerData && typeof playerData.playerIndex === "number") {
163
- const controllerInfo = {
164
- playerIndex: playerData.playerIndex,
165
- nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
166
- connected: playerData.connected !== false,
167
- appearance: playerData.appearance ?? playerData.character
168
- };
197
+ const controllerInfo = shared.mapPlayerDTO(playerData, playerData.playerIndex);
169
198
  if (this._controllers.some((c) => c.playerIndex === controllerInfo.playerIndex)) return;
170
199
  this._controllers = [...this._controllers, controllerInfo];
171
200
  this.logger.lifecycle("Controller joined", { playerIndex: controllerInfo.playerIndex });
172
- this.config.onControllerJoin?.(controllerInfo.playerIndex, controllerInfo);
201
+ this._emitLifecycle("$controller-join", controllerInfo.playerIndex, controllerInfo);
173
202
  }
174
203
  });
175
204
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_LEFT, (data) => {
@@ -179,7 +208,7 @@ class ScreenImpl {
179
208
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
180
209
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
181
210
  this.logger.lifecycle("Controller left", { playerIndex });
182
- this.config.onControllerLeave?.(playerIndex);
211
+ this._emitLifecycle("$controller-leave", playerIndex);
183
212
  }
184
213
  });
185
214
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_DISCONNECTED, (data) => {
@@ -190,55 +219,58 @@ class ScreenImpl {
190
219
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
191
220
  );
192
221
  this.logger.lifecycle("Controller disconnected", { playerIndex });
193
- this.config.onControllerDisconnect?.(playerIndex);
222
+ this._emitLifecycle("$controller-disconnect", playerIndex);
194
223
  }
195
224
  });
196
225
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_RECONNECTED, (data) => {
197
226
  const payload = data;
198
227
  const playerData = payload?.player;
199
228
  if (playerData && typeof playerData.playerIndex === "number") {
200
- const controllerInfo = {
201
- playerIndex: playerData.playerIndex,
202
- nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
203
- connected: true,
204
- appearance: playerData.appearance ?? playerData.character
205
- };
229
+ const controllerInfo = shared.mapPlayerDTO(playerData, playerData.playerIndex);
206
230
  this._controllers = this._controllers.map(
207
231
  (c) => c.playerIndex === controllerInfo.playerIndex ? controllerInfo : c
208
232
  );
209
233
  this.logger.lifecycle("Controller reconnected", { playerIndex: controllerInfo.playerIndex });
210
- this.config.onControllerReconnect?.(controllerInfo.playerIndex, controllerInfo);
234
+ this._emitLifecycle("$controller-reconnect", controllerInfo.playerIndex, controllerInfo);
211
235
  }
212
236
  });
213
237
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (data) => {
214
238
  const payload = data;
215
239
  const playerData = payload?.player;
216
240
  if (playerData && typeof playerData.playerIndex === "number") {
241
+ const pi = playerData.playerIndex;
217
242
  const appearance = playerData.character ?? null;
218
243
  this._controllers = this._controllers.map(
219
- (c) => c.playerIndex === playerData.playerIndex ? { ...c, appearance } : c
244
+ (c) => c.playerIndex === pi ? { ...c, appearance } : c
220
245
  );
221
- this.logger.lifecycle("Player character updated", { playerIndex: playerData.playerIndex });
222
- this.config.onCharacterUpdated?.(playerData.playerIndex, appearance ?? null);
246
+ this.logger.lifecycle("Player character updated", { playerIndex: pi });
247
+ this._emitLifecycle("$character-updated", pi, appearance ?? null);
223
248
  }
224
249
  });
225
250
  this.registerTransportHandler(events.SMORE_EVENTS.RATE_LIMITED, (data) => {
226
251
  const payload = data;
227
- const event = payload?.event ?? "unknown";
228
- this.logger.warn(`Rate limited: ${event}`);
229
- this.config.onRateLimited?.(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
+ );
230
258
  });
231
259
  this.registerTransportHandler(events.SMORE_EVENTS.ALL_READY, () => {
232
260
  this.logger.lifecycle("All participants ready");
233
- this.config.onReady?.();
261
+ this._allReadyFired = true;
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);
234
273
  });
235
- if (this.config.listeners) {
236
- for (const [event, handler] of Object.entries(this.config.listeners)) {
237
- if (!handler) continue;
238
- this._configListenerEvents.add(event);
239
- this.setupUserEventHandler(event, handler);
240
- }
241
- }
242
274
  }
243
275
  /**
244
276
  * Sets up a user event handler with playerIndex extraction.
@@ -280,6 +312,7 @@ class ScreenImpl {
280
312
  this.eventHandlers.set(event, handlers);
281
313
  }
282
314
  handlers.add(handler);
315
+ this.handlerToTransport.set(handler, { event, transportHandler: wrappedHandler });
283
316
  }
284
317
  registerTransportHandler(event, handler) {
285
318
  if (!this.transport) return;
@@ -305,6 +338,75 @@ class ScreenImpl {
305
338
  get isDestroyed() {
306
339
  return this._isDestroyed;
307
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
+ }
380
+ // ---------------------------------------------------------------------------
381
+ // Lifecycle Methods
382
+ // ---------------------------------------------------------------------------
383
+ onAllReady(callback) {
384
+ if (this._allReadyFired) {
385
+ callback();
386
+ }
387
+ return this._addLifecycleListener("$all-ready", callback);
388
+ }
389
+ onControllerJoin(callback) {
390
+ return this._addLifecycleListener("$controller-join", callback);
391
+ }
392
+ onControllerLeave(callback) {
393
+ return this._addLifecycleListener("$controller-leave", callback);
394
+ }
395
+ onControllerDisconnect(callback) {
396
+ return this._addLifecycleListener("$controller-disconnect", callback);
397
+ }
398
+ onControllerReconnect(callback) {
399
+ return this._addLifecycleListener("$controller-reconnect", callback);
400
+ }
401
+ onCharacterUpdated(callback) {
402
+ return this._addLifecycleListener("$character-updated", callback);
403
+ }
404
+ onError(callback) {
405
+ return this._addLifecycleListener("$error", callback);
406
+ }
407
+ onConnectionChange(callback) {
408
+ return this._addLifecycleListener("$connection-change", callback);
409
+ }
308
410
  // ---------------------------------------------------------------------------
309
411
  // Communication Methods
310
412
  // ---------------------------------------------------------------------------
@@ -312,7 +414,6 @@ class ScreenImpl {
312
414
  * Send type-safe events to all controllers.
313
415
  *
314
416
  * Uses EventMap generic for compile-time type checking of event names and data payloads.
315
- * Runtime behavior is identical to broadcastRaw - both call validateEventName at runtime.
316
417
  *
317
418
  * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
318
419
  * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
@@ -321,31 +422,18 @@ class ScreenImpl {
321
422
  *
322
423
  * Warning: Avoid sending primitive values directly (string, number, boolean).
323
424
  * Wrap in an object: broadcast('event', { value: 42 }) instead of broadcast('event', 42)
324
- *
325
- * @see broadcastRaw for bypassing TypeScript type checking (runtime behavior identical)
326
425
  */
327
426
  broadcast(event, data) {
328
- this.ensureReady("broadcast");
329
- events.validateEventName(event);
330
- this.logger.send(event, data);
331
- this.transport.emit(event, data);
332
- }
333
- /**
334
- * Send events to all controllers without TypeScript type checking.
335
- *
336
- * Bypasses EventMap generic type checks at compile time.
337
- * Runtime behavior is identical to broadcast - both call validateEventName at runtime.
338
- *
339
- * Use this when you need dynamic event names or when working without a predefined EventMap.
340
- *
341
- * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
342
- * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
343
- *
344
- * @see broadcast for type-safe version using EventMap generic
345
- */
346
- broadcastRaw(event, data) {
347
- 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
+ }
348
435
  events.validateEventName(event);
436
+ shared.validatePayloadSize(data);
349
437
  this.logger.send(event, data);
350
438
  this.transport.emit(event, data);
351
439
  }
@@ -364,24 +452,17 @@ class ScreenImpl {
364
452
  * @param data - Event data payload
365
453
  */
366
454
  sendToController(playerIndex, event, data) {
367
- this.ensureReady("sendToController");
368
- events.validateEventName(event);
369
- validatePlayerIndex(playerIndex, this._controllers);
370
- if (data && typeof data === "object" && "targetPlayerIndex" in data) {
371
- this.logger.warn(
372
- `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
373
- );
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;
374
462
  }
375
- this.logger.send(`${event} -> Player ${playerIndex}`, data);
376
- this.transport.emit(event, {
377
- targetPlayerIndex: playerIndex,
378
- ...data && typeof data === "object" ? data : { data }
379
- });
380
- }
381
- sendToControllerRaw(playerIndex, event, data) {
382
- this.ensureReady("sendToControllerRaw");
383
463
  events.validateEventName(event);
384
464
  validatePlayerIndex(playerIndex, this._controllers);
465
+ shared.validatePayloadSize(data);
385
466
  if (data && typeof data === "object" && "targetPlayerIndex" in data) {
386
467
  this.logger.warn(
387
468
  `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
@@ -412,13 +493,20 @@ class ScreenImpl {
412
493
  /**
413
494
  * Register an event handler for messages from controllers.
414
495
  *
415
- * **Important:** If called before the Screen is ready (i.e., before `await createScreen()`
416
- * resolves or before the `onReady` callback fires), the handler is stored locally but
417
- * will NOT be registered with the transport layer. This means the handler will never
418
- * fire for events received during the pre-ready window. Always call `on()` after
419
- * initialization completes, or use `config.listeners` for handlers needed from the start.
496
+ * Can be called before the Screen is ready. Handlers registered before ready
497
+ * are queued and activated when the transport becomes available.
420
498
  */
421
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
+ }
422
510
  events.validateEventName(event);
423
511
  let handlers = this.eventHandlers.get(event);
424
512
  if (!handlers) {
@@ -426,9 +514,8 @@ class ScreenImpl {
426
514
  this.eventHandlers.set(event, handlers);
427
515
  }
428
516
  handlers.add(handler);
429
- let wrappedHandler = null;
430
517
  if (this.transport) {
431
- wrappedHandler = (data) => {
518
+ const wrappedHandler = (data) => {
432
519
  this.logger.receive(event, data);
433
520
  const payload = data;
434
521
  const { playerIndex, ...rest } = payload;
@@ -448,16 +535,22 @@ class ScreenImpl {
448
535
  };
449
536
  this.registerTransportHandler(event, wrappedHandler);
450
537
  this.handlerToTransport.set(handler, { event, transportHandler: wrappedHandler });
538
+ } else {
539
+ this._pendingHandlers.push({ event, handler });
451
540
  }
452
541
  return () => {
453
542
  handlers?.delete(handler);
454
543
  if (handlers?.size === 0) {
455
544
  this.eventHandlers.delete(event);
456
545
  }
457
- if (wrappedHandler) {
458
- this.transport?.off(event, wrappedHandler);
546
+ this._pendingHandlers = this._pendingHandlers.filter(
547
+ (p) => !(p.event === event && p.handler === handler)
548
+ );
549
+ const entry = this.handlerToTransport.get(handler);
550
+ if (entry) {
551
+ this.transport?.off(event, entry.transportHandler);
459
552
  this.registeredTransportHandlers = this.registeredTransportHandlers.filter(
460
- (h) => h.handler !== wrappedHandler
553
+ (h) => h.handler !== entry.transportHandler
461
554
  );
462
555
  this.handlerToTransport.delete(handler);
463
556
  }
@@ -474,18 +567,25 @@ class ScreenImpl {
474
567
  * @param event - Event name to listen for
475
568
  * @param handler - Handler function to call once
476
569
  * @returns Unsubscribe function to remove the handler before it fires
477
- *
478
- * @example
479
- * ```ts
480
- * const unsubscribe = screen.once('ready', (playerIndex, data) => {
481
- * console.log('Ready event received');
482
- * });
483
- *
484
- * // To remove before the event fires:
485
- * unsubscribe();
486
- * ```
487
570
  */
488
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
+ }
489
589
  const wrappedHandler = (playerIndex, data) => {
490
590
  unsubscribe();
491
591
  handler(playerIndex, data);
@@ -494,25 +594,22 @@ class ScreenImpl {
494
594
  return unsubscribe;
495
595
  }
496
596
  off(event, handler) {
497
- if (!handler) {
498
- if (this._configListenerEvents.has(event)) {
499
- for (const [key, val] of this.handlerToTransport) {
500
- if (val.event === event) {
501
- this.transport?.off(event, val.transportHandler);
502
- this.registeredTransportHandlers = this.registeredTransportHandlers.filter(
503
- (h) => h.handler !== val.transportHandler
504
- );
505
- this.handlerToTransport.delete(key);
506
- }
507
- }
597
+ if (typeof event === "string" && event.startsWith("$")) {
598
+ if (!handler) {
599
+ this._lifecycleListeners.delete(event);
508
600
  } else {
509
- this.eventHandlers.delete(event);
510
- this.transport?.off(event);
511
- this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
512
- for (const [key, val] of this.handlerToTransport) {
513
- if (val.event === event) this.handlerToTransport.delete(key);
514
- }
601
+ this._lifecycleListeners.get(event)?.delete(handler);
602
+ }
603
+ return;
604
+ }
605
+ if (!handler) {
606
+ this.eventHandlers.delete(event);
607
+ this.transport?.off(event);
608
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
609
+ for (const [key, val] of this.handlerToTransport) {
610
+ if (val.event === event) this.handlerToTransport.delete(key);
515
611
  }
612
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
516
613
  } else {
517
614
  const handlers = this.eventHandlers.get(event);
518
615
  handlers?.delete(handler);
@@ -527,6 +624,24 @@ class ScreenImpl {
527
624
  );
528
625
  this.handlerToTransport.delete(handler);
529
626
  }
627
+ this._pendingHandlers = this._pendingHandlers.filter(
628
+ (p) => !(p.event === event && p.handler === handler)
629
+ );
630
+ }
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
+ }
530
645
  }
531
646
  }
532
647
  // ---------------------------------------------------------------------------
@@ -538,28 +653,6 @@ class ScreenImpl {
538
653
  getControllerCount() {
539
654
  return this._controllers.filter((c) => c.connected).length;
540
655
  }
541
- /**
542
- * Check if there is at least one connected controller.
543
- * Useful for detecting when all players have disconnected
544
- * (e.g., to pause the game or show a waiting screen).
545
- *
546
- * Use this in onControllerDisconnect callback to detect when all controllers have disconnected.
547
- *
548
- * @example
549
- * ```ts
550
- * const screen = await createScreen<MyEvents>({
551
- * onControllerDisconnect: (playerIndex) => {
552
- * if (!screen.hasAnyConnectedControllers()) {
553
- * console.log('All controllers disconnected!');
554
- * screen.broadcast('waiting-for-players', {});
555
- * }
556
- * },
557
- * });
558
- * ```
559
- */
560
- hasAnyConnectedControllers() {
561
- return this._controllers.some((c) => c.connected);
562
- }
563
656
  // ---------------------------------------------------------------------------
564
657
  // Cleanup
565
658
  // ---------------------------------------------------------------------------
@@ -572,12 +665,20 @@ class ScreenImpl {
572
665
  this.logger.lifecycle("Screen destroyed");
573
666
  }
574
667
  cleanup() {
668
+ if (this._initTimeoutId) {
669
+ clearTimeout(this._initTimeoutId);
670
+ this._initTimeoutId = null;
671
+ }
575
672
  for (const { event, handler } of this.registeredTransportHandlers) {
576
673
  this.transport?.off(event, handler);
577
674
  }
578
675
  this.registeredTransportHandlers = [];
579
676
  this.eventHandlers.clear();
580
677
  this.handlerToTransport.clear();
678
+ this._pendingHandlers = [];
679
+ this._lifecycleListeners.clear();
680
+ this._isConnected = false;
681
+ this._outboundBuffer = [];
581
682
  if (this.transport instanceof PostMessageTransport.PostMessageTransport) {
582
683
  this.transport.destroy();
583
684
  }
@@ -593,8 +694,8 @@ class ScreenImpl {
593
694
  handleError(error) {
594
695
  this.logger.warn(`Error in handler: ${error.message}`);
595
696
  const smoreError = error.toSmoreError();
596
- if (this.config.onError) {
597
- this.config.onError(smoreError);
697
+ if (this._hasLifecycleListeners("$error")) {
698
+ this._emitLifecycle("$error", smoreError);
598
699
  } else {
599
700
  this.logger.error(error.message, error.details);
600
701
  }
@@ -610,17 +711,14 @@ class ScreenImpl {
610
711
  if (!this._isReady || !this.transport) {
611
712
  throw new errors.SmoreSDKError(
612
713
  "NOT_READY",
613
- `Cannot call ${method}() before screen is ready. Use await createScreen() or onReady callback.`,
714
+ `Cannot call ${method}() before screen is ready. Use await screen.ready.`,
614
715
  { details: { method } }
615
716
  );
616
717
  }
617
718
  }
618
719
  }
619
720
  function createScreen(config) {
620
- const screen = new ScreenImpl(config);
621
- const promise = screen.initialize().then(() => screen);
622
- promise.instance = screen;
623
- return promise;
721
+ return new ScreenImpl(config);
624
722
  }
625
723
 
626
724
  exports.createScreen = createScreen;