@usions/sdk 2.1.5 → 2.10.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.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Usion SDK — unified backend channel.
3
+ *
4
+ * One switchboard for backend request/response + server push that works in
5
+ * every mode, so features (lobby, matchmaking, presence, …) never reach for a
6
+ * specific socket:
7
+ *
8
+ * - standalone / platform : uses the SDK's own Socket.IO socket (game.socket)
9
+ * - embedded (iframe/WebView): relays through the parent app via postMessage
10
+ * (BACKEND_EMIT request → host emits on its authenticated socket; the host
11
+ * forwards allow-listed server pushes back as BACKEND_EVENT)
12
+ *
13
+ * Security: in embedded mode the host MUST restrict which events it relays to a
14
+ * safe, namespaced allow-list (e.g. lobby:* / mm:*) so a mini-app can't abuse
15
+ * the user's authenticated connection. The backend re-validates every call.
16
+ */
17
+ export function applyBackendChannel(Usion) {
18
+ Usion._backendHandlers = {};
19
+ Usion._boundSockets = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
20
+
21
+ /**
22
+ * Bind a Socket.IO socket so any allow-listed server event is routed to the
23
+ * registered _backendOn handlers (via onAny — robust to registration order).
24
+ */
25
+ Usion._bindBackendSocket = function (socket) {
26
+ if (!socket || typeof socket.onAny !== 'function') return;
27
+ if (this._boundSockets) {
28
+ if (this._boundSockets.has(socket)) return;
29
+ this._boundSockets.add(socket);
30
+ }
31
+ const self = this;
32
+ socket.onAny(function (event, payload) {
33
+ const h = self._backendHandlers[event];
34
+ if (h) h(payload);
35
+ });
36
+ };
37
+
38
+ /** Subscribe to a backend server-push event (works in all modes). */
39
+ Usion._backendOn = function (event, handler) {
40
+ this._backendHandlers[event] = handler;
41
+ const s = this.game && this.game.socket;
42
+ if (s) {
43
+ if (typeof s.onAny === 'function') this._bindBackendSocket(s);
44
+ else if (typeof s.on === 'function') s.on(event, handler); // fallback (tests / minimal sockets)
45
+ }
46
+ };
47
+
48
+ /**
49
+ * Emit a backend request and await its ack. Routes to the SDK socket when
50
+ * standalone, or through the parent host when embedded.
51
+ * @returns {Promise<any>}
52
+ */
53
+ Usion._backendEmit = function (event, data, timeout) {
54
+ const self = this;
55
+ timeout = timeout || 8000;
56
+ const s = self.game && self.game.socket;
57
+ if (s && s.connected) {
58
+ return new Promise(function (resolve, reject) {
59
+ let done = false;
60
+ const timer = setTimeout(function () { if (done) return; done = true; reject(new Error('Backend request timeout')); }, timeout);
61
+ try {
62
+ s.emit(event, data || {}, function (resp) {
63
+ if (done) return; done = true; clearTimeout(timer);
64
+ if (resp && resp.error) reject(new Error(resp.message || resp.error));
65
+ else resolve(resp);
66
+ });
67
+ } catch (e) { clearTimeout(timer); reject(e); }
68
+ });
69
+ }
70
+ if (self._isEmbedded) {
71
+ // Host relays this onto its authenticated socket and replies with the ack.
72
+ return self._request('BACKEND_EMIT', { event: event, data: data || {} }, timeout);
73
+ }
74
+ return Promise.reject(new Error('No backend connection — call Usion.game.connect() first'));
75
+ };
76
+ }
@@ -10,6 +10,58 @@ export function getNextRequestId() {
10
10
  return ++_requestId;
11
11
  }
12
12
 
