@remix-gg/sdk 0.9.0 → 0.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/dist/index.d.mts +187 -4
- package/dist/index.d.ts +187 -4
- package/dist/index.js +864 -1
- package/dist/index.min.js +806 -3
- package/dist/index.min.js.map +6 -4
- package/dist/index.mjs +861 -1
- package/package.json +1 -1
package/dist/index.min.js
CHANGED
|
@@ -32,15 +32,813 @@
|
|
|
32
32
|
__export(exports_src, {
|
|
33
33
|
sdk: () => sdk,
|
|
34
34
|
ZERO_SAFE_AREA_INSET: () => ZERO_SAFE_AREA_INSET,
|
|
35
|
-
RemixSDK: () => RemixSDK
|
|
35
|
+
RemixSDK: () => RemixSDK,
|
|
36
|
+
RealtimeRoomError: () => RealtimeRoomError
|
|
36
37
|
});
|
|
38
|
+
|
|
39
|
+
// src/mesh.ts
|
|
40
|
+
function isPolitePeer(selfId, peerId) {
|
|
41
|
+
return selfId < peerId;
|
|
42
|
+
}
|
|
43
|
+
var EVENTS_CHANNEL_ID = 1;
|
|
44
|
+
var STATE_CHANNEL_ID = 2;
|
|
45
|
+
var MAX_STATE_BUFFERED_BYTES = 64 * 1024;
|
|
46
|
+
var DEFAULT_TUNING = {
|
|
47
|
+
connectTimeoutMs: 12000,
|
|
48
|
+
disconnectedGraceMs: 4000,
|
|
49
|
+
maxRebuilds: 3,
|
|
50
|
+
giveUpRetryMs: 15000,
|
|
51
|
+
maxQueuedEventMessages: 64
|
|
52
|
+
};
|
|
53
|
+
function wireDescription(description, epoch) {
|
|
54
|
+
return epoch === undefined ? { type: description.type, sdp: description.sdp } : { type: description.type, sdp: description.sdp, epoch };
|
|
55
|
+
}
|
|
56
|
+
function parseWireDescription(payload) {
|
|
57
|
+
const value = parseJson(payload);
|
|
58
|
+
if (!isRecord(value))
|
|
59
|
+
return null;
|
|
60
|
+
const { type, sdp, epoch } = value;
|
|
61
|
+
if (type !== "offer" && type !== "answer")
|
|
62
|
+
return null;
|
|
63
|
+
if (typeof sdp !== "string")
|
|
64
|
+
return null;
|
|
65
|
+
if (epoch === undefined)
|
|
66
|
+
return { type, sdp };
|
|
67
|
+
if (typeof epoch !== "number" || !Number.isFinite(epoch))
|
|
68
|
+
return null;
|
|
69
|
+
return { type, sdp, epoch };
|
|
70
|
+
}
|
|
71
|
+
function parseCandidate(payload) {
|
|
72
|
+
const value = parseJson(payload);
|
|
73
|
+
return isRecord(value) ? value : null;
|
|
74
|
+
}
|
|
75
|
+
function byeIsDeparture(payload) {
|
|
76
|
+
const value = parseJson(payload);
|
|
77
|
+
if (!isRecord(value))
|
|
78
|
+
return true;
|
|
79
|
+
return value.reason === undefined;
|
|
80
|
+
}
|
|
81
|
+
function parseJson(payload) {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(payload);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function isRecord(value) {
|
|
89
|
+
return typeof value === "object" && value !== null;
|
|
90
|
+
}
|
|
91
|
+
function createDataMesh(opts) {
|
|
92
|
+
const { selfId, host } = opts;
|
|
93
|
+
const timing = { ...DEFAULT_TUNING, ...opts.tuning };
|
|
94
|
+
const slots = new Map;
|
|
95
|
+
const backlogs = new Map;
|
|
96
|
+
const rebuildCounts = new Map;
|
|
97
|
+
const reportedFailures = new Set;
|
|
98
|
+
const wanted = new Set;
|
|
99
|
+
const giveUpRetries = new Map;
|
|
100
|
+
const clearGiveUpRetry = (peerId) => {
|
|
101
|
+
const timer = giveUpRetries.get(peerId);
|
|
102
|
+
if (timer) {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
giveUpRetries.delete(peerId);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
let iceServers = opts.iceServers;
|
|
108
|
+
let running = true;
|
|
109
|
+
let epochCounter = 0;
|
|
110
|
+
let signalChain = Promise.resolve();
|
|
111
|
+
const clearSlotTimers = (slot) => {
|
|
112
|
+
if (slot.connectTimer) {
|
|
113
|
+
clearTimeout(slot.connectTimer);
|
|
114
|
+
slot.connectTimer = null;
|
|
115
|
+
}
|
|
116
|
+
if (slot.disconnectedTimer) {
|
|
117
|
+
clearTimeout(slot.disconnectedTimer);
|
|
118
|
+
slot.disconnectedTimer = null;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const teardownSlot = (peerId, options = {}) => {
|
|
122
|
+
if (options.departed)
|
|
123
|
+
backlogs.delete(peerId);
|
|
124
|
+
const slot = slots.get(peerId);
|
|
125
|
+
if (!slot) {
|
|
126
|
+
if (options.departed)
|
|
127
|
+
host.onPeerGone(peerId);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
slots.delete(peerId);
|
|
131
|
+
clearSlotTimers(slot);
|
|
132
|
+
slot.events.onopen = null;
|
|
133
|
+
slot.events.onmessage = null;
|
|
134
|
+
slot.state.onmessage = null;
|
|
135
|
+
slot.pc.close();
|
|
136
|
+
if (options.departed)
|
|
137
|
+
host.onPeerGone(peerId);
|
|
138
|
+
};
|
|
139
|
+
const scheduleGiveUpRetry = (peerId) => {
|
|
140
|
+
clearGiveUpRetry(peerId);
|
|
141
|
+
giveUpRetries.set(peerId, setTimeout(() => {
|
|
142
|
+
giveUpRetries.delete(peerId);
|
|
143
|
+
if (!running || !wanted.has(peerId) || slots.has(peerId))
|
|
144
|
+
return;
|
|
145
|
+
host.onDiagnostic?.(`mesh: retrying peer ${peerId} after the give-up rest`);
|
|
146
|
+
ensureSlot(peerId);
|
|
147
|
+
}, timing.giveUpRetryMs));
|
|
148
|
+
};
|
|
149
|
+
const rebuildSlot = (peerId, why) => {
|
|
150
|
+
if (!running || !slots.has(peerId))
|
|
151
|
+
return;
|
|
152
|
+
const attempts = (rebuildCounts.get(peerId) ?? 0) + 1;
|
|
153
|
+
teardownSlot(peerId);
|
|
154
|
+
if (attempts > timing.maxRebuilds) {
|
|
155
|
+
rebuildCounts.delete(peerId);
|
|
156
|
+
host.onDiagnostic?.(`mesh: gave up connecting peer ${peerId} after ${timing.maxRebuilds} rebuilds`);
|
|
157
|
+
scheduleGiveUpRetry(peerId);
|
|
158
|
+
host.onRelayRefreshNeeded?.();
|
|
159
|
+
if (!reportedFailures.has(peerId)) {
|
|
160
|
+
reportedFailures.add(peerId);
|
|
161
|
+
host.onError("Could not connect to a player. One of you may be on a network that blocks direct connections.");
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
rebuildCounts.set(peerId, attempts);
|
|
166
|
+
host.post({ toUserId: peerId, kind: "bye", payload: JSON.stringify({ reason: why }) });
|
|
167
|
+
ensureSlot(peerId);
|
|
168
|
+
};
|
|
169
|
+
const armConnectWatchdog = (peerId, slot) => {
|
|
170
|
+
if (slot.connectTimer)
|
|
171
|
+
clearTimeout(slot.connectTimer);
|
|
172
|
+
slot.connectTimer = setTimeout(() => {
|
|
173
|
+
slot.connectTimer = null;
|
|
174
|
+
if (slots.get(peerId) !== slot)
|
|
175
|
+
return;
|
|
176
|
+
if (slot.pc.connectionState !== "connected")
|
|
177
|
+
rebuildSlot(peerId, "connect-timeout");
|
|
178
|
+
}, timing.connectTimeoutMs);
|
|
179
|
+
};
|
|
180
|
+
const reportConnectedPairType = (peerId, pc) => {
|
|
181
|
+
const diagnostic = host.onDiagnostic;
|
|
182
|
+
if (!diagnostic || typeof pc.getStats !== "function")
|
|
183
|
+
return;
|
|
184
|
+
pc.getStats().then((stats) => {
|
|
185
|
+
const candidateTypes = new Map;
|
|
186
|
+
const nominatedPairs = [];
|
|
187
|
+
for (const report of stats.values()) {
|
|
188
|
+
const row = report;
|
|
189
|
+
if (row.type === "local-candidate" || row.type === "remote-candidate") {
|
|
190
|
+
if (row.id && row.candidateType)
|
|
191
|
+
candidateTypes.set(row.id, row.candidateType);
|
|
192
|
+
} else if (row.type === "candidate-pair" && row.nominated && row.state === "succeeded") {
|
|
193
|
+
nominatedPairs.push(row);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const pair = nominatedPairs[0];
|
|
197
|
+
if (!pair)
|
|
198
|
+
return;
|
|
199
|
+
const local = candidateTypes.get(pair.localCandidateId ?? "") ?? "unknown";
|
|
200
|
+
const remote = candidateTypes.get(pair.remoteCandidateId ?? "") ?? "unknown";
|
|
201
|
+
diagnostic(`mesh: peer ${peerId} connected via ${local}/${remote}`);
|
|
202
|
+
}).catch(() => {});
|
|
203
|
+
};
|
|
204
|
+
const flushQueued = (peerId, slot) => {
|
|
205
|
+
const backlog = backlogs.get(peerId) ?? [];
|
|
206
|
+
backlogs.delete(peerId);
|
|
207
|
+
for (const data of backlog) {
|
|
208
|
+
try {
|
|
209
|
+
slot.events.send(data);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
host.onDiagnostic?.(`mesh: flush to ${peerId} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const ensureSlot = (peerId) => {
|
|
216
|
+
const existing = slots.get(peerId);
|
|
217
|
+
if (existing)
|
|
218
|
+
return existing;
|
|
219
|
+
const pc = new RTCPeerConnection({ iceServers });
|
|
220
|
+
const events = pc.createDataChannel("events", { negotiated: true, id: EVENTS_CHANNEL_ID });
|
|
221
|
+
const state = pc.createDataChannel("state", {
|
|
222
|
+
negotiated: true,
|
|
223
|
+
id: STATE_CHANNEL_ID,
|
|
224
|
+
ordered: false,
|
|
225
|
+
maxRetransmits: 0
|
|
226
|
+
});
|
|
227
|
+
events.binaryType = "arraybuffer";
|
|
228
|
+
state.binaryType = "arraybuffer";
|
|
229
|
+
const slot = {
|
|
230
|
+
pc,
|
|
231
|
+
events,
|
|
232
|
+
state,
|
|
233
|
+
makingOffer: false,
|
|
234
|
+
opened: false,
|
|
235
|
+
connectTimer: null,
|
|
236
|
+
disconnectedTimer: null,
|
|
237
|
+
pendingCandidates: [],
|
|
238
|
+
offerEpoch: 0,
|
|
239
|
+
answeringEpoch: null
|
|
240
|
+
};
|
|
241
|
+
slots.set(peerId, slot);
|
|
242
|
+
const isCurrent = () => slots.get(peerId)?.pc === pc;
|
|
243
|
+
events.onopen = () => {
|
|
244
|
+
if (!isCurrent())
|
|
245
|
+
return;
|
|
246
|
+
flushQueued(peerId, slot);
|
|
247
|
+
if (!slot.opened) {
|
|
248
|
+
slot.opened = true;
|
|
249
|
+
host.onPeerOpen(peerId);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
events.onmessage = (event) => {
|
|
253
|
+
if (!isCurrent())
|
|
254
|
+
return;
|
|
255
|
+
host.onMessage(peerId, "events", event.data);
|
|
256
|
+
};
|
|
257
|
+
state.onmessage = (event) => {
|
|
258
|
+
if (!isCurrent())
|
|
259
|
+
return;
|
|
260
|
+
host.onMessage(peerId, "state", event.data);
|
|
261
|
+
};
|
|
262
|
+
pc.onicecandidate = (event) => {
|
|
263
|
+
if (!isCurrent())
|
|
264
|
+
return;
|
|
265
|
+
if (!event.candidate)
|
|
266
|
+
return;
|
|
267
|
+
host.post({ toUserId: peerId, kind: "ice", payload: JSON.stringify(event.candidate) });
|
|
268
|
+
};
|
|
269
|
+
pc.onnegotiationneeded = async () => {
|
|
270
|
+
if (!isCurrent())
|
|
271
|
+
return;
|
|
272
|
+
try {
|
|
273
|
+
slot.makingOffer = true;
|
|
274
|
+
await pc.setLocalDescription();
|
|
275
|
+
if (!isCurrent())
|
|
276
|
+
return;
|
|
277
|
+
if (pc.localDescription) {
|
|
278
|
+
epochCounter += 1;
|
|
279
|
+
slot.offerEpoch = epochCounter;
|
|
280
|
+
host.post({
|
|
281
|
+
toUserId: peerId,
|
|
282
|
+
kind: "offer",
|
|
283
|
+
payload: JSON.stringify(wireDescription(pc.localDescription, slot.offerEpoch))
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (isCurrent()) {
|
|
288
|
+
host.onError(error instanceof Error ? error.message : "Mesh negotiation failed");
|
|
289
|
+
}
|
|
290
|
+
} finally {
|
|
291
|
+
slot.makingOffer = false;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
pc.onconnectionstatechange = () => {
|
|
295
|
+
if (!isCurrent())
|
|
296
|
+
return;
|
|
297
|
+
const connection = pc.connectionState;
|
|
298
|
+
if (connection === "connected") {
|
|
299
|
+
clearSlotTimers(slot);
|
|
300
|
+
rebuildCounts.delete(peerId);
|
|
301
|
+
reportConnectedPairType(peerId, pc);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (connection === "failed") {
|
|
305
|
+
slot.pc.restartIce();
|
|
306
|
+
armConnectWatchdog(peerId, slot);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (connection === "disconnected") {
|
|
310
|
+
if (slot.disconnectedTimer)
|
|
311
|
+
return;
|
|
312
|
+
slot.disconnectedTimer = setTimeout(() => {
|
|
313
|
+
slot.disconnectedTimer = null;
|
|
314
|
+
if (slot.pc.connectionState === "disconnected") {
|
|
315
|
+
slot.pc.restartIce();
|
|
316
|
+
armConnectWatchdog(peerId, slot);
|
|
317
|
+
}
|
|
318
|
+
}, timing.disconnectedGraceMs);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (connection === "closed")
|
|
322
|
+
teardownSlot(peerId, { departed: true });
|
|
323
|
+
};
|
|
324
|
+
armConnectWatchdog(peerId, slot);
|
|
325
|
+
return slot;
|
|
326
|
+
};
|
|
327
|
+
const applySignal = async (fromUserId, kind, payload) => {
|
|
328
|
+
if (!running)
|
|
329
|
+
return;
|
|
330
|
+
if (kind === "bye") {
|
|
331
|
+
rebuildCounts.delete(fromUserId);
|
|
332
|
+
teardownSlot(fromUserId, { departed: byeIsDeparture(payload) });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const slot = ensureSlot(fromUserId);
|
|
336
|
+
const polite = isPolitePeer(selfId, fromUserId);
|
|
337
|
+
const stale = () => slots.get(fromUserId) !== slot;
|
|
338
|
+
if (kind === "ice") {
|
|
339
|
+
const candidate = parseCandidate(payload);
|
|
340
|
+
if (!candidate)
|
|
341
|
+
return;
|
|
342
|
+
if (!slot.pc.remoteDescription) {
|
|
343
|
+
slot.pendingCandidates.push(candidate);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
await slot.pc.addIceCandidate(candidate);
|
|
348
|
+
} catch {}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (kind !== "offer" && kind !== "answer")
|
|
352
|
+
return;
|
|
353
|
+
const description = parseWireDescription(payload);
|
|
354
|
+
if (!description)
|
|
355
|
+
return;
|
|
356
|
+
const flushPending = async () => {
|
|
357
|
+
const queued = slot.pendingCandidates;
|
|
358
|
+
slot.pendingCandidates = [];
|
|
359
|
+
for (const candidate of queued) {
|
|
360
|
+
if (stale())
|
|
361
|
+
return;
|
|
362
|
+
try {
|
|
363
|
+
await slot.pc.addIceCandidate(candidate);
|
|
364
|
+
} catch {}
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
const offerCollision = description.type === "offer" && (slot.makingOffer || slot.pc.signalingState !== "stable");
|
|
368
|
+
if (!polite && offerCollision)
|
|
369
|
+
return;
|
|
370
|
+
if (description.type === "answer" && slot.pc.signalingState !== "have-local-offer")
|
|
371
|
+
return;
|
|
372
|
+
if (description.type === "answer" && description.epoch !== undefined && description.epoch !== slot.offerEpoch) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (description.type === "offer")
|
|
376
|
+
slot.answeringEpoch = description.epoch ?? null;
|
|
377
|
+
try {
|
|
378
|
+
await slot.pc.setRemoteDescription(description);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (stale())
|
|
381
|
+
return;
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
if (stale())
|
|
385
|
+
return;
|
|
386
|
+
await flushPending();
|
|
387
|
+
if (description.type === "answer" || stale())
|
|
388
|
+
return;
|
|
389
|
+
await slot.pc.setLocalDescription();
|
|
390
|
+
if (stale())
|
|
391
|
+
return;
|
|
392
|
+
if (slot.pc.localDescription) {
|
|
393
|
+
host.post({
|
|
394
|
+
toUserId: fromUserId,
|
|
395
|
+
kind: "answer",
|
|
396
|
+
payload: JSON.stringify(wireDescription(slot.pc.localDescription, slot.answeringEpoch ?? undefined))
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
return {
|
|
401
|
+
setIceServers(next) {
|
|
402
|
+
iceServers = next;
|
|
403
|
+
},
|
|
404
|
+
setPeers(peers) {
|
|
405
|
+
if (!running)
|
|
406
|
+
return;
|
|
407
|
+
wanted.clear();
|
|
408
|
+
for (const peer of peers) {
|
|
409
|
+
if (peer.userId !== selfId)
|
|
410
|
+
wanted.add(peer.userId);
|
|
411
|
+
}
|
|
412
|
+
for (const peerId of [...slots.keys()]) {
|
|
413
|
+
if (!wanted.has(peerId)) {
|
|
414
|
+
rebuildCounts.delete(peerId);
|
|
415
|
+
teardownSlot(peerId, { departed: true });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const peerId of [...giveUpRetries.keys()]) {
|
|
419
|
+
if (!wanted.has(peerId))
|
|
420
|
+
clearGiveUpRetry(peerId);
|
|
421
|
+
}
|
|
422
|
+
for (const peerId of backlogs.keys()) {
|
|
423
|
+
if (!wanted.has(peerId))
|
|
424
|
+
backlogs.delete(peerId);
|
|
425
|
+
}
|
|
426
|
+
for (const peerId of wanted) {
|
|
427
|
+
if (!giveUpRetries.has(peerId))
|
|
428
|
+
ensureSlot(peerId);
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
handleSignal(fromUserId, kind, payload) {
|
|
432
|
+
const chained = signalChain.then(() => applySignal(fromUserId, kind, payload).catch((error) => {
|
|
433
|
+
if (running) {
|
|
434
|
+
host.onError(error instanceof Error ? error.message : "Mesh signal failed");
|
|
435
|
+
}
|
|
436
|
+
}));
|
|
437
|
+
signalChain = chained;
|
|
438
|
+
return chained;
|
|
439
|
+
},
|
|
440
|
+
send(toUserId, channel, data) {
|
|
441
|
+
if (!running)
|
|
442
|
+
return false;
|
|
443
|
+
const slot = slots.get(toUserId);
|
|
444
|
+
if (!slot && !wanted.has(toUserId))
|
|
445
|
+
return false;
|
|
446
|
+
const target = channel === "events" ? slot?.events : slot?.state;
|
|
447
|
+
if (!target || target.readyState !== "open") {
|
|
448
|
+
if (channel !== "events")
|
|
449
|
+
return false;
|
|
450
|
+
const queued = backlogs.get(toUserId) ?? [];
|
|
451
|
+
if (queued.length >= timing.maxQueuedEventMessages) {
|
|
452
|
+
queued.shift();
|
|
453
|
+
host.onDiagnostic?.(`mesh: event backlog for ${toUserId} overflowed; dropped oldest`);
|
|
454
|
+
}
|
|
455
|
+
queued.push(data);
|
|
456
|
+
backlogs.set(toUserId, queued);
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
if (channel === "state" && target.bufferedAmount >= MAX_STATE_BUFFERED_BYTES)
|
|
460
|
+
return false;
|
|
461
|
+
try {
|
|
462
|
+
target.send(data);
|
|
463
|
+
return true;
|
|
464
|
+
} catch (error) {
|
|
465
|
+
host.onDiagnostic?.(`mesh: send to ${toUserId} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
broadcast(channel, data) {
|
|
470
|
+
for (const peerId of slots.keys())
|
|
471
|
+
this.send(peerId, channel, data);
|
|
472
|
+
},
|
|
473
|
+
stop() {
|
|
474
|
+
running = false;
|
|
475
|
+
for (const peerId of [...slots.keys()])
|
|
476
|
+
teardownSlot(peerId);
|
|
477
|
+
for (const peerId of [...giveUpRetries.keys()])
|
|
478
|
+
clearGiveUpRetry(peerId);
|
|
479
|
+
wanted.clear();
|
|
480
|
+
rebuildCounts.clear();
|
|
481
|
+
reportedFailures.clear();
|
|
482
|
+
backlogs.clear();
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/rooms.ts
|
|
488
|
+
class RealtimeRoomError extends Error {
|
|
489
|
+
code;
|
|
490
|
+
constructor(code, message) {
|
|
491
|
+
super(message);
|
|
492
|
+
this.name = "RealtimeRoomError";
|
|
493
|
+
this.code = code;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
var subscribe = (set, callback) => {
|
|
497
|
+
set.add(callback);
|
|
498
|
+
return () => set.delete(callback);
|
|
499
|
+
};
|
|
500
|
+
var textDecoder = new TextDecoder;
|
|
501
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 45000;
|
|
502
|
+
var MAX_JOIN_CODE_LENGTH = 32;
|
|
503
|
+
function isPeers(value) {
|
|
504
|
+
return Array.isArray(value) && value.every((peer) => {
|
|
505
|
+
if (typeof peer !== "object" || peer === null)
|
|
506
|
+
return false;
|
|
507
|
+
const row = peer;
|
|
508
|
+
return typeof row.userId === "string" && typeof row.username === "string" && (row.pfp === null || typeof row.pfp === "string") && (row.joinedAt === null || typeof row.joinedAt === "string");
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
function isIceServers(value) {
|
|
512
|
+
return Array.isArray(value) && value.every((server) => {
|
|
513
|
+
if (typeof server !== "object" || server === null)
|
|
514
|
+
return false;
|
|
515
|
+
const row = server;
|
|
516
|
+
return (typeof row.urls === "string" || Array.isArray(row.urls) && row.urls.every((url) => typeof url === "string")) && (row.username === undefined || typeof row.username === "string") && (row.credential === undefined || typeof row.credential === "string");
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
function createRealtimeController(wire, options = {}) {
|
|
520
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
521
|
+
let live = null;
|
|
522
|
+
let pending = null;
|
|
523
|
+
const roomListeners = new Set;
|
|
524
|
+
const roomErrorListeners = new Set;
|
|
525
|
+
let unheardRoomError = null;
|
|
526
|
+
const reportRoomError = (failure) => {
|
|
527
|
+
if (roomErrorListeners.size === 0)
|
|
528
|
+
unheardRoomError = failure;
|
|
529
|
+
else
|
|
530
|
+
for (const callback of roomErrorListeners)
|
|
531
|
+
callback(failure);
|
|
532
|
+
};
|
|
533
|
+
const endRoom = (room, reason) => {
|
|
534
|
+
if (room.ended)
|
|
535
|
+
return;
|
|
536
|
+
room.ended = true;
|
|
537
|
+
if (live === room)
|
|
538
|
+
live = null;
|
|
539
|
+
room.mesh.stop();
|
|
540
|
+
for (const callback of room.callbacks.ended)
|
|
541
|
+
callback(reason);
|
|
542
|
+
};
|
|
543
|
+
const requestRoom = (post) => {
|
|
544
|
+
if (pending) {
|
|
545
|
+
return Promise.reject(new RealtimeRoomError("join_failed", "A room request is already in flight."));
|
|
546
|
+
}
|
|
547
|
+
if (live) {
|
|
548
|
+
const previous = live;
|
|
549
|
+
endRoom(previous, "closed");
|
|
550
|
+
wire.post("multiplayer_leave_room");
|
|
551
|
+
}
|
|
552
|
+
return new Promise((resolve, reject) => {
|
|
553
|
+
const timer = setTimeout(() => {
|
|
554
|
+
if (pending !== mine)
|
|
555
|
+
return;
|
|
556
|
+
pending = null;
|
|
557
|
+
reject(new RealtimeRoomError("join_failed", "The platform did not answer. Realtime rooms are available on Remix Desktop only."));
|
|
558
|
+
}, requestTimeoutMs);
|
|
559
|
+
const mine = {
|
|
560
|
+
resolve: (room) => {
|
|
561
|
+
clearTimeout(timer);
|
|
562
|
+
resolve(room);
|
|
563
|
+
},
|
|
564
|
+
reject: (error) => {
|
|
565
|
+
clearTimeout(timer);
|
|
566
|
+
reject(error);
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
pending = mine;
|
|
570
|
+
post();
|
|
571
|
+
});
|
|
572
|
+
};
|
|
573
|
+
const channelFor = (reliable) => reliable ? "events" : "state";
|
|
574
|
+
const encodePayload = (data) => JSON.stringify(data === undefined ? null : data);
|
|
575
|
+
const withoutSelf = (peers, selfId) => peers.filter((peer) => peer.userId !== selfId);
|
|
576
|
+
const startRoom = (session) => {
|
|
577
|
+
const peers = withoutSelf(session.peers, session.selfId);
|
|
578
|
+
const roster = new Map(peers.map((peer) => [peer.userId, peer]));
|
|
579
|
+
const callbacks = {
|
|
580
|
+
message: new Set,
|
|
581
|
+
join: new Set,
|
|
582
|
+
leave: new Set,
|
|
583
|
+
ended: new Set,
|
|
584
|
+
error: new Set
|
|
585
|
+
};
|
|
586
|
+
const mesh = createDataMesh({
|
|
587
|
+
selfId: session.selfId,
|
|
588
|
+
iceServers: session.iceServers,
|
|
589
|
+
host: {
|
|
590
|
+
post: (signal) => wire.post("multiplayer_signal", {
|
|
591
|
+
toUserId: signal.toUserId,
|
|
592
|
+
kind: signal.kind,
|
|
593
|
+
payload: signal.payload
|
|
594
|
+
}),
|
|
595
|
+
onMessage: (userId, _channel, data) => {
|
|
596
|
+
if (room.ended)
|
|
597
|
+
return;
|
|
598
|
+
let parsed;
|
|
599
|
+
try {
|
|
600
|
+
parsed = JSON.parse(typeof data === "string" ? data : textDecoder.decode(data));
|
|
601
|
+
} catch {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
for (const callback of room.callbacks.message)
|
|
605
|
+
callback(userId, parsed);
|
|
606
|
+
},
|
|
607
|
+
onPeerOpen: () => {},
|
|
608
|
+
onPeerGone: () => {},
|
|
609
|
+
onError: (message) => {
|
|
610
|
+
if (room.ended)
|
|
611
|
+
return;
|
|
612
|
+
for (const callback of room.callbacks.error)
|
|
613
|
+
callback(message);
|
|
614
|
+
},
|
|
615
|
+
onRelayRefreshNeeded: () => {
|
|
616
|
+
if (!room.ended)
|
|
617
|
+
wire.post("multiplayer_refresh_ice");
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
const surface = {
|
|
622
|
+
roomId: session.roomId,
|
|
623
|
+
code: session.code,
|
|
624
|
+
selfId: session.selfId,
|
|
625
|
+
hostUserId: session.hostUserId,
|
|
626
|
+
isHost: session.hostUserId === session.selfId,
|
|
627
|
+
get peers() {
|
|
628
|
+
return room.peerList;
|
|
629
|
+
},
|
|
630
|
+
get ended() {
|
|
631
|
+
return room.ended;
|
|
632
|
+
},
|
|
633
|
+
send: (data, options2) => {
|
|
634
|
+
if (room.ended)
|
|
635
|
+
return;
|
|
636
|
+
const channel = channelFor(options2?.reliable !== false);
|
|
637
|
+
const encoded = encodePayload(data);
|
|
638
|
+
for (const userId of room.roster.keys())
|
|
639
|
+
room.mesh.send(userId, channel, encoded);
|
|
640
|
+
},
|
|
641
|
+
sendTo: (userId, data, options2) => {
|
|
642
|
+
if (room.ended)
|
|
643
|
+
return;
|
|
644
|
+
room.mesh.send(userId, channelFor(options2?.reliable !== false), encodePayload(data));
|
|
645
|
+
},
|
|
646
|
+
onMessage: (callback) => subscribe(room.callbacks.message, callback),
|
|
647
|
+
onPeerJoin: (callback) => subscribe(room.callbacks.join, callback),
|
|
648
|
+
onPeerLeave: (callback) => subscribe(room.callbacks.leave, callback),
|
|
649
|
+
onEnded: (callback) => subscribe(room.callbacks.ended, callback),
|
|
650
|
+
onError: (callback) => subscribe(room.callbacks.error, callback),
|
|
651
|
+
invite: () => {
|
|
652
|
+
if (!room.ended)
|
|
653
|
+
wire.post("multiplayer_request_invite");
|
|
654
|
+
},
|
|
655
|
+
leave: () => {
|
|
656
|
+
if (room.ended)
|
|
657
|
+
return;
|
|
658
|
+
endRoom(room, "left");
|
|
659
|
+
wire.post("multiplayer_leave_room");
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
const room = {
|
|
663
|
+
session,
|
|
664
|
+
roster,
|
|
665
|
+
peerList: peers,
|
|
666
|
+
ended: false,
|
|
667
|
+
mesh,
|
|
668
|
+
surface,
|
|
669
|
+
callbacks
|
|
670
|
+
};
|
|
671
|
+
try {
|
|
672
|
+
mesh.setPeers(peers);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
mesh.stop();
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
return room;
|
|
678
|
+
};
|
|
679
|
+
const readSession = (data) => {
|
|
680
|
+
if (typeof data !== "object" || data === null)
|
|
681
|
+
return null;
|
|
682
|
+
const session = data;
|
|
683
|
+
if (typeof session.roomId !== "string" || typeof session.selfId !== "string" || typeof session.code !== "string" || typeof session.gameId !== "string" || typeof session.hostUserId !== "string" || !isPeers(session.peers) || !isIceServers(session.iceServers)) {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
roomId: session.roomId,
|
|
688
|
+
code: session.code,
|
|
689
|
+
gameId: session.gameId,
|
|
690
|
+
selfId: session.selfId,
|
|
691
|
+
hostUserId: session.hostUserId,
|
|
692
|
+
peers: session.peers,
|
|
693
|
+
iceServers: session.iceServers
|
|
694
|
+
};
|
|
695
|
+
};
|
|
696
|
+
return {
|
|
697
|
+
createRoom() {
|
|
698
|
+
return requestRoom(() => wire.post("multiplayer_create_room"));
|
|
699
|
+
},
|
|
700
|
+
joinRoom(code) {
|
|
701
|
+
const trimmed = code.trim();
|
|
702
|
+
if (trimmed.length === 0 || trimmed.length > MAX_JOIN_CODE_LENGTH) {
|
|
703
|
+
return Promise.reject(new RealtimeRoomError("room_not_found", "That is not an invite code."));
|
|
704
|
+
}
|
|
705
|
+
return requestRoom(() => wire.post("multiplayer_join_room", { code: trimmed }));
|
|
706
|
+
},
|
|
707
|
+
get room() {
|
|
708
|
+
return live?.surface ?? null;
|
|
709
|
+
},
|
|
710
|
+
onRoom(callback) {
|
|
711
|
+
const off = subscribe(roomListeners, callback);
|
|
712
|
+
if (live && !live.ended)
|
|
713
|
+
callback(live.surface);
|
|
714
|
+
return off;
|
|
715
|
+
},
|
|
716
|
+
onRoomError(callback) {
|
|
717
|
+
const off = subscribe(roomErrorListeners, callback);
|
|
718
|
+
if (unheardRoomError) {
|
|
719
|
+
const failure = unheardRoomError;
|
|
720
|
+
unheardRoomError = null;
|
|
721
|
+
callback(failure);
|
|
722
|
+
}
|
|
723
|
+
return off;
|
|
724
|
+
},
|
|
725
|
+
handleHostEvent(type, data) {
|
|
726
|
+
switch (type) {
|
|
727
|
+
case "multiplayer_session": {
|
|
728
|
+
const session = readSession(data);
|
|
729
|
+
if (!session) {
|
|
730
|
+
const waiting2 = pending;
|
|
731
|
+
pending = null;
|
|
732
|
+
waiting2?.reject(new RealtimeRoomError("join_failed", "The platform sent a malformed session."));
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (live)
|
|
736
|
+
endRoom(live, "closed");
|
|
737
|
+
unheardRoomError = null;
|
|
738
|
+
const waiting = pending;
|
|
739
|
+
pending = null;
|
|
740
|
+
let room;
|
|
741
|
+
try {
|
|
742
|
+
room = startRoom(session);
|
|
743
|
+
} catch (error) {
|
|
744
|
+
const failure = new RealtimeRoomError("join_failed", error instanceof Error ? error.message : "Could not start the room transport.");
|
|
745
|
+
wire.post("multiplayer_leave_room");
|
|
746
|
+
if (waiting)
|
|
747
|
+
waiting.reject(failure);
|
|
748
|
+
else
|
|
749
|
+
reportRoomError(failure);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
live = room;
|
|
753
|
+
waiting?.resolve(room.surface);
|
|
754
|
+
for (const callback of roomListeners)
|
|
755
|
+
callback(room.surface);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
case "multiplayer_peers": {
|
|
759
|
+
const room = live;
|
|
760
|
+
if (!room)
|
|
761
|
+
return;
|
|
762
|
+
const raw = data?.peers;
|
|
763
|
+
if (!isPeers(raw))
|
|
764
|
+
return;
|
|
765
|
+
const peers = withoutSelf(raw, room.session.selfId);
|
|
766
|
+
const next = new Map(peers.map((peer) => [peer.userId, peer]));
|
|
767
|
+
const joined = [];
|
|
768
|
+
const left = [];
|
|
769
|
+
for (const [userId, peer] of next) {
|
|
770
|
+
if (!room.roster.has(userId))
|
|
771
|
+
joined.push(peer);
|
|
772
|
+
}
|
|
773
|
+
for (const [userId, peer] of room.roster) {
|
|
774
|
+
if (!next.has(userId))
|
|
775
|
+
left.push(peer);
|
|
776
|
+
}
|
|
777
|
+
room.roster = next;
|
|
778
|
+
room.peerList = peers;
|
|
779
|
+
room.mesh.setPeers(peers);
|
|
780
|
+
for (const peer of joined)
|
|
781
|
+
for (const callback of room.callbacks.join)
|
|
782
|
+
callback(peer);
|
|
783
|
+
for (const peer of left)
|
|
784
|
+
for (const callback of room.callbacks.leave)
|
|
785
|
+
callback(peer);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
case "multiplayer_signals": {
|
|
789
|
+
const room = live;
|
|
790
|
+
if (!room)
|
|
791
|
+
return;
|
|
792
|
+
const signals = data?.signals;
|
|
793
|
+
if (!Array.isArray(signals))
|
|
794
|
+
return;
|
|
795
|
+
for (const signal of signals) {
|
|
796
|
+
if (typeof signal?.fromUserId !== "string" || typeof signal.kind !== "string" || typeof signal.payload !== "string") {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
room.mesh.handleSignal(signal.fromUserId, signal.kind, signal.payload);
|
|
800
|
+
}
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
case "multiplayer_ice_servers": {
|
|
804
|
+
const iceServers = data?.iceServers;
|
|
805
|
+
if (live && isIceServers(iceServers))
|
|
806
|
+
live.mesh.setIceServers(iceServers);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
case "multiplayer_session_ended": {
|
|
810
|
+
const reason = data?.reason ?? "closed";
|
|
811
|
+
if (live)
|
|
812
|
+
endRoom(live, reason);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
case "multiplayer_error": {
|
|
816
|
+
const payload = data;
|
|
817
|
+
const failure = new RealtimeRoomError(payload?.code ?? "join_failed", payload?.message ?? "The room request failed.");
|
|
818
|
+
const waiting = pending;
|
|
819
|
+
pending = null;
|
|
820
|
+
if (waiting) {
|
|
821
|
+
waiting.reject(failure);
|
|
822
|
+
} else {
|
|
823
|
+
reportRoomError(failure);
|
|
824
|
+
}
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
default:
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// src/index.ts
|
|
37
835
|
var ZERO_SAFE_AREA_INSET = {
|
|
38
836
|
top: 0,
|
|
39
837
|
right: 0,
|
|
40
838
|
bottom: 0,
|
|
41
839
|
left: 0
|
|
42
840
|
};
|
|
43
|
-
var SDK_VERSION = "0.
|
|
841
|
+
var SDK_VERSION = "0.10.0";
|
|
44
842
|
|
|
45
843
|
class RemixSDK {
|
|
46
844
|
version = SDK_VERSION;
|
|
@@ -207,7 +1005,12 @@
|
|
|
207
1005
|
}
|
|
208
1006
|
}
|
|
209
1007
|
};
|
|
1008
|
+
realtimeController = createRealtimeController({
|
|
1009
|
+
post: (type, data) => this.sendMessage(type, data)
|
|
1010
|
+
});
|
|
1011
|
+
realtime = this.realtimeController;
|
|
210
1012
|
emit(eventType, data) {
|
|
1013
|
+
this.realtimeController.handleHostEvent(eventType, data);
|
|
211
1014
|
if (eventType === "game_info") {
|
|
212
1015
|
const eventData = data;
|
|
213
1016
|
this._gameInfo = eventData;
|
|
@@ -290,4 +1093,4 @@
|
|
|
290
1093
|
}
|
|
291
1094
|
})();
|
|
292
1095
|
|
|
293
|
-
//# debugId=
|
|
1096
|
+
//# debugId=69C4A9F79EEEBD5964756E2164756E21
|