@smoregg/sdk 0.6.2 → 1.0.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/README.md +29 -38
  2. package/dist/cjs/controller.cjs +299 -144
  3. package/dist/cjs/controller.cjs.map +1 -1
  4. package/dist/cjs/errors.cjs +36 -0
  5. package/dist/cjs/errors.cjs.map +1 -0
  6. package/dist/cjs/events.cjs +40 -19
  7. package/dist/cjs/events.cjs.map +1 -1
  8. package/dist/cjs/index.cjs +3 -8
  9. package/dist/cjs/index.cjs.map +1 -1
  10. package/dist/cjs/logger.cjs +75 -0
  11. package/dist/cjs/logger.cjs.map +1 -0
  12. package/dist/cjs/screen.cjs +302 -215
  13. package/dist/cjs/screen.cjs.map +1 -1
  14. package/dist/cjs/testing.cjs +265 -22
  15. package/dist/cjs/testing.cjs.map +1 -1
  16. package/dist/cjs/transport/DirectTransport.cjs.map +1 -1
  17. package/dist/cjs/transport/PostMessageTransport.cjs +11 -6
  18. package/dist/cjs/transport/PostMessageTransport.cjs.map +1 -1
  19. package/dist/cjs/transport/protocol.cjs +25 -5
  20. package/dist/cjs/transport/protocol.cjs.map +1 -1
  21. package/dist/esm/controller.js +292 -136
  22. package/dist/esm/controller.js.map +1 -1
  23. package/dist/esm/errors.js +34 -0
  24. package/dist/esm/errors.js.map +1 -0
  25. package/dist/esm/events.js +38 -18
  26. package/dist/esm/events.js.map +1 -1
  27. package/dist/esm/index.js +3 -4
  28. package/dist/esm/index.js.map +1 -1
  29. package/dist/esm/logger.js +73 -0
  30. package/dist/esm/logger.js.map +1 -0
  31. package/dist/esm/screen.js +290 -202
  32. package/dist/esm/screen.js.map +1 -1
  33. package/dist/esm/testing.js +265 -22
  34. package/dist/esm/testing.js.map +1 -1
  35. package/dist/esm/transport/DirectTransport.js.map +1 -1
  36. package/dist/esm/transport/PostMessageTransport.js +12 -7
  37. package/dist/esm/transport/PostMessageTransport.js.map +1 -1
  38. package/dist/esm/transport/protocol.js +23 -4
  39. package/dist/esm/transport/protocol.js.map +1 -1
  40. package/dist/types/controller.d.ts +1 -14
  41. package/dist/types/controller.d.ts.map +1 -1
  42. package/dist/types/errors.d.ts +45 -0
  43. package/dist/types/errors.d.ts.map +1 -0
  44. package/dist/types/events.d.ts +52 -12
  45. package/dist/types/events.d.ts.map +1 -1
  46. package/dist/types/index.d.ts +4 -6
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/logger.d.ts +35 -0
  49. package/dist/types/logger.d.ts.map +1 -0
  50. package/dist/types/screen.d.ts +1 -14
  51. package/dist/types/screen.d.ts.map +1 -1
  52. package/dist/types/testing.d.ts +0 -1
  53. package/dist/types/testing.d.ts.map +1 -1
  54. package/dist/types/transport/DirectTransport.d.ts +2 -1
  55. package/dist/types/transport/DirectTransport.d.ts.map +1 -1
  56. package/dist/types/transport/PostMessageTransport.d.ts +17 -2
  57. package/dist/types/transport/PostMessageTransport.d.ts.map +1 -1
  58. package/dist/types/transport/index.d.ts +2 -2
  59. package/dist/types/transport/index.d.ts.map +1 -1
  60. package/dist/types/transport/protocol.d.ts +71 -23
  61. package/dist/types/transport/protocol.d.ts.map +1 -1
  62. package/dist/types/transport/types.d.ts +24 -2
  63. package/dist/types/transport/types.d.ts.map +1 -1
  64. package/dist/types/types.d.ts +298 -215
  65. package/dist/types/types.d.ts.map +1 -1
  66. package/dist/umd/smore-sdk.umd.js +950 -349
  67. package/dist/umd/smore-sdk.umd.js.map +1 -1
  68. package/dist/umd/smore-sdk.umd.min.js +1 -1
  69. package/dist/umd/smore-sdk.umd.min.js.map +1 -1
  70. package/package.json +8 -13
