@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
package/dist/strategy.js
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
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/strategy/index.ts
|
|
31
|
+
var strategy_exports = {};
|
|
32
|
+
__export(strategy_exports, {
|
|
33
|
+
FirebaseStrategy: () => FirebaseStrategy,
|
|
34
|
+
MqttStrategy: () => MqttStrategy,
|
|
35
|
+
generatePeerId: () => generatePeerId
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(strategy_exports);
|
|
38
|
+
|
|
39
|
+
// src/transport/strategy/utils.ts
|
|
40
|
+
function generatePeerId() {
|
|
41
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
42
|
+
const bytes = new Uint8Array(20);
|
|
43
|
+
crypto.getRandomValues(bytes);
|
|
44
|
+
let id = "";
|
|
45
|
+
for (let i = 0; i < 20; i++) {
|
|
46
|
+
id += chars[bytes[i] % chars.length];
|
|
47
|
+
}
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
function mqttTopics(appId, roomId, peerId) {
|
|
51
|
+
const base = `carver/${appId}`;
|
|
52
|
+
return {
|
|
53
|
+
/** Lobby wildcard: subscribe to discover all room announcements */
|
|
54
|
+
lobbyWildcard: `${base}/lobby/+`,
|
|
55
|
+
/** Single room lobby entry */
|
|
56
|
+
roomLobbyEntry: `${base}/lobby/${roomId}`,
|
|
57
|
+
/** Wildcard for all peer presence in a room */
|
|
58
|
+
roomPresenceWildcard: `${base}/room/${roomId}/presence/+`,
|
|
59
|
+
/** This peer's presence topic */
|
|
60
|
+
peerPresence: peerId ? `${base}/room/${roomId}/presence/${peerId}` : "",
|
|
61
|
+
/** This peer's signal inbox */
|
|
62
|
+
peerSignalInbox: peerId ? `${base}/room/${roomId}/signal/${peerId}` : ""
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function firebasePaths(appId, roomId, peerId) {
|
|
66
|
+
const base = `${appId}/__carver__`;
|
|
67
|
+
return {
|
|
68
|
+
lobby: `${base}/lobby`,
|
|
69
|
+
roomLobbyEntry: `${base}/lobby/${roomId}`,
|
|
70
|
+
peers: `${base}/rooms/${roomId}/peers`,
|
|
71
|
+
peerPresence: peerId ? `${base}/rooms/${roomId}/peers/${peerId}` : "",
|
|
72
|
+
peerSignalInbox: peerId ? `${base}/rooms/${roomId}/signals/${peerId}` : ""
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
var DEFAULT_MQTT_BROKERS = [
|
|
76
|
+
"wss://broker.emqx.io:8084/mqtt",
|
|
77
|
+
"wss://test.mosquitto.org:8081/mqtt"
|
|
78
|
+
];
|
|
79
|
+
var ROOM_ANNOUNCE_EXPIRY_MS = 3e4;
|
|
80
|
+
var ROOM_ANNOUNCE_INTERVAL_MS = 1e4;
|
|
81
|
+
var PRESENCE_HEARTBEAT_MS = 5e3;
|
|
82
|
+
var PEER_EXPIRY_MS = PRESENCE_HEARTBEAT_MS * 3;
|
|
83
|
+
var PRESENCE_WARMUP_DELAYS_MS = [200, 500, 1500];
|
|
84
|
+
function removeFromArray(arr, item) {
|
|
85
|
+
const idx = arr.indexOf(item);
|
|
86
|
+
if (idx >= 0) {
|
|
87
|
+
arr.splice(idx, 1);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/transport/strategy/mqtt.ts
|
|
94
|
+
var MqttStrategy = class {
|
|
95
|
+
constructor(appId, config = { type: "mqtt" }) {
|
|
96
|
+
this._client = null;
|
|
97
|
+
this._roomId = null;
|
|
98
|
+
this._peerMeta = {};
|
|
99
|
+
/** Monotonic counter to detect stale leaveRoom completions */
|
|
100
|
+
this._joinGeneration = 0;
|
|
101
|
+
// Lazy init
|
|
102
|
+
this._initPromise = null;
|
|
103
|
+
// Callbacks
|
|
104
|
+
this._onPeerDiscovered = [];
|
|
105
|
+
this._onPeerLeft = [];
|
|
106
|
+
this._onSignal = [];
|
|
107
|
+
this._onLobby = [];
|
|
108
|
+
// State
|
|
109
|
+
this._knownPeers = /* @__PURE__ */ new Map();
|
|
110
|
+
this._lobbyRooms = /* @__PURE__ */ new Map();
|
|
111
|
+
this._presenceTimer = null;
|
|
112
|
+
this._warmupTimers = [];
|
|
113
|
+
this._lobbyAnnounceTimer = null;
|
|
114
|
+
this._peerExpiryTimer = null;
|
|
115
|
+
this._lobbySubscribed = false;
|
|
116
|
+
this._destroyed = false;
|
|
117
|
+
this.selfId = generatePeerId();
|
|
118
|
+
this._appId = appId;
|
|
119
|
+
this._config = config;
|
|
120
|
+
}
|
|
121
|
+
// ── Public API ──
|
|
122
|
+
async init() {
|
|
123
|
+
return this._ensureInit();
|
|
124
|
+
}
|
|
125
|
+
async joinRoom(roomId, peerMeta) {
|
|
126
|
+
await this._ensureInit();
|
|
127
|
+
if (!this._client) throw new Error("MQTT client not available");
|
|
128
|
+
this._joinGeneration++;
|
|
129
|
+
this._roomId = roomId;
|
|
130
|
+
this._peerMeta = peerMeta;
|
|
131
|
+
const topics = mqttTopics(this._appId, roomId, this.selfId);
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
this._client.subscribe(
|
|
134
|
+
[topics.roomPresenceWildcard, topics.peerSignalInbox],
|
|
135
|
+
{ qos: 1 },
|
|
136
|
+
(err) => err ? reject(err) : resolve()
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
this._publishPresence();
|
|
140
|
+
for (const delay of PRESENCE_WARMUP_DELAYS_MS) {
|
|
141
|
+
this._warmupTimers.push(setTimeout(() => this._publishPresence(), delay));
|
|
142
|
+
}
|
|
143
|
+
this._presenceTimer = setInterval(() => this._publishPresence(), PRESENCE_HEARTBEAT_MS);
|
|
144
|
+
this._peerExpiryTimer = setInterval(() => this._checkPeerExpiry(), PRESENCE_HEARTBEAT_MS);
|
|
145
|
+
}
|
|
146
|
+
async leaveRoom() {
|
|
147
|
+
if (!this._client || !this._roomId) return;
|
|
148
|
+
const generation = this._joinGeneration;
|
|
149
|
+
const topics = mqttTopics(this._appId, this._roomId, this.selfId);
|
|
150
|
+
this._clearRoomTimers();
|
|
151
|
+
this._client.publish(topics.peerPresence, "", { retain: true, qos: 1 });
|
|
152
|
+
this._client.unsubscribe([topics.roomPresenceWildcard, topics.peerSignalInbox]);
|
|
153
|
+
this._knownPeers.clear();
|
|
154
|
+
if (this._joinGeneration === generation) {
|
|
155
|
+
this._roomId = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
signal(targetPeerId, data) {
|
|
159
|
+
if (!this._client || !this._roomId) return;
|
|
160
|
+
const targetTopic = `carver/${this._appId}/room/${this._roomId}/signal/${targetPeerId}`;
|
|
161
|
+
this._client.publish(
|
|
162
|
+
targetTopic,
|
|
163
|
+
JSON.stringify({ from: this.selfId, data, ts: Date.now() }),
|
|
164
|
+
{ qos: 1 }
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
subscribeToLobby(cb) {
|
|
168
|
+
this._onLobby.push(cb);
|
|
169
|
+
if (!this._lobbySubscribed) {
|
|
170
|
+
this._lobbySubscribed = true;
|
|
171
|
+
this._ensureInit().then(() => {
|
|
172
|
+
if (this._client && !this._destroyed) {
|
|
173
|
+
const lobbyTopic = mqttTopics(this._appId, "", "").lobbyWildcard;
|
|
174
|
+
this._client.subscribe(lobbyTopic, { qos: 0 });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return () => {
|
|
179
|
+
removeFromArray(this._onLobby, cb);
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
announceRoom(announcement) {
|
|
183
|
+
if (!this._client) return;
|
|
184
|
+
const topic = mqttTopics(this._appId, announcement.roomId, "").roomLobbyEntry;
|
|
185
|
+
announcement.lastSeen = Date.now();
|
|
186
|
+
this._client.publish(topic, JSON.stringify(announcement), { retain: true, qos: 1 });
|
|
187
|
+
if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
|
|
188
|
+
this._lobbyAnnounceTimer = setInterval(() => {
|
|
189
|
+
announcement.lastSeen = Date.now();
|
|
190
|
+
this._client?.publish(topic, JSON.stringify(announcement), { retain: true, qos: 1 });
|
|
191
|
+
}, ROOM_ANNOUNCE_INTERVAL_MS);
|
|
192
|
+
}
|
|
193
|
+
removeRoomAnnouncement(roomId) {
|
|
194
|
+
if (!this._client) return;
|
|
195
|
+
const topic = mqttTopics(this._appId, roomId, "").roomLobbyEntry;
|
|
196
|
+
this._client.publish(topic, "", { retain: true, qos: 1 });
|
|
197
|
+
if (this._lobbyAnnounceTimer) {
|
|
198
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
199
|
+
this._lobbyAnnounceTimer = null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
onPeerDiscovered(cb) {
|
|
203
|
+
this._onPeerDiscovered.push(cb);
|
|
204
|
+
return () => {
|
|
205
|
+
removeFromArray(this._onPeerDiscovered, cb);
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
onPeerLeft(cb) {
|
|
209
|
+
this._onPeerLeft.push(cb);
|
|
210
|
+
return () => {
|
|
211
|
+
removeFromArray(this._onPeerLeft, cb);
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
onSignal(cb) {
|
|
215
|
+
this._onSignal.push(cb);
|
|
216
|
+
return () => {
|
|
217
|
+
removeFromArray(this._onSignal, cb);
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
destroy() {
|
|
221
|
+
this._destroyed = true;
|
|
222
|
+
this._clearRoomTimers();
|
|
223
|
+
if (this._client && this._roomId) {
|
|
224
|
+
const topics = mqttTopics(this._appId, this._roomId, this.selfId);
|
|
225
|
+
this._client.publish(topics.peerPresence, "", { retain: true, qos: 1 });
|
|
226
|
+
}
|
|
227
|
+
this._client?.end(true);
|
|
228
|
+
this._client = null;
|
|
229
|
+
this._knownPeers.clear();
|
|
230
|
+
this._lobbyRooms.clear();
|
|
231
|
+
this._onPeerDiscovered = [];
|
|
232
|
+
this._onPeerLeft = [];
|
|
233
|
+
this._onSignal = [];
|
|
234
|
+
this._onLobby = [];
|
|
235
|
+
}
|
|
236
|
+
// ── Private ──
|
|
237
|
+
_ensureInit() {
|
|
238
|
+
if (!this._initPromise) {
|
|
239
|
+
this._initPromise = this._doInit();
|
|
240
|
+
}
|
|
241
|
+
return this._initPromise;
|
|
242
|
+
}
|
|
243
|
+
async _doInit() {
|
|
244
|
+
const mqtt = await import("mqtt");
|
|
245
|
+
const brokers = this._config.brokerUrls ?? DEFAULT_MQTT_BROKERS;
|
|
246
|
+
const brokerUrl = brokers[Math.floor(Math.random() * brokers.length)];
|
|
247
|
+
return new Promise((resolve, reject) => {
|
|
248
|
+
const connectFn = mqtt.default?.connect ?? mqtt.connect;
|
|
249
|
+
this._client = connectFn(brokerUrl, {
|
|
250
|
+
clientId: `carver_${this.selfId}`,
|
|
251
|
+
clean: true,
|
|
252
|
+
connectTimeout: 1e4,
|
|
253
|
+
keepalive: 30
|
|
254
|
+
});
|
|
255
|
+
this._client.on("connect", () => {
|
|
256
|
+
if (!this._destroyed) resolve();
|
|
257
|
+
});
|
|
258
|
+
this._client.on("error", (err) => {
|
|
259
|
+
if (!this._initPromise) return;
|
|
260
|
+
reject(err);
|
|
261
|
+
});
|
|
262
|
+
this._client.on("message", (topic, payload) => {
|
|
263
|
+
this._handleMessage(topic, payload);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
_publishPresence() {
|
|
268
|
+
if (!this._client || !this._roomId) return;
|
|
269
|
+
const topics = mqttTopics(this._appId, this._roomId, this.selfId);
|
|
270
|
+
this._client.publish(
|
|
271
|
+
topics.peerPresence,
|
|
272
|
+
JSON.stringify({ peerId: this.selfId, meta: this._peerMeta, ts: Date.now() }),
|
|
273
|
+
{ retain: true, qos: 1 }
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
_handleMessage(topic, payload) {
|
|
277
|
+
const raw = payload.toString();
|
|
278
|
+
const presenceMatch = topic.match(/\/room\/[^/]+\/presence\/([^/]+)$/);
|
|
279
|
+
if (presenceMatch) {
|
|
280
|
+
const peerId = presenceMatch[1];
|
|
281
|
+
if (peerId === this.selfId) return;
|
|
282
|
+
if (!raw) {
|
|
283
|
+
if (this._knownPeers.has(peerId)) {
|
|
284
|
+
this._knownPeers.delete(peerId);
|
|
285
|
+
for (const cb of this._onPeerLeft) cb(peerId);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
const msg = JSON.parse(raw);
|
|
291
|
+
const isNew = !this._knownPeers.has(peerId);
|
|
292
|
+
this._knownPeers.set(peerId, { meta: msg.meta ?? {}, lastSeen: msg.ts ?? Date.now() });
|
|
293
|
+
if (isNew) {
|
|
294
|
+
for (const cb of this._onPeerDiscovered) cb(peerId, msg.meta ?? {});
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const signalMatch = topic.match(/\/room\/[^/]+\/signal\/([^/]+)$/);
|
|
301
|
+
if (signalMatch) {
|
|
302
|
+
try {
|
|
303
|
+
const msg = JSON.parse(raw);
|
|
304
|
+
if (msg.from && msg.from !== this.selfId) {
|
|
305
|
+
for (const cb of this._onSignal) cb(msg.from, msg.data);
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const lobbyMatch = topic.match(/\/lobby\/([^/]+)$/);
|
|
312
|
+
if (lobbyMatch) {
|
|
313
|
+
const roomId = lobbyMatch[1];
|
|
314
|
+
if (!raw) {
|
|
315
|
+
this._lobbyRooms.delete(roomId);
|
|
316
|
+
} else {
|
|
317
|
+
try {
|
|
318
|
+
const ann = JSON.parse(raw);
|
|
319
|
+
if (Date.now() - ann.lastSeen < ROOM_ANNOUNCE_EXPIRY_MS) {
|
|
320
|
+
this._lobbyRooms.set(roomId, ann);
|
|
321
|
+
} else {
|
|
322
|
+
this._lobbyRooms.delete(roomId);
|
|
323
|
+
}
|
|
324
|
+
} catch {
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const rooms = Array.from(this._lobbyRooms.values());
|
|
328
|
+
for (const cb of this._onLobby) cb(rooms);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
_checkPeerExpiry() {
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
for (const [peerId, data] of this._knownPeers) {
|
|
334
|
+
if (now - data.lastSeen > PEER_EXPIRY_MS) {
|
|
335
|
+
this._knownPeers.delete(peerId);
|
|
336
|
+
for (const cb of this._onPeerLeft) cb(peerId);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
_clearRoomTimers() {
|
|
341
|
+
if (this._presenceTimer) {
|
|
342
|
+
clearInterval(this._presenceTimer);
|
|
343
|
+
this._presenceTimer = null;
|
|
344
|
+
}
|
|
345
|
+
for (const t of this._warmupTimers) clearTimeout(t);
|
|
346
|
+
this._warmupTimers = [];
|
|
347
|
+
if (this._lobbyAnnounceTimer) {
|
|
348
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
349
|
+
this._lobbyAnnounceTimer = null;
|
|
350
|
+
}
|
|
351
|
+
if (this._peerExpiryTimer) {
|
|
352
|
+
clearInterval(this._peerExpiryTimer);
|
|
353
|
+
this._peerExpiryTimer = null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// src/transport/strategy/firebase.ts
|
|
359
|
+
var FirebaseStrategy = class {
|
|
360
|
+
constructor(appId, config) {
|
|
361
|
+
this._db = null;
|
|
362
|
+
this._firebaseApp = null;
|
|
363
|
+
this._ownApp = false;
|
|
364
|
+
this._roomId = null;
|
|
365
|
+
this._peerMeta = {};
|
|
366
|
+
/** Monotonic counter to detect stale leaveRoom completions */
|
|
367
|
+
this._joinGeneration = 0;
|
|
368
|
+
// Lazy init
|
|
369
|
+
this._initPromise = null;
|
|
370
|
+
// Firebase module references (filled after dynamic import)
|
|
371
|
+
this._fb = null;
|
|
372
|
+
// Unsubscribe handles for Firebase listeners
|
|
373
|
+
this._listeners = [];
|
|
374
|
+
// Callbacks
|
|
375
|
+
this._onPeerDiscovered = [];
|
|
376
|
+
this._onPeerLeft = [];
|
|
377
|
+
this._onSignal = [];
|
|
378
|
+
this._onLobby = [];
|
|
379
|
+
// State
|
|
380
|
+
this._knownPeers = /* @__PURE__ */ new Set();
|
|
381
|
+
this._lobbyAnnounceTimer = null;
|
|
382
|
+
this._destroyed = false;
|
|
383
|
+
this.selfId = generatePeerId();
|
|
384
|
+
this._appId = appId;
|
|
385
|
+
this._config = config;
|
|
386
|
+
}
|
|
387
|
+
// ── Public API ──
|
|
388
|
+
async init() {
|
|
389
|
+
return this._ensureInit();
|
|
390
|
+
}
|
|
391
|
+
async joinRoom(roomId, peerMeta) {
|
|
392
|
+
await this._ensureInit();
|
|
393
|
+
if (!this._db || !this._fb) throw new Error("Firebase not initialized");
|
|
394
|
+
this._joinGeneration++;
|
|
395
|
+
this._roomId = roomId;
|
|
396
|
+
this._peerMeta = peerMeta;
|
|
397
|
+
const { ref, set, onChildAdded, onChildRemoved, onDisconnect, remove } = this._fb;
|
|
398
|
+
const paths = firebasePaths(this._appId, roomId, this.selfId);
|
|
399
|
+
await remove(ref(this._db, paths.peerSignalInbox)).catch(() => {
|
|
400
|
+
});
|
|
401
|
+
const presenceRef = ref(this._db, paths.peerPresence);
|
|
402
|
+
await set(presenceRef, {
|
|
403
|
+
peerId: this.selfId,
|
|
404
|
+
meta: peerMeta,
|
|
405
|
+
ts: Date.now()
|
|
406
|
+
});
|
|
407
|
+
onDisconnect(presenceRef).remove();
|
|
408
|
+
const peersRef = ref(this._db, paths.peers);
|
|
409
|
+
const addedUnsub = onChildAdded(peersRef, (snapshot) => {
|
|
410
|
+
const data = snapshot.val();
|
|
411
|
+
if (!data || data.peerId === this.selfId) return;
|
|
412
|
+
if (!this._knownPeers.has(data.peerId)) {
|
|
413
|
+
this._knownPeers.add(data.peerId);
|
|
414
|
+
for (const cb of this._onPeerDiscovered) cb(data.peerId, data.meta ?? {});
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
this._listeners.push(() => addedUnsub());
|
|
418
|
+
const removedUnsub = onChildRemoved(peersRef, (snapshot) => {
|
|
419
|
+
const data = snapshot.val();
|
|
420
|
+
const peerId = data?.peerId ?? snapshot.key;
|
|
421
|
+
if (peerId && this._knownPeers.has(peerId)) {
|
|
422
|
+
this._knownPeers.delete(peerId);
|
|
423
|
+
for (const cb of this._onPeerLeft) cb(peerId);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
this._listeners.push(() => removedUnsub());
|
|
427
|
+
const signalRef = ref(this._db, paths.peerSignalInbox);
|
|
428
|
+
const signalUnsub = onChildAdded(signalRef, (snapshot) => {
|
|
429
|
+
const msg = snapshot.val();
|
|
430
|
+
if (!msg || msg.from === this.selfId) return;
|
|
431
|
+
for (const cb of this._onSignal) cb(msg.from, msg.data);
|
|
432
|
+
remove(snapshot.ref);
|
|
433
|
+
});
|
|
434
|
+
this._listeners.push(() => signalUnsub());
|
|
435
|
+
}
|
|
436
|
+
async leaveRoom() {
|
|
437
|
+
if (!this._db || !this._fb || !this._roomId) return;
|
|
438
|
+
const leavingRoomId = this._roomId;
|
|
439
|
+
const generation = this._joinGeneration;
|
|
440
|
+
const { ref, remove } = this._fb;
|
|
441
|
+
const paths = firebasePaths(this._appId, leavingRoomId, this.selfId);
|
|
442
|
+
for (const unsub of this._listeners) unsub();
|
|
443
|
+
this._listeners = [];
|
|
444
|
+
await Promise.all([
|
|
445
|
+
remove(ref(this._db, paths.peerPresence)),
|
|
446
|
+
remove(ref(this._db, paths.peerSignalInbox))
|
|
447
|
+
]).catch(() => {
|
|
448
|
+
});
|
|
449
|
+
if (this._lobbyAnnounceTimer) {
|
|
450
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
451
|
+
this._lobbyAnnounceTimer = null;
|
|
452
|
+
}
|
|
453
|
+
this._knownPeers.clear();
|
|
454
|
+
if (this._joinGeneration === generation) {
|
|
455
|
+
this._roomId = null;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
signal(targetPeerId, data) {
|
|
459
|
+
if (!this._db || !this._fb || !this._roomId) return;
|
|
460
|
+
const { ref, push } = this._fb;
|
|
461
|
+
const inboxPath = firebasePaths(this._appId, this._roomId, targetPeerId).peerSignalInbox;
|
|
462
|
+
push(ref(this._db, inboxPath), {
|
|
463
|
+
from: this.selfId,
|
|
464
|
+
data: sanitizeForFirebase(data),
|
|
465
|
+
ts: Date.now()
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
subscribeToLobby(cb) {
|
|
469
|
+
this._onLobby.push(cb);
|
|
470
|
+
this._ensureInit().then(() => {
|
|
471
|
+
if (!this._db || !this._fb || this._destroyed) return;
|
|
472
|
+
const { ref, onValue } = this._fb;
|
|
473
|
+
const paths = firebasePaths(this._appId, "", "");
|
|
474
|
+
const lobbyRef = ref(this._db, paths.lobby);
|
|
475
|
+
const unsub = onValue(lobbyRef, (snapshot) => {
|
|
476
|
+
const data = snapshot.val();
|
|
477
|
+
if (!data) {
|
|
478
|
+
for (const lcb of this._onLobby) lcb([]);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const now = Date.now();
|
|
482
|
+
const rooms = Object.values(data).filter(
|
|
483
|
+
(r) => r && now - (r.lastSeen ?? 0) < ROOM_ANNOUNCE_EXPIRY_MS
|
|
484
|
+
);
|
|
485
|
+
for (const lcb of this._onLobby) lcb(rooms);
|
|
486
|
+
});
|
|
487
|
+
this._listeners.push(() => unsub());
|
|
488
|
+
});
|
|
489
|
+
return () => {
|
|
490
|
+
removeFromArray(this._onLobby, cb);
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
announceRoom(announcement) {
|
|
494
|
+
if (!this._db || !this._fb) return;
|
|
495
|
+
const { ref, set } = this._fb;
|
|
496
|
+
const paths = firebasePaths(this._appId, announcement.roomId, "");
|
|
497
|
+
announcement.lastSeen = Date.now();
|
|
498
|
+
set(ref(this._db, paths.roomLobbyEntry), announcement);
|
|
499
|
+
if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
|
|
500
|
+
this._lobbyAnnounceTimer = setInterval(() => {
|
|
501
|
+
announcement.lastSeen = Date.now();
|
|
502
|
+
set(ref(this._db, paths.roomLobbyEntry), announcement);
|
|
503
|
+
}, ROOM_ANNOUNCE_INTERVAL_MS);
|
|
504
|
+
}
|
|
505
|
+
removeRoomAnnouncement(roomId) {
|
|
506
|
+
if (!this._db || !this._fb) return;
|
|
507
|
+
const { ref, remove } = this._fb;
|
|
508
|
+
remove(ref(this._db, firebasePaths(this._appId, roomId, "").roomLobbyEntry));
|
|
509
|
+
if (this._lobbyAnnounceTimer) {
|
|
510
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
511
|
+
this._lobbyAnnounceTimer = null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
onPeerDiscovered(cb) {
|
|
515
|
+
this._onPeerDiscovered.push(cb);
|
|
516
|
+
return () => {
|
|
517
|
+
removeFromArray(this._onPeerDiscovered, cb);
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
onPeerLeft(cb) {
|
|
521
|
+
this._onPeerLeft.push(cb);
|
|
522
|
+
return () => {
|
|
523
|
+
removeFromArray(this._onPeerLeft, cb);
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
onSignal(cb) {
|
|
527
|
+
this._onSignal.push(cb);
|
|
528
|
+
return () => {
|
|
529
|
+
removeFromArray(this._onSignal, cb);
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
destroy() {
|
|
533
|
+
this._destroyed = true;
|
|
534
|
+
for (const unsub of this._listeners) unsub();
|
|
535
|
+
this._listeners = [];
|
|
536
|
+
if (this._lobbyAnnounceTimer) {
|
|
537
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
538
|
+
this._lobbyAnnounceTimer = null;
|
|
539
|
+
}
|
|
540
|
+
if (this._db && this._fb && this._roomId) {
|
|
541
|
+
const { ref, remove } = this._fb;
|
|
542
|
+
const paths = firebasePaths(this._appId, this._roomId, this.selfId);
|
|
543
|
+
remove(ref(this._db, paths.peerPresence)).catch(() => {
|
|
544
|
+
});
|
|
545
|
+
remove(ref(this._db, paths.peerSignalInbox)).catch(() => {
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
if (this._ownApp && this._firebaseApp) {
|
|
549
|
+
import("firebase/app").then(({ deleteApp }) => {
|
|
550
|
+
deleteApp(this._firebaseApp).catch(() => {
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
this._db = null;
|
|
555
|
+
this._firebaseApp = null;
|
|
556
|
+
this._fb = null;
|
|
557
|
+
this._knownPeers.clear();
|
|
558
|
+
this._onPeerDiscovered = [];
|
|
559
|
+
this._onPeerLeft = [];
|
|
560
|
+
this._onSignal = [];
|
|
561
|
+
this._onLobby = [];
|
|
562
|
+
}
|
|
563
|
+
// ── Private ──
|
|
564
|
+
_ensureInit() {
|
|
565
|
+
if (!this._initPromise) {
|
|
566
|
+
this._initPromise = this._doInit();
|
|
567
|
+
}
|
|
568
|
+
return this._initPromise;
|
|
569
|
+
}
|
|
570
|
+
async _doInit() {
|
|
571
|
+
const { initializeApp, getApps } = await import("firebase/app");
|
|
572
|
+
const {
|
|
573
|
+
getDatabase,
|
|
574
|
+
ref,
|
|
575
|
+
set,
|
|
576
|
+
push,
|
|
577
|
+
remove,
|
|
578
|
+
onValue,
|
|
579
|
+
onChildAdded,
|
|
580
|
+
onChildRemoved,
|
|
581
|
+
onDisconnect
|
|
582
|
+
} = await import("firebase/database");
|
|
583
|
+
this._fb = { ref, set, push, remove, onValue, onChildAdded, onChildRemoved, onDisconnect };
|
|
584
|
+
if (this._config.firebaseApp) {
|
|
585
|
+
this._firebaseApp = this._config.firebaseApp;
|
|
586
|
+
this._ownApp = false;
|
|
587
|
+
} else {
|
|
588
|
+
const appName = `carver_${this._appId}`;
|
|
589
|
+
const existing = getApps().find((a) => a.name === appName);
|
|
590
|
+
if (existing) {
|
|
591
|
+
this._firebaseApp = existing;
|
|
592
|
+
this._ownApp = false;
|
|
593
|
+
} else {
|
|
594
|
+
this._firebaseApp = initializeApp({ databaseURL: this._config.databaseURL }, appName);
|
|
595
|
+
this._ownApp = true;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
this._db = getDatabase(this._firebaseApp);
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
function sanitizeForFirebase(obj) {
|
|
602
|
+
if (obj === null) return "__null__";
|
|
603
|
+
if (Array.isArray(obj)) return obj.map(sanitizeForFirebase);
|
|
604
|
+
if (typeof obj === "object" && obj !== null) {
|
|
605
|
+
const result = {};
|
|
606
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
607
|
+
result[key] = value === null ? "__null__" : sanitizeForFirebase(value);
|
|
608
|
+
}
|
|
609
|
+
return result;
|
|
610
|
+
}
|
|
611
|
+
return obj;
|
|
612
|
+
}
|
|
613
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
614
|
+
0 && (module.exports = {
|
|
615
|
+
FirebaseStrategy,
|
|
616
|
+
MqttStrategy,
|
|
617
|
+
generatePeerId
|
|
618
|
+
});
|
|
619
|
+
//# sourceMappingURL=strategy.js.map
|