13
+ // Trusted origin of the host shell that embedded us (web iframe only).
14
+ // Resolved lazily from the real embedder, never from message contents.
15
+ let _parentOrigin = null;
16
+
17
+ function _resolveParentOrigin() {
18
+ try {
19
+ if (window.location.ancestorOrigins && window.location.ancestorOrigins.length) {
20
+ return window.location.ancestorOrigins[0];
21
+ }
22
+ } catch (e) { /* not supported */ }
23
+ try {
24
+ if (typeof document !== 'undefined' && document.referrer) {
25
+ return new URL(document.referrer).origin;
26
+ }
27
+ } catch (e) { /* malformed referrer */ }
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * Decide whether an incoming postMessage may be trusted.
33
+ *
34
+ * The host shell is the only legitimate sender. A sibling iframe or any other
35
+ * script on the page must NOT be able to forge messages (e.g. a fake
36
+ * PAYMENT_SUCCESS that unlocks paid value for free).
37
+ *
38
+ * - React Native WebView: messages are delivered in-process and carry no
39
+ * usable origin/source, so they are trusted.
40
+ * - Web iframe: the only window that equals `window.parent` is the real
41
+ * embedder. `event.source` is set by the browser and cannot be spoofed, so
42
+ * `event.source === window.parent` rejects siblings and self-posts. We then
43
+ * cross-check `event.origin` against the embedder's origin as defense-in-depth.
44
+ * - Not embedded (standalone / tests): nothing to protect against; allowed.
45
+ *
46
+ * @param {MessageEvent} event
47
+ * @returns {boolean}
48
+ */
49
+ export function isTrustedMessageSource(event) {
50
+ if (typeof window === 'undefined') return true;
51
+ if (window.ReactNativeWebView) return true;
52
+ if (window.parent === window) return true;
53
+
54
+ if (event && event.source && event.source !== window.parent) return false;
55
+
56
+ if (_parentOrigin === null) {
57
+ _parentOrigin = _resolveParentOrigin();
58
+ }
59
+ if (_parentOrigin && event && event.origin && event.origin !== _parentOrigin) {
60
+ return false;
61
+ }
62
+ return true;
63
+ }
64
+
13
65
  /**
14
66
  * Core Usion object with init, _post, _request
15
67
  */
@@ -46,6 +98,9 @@ export const core = {
46
98
 
47
99
  // Setup global message handler
48
100
  window.addEventListener('message', function(event) {
101
+ // Reject messages from anything other than the host shell.
102
+ if (!isTrustedMessageSource(event)) return;
103
+
49
104
  let data;
50
105
  try {
51
106
  data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
@@ -126,6 +181,12 @@ export const core = {
126
181
  if (data.type === 'BOT_MESSAGE' && self.bot && self.bot._messageHandler) {
127
182
  self.bot._messageHandler(data.message);
128
183
  }
184
+
185
+ // Handle backend server-push events relayed by the host (embedded mode).
186
+ if (data.type === 'BACKEND_EVENT' && data.event && self._backendHandlers) {
187
+ const h = self._backendHandlers[data.event];
188
+ if (h) h(data.data);
189
+ }
129
190
  });
130
191
 
131
192
  // Signal ready to parent
@@ -6,6 +6,7 @@ import { applyGameDirect } from './game-direct.js';
6
6
  import { applyGameSocket } from './game-socket.js';
7
7
  import { applyGameProxy } from './game-proxy.js';
8
8
  import { applyGameMethods } from './game-methods.js';
9
+ import { applyGameNetcode } from './game-netcode.js';
9
10
 
10
11
  /**
11
12
  * Create the game module with all sub-modules applied
@@ -30,6 +31,8 @@ export function createGameModule(Usion) {
30
31
  _useProxy: false,
31
32
  _proxyListenerSetup: false,
32
33
  _heartbeatInterval: null,
34
+ _pingMeter: null,
35
+ _pongWaiters: [],
33
36
 
34
37
  /**
35
38
  * Connect to the game socket server
@@ -145,6 +148,7 @@ export function createGameModule(Usion) {
145
148
  applyGameSocket(game, Usion);
146
149
  applyGameProxy(game, Usion);
147
150
  applyGameMethods(game, Usion);
151
+ applyGameNetcode(game, Usion);
148
152
 
149
153
  return game;
150
154
  }
@@ -26,6 +26,9 @@ export function applyGameDirect(game, Usion) {
26
26
 
27
27
  self._connecting = true;
28
28
  self.directMode = true;
29
+ self._autoReconnect = config.autoReconnect !== undefined
30
+ ? config.autoReconnect
31
+ : !(Usion.config && Usion.config.autoReconnect === false);
29
32
  self._connectPromise = self._fetchDirectAccess(config)
30
33
  .then(function(access) {
31
34
  self.directConfig = access;
@@ -154,6 +157,10 @@ export function applyGameDirect(game, Usion) {
154
157
  if (self._eventHandlers.disconnect) {
155
158
  self._eventHandlers.disconnect(evt && evt.reason ? evt.reason : 'direct socket closed');
156
159
  }
160
+ // Seamless resume: if the drop wasn't an intentional disconnect()
161
+ // (which clears directMode), transparently re-establish + re-join +
162
+ // resync from the last sequence.
163
+ if (self.directMode && self._autoReconnect !== false) self._scheduleDirectReconnect();
157
164
  };
158
165
 
159
166
  ws.onmessage = function(evt) {
@@ -162,6 +169,39 @@ export function applyGameDirect(game, Usion) {
162
169
  });
163
170
  };
164
171
 
172
+ /**
173
+ * Reconnect a dropped direct-mode socket with capped exponential backoff,
174
+ * then re-join the room and request a resync (Dota-style "fetch latest
175
+ * snapshot = instant rejoin"). Keeps retrying while still in directMode.
176
+ * @private
177
+ */
178
+ game._scheduleDirectReconnect = function() {
179
+ var self = this;
180
+ if (self._reconnecting) return;
181
+ self._reconnecting = true;
182
+ var attempt = self._reconnectAttempt || 0;
183
+ var go = function() {
184
+ if (!self.directMode) { self._reconnecting = false; return; } // disconnected meanwhile
185
+ attempt += 1;
186
+ self._reconnectAttempt = attempt;
187
+ self._fetchDirectAccess({})
188
+ .then(function(access) { self.directConfig = access; return self._initDirectSocket(access); })
189
+ .then(function() {
190
+ self.connected = true;
191
+ self._reconnecting = false;
192
+ self._reconnectAttempt = 0;
193
+ if (self._eventHandlers.reconnect) self._eventHandlers.reconnect(attempt);
194
+ if (self.roomId) self.requestSync(self._lastSequence || 0); // resync / resume
195
+ })
196
+ .catch(function() {
197
+ if (!self.directMode) { self._reconnecting = false; return; }
198
+ var delay = Math.min(1000 * Math.pow(2, attempt - 1), 15000);
199
+ setTimeout(go, delay);
200
+ });
201
+ };
202
+ setTimeout(go, 500);
203
+ };
204
+
165
205
  game._sendDirect = function(type, payload) {
166
206
  if (!this.directSocket || this.directSocket.readyState !== WebSocket.OPEN) return;
167
207
  this._directSeq = this._directSeq + 1;
@@ -205,6 +245,11 @@ export function applyGameDirect(game, Usion) {
205
245
  return;
206
246
  }
207
247
  if (data.type === 'pong') {
248
+ // Resolve a pending game.ping() RTT probe, if any.
249
+ if (this._pongWaiters && this._pongWaiters.length) {
250
+ const waiter = this._pongWaiters.shift();
251
+ if (waiter) waiter();
252
+ }
208
253
  if (this._eventHandlers.sync) this._eventHandlers.sync(payload);
209
254
  return;
210
255
  }
@@ -121,7 +121,10 @@ export function applyGameMethods(game, Usion) {
121
121
  const self = this;
122
122
 
123
123
  if (self.directMode) {
124
- self._sendDirect(actionType || 'action', actionData || {});
124
+ self._sendDirect('action', {
125
+ action_type: actionType || 'default',
126
+ action_data: actionData || {}
127
+ });
125
128
  return Promise.resolve({ success: true });
126
129
  }
127
130
 
@@ -321,6 +324,109 @@ export function applyGameMethods(game, Usion) {
321
324
  }
322
325
  };
323
326
 
327
+ // ───────────────────────────────────────────────────────────
328
+ // Cross-reload state persistence
329
+ // ───────────────────────────────────────────────────────────
330
+ // When a mini-app's iframe is unmounted and later re-mounted (e.g. the
331
+ // user navigates back to the chat and re-opens the game from the same
332
+ // room), the entire JS context is destroyed. Server-side room state is
333
+ // preserved, but anything the game holds in memory — board state, phase,
334
+ // whose turn it is, placement choices — is lost. The platform sync
335
+ // mechanism only replays raw actions; reconstructing client-visible
336
+ // state from a zero baseline is fragile or impossible.
337
+ //
338
+ // These helpers give every game a uniform way to snapshot whatever it
339
+ // needs to localStorage. Keys are scoped to (player_id, room_id) so a
340
+ // single browser can hold independent state for different rooms or
341
+ // accounts without collision, and "play a different match in the same
342
+ // room" naturally collides — which is the correct outcome.
343
+
344
+ const STATE_KEY_PREFIX = '_usion_game_state:';
345
+
346
+ function _stateKey(self) {
347
+ const rid = self.roomId || (Usion.config && Usion.config.roomId);
348
+ const pid = self.playerId
349
+ || (Usion.user && typeof Usion.user.getId === 'function' && Usion.user.getId())
350
+ || (Usion.config && Usion.config.userId);
351
+ if (!rid || !pid) return null;
352
+ return STATE_KEY_PREFIX + pid + ':' + rid;
353
+ }
354
+
355
+ /**
356
+ * Persist arbitrary JSON-serializable game state across iframe reloads.
357
+ * The mini-app decides the schema; the SDK only stores/retrieves.
358
+ * @param {*} state - Any JSON-serializable value
359
+ * @returns {boolean} true if saved, false if not (no room/player yet, or storage error)
360
+ */
361
+ game.saveState = function(state) {
362
+ const self = this;
363
+ const key = _stateKey(self);
364
+ if (!key) return false;
365
+ try {
366
+ localStorage.setItem(key, JSON.stringify({ state: state, savedAt: Date.now() }));
367
+ return true;
368
+ } catch (e) {
369
+ // Quota exceeded, private-mode rejection, etc. — non-fatal.
370
+ return false;
371
+ }
372
+ };
373
+
374
+ /**
375
+ * Retrieve previously-saved state for the current (player, room).
376
+ * @returns {*} The saved state value, or null if none / unreadable.
377
+ */
378
+ game.loadState = function() {
379
+ const self = this;
380
+ const key = _stateKey(self);
381
+ if (!key) return null;
382
+ try {
383
+ const raw = localStorage.getItem(key);
384
+ if (!raw) return null;
385
+ const parsed = JSON.parse(raw);
386
+ return parsed && parsed.state !== undefined ? parsed.state : null;
387
+ } catch (e) {
388
+ return null;
389
+ }
390
+ };
391
+
392
+ /**
393
+ * Drop any persisted state for the current (player, room).
394
+ * Call this when the game ends or starts fresh, so the next iframe
395
+ * mount in the same room doesn't pick up stale data.
396
+ */
397
+ game.clearState = function() {
398
+ const self = this;
399
+ const key = _stateKey(self);
400
+ if (!key) return;
401
+ try { localStorage.removeItem(key); } catch (e) { /* non-fatal */ }
402
+ };
403
+
404
+ /**
405
+ * Forward a debug snapshot to the parent platform. The platform renders
406
+ * it in a top-right overlay when the iframe host is opened with
407
+ * `?debug=1`. The payload schema is up to the game — anything JSON-
408
+ * serializable. No-op when not running inside an iframe.
409
+ *
410
+ * Games should call this at every meaningful state transition (turn
411
+ * change, action sent, action received, sync, phase change, etc.) so
412
+ * the overlay reflects live state.
413
+ *
414
+ * @param {object} payload - Arbitrary JSON-serializable debug data
415
+ */
416
+ game.debug = function(payload) {
417
+ try {
418
+ // Must work in both web iframes (window.parent !== window) and
419
+ // React Native WebView (window.parent === window, but a host bridge
420
+ // exists at window.ReactNativeWebView). Usion._post handles routing
421
+ // for both; this guard just avoids no-op work in standalone pages.
422
+ var inFrame = window.parent && window.parent !== window;
423
+ var inRNWebView = !!window.ReactNativeWebView;
424
+ if (inFrame || inRNWebView) {
425
+ Usion._post({ type: 'GAME_DEBUG', payload: payload || {} });
426
+ }
427
+ } catch (e) { /* non-fatal */ }
428
+ };
429
+
324
430
  /**
325
431
  * Get connection status
326
432
  * @returns {boolean}