@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
@@ -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 { validateEventName, SMORE_EVENTS } from './events.js';
4
+ import { SMORE_EVENTS, validateEventName, CONTROLLER_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
  class ControllerImpl {
@@ -11,31 +11,46 @@ class ControllerImpl {
11
11
  config;
12
12
  logger;
13
13
  _roomCode = "";
14
- _myIndex = -1;
14
+ _myPlayerIndex = -1;
15
15
  _isReady = false;
16
16
  _isDestroyed = false;
17
+ _initTimeoutId = null;
17
18
  boundMessageHandler = null;
18
19
  registeredHandlers = [];
19
20
  eventListeners = /* @__PURE__ */ new Map();
20
21
  // Maps user-facing handler -> transport wrappedHandler for proper cleanup in on()/off()
21
22
  handlerToTransport = /* @__PURE__ */ new Map();
22
23
  _controllers = [];
23
- // Tracks event names registered via config.listeners so off(event) without handler won't remove them
24
- _configListenerEvents = /* @__PURE__ */ new Set();
24
+ // Pending handlers registered via on() before transport is ready
25
+ _pendingHandlers = [];
26
+ // Unified lifecycle listener map (supports both onXxx() and on('$xxx') patterns)
27
+ _lifecycleListeners = /* @__PURE__ */ new Map();
28
+ // Outbound message buffer (messages sent before ready)
29
+ _outboundBuffer = [];
30
+ // Whether all-ready has fired
31
+ _allReadyFired = false;
32
+ // Self-connection awareness
33
+ _isConnected = false;
34
+ // Protocol versioning
35
+ _protocolVersion = PROTOCOL_VERSION;
36
+ // Ready promise
37
+ _readyResolve;
38
+ _readyReject;
39
+ ready;
25
40
  constructor(config = {}) {
26
41
  this.config = config;
27
42
  this.logger = new DebugLogger(config.debug, "[SmoreController]");
28
- if (config.listeners) {
29
- for (const event of Object.keys(config.listeners)) {
30
- validateEventName(event);
31
- }
32
- }
43
+ this.ready = new Promise((resolve, reject) => {
44
+ this._readyResolve = resolve;
45
+ this._readyReject = reject;
46
+ });
47
+ this.startInitialization();
33
48
  }
34
49
  // ---------------------------------------------------------------------------
35
50
  // Properties (readonly)
36
51
  // ---------------------------------------------------------------------------
37
- get myIndex() {
38
- return this._myIndex;
52
+ get myPlayerIndex() {
53
+ return this._myPlayerIndex;
39
54
  }
40
55
  get roomCode() {
41
56
  return this._roomCode;
@@ -46,6 +61,12 @@ class ControllerImpl {
46
61
  get isDestroyed() {
47
62
  return this._isDestroyed;
48
63
  }
64
+ get isConnected() {
65
+ return this._isConnected;
66
+ }
67
+ get protocolVersion() {
68
+ return this._protocolVersion;
69
+ }
49
70
  /**
50
71
  * Read-only list of all known controllers (players) in the room.
51
72
  * Returns full ControllerInfo including playerIndex, nickname, connected status, and appearance.
@@ -62,41 +83,42 @@ class ControllerImpl {
62
83
  getControllerCount() {
63
84
  return this._controllers.filter((c) => c.connected).length;
64
85
  }
86
+ getController(playerIndex) {
87
+ return this._controllers.find((c) => c.playerIndex === playerIndex);
88
+ }
65
89
  // ---------------------------------------------------------------------------
66
90
  // Initialization
67
91
  // ---------------------------------------------------------------------------
68
- async initialize() {
92
+ startInitialization() {
69
93
  const parentOrigin = this.config.parentOrigin ?? "*";
70
94
  const timeout = this.config.timeout ?? DEFAULT_TIMEOUT;
71
95
  this.logger.lifecycle("Initializing controller...", { parentOrigin, timeout });
72
- return new Promise((resolve, reject) => {
73
- const timeoutId = setTimeout(() => {
74
- this.cleanup();
75
- const error = new SmoreSDKError(
76
- "TIMEOUT",
77
- `Controller initialization timed out after ${timeout}ms. Make sure the parent window sends _bridge:init message. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Controller instance to retry (this instance has been cleaned up).`,
78
- { details: { timeout } }
79
- );
80
- this.handleError(error);
81
- reject(error);
82
- }, timeout);
83
- this.boundMessageHandler = (e) => {
84
- if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
85
- const msg = e.data;
86
- if (!isBridgeMessage(msg)) return;
87
- if (msg.type === "_bridge:init") {
88
- clearTimeout(timeoutId);
89
- this.handleInit(msg, parentOrigin, resolve, reject);
90
- } else if (msg.type === "_bridge:update") {
91
- this.handleUpdate(msg);
92
- }
93
- };
94
- window.addEventListener("message", this.boundMessageHandler);
95
- this.logger.lifecycle("Sending _bridge:ready to parent");
96
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
97
- });
96
+ this._initTimeoutId = setTimeout(() => {
97
+ this.cleanup();
98
+ const error = new SmoreSDKError(
99
+ "TIMEOUT",
100
+ `Controller initialization timed out after ${timeout}ms. Make sure the parent window sends _bridge:init message. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Controller instance to retry (this instance has been cleaned up).`,
101
+ { details: { timeout } }
102
+ );
103
+ this.handleError(error);
104
+ this._readyReject(error);
105
+ }, timeout);
106
+ this.boundMessageHandler = (e) => {
107
+ if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
108
+ const msg = e.data;
109
+ if (!isBridgeMessage(msg)) return;
110
+ if (msg.type === "_bridge:init") {
111
+ clearTimeout(this._initTimeoutId);
112
+ this.handleInit(msg, parentOrigin);
113
+ } else if (msg.type === "_bridge:update") {
114
+ this.handleUpdate(msg);
115
+ }
116
+ };
117
+ window.addEventListener("message", this.boundMessageHandler);
118
+ this.logger.lifecycle("Sending _bridge:ready to parent");
119
+ window.parent.postMessage({ type: "_bridge:ready", protocolVersion: PROTOCOL_VERSION }, parentOrigin);
98
120
  }
99
- handleInit(msg, parentOrigin, resolve, reject) {
121
+ handleInit(msg, parentOrigin) {
100
122
  const initPayload = msg.payload;
101
123
  this.logger.debug("Received _bridge:init", initPayload);
102
124
  try {
@@ -109,7 +131,7 @@ class ControllerImpl {
109
131
  );
110
132
  this.logger.warn("_bridge:init validation failed", error);
111
133
  this.handleError(error);
112
- reject(error);
134
+ this._readyReject(error);
113
135
  return;
114
136
  }
115
137
  const initData = initPayload;
@@ -120,7 +142,7 @@ class ControllerImpl {
120
142
  { details: { side: initData.side } }
121
143
  );
122
144
  this.handleError(error);
123
- reject(error);
145
+ this._readyReject(error);
124
146
  return;
125
147
  }
126
148
  if (initData.myIndex === void 0) {
@@ -130,31 +152,51 @@ class ControllerImpl {
130
152
  { details: initData }
131
153
  );
132
154
  this.handleError(error);
133
- reject(error);
155
+ this._readyReject(error);
134
156
  return;
135
157
  }
136
- this.transport = new PostMessageTransport(parentOrigin);
158
+ this.transport = this.config.transport ?? new PostMessageTransport(parentOrigin);
137
159
  this._roomCode = initData.roomCode;
138
- this._myIndex = initData.myIndex;
160
+ const serverProtocolVersion = initData.protocolVersion;
161
+ if (serverProtocolVersion !== void 0) {
162
+ this._protocolVersion = serverProtocolVersion;
163
+ if (serverProtocolVersion !== PROTOCOL_VERSION) {
164
+ this.logger.warn(
165
+ `Protocol version mismatch: SDK v${PROTOCOL_VERSION}, server v${serverProtocolVersion}. Some features may not work correctly.`
166
+ );
167
+ }
168
+ }
169
+ this._myPlayerIndex = initData.myIndex;
139
170
  const initPlayers = initData.players;
140
- this._controllers = initPlayers.filter((p) => typeof p.playerIndex === "number").map((p) => ({
141
- playerIndex: p.playerIndex,
142
- nickname: p.nickname || p.name || `Player ${p.playerIndex + 1}`,
143
- connected: p.connected !== false,
144
- appearance: p.appearance ?? p.character
145
- }));
171
+ this._controllers = initPlayers.filter((p) => typeof p.playerIndex === "number").map((p, index) => mapPlayerDTO(p, index));
146
172
  this.setupEventHandlers();
173
+ for (const { event, handler } of this._pendingHandlers) {
174
+ this.setupUserEventHandler(event, handler);
175
+ }
176
+ this._pendingHandlers = [];
177
+ this._isConnected = true;
147
178
  this._isReady = true;
179
+ for (const buffered of this._outboundBuffer) {
180
+ try {
181
+ switch (buffered.method) {
182
+ case "send":
183
+ this.send(buffered.args[0], buffered.args[1]);
184
+ break;
185
+ }
186
+ } catch (err) {
187
+ this.handleError(err instanceof SmoreSDKError ? err : new SmoreSDKError("UNKNOWN", "Failed to flush buffered message"));
188
+ }
189
+ }
190
+ this._outboundBuffer = [];
148
191
  this.logger.lifecycle("Controller ready", {
149
192
  roomCode: this._roomCode,
150
- myIndex: this._myIndex
193
+ myIndex: this._myPlayerIndex
151
194
  });
152
- const autoReady = getGlobalConfig().autoReady ?? true;
153
- if (autoReady) {
195
+ if (this.config.autoReady !== false) {
154
196
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
155
197
  this.signalReady();
156
198
  }
157
- resolve();
199
+ this._readyResolve();
158
200
  }
159
201
  handleUpdate(msg) {
160
202
  if (!this._isReady) {
@@ -165,21 +207,16 @@ class ControllerImpl {
165
207
  this.logger.debug("Received _bridge:update", updateData);
166
208
  if (updateData.players && Array.isArray(updateData.players)) {
167
209
  const players = updateData.players;
168
- const newControllers = players.filter((p) => typeof p.playerIndex === "number").map((p) => ({
169
- playerIndex: p.playerIndex,
170
- nickname: p.nickname || p.name || `Player ${p.playerIndex + 1}`,
171
- connected: p.connected !== false,
172
- appearance: p.appearance ?? p.character
173
- }));
210
+ const newControllers = players.filter((p) => typeof p.playerIndex === "number").map((p, index) => mapPlayerDTO(p, index));
174
211
  const oldControllers = this._controllers;
175
212
  for (const nc of newControllers) {
176
213
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
177
- this.config.onControllerJoin?.(nc.playerIndex, nc);
214
+ this._emitLifecycle("$controller-join", nc.playerIndex, nc);
178
215
  }
179
216
  }
180
217
  for (const oc of oldControllers) {
181
218
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
182
- this.config.onControllerLeave?.(oc.playerIndex);
219
+ this._emitLifecycle("$controller-leave", oc.playerIndex);
183
220
  }
184
221
  }
185
222
  for (const nc of newControllers) {
@@ -187,11 +224,11 @@ class ControllerImpl {
187
224
  if (oc) {
188
225
  if (oc.connected && !nc.connected) {
189
226
  this.logger.debug("Player disconnected (via update)", { playerIndex: nc.playerIndex });
190
- this.config.onControllerDisconnect?.(nc.playerIndex);
227
+ this._emitLifecycle("$controller-disconnect", nc.playerIndex);
191
228
  }
192
229
  if (!oc.connected && nc.connected) {
193
230
  this.logger.debug("Player reconnected (via update)", { playerIndex: nc.playerIndex });
194
- this.config.onControllerReconnect?.(nc.playerIndex, nc);
231
+ this._emitLifecycle("$controller-reconnect", nc.playerIndex, nc);
195
232
  }
196
233
  }
197
234
  }
@@ -206,19 +243,10 @@ class ControllerImpl {
206
243
  const playerIndex = playerInfo?.playerIndex ?? data.playerIndex;
207
244
  if (playerIndex !== void 0) {
208
245
  if (this._controllers.some((c) => c.playerIndex === playerIndex)) return;
209
- const controllerInfo = playerInfo ? {
210
- playerIndex,
211
- nickname: playerInfo.nickname || playerInfo.name || `Player ${playerIndex + 1}`,
212
- connected: playerInfo.connected !== false,
213
- appearance: playerInfo.appearance ?? playerInfo.character
214
- } : {
215
- playerIndex,
216
- nickname: `Player ${playerIndex + 1}`,
217
- connected: true
218
- };
246
+ const controllerInfo = playerInfo ? mapPlayerDTO(playerInfo, playerIndex) : mapPlayerDTO({ playerIndex, connected: true }, playerIndex);
219
247
  this._controllers = [...this._controllers, controllerInfo];
220
248
  this.logger.debug("Player joined", { playerIndex });
221
- this.config.onControllerJoin?.(playerIndex, controllerInfo);
249
+ this._emitLifecycle("$controller-join", playerIndex, controllerInfo);
222
250
  }
223
251
  });
224
252
  this.registerHandler(SMORE_EVENTS.PLAYER_LEFT, (raw) => {
@@ -228,7 +256,7 @@ class ControllerImpl {
228
256
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
229
257
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
230
258
  this.logger.debug("Player left", { playerIndex });
231
- this.config.onControllerLeave?.(playerIndex);
259
+ this._emitLifecycle("$controller-leave", playerIndex);
232
260
  }
233
261
  });
234
262
  this.registerHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (raw) => {
@@ -240,7 +268,7 @@ class ControllerImpl {
240
268
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
241
269
  );
242
270
  this.logger.debug("Player disconnected", { playerIndex });
243
- this.config.onControllerDisconnect?.(playerIndex);
271
+ this._emitLifecycle("$controller-disconnect", playerIndex);
244
272
  }
245
273
  });
246
274
  this.registerHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (raw) => {
@@ -248,70 +276,86 @@ class ControllerImpl {
248
276
  const playerData = data.player;
249
277
  const playerIndex = playerData?.playerIndex ?? data.playerIndex;
250
278
  if (playerIndex !== void 0) {
251
- const controllerInfo = playerData ? {
252
- playerIndex,
253
- nickname: playerData.nickname || playerData.name || `Player ${playerIndex + 1}`,
254
- connected: true,
255
- appearance: playerData.appearance ?? playerData.character
256
- } : {
257
- playerIndex,
258
- nickname: `Player ${playerIndex + 1}`,
259
- connected: true
260
- };
279
+ const controllerInfo = playerData ? mapPlayerDTO(playerData, playerIndex) : mapPlayerDTO({ playerIndex, connected: true }, playerIndex);
261
280
  this._controllers = this._controllers.map(
262
281
  (c) => c.playerIndex === playerIndex ? controllerInfo : c
263
282
  );
264
283
  this.logger.debug("Player reconnected", { playerIndex });
265
- this.config.onControllerReconnect?.(playerIndex, controllerInfo);
284
+ this._emitLifecycle("$controller-reconnect", playerIndex, controllerInfo);
266
285
  }
267
286
  });
268
287
  this.registerHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (raw) => {
269
288
  const payload = raw;
270
289
  const playerData = payload?.player;
271
290
  if (playerData && typeof playerData.playerIndex === "number") {
291
+ const pi = playerData.playerIndex;
272
292
  const appearance = playerData.character ?? null;
273
293
  this._controllers = this._controllers.map(
274
- (c) => c.playerIndex === playerData.playerIndex ? { ...c, appearance } : c
294
+ (c) => c.playerIndex === pi ? { ...c, appearance } : c
275
295
  );
276
- this.logger.debug("Player character updated", { playerIndex: playerData.playerIndex });
277
- this.config.onCharacterUpdated?.(playerData.playerIndex, appearance ?? null);
296
+ this.logger.debug("Player character updated", { playerIndex: pi });
297
+ this._emitLifecycle("$character-updated", pi, appearance ?? null);
278
298
  }
279
299
  });
280
300
  this.registerHandler(SMORE_EVENTS.RATE_LIMITED, (raw) => {
281
301
  const data = raw;
282
- const event = data?.event ?? "unknown";
283
- this.logger.warn(`Rate limited: ${event}`);
284
- this.config.onRateLimited?.(event);
302
+ const eventName = data?.event ?? "unknown";
303
+ this.handleError(
304
+ new SmoreSDKError("RATE_LIMITED", `Server rate-limited event: ${eventName}`, {
305
+ details: { event: eventName }
306
+ })
307
+ );
308
+ });
309
+ this.registerHandler(SMORE_EVENTS.GAME_OVER, (raw) => {
310
+ const data = raw;
311
+ this.logger.lifecycle("Game over", data?.results);
312
+ this._emitLifecycle("$game-over", data?.results);
285
313
  });
286
314
  this.registerHandler(SMORE_EVENTS.ALL_READY, () => {
287
315
  this.logger.lifecycle("All participants ready");
288
- this.config.onReady?.();
316
+ this._allReadyFired = true;
317
+ this._emitLifecycle("$all-ready");
289
318
  });
290
- if (this.config.onHostDisconnect) {
291
- this.logger.warn("onHostDisconnect is reserved for future use and currently non-functional");
292
- }
293
- if (this.config.onHostReconnect) {
294
- this.logger.warn("onHostReconnect is reserved for future use and currently non-functional");
295
- }
296
- if (this.config.listeners) {
297
- for (const [event, handler] of Object.entries(this.config.listeners)) {
298
- if (!handler) continue;
299
- this._configListenerEvents.add(event);
300
- this.registerHandler(event, (data) => {
301
- this.logReceive(event, data);
302
- try {
303
- handler(data);
304
- } catch (err) {
305
- this.handleError(
306
- new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
307
- cause: err instanceof Error ? err : void 0,
308
- details: { event }
309
- })
310
- );
311
- }
312
- });
319
+ this.registerHandler(SMORE_EVENTS.SELF_DISCONNECTED, () => {
320
+ this._isConnected = false;
321
+ this.logger.lifecycle("Connection lost");
322
+ this._emitLifecycle("$connection-change", false);
323
+ });
324
+ this.registerHandler(SMORE_EVENTS.SELF_RECONNECTED, () => {
325
+ this._isConnected = true;
326
+ this.logger.lifecycle("Connection restored");
327
+ this._emitLifecycle("$connection-change", true);
328
+ });
329
+ }
330
+ /**
331
+ * Sets up a user event handler for controller events.
332
+ * Used for registering pending handlers after transport becomes available.
333
+ */
334
+ setupUserEventHandler(event, handler) {
335
+ const transportHandler = (data) => {
336
+ this.logReceive(event, data);
337
+ try {
338
+ handler(data);
339
+ } catch (err) {
340
+ this.handleError(
341
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
342
+ cause: err instanceof Error ? err : void 0,
343
+ details: { event }
344
+ })
345
+ );
313
346
  }
347
+ };
348
+ if (this.transport) {
349
+ this.transport.on(event, transportHandler);
350
+ this.registeredHandlers.push({ event, handler: transportHandler });
351
+ this.handlerToTransport.set(handler, { event, transportHandler });
352
+ }
353
+ let listeners = this.eventListeners.get(event);
354
+ if (!listeners) {
355
+ listeners = /* @__PURE__ */ new Set();
356
+ this.eventListeners.set(event, listeners);
314
357
  }
358
+ listeners.add(handler);
315
359
  }
316
360
  registerHandler(event, handler) {
317
361
  if (!this.transport) return;
@@ -319,21 +363,95 @@ class ControllerImpl {
319
363
  this.registeredHandlers.push({ event, handler });
320
364
  }
321
365
  // ---------------------------------------------------------------------------
366
+ // Lifecycle Listener Helpers
367
+ // ---------------------------------------------------------------------------
368
+ _addLifecycleListener(event, listener) {
369
+ let set = this._lifecycleListeners.get(event);
370
+ if (!set) {
371
+ set = /* @__PURE__ */ new Set();
372
+ this._lifecycleListeners.set(event, set);
373
+ }
374
+ set.add(listener);
375
+ return () => {
376
+ set.delete(listener);
377
+ if (set.size === 0) this._lifecycleListeners.delete(event);
378
+ };
379
+ }
380
+ _emitLifecycle(event, ...args) {
381
+ this._lifecycleListeners.get(event)?.forEach((cb) => {
382
+ try {
383
+ cb(...args);
384
+ } catch (err) {
385
+ this.handleError(
386
+ new SmoreSDKError("UNKNOWN", `Error in lifecycle handler for "${event}"`, {
387
+ cause: err instanceof Error ? err : void 0,
388
+ details: { event }
389
+ })
390
+ );
391
+ }
392
+ });
393
+ }
394
+ _hasLifecycleListeners(event) {
395
+ const set = this._lifecycleListeners.get(event);
396
+ return set !== void 0 && set.size > 0;
397
+ }
398
+ // ---------------------------------------------------------------------------
399
+ // Lifecycle Methods
400
+ // ---------------------------------------------------------------------------
401
+ onAllReady(callback) {
402
+ if (this._allReadyFired) {
403
+ callback();
404
+ }
405
+ return this._addLifecycleListener("$all-ready", callback);
406
+ }
407
+ onControllerJoin(callback) {
408
+ return this._addLifecycleListener("$controller-join", callback);
409
+ }
410
+ onControllerLeave(callback) {
411
+ return this._addLifecycleListener("$controller-leave", callback);
412
+ }
413
+ onControllerDisconnect(callback) {
414
+ return this._addLifecycleListener("$controller-disconnect", callback);
415
+ }
416
+ onControllerReconnect(callback) {
417
+ return this._addLifecycleListener("$controller-reconnect", callback);
418
+ }
419
+ onCharacterUpdated(callback) {
420
+ return this._addLifecycleListener("$character-updated", callback);
421
+ }
422
+ onError(callback) {
423
+ return this._addLifecycleListener("$error", callback);
424
+ }
425
+ onConnectionChange(callback) {
426
+ return this._addLifecycleListener("$connection-change", callback);
427
+ }
428
+ onGameOver(callback) {
429
+ return this._addLifecycleListener("$game-over", callback);
430
+ }
431
+ // ---------------------------------------------------------------------------
322
432
  // Communication Methods
323
433
  // ---------------------------------------------------------------------------
324
434
  /**
325
435
  * Send an event to the Screen. Controller-to-Controller direct communication
326
436
  * is not supported; all messages must go through the Screen.
327
437
  *
328
- * Data is sent to the Screen only (not to other controllers). For ScreenController communication,
438
+ * Data is sent to the Screen only (not to other controllers). For Screen->Controller communication,
329
439
  * Screen uses broadcast() or sendToController().
330
440
  *
331
441
  * @note Fire-and-forget sends (no callback) will silently fail if rate-limited.
332
442
  * Use the onError callback or smore:rate-limited event to detect rate limiting.
333
443
  */
334
444
  send(event, data) {
335
- this.ensureReady("send");
445
+ if (this._isDestroyed) {
446
+ throw new SmoreSDKError("DESTROYED", "Cannot call send() after destroy()");
447
+ }
448
+ if (!this._isReady || !this.transport) {
449
+ this._outboundBuffer.push({ method: "send", args: [event, data] });
450
+ this.logger.debug(`Buffered send "${event}" (controller not ready yet)`);
451
+ return;
452
+ }
336
453
  validateEventName(event);
454
+ validatePayloadSize(data);
337
455
  if (typeof data !== "object" || data === null) {
338
456
  this.logger.warn(
339
457
  'Event data should be an object. Primitive values will be wrapped as { data: value } by the relay server. To avoid confusion, wrap explicitly: send("event", { value: 42 }) instead of send("event", 42).'
@@ -342,12 +460,6 @@ class ControllerImpl {
342
460
  this.logSend(event, data);
343
461
  this.transport.emit(event, data);
344
462
  }
345
- sendRaw(event, data) {
346
- this.ensureReady("sendRaw");
347
- validateEventName(event);
348
- this.logSend(event, data);
349
- this.transport.emit(event, data);
350
- }
351
463
  signalReady() {
352
464
  this.ensureReady("signalReady");
353
465
  this.logSend(SMORE_EVENTS.GAME_READY, {});
@@ -359,26 +471,26 @@ class ControllerImpl {
359
471
  /**
360
472
  * Register a handler for custom events.
361
473
  *
474
+ * Can be called before the Controller is ready. Handlers registered before ready
475
+ * are queued and activated when the transport becomes available.
476
+ *
362
477
  * When receiving events from Screen's `broadcast()`:
363
- * handler receives `(data)` no playerIndex included.
478
+ * handler receives `(data)` -- no playerIndex included.
364
479
  *
365
480
  * When receiving events from Screen's `sendToController()`:
366
- * handler receives `(data)` targeted to this specific controller.
367
- *
368
- * @note Unlike Screen's `on()` which receives `(playerIndex, data)`,
369
- * Controller's `on()` receives only `(data)` since there's only one player per controller.
370
- *
371
- * Controller's on() handler signature: (data) => void
372
- * Unlike Screen's (playerIndex, data) => void, Controller doesn't receive playerIndex
373
- * because Controller only receives events from Screen, not from other controllers.
374
- * The sender is always the Screen, so playerIndex is not applicable.
375
- *
376
- * **Important:** If called before the Controller is ready (i.e., before `await createController()`
377
- * resolves or before the `onReady` callback fires), the handler is stored locally but
378
- * will NOT receive events until the transport is initialized. Always call `on()` after
379
- * initialization completes, or use `config.listeners` for handlers needed from the start.
481
+ * handler receives `(data)` -- targeted to this specific controller.
380
482
  */
381
483
  on(event, handler) {
484
+ if (typeof event === "string" && event.startsWith("$")) {
485
+ const validEvents = CONTROLLER_LIFECYCLE_EVENTS;
486
+ if (!validEvents.has(event)) {
487
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}". Valid lifecycle events: ${Array.from(validEvents).join(", ")}`);
488
+ }
489
+ if (event === "$all-ready" && this._allReadyFired) {
490
+ handler();
491
+ }
492
+ return this._addLifecycleListener(event, handler);
493
+ }
382
494
  validateEventName(event);
383
495
  let listeners = this.eventListeners.get(event);
384
496
  if (!listeners) {
@@ -386,34 +498,42 @@ class ControllerImpl {
386
498
  this.eventListeners.set(event, listeners);
387
499
  }
388
500
  listeners.add(handler);
389
- const transportHandler = (data) => {
390
- this.logReceive(event, data);
391
- try {
392
- handler(data);
393
- } catch (err) {
394
- this.handleError(
395
- new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
396
- cause: err instanceof Error ? err : void 0,
397
- details: { event }
398
- })
399
- );
400
- }
401
- };
402
501
  if (this.transport) {
502
+ const transportHandler = (data) => {
503
+ this.logReceive(event, data);
504
+ try {
505
+ handler(data);
506
+ } catch (err) {
507
+ this.handleError(
508
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
509
+ cause: err instanceof Error ? err : void 0,
510
+ details: { event }
511
+ })
512
+ );
513
+ }
514
+ };
403
515
  this.transport.on(event, transportHandler);
404
516
  this.registeredHandlers.push({ event, handler: transportHandler });
405
517
  this.handlerToTransport.set(handler, { event, transportHandler });
518
+ } else {
519
+ this._pendingHandlers.push({ event, handler });
406
520
  }
407
521
  return () => {
408
522
  listeners?.delete(handler);
409
523
  if (listeners?.size === 0) {
410
524
  this.eventListeners.delete(event);
411
525
  }
412
- this.transport?.off(event, transportHandler);
413
- this.registeredHandlers = this.registeredHandlers.filter(
414
- (h) => h.handler !== transportHandler
526
+ this._pendingHandlers = this._pendingHandlers.filter(
527
+ (p) => !(p.event === event && p.handler === handler)
415
528
  );
416
- this.handlerToTransport.delete(handler);
529
+ const entry = this.handlerToTransport.get(handler);
530
+ if (entry) {
531
+ this.transport?.off(event, entry.transportHandler);
532
+ this.registeredHandlers = this.registeredHandlers.filter(
533
+ (h) => h.handler !== entry.transportHandler
534
+ );
535
+ this.handlerToTransport.delete(handler);
536
+ }
417
537
  };
418
538
  }
419
539
  /**
@@ -421,18 +541,25 @@ class ControllerImpl {
421
541
  *
422
542
  * @note The handler is internally wrapped, so it cannot be removed via
423
543
  * `off(event, originalHandler)`. Use the returned unsubscribe function instead.
424
- *
425
- * @example
426
- * ```ts
427
- * const unsubscribe = controller.once('game-start', (data) => {
428
- * console.log('Game started!', data);
429
- * });
430
- *
431
- * // To cancel before it fires:
432
- * unsubscribe();
433
- * ```
434
544
  */
435
545
  once(event, handler) {
546
+ if (typeof event === "string" && event.startsWith("$")) {
547
+ const validEvents = CONTROLLER_LIFECYCLE_EVENTS;
548
+ if (!validEvents.has(event)) {
549
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}"`);
550
+ }
551
+ if (event === "$all-ready" && this._allReadyFired) {
552
+ handler();
553
+ return () => {
554
+ };
555
+ }
556
+ const wrapper = (...args) => {
557
+ unsub();
558
+ handler(...args);
559
+ };
560
+ const unsub = this._addLifecycleListener(event, wrapper);
561
+ return unsub;
562
+ }
436
563
  const unsubscribe = this.on(event, ((data) => {
437
564
  unsubscribe();
438
565
  handler(data);
@@ -440,25 +567,22 @@ class ControllerImpl {
440
567
  return unsubscribe;
441
568
  }
442
569
  off(event, handler) {
443
- if (!handler) {
444
- if (this._configListenerEvents.has(event)) {
445
- for (const [key, val] of this.handlerToTransport) {
446
- if (val.event === event) {
447
- this.transport?.off(event, val.transportHandler);
448
- this.registeredHandlers = this.registeredHandlers.filter(
449
- (h) => h.handler !== val.transportHandler
450
- );
451
- this.handlerToTransport.delete(key);
452
- }
453
- }
570
+ if (typeof event === "string" && event.startsWith("$")) {
571
+ if (!handler) {
572
+ this._lifecycleListeners.delete(event);
454
573
  } else {
455
- this.eventListeners.delete(event);
456
- this.transport?.off(event);
457
- this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
458
- for (const [key, val] of this.handlerToTransport) {
459
- if (val.event === event) this.handlerToTransport.delete(key);
460
- }
574
+ this._lifecycleListeners.get(event)?.delete(handler);
461
575
  }
576
+ return;
577
+ }
578
+ if (!handler) {
579
+ this.eventListeners.delete(event);
580
+ this.transport?.off(event);
581
+ this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
582
+ for (const [key, val] of this.handlerToTransport) {
583
+ if (val.event === event) this.handlerToTransport.delete(key);
584
+ }
585
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
462
586
  } else {
463
587
  const listeners = this.eventListeners.get(event);
464
588
  listeners?.delete(handler);
@@ -473,6 +597,24 @@ class ControllerImpl {
473
597
  );
474
598
  this.handlerToTransport.delete(handler);
475
599
  }
600
+ this._pendingHandlers = this._pendingHandlers.filter(
601
+ (p) => !(p.event === event && p.handler === handler)
602
+ );
603
+ }
604
+ }
605
+ removeAllListeners(event) {
606
+ if (event) {
607
+ this.eventListeners.delete(event);
608
+ this.transport?.off(event);
609
+ this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
610
+ for (const [key, val] of this.handlerToTransport) {
611
+ if (val.event === event) this.handlerToTransport.delete(key);
612
+ }
613
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
614
+ } else {
615
+ for (const evt of [...this.eventListeners.keys()]) {
616
+ this.removeAllListeners(evt);
617
+ }
476
618
  }
477
619
  }
478
620
  // ---------------------------------------------------------------------------
@@ -481,10 +623,15 @@ class ControllerImpl {
481
623
  destroy() {
482
624
  if (this._isDestroyed) return;
483
625
  this.logger.lifecycle("Destroying controller");
484
- this.cleanup();
485
626
  this._isDestroyed = true;
627
+ this._isReady = false;
628
+ this.cleanup();
486
629
  }
487
630
  cleanup() {
631
+ if (this._initTimeoutId) {
632
+ clearTimeout(this._initTimeoutId);
633
+ this._initTimeoutId = null;
634
+ }
488
635
  this._isReady = false;
489
636
  for (const { event, handler } of this.registeredHandlers) {
490
637
  this.transport?.off(event, handler);
@@ -492,6 +639,10 @@ class ControllerImpl {
492
639
  this.registeredHandlers = [];
493
640
  this.eventListeners.clear();
494
641
  this.handlerToTransport.clear();
642
+ this._pendingHandlers = [];
643
+ this._lifecycleListeners.clear();
644
+ this._isConnected = false;
645
+ this._outboundBuffer = [];
495
646
  if (this.transport) {
496
647
  this.transport.destroy();
497
648
  this.transport = null;
@@ -515,15 +666,16 @@ class ControllerImpl {
515
666
  if (!this._isReady || !this.transport) {
516
667
  throw new SmoreSDKError(
517
668
  "NOT_READY",
518
- `Cannot call ${method}() before controller is ready. Use await createController() or wait for onReady callback.`,
669
+ `Cannot call ${method}() before controller is ready. Use await controller.ready.`,
519
670
  { details: { method, isReady: this._isReady } }
520
671
  );
521
672
  }
522
673
  }
523
674
  handleError(error) {
524
675
  this.logger.warn(`Error in handler: ${error.message}`);
525
- if (this.config.onError) {
526
- this.config.onError(error.toSmoreError());
676
+ const smoreError = error.toSmoreError();
677
+ if (this._hasLifecycleListeners("$error")) {
678
+ this._emitLifecycle("$error", smoreError);
527
679
  } else {
528
680
  this.logger.error(error.message, error.details);
529
681
  }
@@ -536,10 +688,7 @@ class ControllerImpl {
536
688
  }
537
689
  }
538
690
  function createController(config) {
539
- const controller = new ControllerImpl(config ?? {});
540
- const promise = controller.initialize().then(() => controller);
541
- promise.instance = controller;
542
- return promise;
691
+ return new ControllerImpl(config ?? {});
543
692
  }
544
693
 
545
694
  export { createController };