@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.
- package/README.md +118 -0
- package/package.json +6 -3
- package/src/browser.js +2658 -124
- package/src/modules/backend-channel.js +76 -0
- package/src/modules/core.js +61 -0
- package/src/modules/game-core.js +4 -0
- package/src/modules/game-direct.js +45 -0
- package/src/modules/game-methods.js +107 -1
- package/src/modules/game-netcode.js +346 -0
- package/src/modules/game-proxy.js +4 -0
- package/src/modules/game-socket.js +4 -0
- package/src/modules/index.js +13 -0
- package/src/modules/leaderboard.js +46 -0
- package/src/modules/lobby.js +103 -0
- package/src/modules/matchmaking.js +61 -0
- package/src/modules/misc.js +4 -0
- package/src/modules/netcode/binary.js +113 -0
- package/src/modules/netcode/delta.js +236 -0
- package/src/modules/netcode/index.js +41 -0
- package/src/modules/netcode/interpolation.js +235 -0
- package/src/modules/netcode/lagcomp.js +104 -0
- package/src/modules/netcode/lockstep.js +103 -0
- package/src/modules/netcode/mesh-network.js +103 -0
- package/src/modules/netcode/mesh.js +188 -0
- package/src/modules/netcode/netsim.js +77 -0
- package/src/modules/netcode/ping.js +69 -0
- package/src/modules/netcode/prediction.js +113 -0
- package/src/modules/netcode/sender.js +102 -0
- package/src/modules/netcode/webtransport.js +195 -0
- package/src/modules/wallet.js +5 -1
- package/types/index.d.ts +463 -0
|
@@ -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
|
+
}
|
package/src/modules/core.js
CHANGED
|
@@ -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
|
package/src/modules/game-core.js
CHANGED
|
@@ -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(
|
|
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}
|