@carverjs/multiplayer 0.0.1
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/NetworkManager-DrKM2tEx.d.mts +369 -0
- package/dist/NetworkManager-nvVAOr1O.d.ts +369 -0
- package/dist/chunk-3KT73N2S.mjs +655 -0
- package/dist/chunk-3KT73N2S.mjs.map +1 -0
- package/dist/chunk-EO3YNPRQ.mjs +817 -0
- package/dist/chunk-EO3YNPRQ.mjs.map +1 -0
- package/dist/chunk-UD6FDZMX.mjs +581 -0
- package/dist/chunk-UD6FDZMX.mjs.map +1 -0
- package/dist/firebase-CPu87KA0.d.ts +100 -0
- package/dist/firebase-PE6MxGdJ.d.mts +100 -0
- package/dist/index.d.mts +316 -0
- package/dist/index.d.ts +316 -0
- package/dist/index.js +3817 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1743 -0
- package/dist/index.mjs.map +1 -0
- package/dist/strategy.d.mts +7 -0
- package/dist/strategy.d.ts +7 -0
- package/dist/strategy.js +619 -0
- package/dist/strategy.js.map +1 -0
- package/dist/strategy.mjs +11 -0
- package/dist/strategy.mjs.map +1 -0
- package/dist/sync.d.mts +212 -0
- package/dist/sync.d.ts +212 -0
- package/dist/sync.js +845 -0
- package/dist/sync.js.map +1 -0
- package/dist/sync.mjs +11 -0
- package/dist/sync.mjs.map +1 -0
- package/dist/transport.d.mts +159 -0
- package/dist/transport.d.ts +159 -0
- package/dist/transport.js +1274 -0
- package/dist/transport.js.map +1 -0
- package/dist/transport.mjs +19 -0
- package/dist/transport.mjs.map +1 -0
- package/dist/types-5LHBOW08.d.mts +74 -0
- package/dist/types-5LHBOW08.d.ts +74 -0
- package/dist/types.d.mts +2 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/dist/types.mjs +1 -0
- package/dist/types.mjs.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,1274 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/transport/index.ts
|
|
31
|
+
var transport_exports = {};
|
|
32
|
+
__export(transport_exports, {
|
|
33
|
+
FirebaseStrategy: () => FirebaseStrategy,
|
|
34
|
+
MqttStrategy: () => MqttStrategy,
|
|
35
|
+
PeerConnection: () => PeerConnection,
|
|
36
|
+
WebRTCTransport: () => WebRTCTransport,
|
|
37
|
+
buildICEConfig: () => buildICEConfig,
|
|
38
|
+
generatePeerId: () => generatePeerId
|
|
39
|
+
});
|
|
40
|
+
module.exports = __toCommonJS(transport_exports);
|
|
41
|
+
|
|
42
|
+
// src/transport/webrtc/ice.ts
|
|
43
|
+
var DEFAULT_STUN_SERVERS = [
|
|
44
|
+
{ urls: "stun:stun.l.google.com:19302" },
|
|
45
|
+
{ urls: "stun:stun1.l.google.com:19302" },
|
|
46
|
+
{ urls: "stun:stun2.l.google.com:19302" },
|
|
47
|
+
{ urls: "stun:stun.cloudflare.com:3478" }
|
|
48
|
+
];
|
|
49
|
+
function buildICEConfig(options) {
|
|
50
|
+
const servers = options?.iceServers && options.iceServers.length > 0 ? options.iceServers : DEFAULT_STUN_SERVERS;
|
|
51
|
+
return {
|
|
52
|
+
iceServers: servers,
|
|
53
|
+
iceCandidatePoolSize: 10,
|
|
54
|
+
iceTransportPolicy: options?.iceTransportPolicy ?? "all"
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/transport/webrtc/peer.ts
|
|
59
|
+
var PeerConnection = class {
|
|
60
|
+
constructor(peerId, config, events) {
|
|
61
|
+
this._channels = /* @__PURE__ */ new Map();
|
|
62
|
+
this._state = "connecting";
|
|
63
|
+
this._remoteDescriptionSet = false;
|
|
64
|
+
this._pendingCandidates = [];
|
|
65
|
+
this.peerId = peerId;
|
|
66
|
+
this._events = events;
|
|
67
|
+
this._connection = new RTCPeerConnection(config);
|
|
68
|
+
this._connection.onicecandidate = (e) => {
|
|
69
|
+
if (e.candidate) {
|
|
70
|
+
this._events.onIceCandidate(e.candidate);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
this._connection.oniceconnectionstatechange = () => {
|
|
74
|
+
this._updateState();
|
|
75
|
+
};
|
|
76
|
+
this._connection.onconnectionstatechange = () => {
|
|
77
|
+
this._updateState();
|
|
78
|
+
};
|
|
79
|
+
this._connection.ondatachannel = (e) => {
|
|
80
|
+
const channel = e.channel;
|
|
81
|
+
this._channels.set(channel.label, channel);
|
|
82
|
+
this._events.onDataChannel(channel);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
get state() {
|
|
86
|
+
return this._state;
|
|
87
|
+
}
|
|
88
|
+
get connection() {
|
|
89
|
+
return this._connection;
|
|
90
|
+
}
|
|
91
|
+
_updateState() {
|
|
92
|
+
const iceState = this._connection.iceConnectionState;
|
|
93
|
+
const connState = this._connection.connectionState;
|
|
94
|
+
let newState;
|
|
95
|
+
if (connState === "connected" || iceState === "connected") {
|
|
96
|
+
newState = "connected";
|
|
97
|
+
} else if (connState === "failed" || iceState === "failed") {
|
|
98
|
+
newState = "failed";
|
|
99
|
+
} else if (connState === "closed" || iceState === "closed" || iceState === "disconnected") {
|
|
100
|
+
newState = "disconnected";
|
|
101
|
+
} else {
|
|
102
|
+
newState = "connecting";
|
|
103
|
+
}
|
|
104
|
+
if (newState !== this._state) {
|
|
105
|
+
this._state = newState;
|
|
106
|
+
this._events.onStateChange(newState);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async createOffer() {
|
|
110
|
+
const offer = await this._connection.createOffer();
|
|
111
|
+
await this._connection.setLocalDescription(offer);
|
|
112
|
+
return offer;
|
|
113
|
+
}
|
|
114
|
+
async handleOffer(offer) {
|
|
115
|
+
await this._connection.setRemoteDescription(new RTCSessionDescription(offer));
|
|
116
|
+
this._remoteDescriptionSet = true;
|
|
117
|
+
await this._flushPendingCandidates();
|
|
118
|
+
const answer = await this._connection.createAnswer();
|
|
119
|
+
await this._connection.setLocalDescription(answer);
|
|
120
|
+
return answer;
|
|
121
|
+
}
|
|
122
|
+
async handleAnswer(answer) {
|
|
123
|
+
await this._connection.setRemoteDescription(new RTCSessionDescription(answer));
|
|
124
|
+
this._remoteDescriptionSet = true;
|
|
125
|
+
await this._flushPendingCandidates();
|
|
126
|
+
}
|
|
127
|
+
async addIceCandidate(candidate) {
|
|
128
|
+
if (!this._remoteDescriptionSet) {
|
|
129
|
+
this._pendingCandidates.push(candidate);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
await this._connection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
134
|
+
} catch {
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async _flushPendingCandidates() {
|
|
138
|
+
const candidates = this._pendingCandidates;
|
|
139
|
+
this._pendingCandidates = [];
|
|
140
|
+
for (const c of candidates) {
|
|
141
|
+
try {
|
|
142
|
+
await this._connection.addIceCandidate(new RTCIceCandidate(c));
|
|
143
|
+
} catch {
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
createDataChannel(name, options) {
|
|
148
|
+
const existing = this._channels.get(name);
|
|
149
|
+
if (existing && existing.readyState !== "closed") {
|
|
150
|
+
return existing;
|
|
151
|
+
}
|
|
152
|
+
const dcOptions = {};
|
|
153
|
+
if (options?.reliable === false) {
|
|
154
|
+
dcOptions.ordered = options?.ordered ?? false;
|
|
155
|
+
dcOptions.maxRetransmits = options?.maxRetransmits ?? 0;
|
|
156
|
+
} else {
|
|
157
|
+
dcOptions.ordered = options?.ordered ?? true;
|
|
158
|
+
}
|
|
159
|
+
const channel = this._connection.createDataChannel(name, dcOptions);
|
|
160
|
+
this._channels.set(name, channel);
|
|
161
|
+
return channel;
|
|
162
|
+
}
|
|
163
|
+
getDataChannel(name) {
|
|
164
|
+
return this._channels.get(name);
|
|
165
|
+
}
|
|
166
|
+
close() {
|
|
167
|
+
for (const channel of this._channels.values()) {
|
|
168
|
+
try {
|
|
169
|
+
channel.close();
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
this._channels.clear();
|
|
174
|
+
this._pendingCandidates = [];
|
|
175
|
+
this._remoteDescriptionSet = false;
|
|
176
|
+
try {
|
|
177
|
+
this._connection.close();
|
|
178
|
+
} catch {
|
|
179
|
+
}
|
|
180
|
+
this._state = "disconnected";
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// src/transport/webrtc/WebRTCTransport.ts
|
|
185
|
+
var ROOM_CONTROL_CHANNEL = "carver:room-control";
|
|
186
|
+
function electHost(peerIds) {
|
|
187
|
+
return [...peerIds].sort()[0];
|
|
188
|
+
}
|
|
189
|
+
var WebRTCTransport = class {
|
|
190
|
+
/**
|
|
191
|
+
* @param strategy Shared SignalingStrategy instance (managed by MultiplayerProvider)
|
|
192
|
+
* @param iceServers Optional ICE servers (STUN + TURN). Defaults to public STUN.
|
|
193
|
+
* @param iceTransportPolicy 'all' (default) or 'relay' (force TURN only).
|
|
194
|
+
*/
|
|
195
|
+
constructor(strategy, iceServers, iceTransportPolicy) {
|
|
196
|
+
this._peers = /* @__PURE__ */ new Map();
|
|
197
|
+
this._peerSet = /* @__PURE__ */ new Set();
|
|
198
|
+
this._hostId = "";
|
|
199
|
+
this._isHost = false;
|
|
200
|
+
this._callbacks = {
|
|
201
|
+
onPeerJoin: [],
|
|
202
|
+
onPeerLeave: [],
|
|
203
|
+
onPeerUpdated: [],
|
|
204
|
+
onHostChanged: []
|
|
205
|
+
};
|
|
206
|
+
this._roomUpdatedCallbacks = [];
|
|
207
|
+
this._channels = /* @__PURE__ */ new Map();
|
|
208
|
+
this._rateLimitConfig = { maxMessagesPerSecond: 60, windowMs: 1e3 };
|
|
209
|
+
this._rateLimitCounters = /* @__PURE__ */ new Map();
|
|
210
|
+
this._connected = false;
|
|
211
|
+
this._room = null;
|
|
212
|
+
this._playerMap = /* @__PURE__ */ new Map();
|
|
213
|
+
this._initialPeers = [];
|
|
214
|
+
this._strategyUnsubs = [];
|
|
215
|
+
this._strategy = strategy;
|
|
216
|
+
this._peerId = strategy.selfId;
|
|
217
|
+
this._iceConfig = buildICEConfig({ iceServers, iceTransportPolicy });
|
|
218
|
+
}
|
|
219
|
+
// ── CarverTransport getters ──
|
|
220
|
+
get peerId() {
|
|
221
|
+
return this._peerId;
|
|
222
|
+
}
|
|
223
|
+
get peers() {
|
|
224
|
+
return this._peerSet;
|
|
225
|
+
}
|
|
226
|
+
get hostId() {
|
|
227
|
+
return this._hostId;
|
|
228
|
+
}
|
|
229
|
+
get isHost() {
|
|
230
|
+
return this._isHost;
|
|
231
|
+
}
|
|
232
|
+
get room() {
|
|
233
|
+
return this._room ?? void 0;
|
|
234
|
+
}
|
|
235
|
+
get initialPlayers() {
|
|
236
|
+
return this._initialPeers;
|
|
237
|
+
}
|
|
238
|
+
// ── Event registration ──
|
|
239
|
+
onPeerJoin(cb) {
|
|
240
|
+
this._callbacks.onPeerJoin.push(cb);
|
|
241
|
+
}
|
|
242
|
+
onPeerLeave(cb) {
|
|
243
|
+
this._callbacks.onPeerLeave.push(cb);
|
|
244
|
+
}
|
|
245
|
+
onPeerUpdated(cb) {
|
|
246
|
+
this._callbacks.onPeerUpdated.push(cb);
|
|
247
|
+
}
|
|
248
|
+
onRoomUpdated(cb) {
|
|
249
|
+
this._roomUpdatedCallbacks.push(cb);
|
|
250
|
+
}
|
|
251
|
+
onHostChanged(cb) {
|
|
252
|
+
this._callbacks.onHostChanged.push(cb);
|
|
253
|
+
}
|
|
254
|
+
// ── Channel management ──
|
|
255
|
+
createChannel(name, options) {
|
|
256
|
+
const existing = this._channels.get(name);
|
|
257
|
+
if (existing) {
|
|
258
|
+
return {
|
|
259
|
+
send: (data, target) => this._sendOnChannel(name, data, target),
|
|
260
|
+
onReceive: (cb) => {
|
|
261
|
+
existing.receivers.push(cb);
|
|
262
|
+
},
|
|
263
|
+
close: () => {
|
|
264
|
+
this._channels.delete(name);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const state = {
|
|
269
|
+
name,
|
|
270
|
+
options: options ?? { reliable: true, ordered: true },
|
|
271
|
+
receivers: []
|
|
272
|
+
};
|
|
273
|
+
this._channels.set(name, state);
|
|
274
|
+
if (this._connected) {
|
|
275
|
+
for (const peer of this._peers.values()) {
|
|
276
|
+
this._createDataChannelOnPeer(peer, name, state.options);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
send: (data, target) => this._sendOnChannel(name, data, target),
|
|
281
|
+
onReceive: (cb) => {
|
|
282
|
+
state.receivers.push(cb);
|
|
283
|
+
},
|
|
284
|
+
close: () => {
|
|
285
|
+
this._channels.delete(name);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// ── Connect / Disconnect ──
|
|
290
|
+
async connect(roomId, config) {
|
|
291
|
+
if (config?.iceServers) {
|
|
292
|
+
this._iceConfig = buildICEConfig({
|
|
293
|
+
iceServers: config.iceServers,
|
|
294
|
+
iceTransportPolicy: config.iceTransportPolicy
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
this._setupRoomControlChannel();
|
|
298
|
+
this._preRegisterChannel("carver:events", { reliable: true, ordered: true });
|
|
299
|
+
this._preRegisterChannel("carver:snapshots", { reliable: false, ordered: false });
|
|
300
|
+
this._preRegisterChannel("carver:acks", { reliable: true, ordered: true });
|
|
301
|
+
this._preRegisterChannel("carver:inputs", { reliable: true, ordered: true });
|
|
302
|
+
this._preRegisterChannel("carver:network-state", { reliable: true, ordered: true });
|
|
303
|
+
this._strategyUnsubs.push(
|
|
304
|
+
this._strategy.onPeerDiscovered((peerId, meta) => {
|
|
305
|
+
this._onStrategyPeerDiscovered(peerId, meta);
|
|
306
|
+
})
|
|
307
|
+
);
|
|
308
|
+
this._strategyUnsubs.push(
|
|
309
|
+
this._strategy.onPeerLeft((peerId) => {
|
|
310
|
+
this._removePeer(peerId);
|
|
311
|
+
this._playerMap.delete(peerId);
|
|
312
|
+
this._electAndSetHost();
|
|
313
|
+
for (const cb of this._callbacks.onPeerLeave) cb(peerId);
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
this._strategyUnsubs.push(
|
|
317
|
+
this._strategy.onSignal((fromPeerId, data) => {
|
|
318
|
+
this._handleSignal(fromPeerId, data);
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
await this._strategy.joinRoom(roomId, {
|
|
322
|
+
displayName: config?.displayName,
|
|
323
|
+
...config?.playerMetadata ?? {}
|
|
324
|
+
});
|
|
325
|
+
const selfPlayer = {
|
|
326
|
+
peerId: this._peerId,
|
|
327
|
+
displayName: config?.displayName ?? `Player-${this._peerId.slice(0, 4)}`,
|
|
328
|
+
isHost: false,
|
|
329
|
+
isSelf: true,
|
|
330
|
+
isReady: false,
|
|
331
|
+
isConnected: true,
|
|
332
|
+
metadata: config?.playerMetadata ?? {},
|
|
333
|
+
latencyMs: 0,
|
|
334
|
+
joinedAt: Date.now()
|
|
335
|
+
};
|
|
336
|
+
this._playerMap.set(this._peerId, selfPlayer);
|
|
337
|
+
this._electAndSetHost();
|
|
338
|
+
this._room = {
|
|
339
|
+
id: roomId,
|
|
340
|
+
name: roomId,
|
|
341
|
+
hostId: this._hostId,
|
|
342
|
+
playerCount: this._playerMap.size,
|
|
343
|
+
maxPlayers: config?.maxPlayers ?? 8,
|
|
344
|
+
isPrivate: false,
|
|
345
|
+
metadata: {},
|
|
346
|
+
createdAt: Date.now(),
|
|
347
|
+
state: "lobby"
|
|
348
|
+
};
|
|
349
|
+
this._initialPeers = Array.from(this._playerMap.values());
|
|
350
|
+
this._connected = true;
|
|
351
|
+
}
|
|
352
|
+
disconnect() {
|
|
353
|
+
this._connected = false;
|
|
354
|
+
for (const unsub of this._strategyUnsubs) unsub();
|
|
355
|
+
this._strategyUnsubs = [];
|
|
356
|
+
for (const peer of this._peers.values()) peer.close();
|
|
357
|
+
this._peers.clear();
|
|
358
|
+
this._peerSet.clear();
|
|
359
|
+
this._channels.clear();
|
|
360
|
+
this._rateLimitCounters.clear();
|
|
361
|
+
this._playerMap.clear();
|
|
362
|
+
this._strategy.leaveRoom().catch(() => {
|
|
363
|
+
});
|
|
364
|
+
this._hostId = "";
|
|
365
|
+
this._isHost = false;
|
|
366
|
+
this._room = null;
|
|
367
|
+
}
|
|
368
|
+
/** Expose strategy for lobby hooks */
|
|
369
|
+
get strategy() {
|
|
370
|
+
return this._strategy;
|
|
371
|
+
}
|
|
372
|
+
// ── Channel pre-registration ──
|
|
373
|
+
/**
|
|
374
|
+
* Register a channel name and options without creating data channels yet.
|
|
375
|
+
* When _connectToPeer runs, it iterates this._channels and creates data
|
|
376
|
+
* channels for every registered name in the initial WebRTC offer.
|
|
377
|
+
* Later, when EventSync/SnapshotSync call createChannel(), the idempotent
|
|
378
|
+
* check returns the pre-registered entry and they just attach receivers.
|
|
379
|
+
*/
|
|
380
|
+
_preRegisterChannel(name, options) {
|
|
381
|
+
if (this._channels.has(name)) return;
|
|
382
|
+
this._channels.set(name, { name, options, receivers: [] });
|
|
383
|
+
}
|
|
384
|
+
// ── Room management (over WebRTC data channels) ──
|
|
385
|
+
setReady(ready) {
|
|
386
|
+
this._sendControlMessage({ type: "request-ready", ready });
|
|
387
|
+
}
|
|
388
|
+
setMetadata(metadata) {
|
|
389
|
+
this._sendControlMessage({ type: "request-metadata", metadata });
|
|
390
|
+
}
|
|
391
|
+
setRoomMetadata(metadata) {
|
|
392
|
+
if (!this._isHost) return;
|
|
393
|
+
this._sendControlMessage({ type: "request-room-metadata", metadata });
|
|
394
|
+
}
|
|
395
|
+
kick(peerId, reason) {
|
|
396
|
+
if (!this._isHost) return;
|
|
397
|
+
this._broadcastControlMessage({ type: "kick", peerId, reason });
|
|
398
|
+
}
|
|
399
|
+
transferHost(peerId) {
|
|
400
|
+
if (!this._isHost) return;
|
|
401
|
+
this._sendControlMessage({ type: "request-transfer-host", peerId });
|
|
402
|
+
}
|
|
403
|
+
setRoomState(state) {
|
|
404
|
+
if (!this._isHost) return;
|
|
405
|
+
this._sendControlMessage({ type: "request-room-state", state });
|
|
406
|
+
}
|
|
407
|
+
setMaxPlayers(n) {
|
|
408
|
+
if (!this._isHost) return;
|
|
409
|
+
this._sendControlMessage({ type: "request-max-players", maxPlayers: n });
|
|
410
|
+
}
|
|
411
|
+
lockRoom() {
|
|
412
|
+
if (!this._isHost) return;
|
|
413
|
+
this._sendControlMessage({ type: "request-lock" });
|
|
414
|
+
}
|
|
415
|
+
unlockRoom() {
|
|
416
|
+
if (!this._isHost) return;
|
|
417
|
+
this._sendControlMessage({ type: "request-unlock" });
|
|
418
|
+
}
|
|
419
|
+
/** No-op: lobby uses strategy.subscribeToLobby() directly */
|
|
420
|
+
requestRoomList() {
|
|
421
|
+
}
|
|
422
|
+
// ── Private: Strategy callbacks ──
|
|
423
|
+
_onStrategyPeerDiscovered(peerId, meta) {
|
|
424
|
+
this._connectToPeer(peerId);
|
|
425
|
+
this._peerSet.add(peerId);
|
|
426
|
+
const player = {
|
|
427
|
+
peerId,
|
|
428
|
+
displayName: meta.displayName ?? `Player-${peerId.slice(0, 4)}`,
|
|
429
|
+
isHost: false,
|
|
430
|
+
isSelf: false,
|
|
431
|
+
isReady: false,
|
|
432
|
+
isConnected: true,
|
|
433
|
+
metadata: meta,
|
|
434
|
+
latencyMs: 0,
|
|
435
|
+
joinedAt: Date.now()
|
|
436
|
+
};
|
|
437
|
+
this._playerMap.set(peerId, player);
|
|
438
|
+
this._electAndSetHost();
|
|
439
|
+
for (const cb of this._callbacks.onPeerJoin) cb(peerId);
|
|
440
|
+
for (const cb of this._callbacks.onPeerUpdated) cb(player);
|
|
441
|
+
}
|
|
442
|
+
// ── Private: Room control channel ──
|
|
443
|
+
_setupRoomControlChannel() {
|
|
444
|
+
const ch = this.createChannel(ROOM_CONTROL_CHANNEL, {
|
|
445
|
+
reliable: true,
|
|
446
|
+
ordered: true
|
|
447
|
+
});
|
|
448
|
+
ch.onReceive((msg, peerId) => {
|
|
449
|
+
this._handleControlMessage(msg, peerId);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
_handleControlMessage(msg, fromPeerId) {
|
|
453
|
+
switch (msg.type) {
|
|
454
|
+
case "player-updated": {
|
|
455
|
+
this._playerMap.set(msg.player.peerId, msg.player);
|
|
456
|
+
for (const cb of this._callbacks.onPeerUpdated) cb(msg.player);
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
case "room-updated": {
|
|
460
|
+
if (this._room) {
|
|
461
|
+
Object.assign(this._room, msg.room);
|
|
462
|
+
for (const cb of this._roomUpdatedCallbacks) cb(this._room);
|
|
463
|
+
}
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
case "kick": {
|
|
467
|
+
if (msg.peerId === this._peerId) {
|
|
468
|
+
this.disconnect();
|
|
469
|
+
}
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
case "host-changed": {
|
|
473
|
+
this._hostId = msg.newHostId;
|
|
474
|
+
this._isHost = msg.newHostId === this._peerId;
|
|
475
|
+
for (const cb of this._callbacks.onHostChanged) cb(msg.newHostId);
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
case "sync-state": {
|
|
479
|
+
this._room = msg.room;
|
|
480
|
+
for (const p of msg.players) {
|
|
481
|
+
this._playerMap.set(p.peerId, { ...p, isSelf: p.peerId === this._peerId });
|
|
482
|
+
for (const cb of this._callbacks.onPeerUpdated) cb(p);
|
|
483
|
+
}
|
|
484
|
+
for (const cb of this._roomUpdatedCallbacks) cb(msg.room);
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
// Host processes requests from peers
|
|
488
|
+
case "request-ready": {
|
|
489
|
+
if (!this._isHost) break;
|
|
490
|
+
const p = this._playerMap.get(fromPeerId);
|
|
491
|
+
if (p) {
|
|
492
|
+
p.isReady = msg.ready;
|
|
493
|
+
this._broadcastControlMessage({ type: "player-updated", player: p });
|
|
494
|
+
}
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
case "request-metadata": {
|
|
498
|
+
if (!this._isHost) break;
|
|
499
|
+
const pm = this._playerMap.get(fromPeerId);
|
|
500
|
+
if (pm) {
|
|
501
|
+
pm.metadata = { ...pm.metadata, ...msg.metadata };
|
|
502
|
+
this._broadcastControlMessage({ type: "player-updated", player: pm });
|
|
503
|
+
}
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
case "request-room-metadata": {
|
|
507
|
+
if (!this._isHost || !this._room) break;
|
|
508
|
+
this._room.metadata = { ...this._room.metadata, ...msg.metadata };
|
|
509
|
+
this._broadcastControlMessage({ type: "room-updated", room: this._room });
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
case "request-room-state": {
|
|
513
|
+
if (!this._isHost || !this._room) break;
|
|
514
|
+
this._room.state = msg.state;
|
|
515
|
+
this._broadcastControlMessage({ type: "room-updated", room: this._room });
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
case "request-max-players": {
|
|
519
|
+
if (!this._isHost || !this._room) break;
|
|
520
|
+
this._room.maxPlayers = msg.maxPlayers;
|
|
521
|
+
this._broadcastControlMessage({ type: "room-updated", room: this._room });
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
case "request-lock": {
|
|
525
|
+
if (!this._isHost || !this._room) break;
|
|
526
|
+
this._room.locked = true;
|
|
527
|
+
this._broadcastControlMessage({ type: "room-updated", room: this._room });
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
case "request-unlock": {
|
|
531
|
+
if (!this._isHost || !this._room) break;
|
|
532
|
+
this._room.locked = false;
|
|
533
|
+
this._broadcastControlMessage({ type: "room-updated", room: this._room });
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
case "request-transfer-host": {
|
|
537
|
+
if (!this._isHost) break;
|
|
538
|
+
this._hostId = msg.peerId;
|
|
539
|
+
this._isHost = false;
|
|
540
|
+
this._broadcastControlMessage({ type: "host-changed", newHostId: msg.peerId });
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
_sendControlMessage(msg) {
|
|
546
|
+
if (this._isHost && msg.type.startsWith("request-")) {
|
|
547
|
+
this._handleControlMessage(msg, this._peerId);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (this._hostId && this._hostId !== this._peerId) {
|
|
551
|
+
this._sendOnChannel(ROOM_CONTROL_CHANNEL, msg, this._hostId);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
_broadcastControlMessage(msg) {
|
|
555
|
+
this._sendOnChannel(ROOM_CONTROL_CHANNEL, msg);
|
|
556
|
+
this._handleControlMessage(msg, this._peerId);
|
|
557
|
+
}
|
|
558
|
+
// ── Private: Host election ──
|
|
559
|
+
_electAndSetHost() {
|
|
560
|
+
const allIds = [this._peerId, ...this._peerSet];
|
|
561
|
+
const newHostId = electHost(allIds);
|
|
562
|
+
const changed = newHostId !== this._hostId;
|
|
563
|
+
this._hostId = newHostId;
|
|
564
|
+
this._isHost = newHostId === this._peerId;
|
|
565
|
+
for (const [id, p] of this._playerMap) {
|
|
566
|
+
p.isHost = id === newHostId;
|
|
567
|
+
}
|
|
568
|
+
if (this._room) {
|
|
569
|
+
this._room.hostId = newHostId;
|
|
570
|
+
this._room.playerCount = this._playerMap.size;
|
|
571
|
+
}
|
|
572
|
+
if (changed) {
|
|
573
|
+
for (const cb of this._callbacks.onHostChanged) cb(newHostId);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// ── Private: WebRTC peer management ──
|
|
577
|
+
_connectToPeer(peerId) {
|
|
578
|
+
if (this._peers.has(peerId)) return;
|
|
579
|
+
const peer = new PeerConnection(peerId, this._iceConfig, {
|
|
580
|
+
onStateChange: (state) => {
|
|
581
|
+
if (state === "connected" && this._isHost && this._room) {
|
|
582
|
+
const syncMsg = {
|
|
583
|
+
type: "sync-state",
|
|
584
|
+
room: this._room,
|
|
585
|
+
players: Array.from(this._playerMap.values())
|
|
586
|
+
};
|
|
587
|
+
setTimeout(() => {
|
|
588
|
+
this._sendOnChannel(ROOM_CONTROL_CHANNEL, syncMsg, peerId);
|
|
589
|
+
}, 100);
|
|
590
|
+
}
|
|
591
|
+
if (state === "failed" || state === "disconnected") {
|
|
592
|
+
this._removePeer(peerId);
|
|
593
|
+
this._playerMap.delete(peerId);
|
|
594
|
+
this._electAndSetHost();
|
|
595
|
+
for (const cb of this._callbacks.onPeerLeave) cb(peerId);
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
onDataChannel: (channel) => {
|
|
599
|
+
this._setupDataChannelReceiver(channel, peerId);
|
|
600
|
+
},
|
|
601
|
+
onIceCandidate: (candidate) => {
|
|
602
|
+
this._strategy.signal(peerId, { type: "ice-candidate", candidate: candidate.toJSON() });
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
this._peers.set(peerId, peer);
|
|
606
|
+
this._peerSet.add(peerId);
|
|
607
|
+
if (this._peerId < peerId) {
|
|
608
|
+
for (const [name, state] of this._channels) {
|
|
609
|
+
this._createDataChannelOnPeer(peer, name, state.options);
|
|
610
|
+
}
|
|
611
|
+
peer.createOffer().then((offer) => {
|
|
612
|
+
this._strategy.signal(peerId, { type: "offer", sdp: offer });
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async _handleSignal(peerId, data) {
|
|
617
|
+
try {
|
|
618
|
+
const signal = data;
|
|
619
|
+
let peer = this._peers.get(peerId);
|
|
620
|
+
if (signal.type === "offer") {
|
|
621
|
+
if (!peer) {
|
|
622
|
+
this._connectToPeer(peerId);
|
|
623
|
+
peer = this._peers.get(peerId);
|
|
624
|
+
}
|
|
625
|
+
const answer = await peer.handleOffer(signal.sdp);
|
|
626
|
+
this._strategy.signal(peerId, { type: "answer", sdp: answer });
|
|
627
|
+
} else if (signal.type === "answer" && peer) {
|
|
628
|
+
await peer.handleAnswer(signal.sdp);
|
|
629
|
+
} else if (signal.type === "ice-candidate" && peer) {
|
|
630
|
+
await peer.addIceCandidate(signal.candidate);
|
|
631
|
+
}
|
|
632
|
+
} catch (err) {
|
|
633
|
+
if (typeof console !== "undefined") {
|
|
634
|
+
console.error("[CarverJS] Signal handling failed:", err);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// ── Private: Data channel helpers ──
|
|
639
|
+
_createDataChannelOnPeer(peer, name, options) {
|
|
640
|
+
const channel = peer.createDataChannel(name, options);
|
|
641
|
+
this._setupDataChannelReceiver(channel, peer.peerId);
|
|
642
|
+
}
|
|
643
|
+
_setupDataChannelReceiver(dataChannel, peerId) {
|
|
644
|
+
const channelName = dataChannel.label;
|
|
645
|
+
dataChannel.onmessage = (event) => {
|
|
646
|
+
if (!this._checkRateLimit(peerId)) return;
|
|
647
|
+
const channelState = this._channels.get(channelName);
|
|
648
|
+
if (!channelState) return;
|
|
649
|
+
try {
|
|
650
|
+
const data = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
|
|
651
|
+
for (const receiver of channelState.receivers) receiver(data, peerId);
|
|
652
|
+
} catch {
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
_sendOnChannel(channelName, data, target) {
|
|
657
|
+
const serialized = typeof data === "object" && data !== null && !(data instanceof ArrayBuffer) && !(data instanceof Uint8Array) ? JSON.stringify(data) : data;
|
|
658
|
+
const targets = target ? Array.isArray(target) ? target : [target] : Array.from(this._peers.keys());
|
|
659
|
+
for (const pid of targets) {
|
|
660
|
+
const peer = this._peers.get(pid);
|
|
661
|
+
const ch = peer?.getDataChannel(channelName);
|
|
662
|
+
if (ch?.readyState === "open") {
|
|
663
|
+
try {
|
|
664
|
+
ch.send(serialized);
|
|
665
|
+
} catch {
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
_removePeer(peerId) {
|
|
671
|
+
const peer = this._peers.get(peerId);
|
|
672
|
+
if (peer) {
|
|
673
|
+
peer.close();
|
|
674
|
+
this._peers.delete(peerId);
|
|
675
|
+
}
|
|
676
|
+
this._peerSet.delete(peerId);
|
|
677
|
+
this._rateLimitCounters.delete(peerId);
|
|
678
|
+
}
|
|
679
|
+
_checkRateLimit(peerId) {
|
|
680
|
+
const now = Date.now();
|
|
681
|
+
let c = this._rateLimitCounters.get(peerId);
|
|
682
|
+
if (!c || now >= c.resetAt) {
|
|
683
|
+
c = { count: 0, resetAt: now + this._rateLimitConfig.windowMs };
|
|
684
|
+
this._rateLimitCounters.set(peerId, c);
|
|
685
|
+
}
|
|
686
|
+
c.count++;
|
|
687
|
+
return c.count <= this._rateLimitConfig.maxMessagesPerSecond;
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
// src/transport/strategy/utils.ts
|
|
692
|
+
function generatePeerId() {
|
|
693
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
694
|
+
const bytes = new Uint8Array(20);
|
|
695
|
+
crypto.getRandomValues(bytes);
|
|
696
|
+
let id = "";
|
|
697
|
+
for (let i = 0; i < 20; i++) {
|
|
698
|
+
id += chars[bytes[i] % chars.length];
|
|
699
|
+
}
|
|
700
|
+
return id;
|
|
701
|
+
}
|
|
702
|
+
function mqttTopics(appId, roomId, peerId) {
|
|
703
|
+
const base = `carver/${appId}`;
|
|
704
|
+
return {
|
|
705
|
+
/** Lobby wildcard: subscribe to discover all room announcements */
|
|
706
|
+
lobbyWildcard: `${base}/lobby/+`,
|
|
707
|
+
/** Single room lobby entry */
|
|
708
|
+
roomLobbyEntry: `${base}/lobby/${roomId}`,
|
|
709
|
+
/** Wildcard for all peer presence in a room */
|
|
710
|
+
roomPresenceWildcard: `${base}/room/${roomId}/presence/+`,
|
|
711
|
+
/** This peer's presence topic */
|
|
712
|
+
peerPresence: peerId ? `${base}/room/${roomId}/presence/${peerId}` : "",
|
|
713
|
+
/** This peer's signal inbox */
|
|
714
|
+
peerSignalInbox: peerId ? `${base}/room/${roomId}/signal/${peerId}` : ""
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
function firebasePaths(appId, roomId, peerId) {
|
|
718
|
+
const base = `${appId}/__carver__`;
|
|
719
|
+
return {
|
|
720
|
+
lobby: `${base}/lobby`,
|
|
721
|
+
roomLobbyEntry: `${base}/lobby/${roomId}`,
|
|
722
|
+
peers: `${base}/rooms/${roomId}/peers`,
|
|
723
|
+
peerPresence: peerId ? `${base}/rooms/${roomId}/peers/${peerId}` : "",
|
|
724
|
+
peerSignalInbox: peerId ? `${base}/rooms/${roomId}/signals/${peerId}` : ""
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
var DEFAULT_MQTT_BROKERS = [
|
|
728
|
+
"wss://broker.emqx.io:8084/mqtt",
|
|
729
|
+
"wss://test.mosquitto.org:8081/mqtt"
|
|
730
|
+
];
|
|
731
|
+
var ROOM_ANNOUNCE_EXPIRY_MS = 3e4;
|
|
732
|
+
var ROOM_ANNOUNCE_INTERVAL_MS = 1e4;
|
|
733
|
+
var PRESENCE_HEARTBEAT_MS = 5e3;
|
|
734
|
+
var PEER_EXPIRY_MS = PRESENCE_HEARTBEAT_MS * 3;
|
|
735
|
+
var PRESENCE_WARMUP_DELAYS_MS = [200, 500, 1500];
|
|
736
|
+
function removeFromArray(arr, item) {
|
|
737
|
+
const idx = arr.indexOf(item);
|
|
738
|
+
if (idx >= 0) {
|
|
739
|
+
arr.splice(idx, 1);
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// src/transport/strategy/mqtt.ts
|
|
746
|
+
var MqttStrategy = class {
|
|
747
|
+
constructor(appId, config = { type: "mqtt" }) {
|
|
748
|
+
this._client = null;
|
|
749
|
+
this._roomId = null;
|
|
750
|
+
this._peerMeta = {};
|
|
751
|
+
/** Monotonic counter to detect stale leaveRoom completions */
|
|
752
|
+
this._joinGeneration = 0;
|
|
753
|
+
// Lazy init
|
|
754
|
+
this._initPromise = null;
|
|
755
|
+
// Callbacks
|
|
756
|
+
this._onPeerDiscovered = [];
|
|
757
|
+
this._onPeerLeft = [];
|
|
758
|
+
this._onSignal = [];
|
|
759
|
+
this._onLobby = [];
|
|
760
|
+
// State
|
|
761
|
+
this._knownPeers = /* @__PURE__ */ new Map();
|
|
762
|
+
this._lobbyRooms = /* @__PURE__ */ new Map();
|
|
763
|
+
this._presenceTimer = null;
|
|
764
|
+
this._warmupTimers = [];
|
|
765
|
+
this._lobbyAnnounceTimer = null;
|
|
766
|
+
this._peerExpiryTimer = null;
|
|
767
|
+
this._lobbySubscribed = false;
|
|
768
|
+
this._destroyed = false;
|
|
769
|
+
this.selfId = generatePeerId();
|
|
770
|
+
this._appId = appId;
|
|
771
|
+
this._config = config;
|
|
772
|
+
}
|
|
773
|
+
// ── Public API ──
|
|
774
|
+
async init() {
|
|
775
|
+
return this._ensureInit();
|
|
776
|
+
}
|
|
777
|
+
async joinRoom(roomId, peerMeta) {
|
|
778
|
+
await this._ensureInit();
|
|
779
|
+
if (!this._client) throw new Error("MQTT client not available");
|
|
780
|
+
this._joinGeneration++;
|
|
781
|
+
this._roomId = roomId;
|
|
782
|
+
this._peerMeta = peerMeta;
|
|
783
|
+
const topics = mqttTopics(this._appId, roomId, this.selfId);
|
|
784
|
+
await new Promise((resolve, reject) => {
|
|
785
|
+
this._client.subscribe(
|
|
786
|
+
[topics.roomPresenceWildcard, topics.peerSignalInbox],
|
|
787
|
+
{ qos: 1 },
|
|
788
|
+
(err) => err ? reject(err) : resolve()
|
|
789
|
+
);
|
|
790
|
+
});
|
|
791
|
+
this._publishPresence();
|
|
792
|
+
for (const delay of PRESENCE_WARMUP_DELAYS_MS) {
|
|
793
|
+
this._warmupTimers.push(setTimeout(() => this._publishPresence(), delay));
|
|
794
|
+
}
|
|
795
|
+
this._presenceTimer = setInterval(() => this._publishPresence(), PRESENCE_HEARTBEAT_MS);
|
|
796
|
+
this._peerExpiryTimer = setInterval(() => this._checkPeerExpiry(), PRESENCE_HEARTBEAT_MS);
|
|
797
|
+
}
|
|
798
|
+
async leaveRoom() {
|
|
799
|
+
if (!this._client || !this._roomId) return;
|
|
800
|
+
const generation = this._joinGeneration;
|
|
801
|
+
const topics = mqttTopics(this._appId, this._roomId, this.selfId);
|
|
802
|
+
this._clearRoomTimers();
|
|
803
|
+
this._client.publish(topics.peerPresence, "", { retain: true, qos: 1 });
|
|
804
|
+
this._client.unsubscribe([topics.roomPresenceWildcard, topics.peerSignalInbox]);
|
|
805
|
+
this._knownPeers.clear();
|
|
806
|
+
if (this._joinGeneration === generation) {
|
|
807
|
+
this._roomId = null;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
signal(targetPeerId, data) {
|
|
811
|
+
if (!this._client || !this._roomId) return;
|
|
812
|
+
const targetTopic = `carver/${this._appId}/room/${this._roomId}/signal/${targetPeerId}`;
|
|
813
|
+
this._client.publish(
|
|
814
|
+
targetTopic,
|
|
815
|
+
JSON.stringify({ from: this.selfId, data, ts: Date.now() }),
|
|
816
|
+
{ qos: 1 }
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
subscribeToLobby(cb) {
|
|
820
|
+
this._onLobby.push(cb);
|
|
821
|
+
if (!this._lobbySubscribed) {
|
|
822
|
+
this._lobbySubscribed = true;
|
|
823
|
+
this._ensureInit().then(() => {
|
|
824
|
+
if (this._client && !this._destroyed) {
|
|
825
|
+
const lobbyTopic = mqttTopics(this._appId, "", "").lobbyWildcard;
|
|
826
|
+
this._client.subscribe(lobbyTopic, { qos: 0 });
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
return () => {
|
|
831
|
+
removeFromArray(this._onLobby, cb);
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
announceRoom(announcement) {
|
|
835
|
+
if (!this._client) return;
|
|
836
|
+
const topic = mqttTopics(this._appId, announcement.roomId, "").roomLobbyEntry;
|
|
837
|
+
announcement.lastSeen = Date.now();
|
|
838
|
+
this._client.publish(topic, JSON.stringify(announcement), { retain: true, qos: 1 });
|
|
839
|
+
if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
|
|
840
|
+
this._lobbyAnnounceTimer = setInterval(() => {
|
|
841
|
+
announcement.lastSeen = Date.now();
|
|
842
|
+
this._client?.publish(topic, JSON.stringify(announcement), { retain: true, qos: 1 });
|
|
843
|
+
}, ROOM_ANNOUNCE_INTERVAL_MS);
|
|
844
|
+
}
|
|
845
|
+
removeRoomAnnouncement(roomId) {
|
|
846
|
+
if (!this._client) return;
|
|
847
|
+
const topic = mqttTopics(this._appId, roomId, "").roomLobbyEntry;
|
|
848
|
+
this._client.publish(topic, "", { retain: true, qos: 1 });
|
|
849
|
+
if (this._lobbyAnnounceTimer) {
|
|
850
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
851
|
+
this._lobbyAnnounceTimer = null;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
onPeerDiscovered(cb) {
|
|
855
|
+
this._onPeerDiscovered.push(cb);
|
|
856
|
+
return () => {
|
|
857
|
+
removeFromArray(this._onPeerDiscovered, cb);
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
onPeerLeft(cb) {
|
|
861
|
+
this._onPeerLeft.push(cb);
|
|
862
|
+
return () => {
|
|
863
|
+
removeFromArray(this._onPeerLeft, cb);
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
onSignal(cb) {
|
|
867
|
+
this._onSignal.push(cb);
|
|
868
|
+
return () => {
|
|
869
|
+
removeFromArray(this._onSignal, cb);
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
destroy() {
|
|
873
|
+
this._destroyed = true;
|
|
874
|
+
this._clearRoomTimers();
|
|
875
|
+
if (this._client && this._roomId) {
|
|
876
|
+
const topics = mqttTopics(this._appId, this._roomId, this.selfId);
|
|
877
|
+
this._client.publish(topics.peerPresence, "", { retain: true, qos: 1 });
|
|
878
|
+
}
|
|
879
|
+
this._client?.end(true);
|
|
880
|
+
this._client = null;
|
|
881
|
+
this._knownPeers.clear();
|
|
882
|
+
this._lobbyRooms.clear();
|
|
883
|
+
this._onPeerDiscovered = [];
|
|
884
|
+
this._onPeerLeft = [];
|
|
885
|
+
this._onSignal = [];
|
|
886
|
+
this._onLobby = [];
|
|
887
|
+
}
|
|
888
|
+
// ── Private ──
|
|
889
|
+
_ensureInit() {
|
|
890
|
+
if (!this._initPromise) {
|
|
891
|
+
this._initPromise = this._doInit();
|
|
892
|
+
}
|
|
893
|
+
return this._initPromise;
|
|
894
|
+
}
|
|
895
|
+
async _doInit() {
|
|
896
|
+
const mqtt = await import("mqtt");
|
|
897
|
+
const brokers = this._config.brokerUrls ?? DEFAULT_MQTT_BROKERS;
|
|
898
|
+
const brokerUrl = brokers[Math.floor(Math.random() * brokers.length)];
|
|
899
|
+
return new Promise((resolve, reject) => {
|
|
900
|
+
const connectFn = mqtt.default?.connect ?? mqtt.connect;
|
|
901
|
+
this._client = connectFn(brokerUrl, {
|
|
902
|
+
clientId: `carver_${this.selfId}`,
|
|
903
|
+
clean: true,
|
|
904
|
+
connectTimeout: 1e4,
|
|
905
|
+
keepalive: 30
|
|
906
|
+
});
|
|
907
|
+
this._client.on("connect", () => {
|
|
908
|
+
if (!this._destroyed) resolve();
|
|
909
|
+
});
|
|
910
|
+
this._client.on("error", (err) => {
|
|
911
|
+
if (!this._initPromise) return;
|
|
912
|
+
reject(err);
|
|
913
|
+
});
|
|
914
|
+
this._client.on("message", (topic, payload) => {
|
|
915
|
+
this._handleMessage(topic, payload);
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
_publishPresence() {
|
|
920
|
+
if (!this._client || !this._roomId) return;
|
|
921
|
+
const topics = mqttTopics(this._appId, this._roomId, this.selfId);
|
|
922
|
+
this._client.publish(
|
|
923
|
+
topics.peerPresence,
|
|
924
|
+
JSON.stringify({ peerId: this.selfId, meta: this._peerMeta, ts: Date.now() }),
|
|
925
|
+
{ retain: true, qos: 1 }
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
_handleMessage(topic, payload) {
|
|
929
|
+
const raw = payload.toString();
|
|
930
|
+
const presenceMatch = topic.match(/\/room\/[^/]+\/presence\/([^/]+)$/);
|
|
931
|
+
if (presenceMatch) {
|
|
932
|
+
const peerId = presenceMatch[1];
|
|
933
|
+
if (peerId === this.selfId) return;
|
|
934
|
+
if (!raw) {
|
|
935
|
+
if (this._knownPeers.has(peerId)) {
|
|
936
|
+
this._knownPeers.delete(peerId);
|
|
937
|
+
for (const cb of this._onPeerLeft) cb(peerId);
|
|
938
|
+
}
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
try {
|
|
942
|
+
const msg = JSON.parse(raw);
|
|
943
|
+
const isNew = !this._knownPeers.has(peerId);
|
|
944
|
+
this._knownPeers.set(peerId, { meta: msg.meta ?? {}, lastSeen: msg.ts ?? Date.now() });
|
|
945
|
+
if (isNew) {
|
|
946
|
+
for (const cb of this._onPeerDiscovered) cb(peerId, msg.meta ?? {});
|
|
947
|
+
}
|
|
948
|
+
} catch {
|
|
949
|
+
}
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const signalMatch = topic.match(/\/room\/[^/]+\/signal\/([^/]+)$/);
|
|
953
|
+
if (signalMatch) {
|
|
954
|
+
try {
|
|
955
|
+
const msg = JSON.parse(raw);
|
|
956
|
+
if (msg.from && msg.from !== this.selfId) {
|
|
957
|
+
for (const cb of this._onSignal) cb(msg.from, msg.data);
|
|
958
|
+
}
|
|
959
|
+
} catch {
|
|
960
|
+
}
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const lobbyMatch = topic.match(/\/lobby\/([^/]+)$/);
|
|
964
|
+
if (lobbyMatch) {
|
|
965
|
+
const roomId = lobbyMatch[1];
|
|
966
|
+
if (!raw) {
|
|
967
|
+
this._lobbyRooms.delete(roomId);
|
|
968
|
+
} else {
|
|
969
|
+
try {
|
|
970
|
+
const ann = JSON.parse(raw);
|
|
971
|
+
if (Date.now() - ann.lastSeen < ROOM_ANNOUNCE_EXPIRY_MS) {
|
|
972
|
+
this._lobbyRooms.set(roomId, ann);
|
|
973
|
+
} else {
|
|
974
|
+
this._lobbyRooms.delete(roomId);
|
|
975
|
+
}
|
|
976
|
+
} catch {
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
const rooms = Array.from(this._lobbyRooms.values());
|
|
980
|
+
for (const cb of this._onLobby) cb(rooms);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
_checkPeerExpiry() {
|
|
984
|
+
const now = Date.now();
|
|
985
|
+
for (const [peerId, data] of this._knownPeers) {
|
|
986
|
+
if (now - data.lastSeen > PEER_EXPIRY_MS) {
|
|
987
|
+
this._knownPeers.delete(peerId);
|
|
988
|
+
for (const cb of this._onPeerLeft) cb(peerId);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
_clearRoomTimers() {
|
|
993
|
+
if (this._presenceTimer) {
|
|
994
|
+
clearInterval(this._presenceTimer);
|
|
995
|
+
this._presenceTimer = null;
|
|
996
|
+
}
|
|
997
|
+
for (const t of this._warmupTimers) clearTimeout(t);
|
|
998
|
+
this._warmupTimers = [];
|
|
999
|
+
if (this._lobbyAnnounceTimer) {
|
|
1000
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
1001
|
+
this._lobbyAnnounceTimer = null;
|
|
1002
|
+
}
|
|
1003
|
+
if (this._peerExpiryTimer) {
|
|
1004
|
+
clearInterval(this._peerExpiryTimer);
|
|
1005
|
+
this._peerExpiryTimer = null;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
// src/transport/strategy/firebase.ts
|
|
1011
|
+
var FirebaseStrategy = class {
|
|
1012
|
+
constructor(appId, config) {
|
|
1013
|
+
this._db = null;
|
|
1014
|
+
this._firebaseApp = null;
|
|
1015
|
+
this._ownApp = false;
|
|
1016
|
+
this._roomId = null;
|
|
1017
|
+
this._peerMeta = {};
|
|
1018
|
+
/** Monotonic counter to detect stale leaveRoom completions */
|
|
1019
|
+
this._joinGeneration = 0;
|
|
1020
|
+
// Lazy init
|
|
1021
|
+
this._initPromise = null;
|
|
1022
|
+
// Firebase module references (filled after dynamic import)
|
|
1023
|
+
this._fb = null;
|
|
1024
|
+
// Unsubscribe handles for Firebase listeners
|
|
1025
|
+
this._listeners = [];
|
|
1026
|
+
// Callbacks
|
|
1027
|
+
this._onPeerDiscovered = [];
|
|
1028
|
+
this._onPeerLeft = [];
|
|
1029
|
+
this._onSignal = [];
|
|
1030
|
+
this._onLobby = [];
|
|
1031
|
+
// State
|
|
1032
|
+
this._knownPeers = /* @__PURE__ */ new Set();
|
|
1033
|
+
this._lobbyAnnounceTimer = null;
|
|
1034
|
+
this._destroyed = false;
|
|
1035
|
+
this.selfId = generatePeerId();
|
|
1036
|
+
this._appId = appId;
|
|
1037
|
+
this._config = config;
|
|
1038
|
+
}
|
|
1039
|
+
// ── Public API ──
|
|
1040
|
+
async init() {
|
|
1041
|
+
return this._ensureInit();
|
|
1042
|
+
}
|
|
1043
|
+
async joinRoom(roomId, peerMeta) {
|
|
1044
|
+
await this._ensureInit();
|
|
1045
|
+
if (!this._db || !this._fb) throw new Error("Firebase not initialized");
|
|
1046
|
+
this._joinGeneration++;
|
|
1047
|
+
this._roomId = roomId;
|
|
1048
|
+
this._peerMeta = peerMeta;
|
|
1049
|
+
const { ref, set, onChildAdded, onChildRemoved, onDisconnect, remove } = this._fb;
|
|
1050
|
+
const paths = firebasePaths(this._appId, roomId, this.selfId);
|
|
1051
|
+
await remove(ref(this._db, paths.peerSignalInbox)).catch(() => {
|
|
1052
|
+
});
|
|
1053
|
+
const presenceRef = ref(this._db, paths.peerPresence);
|
|
1054
|
+
await set(presenceRef, {
|
|
1055
|
+
peerId: this.selfId,
|
|
1056
|
+
meta: peerMeta,
|
|
1057
|
+
ts: Date.now()
|
|
1058
|
+
});
|
|
1059
|
+
onDisconnect(presenceRef).remove();
|
|
1060
|
+
const peersRef = ref(this._db, paths.peers);
|
|
1061
|
+
const addedUnsub = onChildAdded(peersRef, (snapshot) => {
|
|
1062
|
+
const data = snapshot.val();
|
|
1063
|
+
if (!data || data.peerId === this.selfId) return;
|
|
1064
|
+
if (!this._knownPeers.has(data.peerId)) {
|
|
1065
|
+
this._knownPeers.add(data.peerId);
|
|
1066
|
+
for (const cb of this._onPeerDiscovered) cb(data.peerId, data.meta ?? {});
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
this._listeners.push(() => addedUnsub());
|
|
1070
|
+
const removedUnsub = onChildRemoved(peersRef, (snapshot) => {
|
|
1071
|
+
const data = snapshot.val();
|
|
1072
|
+
const peerId = data?.peerId ?? snapshot.key;
|
|
1073
|
+
if (peerId && this._knownPeers.has(peerId)) {
|
|
1074
|
+
this._knownPeers.delete(peerId);
|
|
1075
|
+
for (const cb of this._onPeerLeft) cb(peerId);
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
this._listeners.push(() => removedUnsub());
|
|
1079
|
+
const signalRef = ref(this._db, paths.peerSignalInbox);
|
|
1080
|
+
const signalUnsub = onChildAdded(signalRef, (snapshot) => {
|
|
1081
|
+
const msg = snapshot.val();
|
|
1082
|
+
if (!msg || msg.from === this.selfId) return;
|
|
1083
|
+
for (const cb of this._onSignal) cb(msg.from, msg.data);
|
|
1084
|
+
remove(snapshot.ref);
|
|
1085
|
+
});
|
|
1086
|
+
this._listeners.push(() => signalUnsub());
|
|
1087
|
+
}
|
|
1088
|
+
async leaveRoom() {
|
|
1089
|
+
if (!this._db || !this._fb || !this._roomId) return;
|
|
1090
|
+
const leavingRoomId = this._roomId;
|
|
1091
|
+
const generation = this._joinGeneration;
|
|
1092
|
+
const { ref, remove } = this._fb;
|
|
1093
|
+
const paths = firebasePaths(this._appId, leavingRoomId, this.selfId);
|
|
1094
|
+
for (const unsub of this._listeners) unsub();
|
|
1095
|
+
this._listeners = [];
|
|
1096
|
+
await Promise.all([
|
|
1097
|
+
remove(ref(this._db, paths.peerPresence)),
|
|
1098
|
+
remove(ref(this._db, paths.peerSignalInbox))
|
|
1099
|
+
]).catch(() => {
|
|
1100
|
+
});
|
|
1101
|
+
if (this._lobbyAnnounceTimer) {
|
|
1102
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
1103
|
+
this._lobbyAnnounceTimer = null;
|
|
1104
|
+
}
|
|
1105
|
+
this._knownPeers.clear();
|
|
1106
|
+
if (this._joinGeneration === generation) {
|
|
1107
|
+
this._roomId = null;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
signal(targetPeerId, data) {
|
|
1111
|
+
if (!this._db || !this._fb || !this._roomId) return;
|
|
1112
|
+
const { ref, push } = this._fb;
|
|
1113
|
+
const inboxPath = firebasePaths(this._appId, this._roomId, targetPeerId).peerSignalInbox;
|
|
1114
|
+
push(ref(this._db, inboxPath), {
|
|
1115
|
+
from: this.selfId,
|
|
1116
|
+
data: sanitizeForFirebase(data),
|
|
1117
|
+
ts: Date.now()
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
subscribeToLobby(cb) {
|
|
1121
|
+
this._onLobby.push(cb);
|
|
1122
|
+
this._ensureInit().then(() => {
|
|
1123
|
+
if (!this._db || !this._fb || this._destroyed) return;
|
|
1124
|
+
const { ref, onValue } = this._fb;
|
|
1125
|
+
const paths = firebasePaths(this._appId, "", "");
|
|
1126
|
+
const lobbyRef = ref(this._db, paths.lobby);
|
|
1127
|
+
const unsub = onValue(lobbyRef, (snapshot) => {
|
|
1128
|
+
const data = snapshot.val();
|
|
1129
|
+
if (!data) {
|
|
1130
|
+
for (const lcb of this._onLobby) lcb([]);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
const now = Date.now();
|
|
1134
|
+
const rooms = Object.values(data).filter(
|
|
1135
|
+
(r) => r && now - (r.lastSeen ?? 0) < ROOM_ANNOUNCE_EXPIRY_MS
|
|
1136
|
+
);
|
|
1137
|
+
for (const lcb of this._onLobby) lcb(rooms);
|
|
1138
|
+
});
|
|
1139
|
+
this._listeners.push(() => unsub());
|
|
1140
|
+
});
|
|
1141
|
+
return () => {
|
|
1142
|
+
removeFromArray(this._onLobby, cb);
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
announceRoom(announcement) {
|
|
1146
|
+
if (!this._db || !this._fb) return;
|
|
1147
|
+
const { ref, set } = this._fb;
|
|
1148
|
+
const paths = firebasePaths(this._appId, announcement.roomId, "");
|
|
1149
|
+
announcement.lastSeen = Date.now();
|
|
1150
|
+
set(ref(this._db, paths.roomLobbyEntry), announcement);
|
|
1151
|
+
if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
|
|
1152
|
+
this._lobbyAnnounceTimer = setInterval(() => {
|
|
1153
|
+
announcement.lastSeen = Date.now();
|
|
1154
|
+
set(ref(this._db, paths.roomLobbyEntry), announcement);
|
|
1155
|
+
}, ROOM_ANNOUNCE_INTERVAL_MS);
|
|
1156
|
+
}
|
|
1157
|
+
removeRoomAnnouncement(roomId) {
|
|
1158
|
+
if (!this._db || !this._fb) return;
|
|
1159
|
+
const { ref, remove } = this._fb;
|
|
1160
|
+
remove(ref(this._db, firebasePaths(this._appId, roomId, "").roomLobbyEntry));
|
|
1161
|
+
if (this._lobbyAnnounceTimer) {
|
|
1162
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
1163
|
+
this._lobbyAnnounceTimer = null;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
onPeerDiscovered(cb) {
|
|
1167
|
+
this._onPeerDiscovered.push(cb);
|
|
1168
|
+
return () => {
|
|
1169
|
+
removeFromArray(this._onPeerDiscovered, cb);
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
onPeerLeft(cb) {
|
|
1173
|
+
this._onPeerLeft.push(cb);
|
|
1174
|
+
return () => {
|
|
1175
|
+
removeFromArray(this._onPeerLeft, cb);
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
onSignal(cb) {
|
|
1179
|
+
this._onSignal.push(cb);
|
|
1180
|
+
return () => {
|
|
1181
|
+
removeFromArray(this._onSignal, cb);
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
destroy() {
|
|
1185
|
+
this._destroyed = true;
|
|
1186
|
+
for (const unsub of this._listeners) unsub();
|
|
1187
|
+
this._listeners = [];
|
|
1188
|
+
if (this._lobbyAnnounceTimer) {
|
|
1189
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
1190
|
+
this._lobbyAnnounceTimer = null;
|
|
1191
|
+
}
|
|
1192
|
+
if (this._db && this._fb && this._roomId) {
|
|
1193
|
+
const { ref, remove } = this._fb;
|
|
1194
|
+
const paths = firebasePaths(this._appId, this._roomId, this.selfId);
|
|
1195
|
+
remove(ref(this._db, paths.peerPresence)).catch(() => {
|
|
1196
|
+
});
|
|
1197
|
+
remove(ref(this._db, paths.peerSignalInbox)).catch(() => {
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
if (this._ownApp && this._firebaseApp) {
|
|
1201
|
+
import("firebase/app").then(({ deleteApp }) => {
|
|
1202
|
+
deleteApp(this._firebaseApp).catch(() => {
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
this._db = null;
|
|
1207
|
+
this._firebaseApp = null;
|
|
1208
|
+
this._fb = null;
|
|
1209
|
+
this._knownPeers.clear();
|
|
1210
|
+
this._onPeerDiscovered = [];
|
|
1211
|
+
this._onPeerLeft = [];
|
|
1212
|
+
this._onSignal = [];
|
|
1213
|
+
this._onLobby = [];
|
|
1214
|
+
}
|
|
1215
|
+
// ── Private ──
|
|
1216
|
+
_ensureInit() {
|
|
1217
|
+
if (!this._initPromise) {
|
|
1218
|
+
this._initPromise = this._doInit();
|
|
1219
|
+
}
|
|
1220
|
+
return this._initPromise;
|
|
1221
|
+
}
|
|
1222
|
+
async _doInit() {
|
|
1223
|
+
const { initializeApp, getApps } = await import("firebase/app");
|
|
1224
|
+
const {
|
|
1225
|
+
getDatabase,
|
|
1226
|
+
ref,
|
|
1227
|
+
set,
|
|
1228
|
+
push,
|
|
1229
|
+
remove,
|
|
1230
|
+
onValue,
|
|
1231
|
+
onChildAdded,
|
|
1232
|
+
onChildRemoved,
|
|
1233
|
+
onDisconnect
|
|
1234
|
+
} = await import("firebase/database");
|
|
1235
|
+
this._fb = { ref, set, push, remove, onValue, onChildAdded, onChildRemoved, onDisconnect };
|
|
1236
|
+
if (this._config.firebaseApp) {
|
|
1237
|
+
this._firebaseApp = this._config.firebaseApp;
|
|
1238
|
+
this._ownApp = false;
|
|
1239
|
+
} else {
|
|
1240
|
+
const appName = `carver_${this._appId}`;
|
|
1241
|
+
const existing = getApps().find((a) => a.name === appName);
|
|
1242
|
+
if (existing) {
|
|
1243
|
+
this._firebaseApp = existing;
|
|
1244
|
+
this._ownApp = false;
|
|
1245
|
+
} else {
|
|
1246
|
+
this._firebaseApp = initializeApp({ databaseURL: this._config.databaseURL }, appName);
|
|
1247
|
+
this._ownApp = true;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
this._db = getDatabase(this._firebaseApp);
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
function sanitizeForFirebase(obj) {
|
|
1254
|
+
if (obj === null) return "__null__";
|
|
1255
|
+
if (Array.isArray(obj)) return obj.map(sanitizeForFirebase);
|
|
1256
|
+
if (typeof obj === "object" && obj !== null) {
|
|
1257
|
+
const result = {};
|
|
1258
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1259
|
+
result[key] = value === null ? "__null__" : sanitizeForFirebase(value);
|
|
1260
|
+
}
|
|
1261
|
+
return result;
|
|
1262
|
+
}
|
|
1263
|
+
return obj;
|
|
1264
|
+
}
|
|
1265
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1266
|
+
0 && (module.exports = {
|
|
1267
|
+
FirebaseStrategy,
|
|
1268
|
+
MqttStrategy,
|
|
1269
|
+
PeerConnection,
|
|
1270
|
+
WebRTCTransport,
|
|
1271
|
+
buildICEConfig,
|
|
1272
|
+
generatePeerId
|
|
1273
|
+
});
|
|
1274
|
+
//# sourceMappingURL=transport.js.map
|