@@ -1,103 +1,29 @@
1
1
  import { PostMessageTransport } from './transport/PostMessageTransport.js';
2
- import { isSmoreMessage } from './transport/protocol.js';
2
+ import { isBridgeMessage, validateInitPayload } from './transport/protocol.js';
3
+ import { SmoreSDKError } from './errors.js';
4
+ import { validateEventName, SMORE_EVENTS } from './events.js';
5
+ import { DebugLogger } from './logger.js';
3
6
 
4
- const SYSTEM_PREFIX = "smore:";
5
- const SYSTEM_EVENTS = {
6
- PLAYER_JOIN: `${SYSTEM_PREFIX}player-join`,
7
- PLAYER_LEAVE: `${SYSTEM_PREFIX}player-leave`
8
- };
9
7
  const DEFAULT_TIMEOUT = 1e4;
10
- class SmoreSDKError extends Error {
11
- code;
12
- cause;
13
- details;
14
- constructor(code, message, options) {
15
- super(message);
16
- this.name = "SmoreSDKError";
17
- this.code = code;
18
- this.cause = options?.cause;
19
- this.details = options?.details;
20
- const ErrorWithCapture = Error;
21
- if (typeof ErrorWithCapture.captureStackTrace === "function") {
22
- ErrorWithCapture.captureStackTrace(this, SmoreSDKError);
23
- }
24
- }
25
- toSmoreError() {
26
- return {
27
- code: this.code,
28
- message: this.message,
29
- cause: this.cause,
30
- details: this.details
31
- };
32
- }
33
- }
34
- const EVENT_NAME_REGEX = /^[a-zA-Z]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
35
- function validateEventName(event) {
36
- if (!event || typeof event !== "string") {
37
- throw new SmoreSDKError("INVALID_EVENT", "Event name must be a non-empty string");
38
- }
39
- if (!EVENT_NAME_REGEX.test(event)) {
40
- throw new SmoreSDKError(
41
- "INVALID_EVENT",
42
- `Invalid event name "${event}". Event names must:
43
- - Start with a letter (a-z, A-Z)
44
- - Only contain letters, numbers, hyphens (-), and underscores (_)
45
- - End with a letter or number`,
46
- { details: { event } }
47
- );
48
- }
49
- }
50
- function createLogger(options) {
51
- const enabled = typeof options === "boolean" ? options : options?.enabled ?? false;
52
- const level = (typeof options === "object" ? options.level : void 0) ?? "debug";
53
- const prefix = (typeof options === "object" ? options.prefix : void 0) ?? "[SmoreController]";
54
- const customLogger = typeof options === "object" ? options.logger : void 0;
55
- const levelPriority = {
56
- debug: 0,
57
- info: 1,
58
- warn: 2,
59
- error: 3
60
- };
61
- const shouldLog = (msgLevel) => {
62
- if (!enabled) return false;
63
- return levelPriority[msgLevel] >= levelPriority[level];
64
- };
65
- const log = (msgLevel, message, data) => {
66
- if (!shouldLog(msgLevel)) return;
67
- if (customLogger) {
68
- customLogger(msgLevel, message, data);
69
- return;
70
- }
71
- const fullMessage = `${prefix} ${message}`;
72
- const consoleFn = console[msgLevel] ?? console.log;
73
- if (data !== void 0) {
74
- consoleFn(fullMessage, data);
75
- } else {
76
- consoleFn(fullMessage);
77
- }
78
- };
79
- return {
80
- debug: (msg, data) => log("debug", msg, data),
81
- info: (msg, data) => log("info", msg, data),
82
- warn: (msg, data) => log("warn", msg, data),
83
- error: (msg, data) => log("error", msg, data)
84
- };
85
- }
86
8
  class ControllerImpl {
87
9
  transport = null;
88
10
  config;
89
11
  logger;
90
12
  _roomCode = "";
91
13
  _myIndex = -1;
92
- _isLeader = false;
93
14
  _isReady = false;
94
15
  _isDestroyed = false;
95
16
  boundMessageHandler = null;
96
17
  registeredHandlers = [];
97
18
  eventListeners = /* @__PURE__ */ new Map();
19
+ // Maps user-facing handler -> transport wrappedHandler for proper cleanup in on()/off()
20
+ handlerToTransport = /* @__PURE__ */ new Map();
21
+ _controllers = [];
22
+ // Tracks event names registered via config.listeners so off(event) without handler won't remove them
23
+ _configListenerEvents = /* @__PURE__ */ new Set();
98
24
  constructor(config = {}) {
99
25
  this.config = config;
100
- this.logger = createLogger(config.debug);
26
+ this.logger = new DebugLogger(config.debug, "[SmoreController]");
101
27
  if (config.listeners) {
102
28
  for (const event of Object.keys(config.listeners)) {
103
29
  validateEventName(event);
@@ -110,9 +36,6 @@ class ControllerImpl {
110
36
  get myIndex() {
111
37
  return this._myIndex;
112
38
  }
113
- get isLeader() {
114
- return this._isLeader;
115
- }
116
39
  get roomCode() {
117
40
  return this._roomCode;
118
41
  }
@@ -122,19 +45,35 @@ class ControllerImpl {
122
45
  get isDestroyed() {
123
46
  return this._isDestroyed;
124
47
  }
48
+ /**
49
+ * Read-only list of all known controllers (players) in the room.
50
+ * Returns full ControllerInfo including playerIndex, nickname, connected status, and appearance.
51
+ *
52
+ * Returns a new shallow copy on every access. Cache the result if accessing
53
+ * repeatedly in the same frame/tick.
54
+ */
55
+ get controllers() {
56
+ return [...this._controllers];
57
+ }
58
+ /**
59
+ * Returns the number of currently connected players.
60
+ */
61
+ getControllerCount() {
62
+ return this._controllers.filter((c) => c.connected).length;
63
+ }
125
64
  // ---------------------------------------------------------------------------
126
65
  // Initialization
127
66
  // ---------------------------------------------------------------------------
128
67
  async initialize() {
129
68
  const parentOrigin = this.config.parentOrigin ?? "*";
130
69
  const timeout = this.config.timeout ?? DEFAULT_TIMEOUT;
131
- this.logger.debug("Initializing controller...", { parentOrigin, timeout });
70
+ this.logger.lifecycle("Initializing controller...", { parentOrigin, timeout });
132
71
  return new Promise((resolve, reject) => {
133
72
  const timeoutId = setTimeout(() => {
134
73
  this.cleanup();
135
74
  const error = new SmoreSDKError(
136
75
  "TIMEOUT",
137
- `Controller initialization timed out after ${timeout}ms. Make sure the parent window sends smore:init message.`,
76
+ `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).`,
138
77
  { details: { timeout } }
139
78
  );
140
79
  this.handleError(error);
@@ -143,22 +82,36 @@ class ControllerImpl {
143
82
  this.boundMessageHandler = (e) => {
144
83
  if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
145
84
  const msg = e.data;
146
- if (!isSmoreMessage(msg)) return;
147
- if (msg.type === "smore:init") {
85
+ if (!isBridgeMessage(msg)) return;
86
+ if (msg.type === "_bridge:init") {
148
87
  clearTimeout(timeoutId);
149
88
  this.handleInit(msg, parentOrigin, resolve, reject);
150
- } else if (msg.type === "smore:update") {
89
+ } else if (msg.type === "_bridge:update") {
151
90
  this.handleUpdate(msg);
152
91
  }
153
92
  };
154
93
  window.addEventListener("message", this.boundMessageHandler);
155
- this.logger.debug("Sending smore:ready to parent");
156
- window.parent.postMessage({ type: "smore:ready" }, parentOrigin);
94
+ this.logger.lifecycle("Sending _bridge:ready to parent");
95
+ window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
157
96
  });
158
97
  }
159
98
  handleInit(msg, parentOrigin, resolve, reject) {
160
- const initData = msg.payload;
161
- this.logger.debug("Received smore:init", initData);
99
+ const initPayload = msg.payload;
100
+ this.logger.debug("Received _bridge:init", initPayload);
101
+ try {
102
+ validateInitPayload(initPayload);
103
+ } catch (err) {
104
+ const error = new SmoreSDKError(
105
+ "INIT_FAILED",
106
+ `Invalid _bridge:init payload: ${err instanceof Error ? err.message : String(err)}`,
107
+ { details: { payload: initPayload } }
108
+ );
109
+ this.logger.warn("_bridge:init validation failed", error);
110
+ this.handleError(error);
111
+ reject(error);
112
+ return;
113
+ }
114
+ const initData = initPayload;
162
115
  if (initData.side !== "player") {
163
116
  const error = new SmoreSDKError(
164
117
  "INIT_FAILED",
@@ -182,49 +135,171 @@ class ControllerImpl {
182
135
  this.transport = new PostMessageTransport(parentOrigin);
183
136
  this._roomCode = initData.roomCode;
184
137
  this._myIndex = initData.myIndex;
185
- this._isLeader = initData.isLeader ?? false;
138
+ const initPlayers = initData.players;
139
+ this._controllers = initPlayers.filter((p) => typeof p.playerIndex === "number").map((p) => ({
140
+ playerIndex: p.playerIndex,
141
+ nickname: p.nickname || p.name || `Player ${p.playerIndex + 1}`,
142
+ connected: p.connected !== false,
143
+ appearance: p.appearance ?? p.character
144
+ }));
186
145
  this.setupEventHandlers();
187
146
  this._isReady = true;
188
- this.logger.info("Controller ready", {
147
+ this.logger.lifecycle("Controller ready", {
189
148
  roomCode: this._roomCode,
190
- myIndex: this._myIndex,
191
- isLeader: this._isLeader
149
+ myIndex: this._myIndex
192
150
  });
193
151
  this.config.onReady?.();
194
152
  resolve();
195
153
  }
196
154
  handleUpdate(msg) {
155
+ if (!this._isReady) {
156
+ this.logger.debug("Ignoring _bridge:update before init completes");
157
+ return;
158
+ }
197
159
  const updateData = msg.payload;
198
- this.logger.debug("Received smore:update", updateData);
160
+ this.logger.debug("Received _bridge:update", updateData);
161
+ if (updateData.players && Array.isArray(updateData.players)) {
162
+ const players = updateData.players;
163
+ const newControllers = players.filter((p) => typeof p.playerIndex === "number").map((p) => ({
164
+ playerIndex: p.playerIndex,
165
+ nickname: p.nickname || p.name || `Player ${p.playerIndex + 1}`,
166
+ connected: p.connected !== false,
167
+ appearance: p.appearance ?? p.character
168
+ }));
169
+ const oldControllers = this._controllers;
170
+ for (const nc of newControllers) {
171
+ if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
172
+ this.config.onControllerJoin?.(nc.playerIndex, nc);
173
+ }
174
+ }
175
+ for (const oc of oldControllers) {
176
+ if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
177
+ this.config.onControllerLeave?.(oc.playerIndex);
178
+ }
179
+ }
180
+ for (const nc of newControllers) {
181
+ const oc = oldControllers.find((c) => c.playerIndex === nc.playerIndex);
182
+ if (oc) {
183
+ if (oc.connected && !nc.connected) {
184
+ this.logger.debug("Player disconnected (via update)", { playerIndex: nc.playerIndex });
185
+ this.config.onControllerDisconnect?.(nc.playerIndex);
186
+ }
187
+ if (!oc.connected && nc.connected) {
188
+ this.logger.debug("Player reconnected (via update)", { playerIndex: nc.playerIndex });
189
+ this.config.onControllerReconnect?.(nc.playerIndex, nc);
190
+ }
191
+ }
192
+ }
193
+ this._controllers = newControllers;
194
+ }
199
195
  }
200
196
  setupEventHandlers() {
201
197
  if (!this.transport) return;
202
- this.registerHandler(
203
- SYSTEM_EVENTS.PLAYER_JOIN,
204
- (data) => {
205
- const playerIndex = data.player?.playerIndex ?? data.playerIndex;
206
- if (playerIndex !== void 0) {
207
- this.logger.debug("Player joined", { playerIndex });
208
- this.config.onControllerJoin?.(playerIndex, data.player);
209
- }
198
+ this.registerHandler(SMORE_EVENTS.PLAYER_JOINED, (raw) => {
199
+ const data = raw;
200
+ const playerInfo = data.player;
201
+ const playerIndex = playerInfo?.playerIndex ?? data.playerIndex;
202
+ if (playerIndex !== void 0) {
203
+ if (this._controllers.some((c) => c.playerIndex === playerIndex)) return;
204
+ const controllerInfo = playerInfo ? {
205
+ playerIndex,
206
+ nickname: playerInfo.nickname || playerInfo.name || `Player ${playerIndex + 1}`,
207
+ connected: playerInfo.connected !== false,
208
+ appearance: playerInfo.appearance ?? playerInfo.character
209
+ } : {
210
+ playerIndex,
211
+ nickname: `Player ${playerIndex + 1}`,
212
+ connected: true
213
+ };
214
+ this._controllers = [...this._controllers, controllerInfo];
215
+ this.logger.debug("Player joined", { playerIndex });
216
+ this.config.onControllerJoin?.(playerIndex, controllerInfo);
210
217
  }
211
- );
212
- this.registerHandler(
213
- SYSTEM_EVENTS.PLAYER_LEAVE,
214
- (data) => {
215
- const playerIndex = data.player?.playerIndex ?? data.playerIndex;
216
- if (playerIndex !== void 0) {
217
- this.logger.debug("Player left", { playerIndex });
218
- this.config.onControllerLeave?.(playerIndex);
219
- }
218
+ });
219
+ this.registerHandler(SMORE_EVENTS.PLAYER_LEFT, (raw) => {
220
+ const data = raw;
221
+ const playerIndex = data.player?.playerIndex ?? data.playerIndex;
222
+ if (playerIndex !== void 0) {
223
+ if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
224
+ this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
225
+ this.logger.debug("Player left", { playerIndex });
226
+ this.config.onControllerLeave?.(playerIndex);
227
+ }
228
+ });
229
+ this.registerHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (raw) => {
230
+ const data = raw;
231
+ const playerData = data.player;
232
+ const playerIndex = playerData?.playerIndex ?? data.playerIndex;
233
+ if (playerIndex !== void 0) {
234
+ this._controllers = this._controllers.map(
235
+ (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
236
+ );
237
+ this.logger.debug("Player disconnected", { playerIndex });
238
+ this.config.onControllerDisconnect?.(playerIndex);
220
239
  }
221
- );
240
+ });
241
+ this.registerHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (raw) => {
242
+ const data = raw;
243
+ const playerData = data.player;
244
+ const playerIndex = playerData?.playerIndex ?? data.playerIndex;
245
+ if (playerIndex !== void 0) {
246
+ const controllerInfo = playerData ? {
247
+ playerIndex,
248
+ nickname: playerData.nickname || playerData.name || `Player ${playerIndex + 1}`,
249
+ connected: true,
250
+ appearance: playerData.appearance ?? playerData.character
251
+ } : {
252
+ playerIndex,
253
+ nickname: `Player ${playerIndex + 1}`,
254
+ connected: true
255
+ };
256
+ this._controllers = this._controllers.map(
257
+ (c) => c.playerIndex === playerIndex ? controllerInfo : c
258
+ );
259
+ this.logger.debug("Player reconnected", { playerIndex });
260
+ this.config.onControllerReconnect?.(playerIndex, controllerInfo);
261
+ }
262
+ });
263
+ this.registerHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (raw) => {
264
+ const payload = raw;
265
+ const playerData = payload?.player;
266
+ if (playerData && typeof playerData.playerIndex === "number") {
267
+ const appearance = playerData.character ?? null;
268
+ this._controllers = this._controllers.map(
269
+ (c) => c.playerIndex === playerData.playerIndex ? { ...c, appearance } : c
270
+ );
271
+ this.logger.debug("Player character updated", { playerIndex: playerData.playerIndex });
272
+ this.config.onCharacterUpdated?.(playerData.playerIndex, appearance ?? null);
273
+ }
274
+ });
275
+ this.registerHandler(SMORE_EVENTS.RATE_LIMITED, (raw) => {
276
+ const data = raw;
277
+ const event = data?.event ?? "unknown";
278
+ this.logger.warn(`Rate limited: ${event}`);
279
+ this.config.onRateLimited?.(event);
280
+ });
281
+ if (this.config.onHostDisconnect) {
282
+ this.logger.warn("onHostDisconnect is reserved for future use and currently non-functional");
283
+ }
284
+ if (this.config.onHostReconnect) {
285
+ this.logger.warn("onHostReconnect is reserved for future use and currently non-functional");
286
+ }
222
287
  if (this.config.listeners) {
223
288
  for (const [event, handler] of Object.entries(this.config.listeners)) {
224
289
  if (!handler) continue;
290
+ this._configListenerEvents.add(event);
225
291
  this.registerHandler(event, (data) => {
226
292
  this.logReceive(event, data);
227
- handler(data);
293
+ try {
294
+ handler(data);
295
+ } catch (err) {
296
+ this.handleError(
297
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
298
+ cause: err instanceof Error ? err : void 0,
299
+ details: { event }
300
+ })
301
+ );
302
+ }
228
303
  });
229
304
  }
230
305
  }
@@ -237,9 +312,24 @@ class ControllerImpl {
237
312
  // ---------------------------------------------------------------------------
238
313
  // Communication Methods
239
314
  // ---------------------------------------------------------------------------
315
+ /**
316
+ * Send an event to the Screen. Controller-to-Controller direct communication
317
+ * is not supported; all messages must go through the Screen.
318
+ *
319
+ * Data is sent to the Screen only (not to other controllers). For Screen→Controller communication,
320
+ * Screen uses broadcast() or sendToController().
321
+ *
322
+ * @note Fire-and-forget sends (no callback) will silently fail if rate-limited.
323
+ * Use the onError callback or smore:rate-limited event to detect rate limiting.
324
+ */
240
325
  send(event, data) {
241
326
  this.ensureReady("send");
242
327
  validateEventName(event);
328
+ if (typeof data !== "object" || data === null) {
329
+ this.logger.warn(
330
+ '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).'
331
+ );
332
+ }
243
333
  this.logSend(event, data);
244
334
  this.transport.emit(event, data);
245
335
  }
@@ -252,6 +342,28 @@ class ControllerImpl {
252
342
  // ---------------------------------------------------------------------------
253
343
  // Event Subscription
254
344
  // ---------------------------------------------------------------------------
345
+ /**
346
+ * Register a handler for custom events.
347
+ *
348
+ * When receiving events from Screen's `broadcast()`:
349
+ * handler receives `(data)` — no playerIndex included.
350
+ *
351
+ * When receiving events from Screen's `sendToController()`:
352
+ * handler receives `(data)` — targeted to this specific controller.
353
+ *
354
+ * @note Unlike Screen's `on()` which receives `(playerIndex, data)`,
355
+ * Controller's `on()` receives only `(data)` since there's only one player per controller.
356
+ *
357
+ * Controller's on() handler signature: (data) => void
358
+ * Unlike Screen's (playerIndex, data) => void, Controller doesn't receive playerIndex
359
+ * because Controller only receives events from Screen, not from other controllers.
360
+ * The sender is always the Screen, so playerIndex is not applicable.
361
+ *
362
+ * **Important:** If called before the Controller is ready (i.e., before `await createController()`
363
+ * resolves or before the `onReady` callback fires), the handler is stored locally but
364
+ * will NOT receive events until the transport is initialized. Always call `on()` after
365
+ * initialization completes, or use `config.listeners` for handlers needed from the start.
366
+ */
255
367
  on(event, handler) {
256
368
  validateEventName(event);
257
369
  let listeners = this.eventListeners.get(event);
@@ -262,11 +374,21 @@ class ControllerImpl {
262
374
  listeners.add(handler);
263
375
  const transportHandler = (data) => {
264
376
  this.logReceive(event, data);
265
- handler(data);
377
+ try {
378
+ handler(data);
379
+ } catch (err) {
380
+ this.handleError(
381
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
382
+ cause: err instanceof Error ? err : void 0,
383
+ details: { event }
384
+ })
385
+ );
386
+ }
266
387
  };
267
388
  if (this.transport) {
268
389
  this.transport.on(event, transportHandler);
269
390
  this.registeredHandlers.push({ event, handler: transportHandler });
391
+ this.handlerToTransport.set(handler, { event, transportHandler });
270
392
  }
271
393
  return () => {
272
394
  listeners?.delete(handler);
@@ -277,8 +399,25 @@ class ControllerImpl {
277
399
  this.registeredHandlers = this.registeredHandlers.filter(
278
400
  (h) => h.handler !== transportHandler
279
401
  );
402
+ this.handlerToTransport.delete(handler);
280
403
  };
281
404
  }
405
+ /**
406
+ * Add a one-time listener that auto-removes after first call.
407
+ *
408
+ * @note The handler is internally wrapped, so it cannot be removed via
409
+ * `off(event, originalHandler)`. Use the returned unsubscribe function instead.
410
+ *
411
+ * @example
412
+ * ```ts
413
+ * const unsubscribe = controller.once('game-start', (data) => {
414
+ * console.log('Game started!', data);
415
+ * });
416
+ *
417
+ * // To cancel before it fires:
418
+ * unsubscribe();
419
+ * ```
420
+ */
282
421
  once(event, handler) {
283
422
  const unsubscribe = this.on(event, ((data) => {
284
423
  unsubscribe();
@@ -288,15 +427,38 @@ class ControllerImpl {
288
427
  }
289
428
  off(event, handler) {
290
429
  if (!handler) {
291
- this.eventListeners.delete(event);
292
- this.transport?.off(event);
293
- this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
430
+ if (this._configListenerEvents.has(event)) {
431
+ for (const [key, val] of this.handlerToTransport) {
432
+ if (val.event === event) {
433
+ this.transport?.off(event, val.transportHandler);
434
+ this.registeredHandlers = this.registeredHandlers.filter(
435
+ (h) => h.handler !== val.transportHandler
436
+ );
437
+ this.handlerToTransport.delete(key);
438
+ }
439
+ }
440
+ } else {
441
+ this.eventListeners.delete(event);
442
+ this.transport?.off(event);
443
+ this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
444
+ for (const [key, val] of this.handlerToTransport) {
445
+ if (val.event === event) this.handlerToTransport.delete(key);
446
+ }
447
+ }
294
448
  } else {
295
449
  const listeners = this.eventListeners.get(event);
296
450
  listeners?.delete(handler);
297
451
  if (listeners?.size === 0) {
298
452
  this.eventListeners.delete(event);
299
453
  }
454
+ const entry = this.handlerToTransport.get(handler);
455
+ if (entry) {
456
+ this.transport?.off(event, entry.transportHandler);
457
+ this.registeredHandlers = this.registeredHandlers.filter(
458
+ (h) => h.handler !== entry.transportHandler
459
+ );
460
+ this.handlerToTransport.delete(handler);
461
+ }
300
462
  }
301
463
  }
302
464
  // ---------------------------------------------------------------------------
@@ -304,7 +466,7 @@ class ControllerImpl {
304
466
  // ---------------------------------------------------------------------------
305
467
  destroy() {
306
468
  if (this._isDestroyed) return;
307
- this.logger.info("Destroying controller");
469
+ this.logger.lifecycle("Destroying controller");
308
470
  this.cleanup();
309
471
  this._isDestroyed = true;
310
472
  }
@@ -315,6 +477,7 @@ class ControllerImpl {
315
477
  }
316
478
  this.registeredHandlers = [];
317
479
  this.eventListeners.clear();
480
+ this.handlerToTransport.clear();
318
481
  if (this.transport) {
319
482
  this.transport.destroy();
320
483
  this.transport = null;
@@ -344,6 +507,7 @@ class ControllerImpl {
344
507
  }
345
508
  }
346
509
  handleError(error) {
510
+ this.logger.warn(`Error in handler: ${error.message}`);
347
511
  if (this.config.onError) {
348
512
  this.config.onError(error.toSmoreError());
349
513
  } else {
@@ -351,18 +515,10 @@ class ControllerImpl {
351
515
  }
352
516
  }
353
517
  logSend(event, data) {
354
- const options = this.config.debug;
355
- const shouldLog = typeof options === "object" ? options.logSend ?? true : Boolean(options);
356
- if (shouldLog) {
357
- this.logger.debug(`\u2192 SEND [${event}]`, data);
358
- }
518
+ this.logger.send(event, data);
359
519
  }
360
520
  logReceive(event, data) {
361
- const options = this.config.debug;
362
- const shouldLog = typeof options === "object" ? options.logReceive ?? true : Boolean(options);
363
- if (shouldLog) {
364
- this.logger.debug(`\u2190 RECV [${event}]`, data);
365
- }
521
+ this.logger.receive(event, data);
366
522
  }
367
523
  }
368
524
  function createController(config) {
@@ -372,5 +528,5 @@ function createController(config) {
372
528
  return promise;
373
529
  }
374
530
 
375
- export { SmoreSDKError, createController };
531
+ export { createController };
376
532
  //# sourceMappingURL=controller.js.map