@mothepro/fancy-p2p
Version:
A quick and efficient way to form p2p groups in the browser
245 lines • 12 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fancy_emitter_1 = require("fancy-emitter");
const parsers_js_1 = require("../util/parsers.js");
const builders_js_1 = require("../util/builders.js");
const Client_js_1 = __importStar(require("./Client.js"));
const HashableSet_js_1 = __importDefault(require("../util/HashableSet.js"));
class LeaveError extends Error {
constructor(client, message) {
super(message);
this.client = client;
}
}
/**
* Handle the communication with the signaling server.
*
* Joins the lobby on server upon construction.
* Allows for creation, approval and rejection of groups.
* Listening on the `groupFinal` emitter will tell caller which offers/answers to create/accept
*/
class default_1 {
constructor(address, lobby, name, protocol) {
/** Map of all clients connected to this signaling server. */
this.allClients = new Map;
this.groups = new Map;
this.state = 0 /* CLOSED */;
this.stateChange = new fancy_emitter_1.Emitter(newState => this.state = newState);
/** Activates when a new client joins the lobby. */
this.connection = new fancy_emitter_1.Emitter;
/** Activates when the server sends a message on behalf of a failed peer connection. */
this.fallbackMessage = new fancy_emitter_1.Emitter;
/** The direct, indirect & mock peers, it's only passed here for server logging. */
this.peersForFallbackLogging = [];
/** Activates when receiving some data from the signaling server. */
this.message = new fancy_emitter_1.SafeEmitter(data => {
try {
switch (this.state) {
case 3 /* FALLBACK */:
this.fallbackMessage.activate(parsers_js_1.parseFallback(data));
break;
// Accept the SDP from the client after they have created.
case 2 /* FINALIZED */:
const { from, sdp } = parsers_js_1.parseSdp(data);
this.getClient(from).acceptor.activate(sdp);
break;
case 1 /* READY */:
switch (data.getUint8(0)) {
case 5 /* YOUR_NAME */:
this.connection.activate(this.self = new Client_js_1.MockClient(parsers_js_1.parseYourName(data)));
return;
case 1 /* CLIENT_JOIN */:
this.handleClientJoin(parsers_js_1.parseClientJoin(data));
return;
case 0 /* CLIENT_LEAVE */:
// TODO Also close peer's connection if in fallback
// server needs to send message since we are in group not lobby state
this.getClient(parsers_js_1.parseClientLeave(data)).proposals.cancel();
return;
case 3 /* GROUP_REJECT */:
case 2 /* GROUP_REQUEST */:
this.handleGroupChange(parsers_js_1.parseGroupChange(data));
return;
case 4 /* GROUP_FINAL */:
this.handleGroupFinalize(parsers_js_1.parseGroupFinalize(data));
return;
default:
throw Error(`Unexpected data from server ${data}`);
}
}
}
catch (err) {
this.stateChange.deactivate(err);
}
});
/** Attempts to get a client that has connected. Throws if unable to. */
this.getClient = (id) => {
if (!this.allClients.has(id))
throw Error(`Received data from unknown client ${id}.`);
return this.allClients.get(id);
};
if (typeof address == 'string')
address = new URL(address);
address.searchParams.set('lobby', lobby);
if (name)
address.searchParams.set('name', name);
this.server = new WebSocket(address.toString(), protocol);
this.server.binaryType = 'arraybuffer';
this.server.addEventListener('open', () => this.stateChange.activate(1 /* READY */));
this.server.addEventListener('close', () => this.stateChange.activate(0 /* CLOSED */));
this.server.addEventListener('error', () => this.stateChange.deactivate(Error('Connection to Server closed unexpectedly.')));
this.server.addEventListener('message', ({ data }) => this.message.activate(new DataView(data)));
this.bindStateChanges(name);
}
async handleClientJoin({ id, name }) {
const client = new Client_js_1.default(id, name);
this.allClients.set(id, client);
this.sendSdpOnCreation(client);
if (this.connection.count == 0) // Ensure the SelfPeer comes out first, otherwise, just wait for it
await this.connection.next;
this.connection.activate(client);
// Clean up on disconnect
for await (const _ of client.proposals)
;
this.allClients.delete(id);
}
handleGroupChange({ approve, actor, members }) {
const rejectGroup = (reason) => {
var _a;
return ((_a = this.groups.get(members.hash)) === null || _a === void 0 ? void 0 : _a.deactivate(reason)) && this.groups.delete(members.hash);
};
if (approve) {
// Initiate the group if it hasn't been propopsed before
if (!this.groups.has(members.hash)) {
// Used to keep track of clients when they accept or reject
this.groups.set(members.hash, new fancy_emitter_1.Emitter);
// Initiate on behalf of the client
this.getClient(actor).proposals.activate({
members: [...members].map(this.getClient),
ack: this.groups.get(members.hash),
action: accept => {
this.serverSend(builders_js_1.buildProposal(accept, ...members));
if (!accept)
rejectGroup(new Error(`Rejected group with ${[...members]}.`));
}
});
}
// TODO decide if that should be in an else
this.groups.get(members.hash).activate(this.getClient(actor));
}
else
rejectGroup(new LeaveError(this.allClients.get(actor), `Group with ${[...members]} was rejected.`));
}
handleGroupFinalize({ code, members, cmp }) {
this.code = code;
this.myId = cmp;
this.members = [...members].map(this.getClient);
for (const member of this.members)
// The `cmp` is sent from the server as a way to determine
// What expression will evaluate the same on both sides of the equation...
member.isOpener = cmp < member.id;
this.stateChange.activate(2 /* FINALIZED */);
}
/** A wrapper around socket send since that method doesn't throw, for some reason. */
serverSend(data) {
if (this.server.readyState != WebSocket.OPEN)
throw Error('WebSocket is not in an OPEN state.');
this.server.send(data);
}
async sendSdpOnCreation({ id, creator }) {
for await (const sdp of creator)
this.serverSend(builders_js_1.buildSdp(id, sdp));
}
async bindStateChanges(presetName) {
try {
for await (const state of this.stateChange)
switch (state) {
case 0 /* CLOSED */:
this.stateChange.cancel();
break;
// Log which connections worked and failed.
case 3 /* FALLBACK */:
const fails = [];
for (const peer of this.peersForFallbackLogging)
if (!peer.isYou && !(await peer.ready)) // this is a promise, so don't use `.filter`, even tho it's already been resolved
fails.push(peer);
if (fails.length) // sanity check
console.warn(`${fails.length} out of ${this.peersForFallbackLogging.length - 1} direct connections broke.
Failed peers IDs: ${fails.map(({ fallbackId }) => fallbackId).join(', ')}`);
// TODO log on server
break;
case 1 /* READY */:
if (presetName) {
await Promise.resolve(); // Wait a tick; Allow `this.connection` listener to be bound first
this.connection.activate(this.self = new Client_js_1.MockClient(presetName));
}
break;
}
}
finally {
this.fallbackMessage.cancel();
this.connection.cancel();
this.server.close();
}
}
/** Proposes a group to the server and returns the emitter that will be activated when clients accept it. */
proposeGroup(...members) {
const ids = new HashableSet_js_1.default;
for (const [id, client] of this.allClients)
if (members.includes(client))
ids.add(id);
if (this.state != 1 /* READY */)
throw Error('Can not propose a group before connecting.');
if (this.groups.has(ids.hash))
throw Error('Can not propose a group that is already formed.');
if (ids.size < 1)
throw Error('Can not propose a group without members.');
const ack = new fancy_emitter_1.Emitter;
this.serverSend(builders_js_1.buildProposal(true, ...ids));
this.groups.set(ids.hash, ack);
this.self.proposals.activate({ members, ack });
return this.groups.get(ids.hash);
}
groupExists(...members) {
const ids = new HashableSet_js_1.default;
// TODO improve this??
// for some reason this allows members to include self.
for (const [id, client] of this.allClients)
if (members.includes(client))
ids.add(id);
return !!ids.size && this.groups.has(ids.hash);
}
/** Sends an indirect message to a peer thru signaling server. */
sendFallback(id, data) {
if (this.state != 3 /* FALLBACK */)
return;
const view = new DataView(new ArrayBuffer(2 /* SHORT */ + data.byteLength));
view.setUint16(0, id, true);
new Uint8Array(view.buffer, 2 /* SHORT */).set(new Uint8Array(data)); // optimize?
this.serverSend(view.buffer);
}
}
exports.default = default_1;
//# sourceMappingURL=Signaling.js.map