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