Files
5etools-mirror-2.github.io/js/utils-p2p.js
TheGiddyLimit 8117ebddc5 v1.198.1
2024-01-01 19:34:49 +00:00

452 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
// region PeerJS implementation
class PeerVe extends Peer {
constructor (role) {
super();
this._role = role;
this._connectionsArray = [];
this._pInit = new Promise((resolve, reject) => {
this.on("open", id => resolve(id));
this.on("error", e => reject(e));
});
}
get connections () { return this._connectionsArray; }
hasConnections () { return !!this._connectionsArray.length; }
getActiveConnections () { return this._connectionsArray.filter(it => it.open); }
pInit () { return this._pInit; }
sendMessage (toSend) {
if (this.disconnected || this.destroyed) throw new Error(`Connection is not active!`);
const packet = {
head: {
type: this._role,
version: "0.0.2",
},
data: toSend,
};
this.getActiveConnections().forEach(connection => connection.send(packet));
}
}
class PeerVeServer extends PeerVe {
constructor () {
super("server");
this.on("connection", conn => this._connectionsArray.push(conn));
this._tempListeners = {};
}
get token () { return this.id; }
/**
* Add a temporary event listener for a Peer event type.
*/
onTemp (eventName, listener) {
(this._tempListeners[eventName] = this._tempListeners[eventName] || []).push(listener);
this.on(eventName, listener);
}
/**
* Remove al temporary event listeners for a Peer event type.
*/
offTemp (eventName) {
(this._tempListeners[eventName] || []).forEach(it => this.off(eventName, it));
}
}
class PeerVeClient extends PeerVe {
constructor () {
super("client");
this._data = null;
}
pConnectToServer (token, dataHandler, options = null) {
const connection = options ? this.connect(token, options) : this.connect(token);
connection.on("data", data => dataHandler(data));
return new Promise((resolve, reject) => {
this.on("open", id => resolve(id));
this.on("error", e => reject(e));
});
}
}
// endregion
/// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// region Custom/legacy implementation
/*
Utilities for creating peer-to-peer connections.
Depends on lib/lzma.js for compression.
Basic usage:
(NB: "SDP" = Session Description Protocol)
(NB: "server" and "client" are both peers))
// Server sets up N connections (in this case, two, "Alpha" and "Bravo")
const serverInfo = await PeerUtilV0.pInitialiseServers(
["Alpha", "Bravo"],
msg => console.log("server", msg),
err => console.error("server", err)
);
// Server user then sends each `serverInfo[N].textifiedSdp` token to client user N, e.g. via Discord
// the string is of the form: `{::alpha|...base64 data...::}`
// Each client user inputs their token into their client
const clientInfo1 = await PeerUtilV0.pInitialiseClient(
serverInfo[0].textifiedSdp,
msg => console.log("client 1", msg),
err => console.error("client 1", err)
);
const clientInfo2 = await PeerUtilV0.pInitialiseClient(
serverInfo[1].textifiedSdp,
msg => console.log("client 2", msg),
err => console.error("client 2", err)
);
// Each client will produces a response token of the same format, which the client user sends back to the
// server user, again e.g. via Discord
// The server user then copy-pastes all the client tokens into the server. These tokens can be mixed in with
// other text, e.g. copied en-masse from a chat log
await PeerUtilV0.pConnectClientsToServers(serverInfo, `[02:16] Client Username 1: This should work, right?${clientInfo1.textifiedSdp}[02:17] Client Username 2: Let's hope so...${clientInfo2.textifiedSdp}`);
// The server can now send data to the clients
serverInfo.forEach(s => s.server.sendMessage("Hello world"));
*/
class PeerV0 {
static _STUN_SERVERS = [
`stun1.l.google.com:19302`,
`stun2.l.google.com:19302`,
`stun3.l.google.com:19302`,
`stun4.l.google.com:19302`,
];
constructor (role) {
this._role = role;
this._ctx = null;
this._isActive = false;
this._isClosed = false;
this._pChannel = null;
}
get isActive () { return this._isActive; }
get isClosed () { return this._isClosed; }
_createPeerConnection (msgHandler, errHandler) {
errHandler = errHandler || (evt => setTimeout(() => { throw new Error(evt); }));
const thisMsgHandler = msgHandler.bind(this);
const thisErrHandler = errHandler.bind(this);
const pc = new RTCPeerConnection({iceServers: PeerV0._STUN_SERVERS.map(url => ({url: `stun:${url}`}))});
this._pChannel = new Promise(resolve => {
pc.addEventListener("datachannel", evt => {
evt.channel.addEventListener("message", evt => thisMsgHandler(JSON.parse(evt.data)));
// only closes local side of the connection
evt.channel.addEventListener("close", () => {
this._ctx = null;
this._isActive = false;
this._isClosed = true;
});
evt.channel.addEventListener("error", evt => thisErrHandler(evt));
resolve();
});
});
const dc = pc.createDataChannel(this._role);
return {pc, dc};
}
close () {
if (this._ctx) this._ctx.pc.close();
else {
this._isActive = false;
this._isClosed = true;
}
}
/**
* STAGE 5: Send messages.
*
* @param toSend Data to be sent.
*/
async sendMessage (toSend) {
if (!this._isActive) throw new Error(`Connection is not active!`);
const packet = {
head: {
type: this._role,
version: "0.0.1",
},
data: toSend,
};
this._ctx.dc.send(JSON.stringify(packet));
}
}
class PeerServerV0 extends PeerV0 {
constructor () {
super("server");
}
/**
* STAGE 1: Make offer.
*
* @param messageHandler Function which handles received messages.
* @param errorHandler Function which handles errors.
* @return {Promise<String>} session description to be provided (manually, e.g. via Discord) to a client
*/
async pMakeOffer (messageHandler, errorHandler) {
return new Promise((resolve, reject) => {
this._ctx = this._createPeerConnection(messageHandler, errorHandler);
this._ctx.pc.addEventListener("icecandidate", evt => {
if (!evt.candidate) resolve(this._ctx.pc.localDescription.sdp);
});
this._ctx.pc.createOffer()
.then(offer => this._ctx.pc.setLocalDescription(offer))
.catch(err => reject(err));
});
}
/**
* STAGE 4: Accept answer.
*
* @param sdpAnswer
*/
async pAcceptAnswer (sdpAnswer) {
const answer = new RTCSessionDescription({type: "answer", sdp: `${(sdpAnswer || "").trim()}\n`});
await this._ctx.pc.setRemoteDescription(answer);
await this._pChannel;
this._isActive = true;
}
}
class PeerClientV0 extends PeerV0 {
constructor () {
super("client");
}
/**
* STAGE 3: Receive offer, and produce answer.
* @return {Promise<String>} session description to be provided (manually, e.g. via Discord) to a server
*/
async pReceiveOfferAndGetAnswer (sdpOffer, messageHandler, errorHandler) {
return new Promise((resolve, reject) => {
this._ctx = this._createPeerConnection(messageHandler, errorHandler);
const offer = new RTCSessionDescription({type: "offer", sdp: `${(sdpOffer || "").trim()}\n`});
this._ctx.pc.setRemoteDescription(offer).then(() => {
this._ctx.pc.addEventListener("icecandidate", evt => {
if (!evt.candidate) {
this._isActive = true;
resolve(this._ctx.pc.localDescription.sdp);
}
});
this._ctx.pc.createAnswer()
.then(answer => this._ctx.pc.setLocalDescription(answer))
.catch(err => reject(err));
});
});
}
}
// Utilities for easing the process of connecting multiple clients
class PeerUtilV0 {
/**
* Convenience wrapper to initialise multiple servers ad produce a textified version of their SDPs, suitable for
* text transmission.
*
* @param names An array of unique-when-sluggified names to give to each server.
* @param msgHandler One copy used per server.
* @param errHandler One copy used per server.
* @return {Promise<Array<Object>>} An array of objects of the form `{name<String>, textifiedSdp<String>, server<PeerServerV0>}`.
*/
static async pInitialiseServers (names, msgHandler, errHandler) {
names = names.map(it => Parser.stringToSlug(it).toUpperCase());
// ensure name uniqueness
if (names.length !== (new Set(names)).size) {
const nameCounts = {};
names.forEach(n => nameCounts[n] = (nameCounts[n] || 0) + 1);
names = [];
Object.entries(nameCounts).forEach(([name, count]) => {
names.push(name);
[...new Array(count - 1)].forEach((_, i) => names.push(`${name}-${i + 1}`));
});
}
return PeerUtilV0._pMapNamesToServers(names, msgHandler, errHandler);
}
static async _pMapNamesToServers (names, msgHandler, errHandler) {
return Promise.all(names.map(async name => {
const server = new PeerServerV0();
const sdpServer = await server.pMakeOffer(msgHandler, errHandler);
return {
name,
textifiedSdp: PeerUtilV0._packToken(name, sdpServer),
server,
};
}));
}
/**
* Convenience method for adding more servers to a list previously created by `pInitialiseServers`, ensuring names
* are unique across all servers.
*
* @param names The desired names for the new servers.
* @param existing The array of existing server metadata, as created by `pInitialiseServers` -- `isDeleted` flags
* set on the metadata will be respected when determining this server's final name.
* @param msgHandler Ideally, the same handler as the `existing` metadata used.
* @param errHandler Ideally, the same handler as the `existing` metadata used.
* @return {Promise<Array<Object>>} An array of the new server metadata; `{name<String>, textifiedSdp<String>, server<PeerServerV0>}`.
*/
static async pInitialiseServersAddToExisting (names, existing, msgHandler, errHandler) {
const existingNames = existing.filter(it => !it.isDeleted).map(it => it.name);
names = names.map(name => {
name = PeerUtilV0.getNextAvailableName(existingNames, name);
existingNames.push(name);
return name;
});
const newServers = await PeerUtilV0._pMapNamesToServers(names, msgHandler, errHandler);
existing.push(...newServers);
return newServers;
}
/**
* @param existingSlugNames An array of existing slugified names.
* @param desiredName The desired (plain text) name.
*/
static getNextAvailableName (existingSlugNames, desiredName) {
existingSlugNames = new Set(existingSlugNames.map(n => n.replace(/-/g, " ").toUpperCase()));
const slugName = Parser.stringToSlug(desiredName).toUpperCase();
if (!existingSlugNames.has(slugName)) return slugName;
let n = 1;
let nextName = `${slugName}-${n}`;
while (existingSlugNames.has(nextName)) {
n++;
nextName = `${slugName}-${n}`;
}
return nextName;
}
static _packToken (name, sdp) {
sdp = sdp.trim();
function stripEquals () {
return sdp.split("\n").map(it => it.trim().replace(/^(.)=/, "$1")).join("\n");
}
const cleaned = stripEquals(sdp).replace(/:/g, "£").replace(/ /g, "×").replace(/\n/g, "•");
return `{::${name}|1|${cleaned}::}`;
}
static _unpackToken (textified) {
const mToken = /(::[^:]+::)/.exec(textified);
if (!mToken) return null;
textified = `{${mToken[1]}}`;
const parts = textified.replace(/\s+/g, "").replace(/^{::/, "").replace(/::}$/, "").split("|");
if (parts.length === 3) {
const [name, compression, mappedCompressed] = parts;
if (compression === "1") {
const withEquals = mappedCompressed.replace(/£/g, ":").replace(/×/g, " ").split("•").map(it => it.replace(/^(.)/, "$1=")).join("\n");
return {name, sdp: withEquals};
} else throw new Error(`Unknown compression type "${compression}"`);
} else return null;
}
static _getTokensFromText (clientsString) {
const nameToSdpData = {};
clientsString = clientsString.replace(/\s+/g, "");
// tolerate single missing characters at start/end, to ease copy-pasting
if (clientsString.startsWith("::")) clientsString = `{${clientsString}`;
if (clientsString.endsWith("::")) clientsString = `${clientsString}}`;
clientsString.replace(/{::([^:])+::}/gi, (...m) => {
const unpacked = PeerUtilV0._unpackToken(m[0]);
if (unpacked) {
nameToSdpData[unpacked.name] = {
sdp: unpacked.sdp,
token: m[0], // pass back the original token, to be displayed as required
};
}
});
return nameToSdpData;
}
/**
* Test if a string contains any tokens.
* @param string A string.
*/
static containsAnyTokens (string) {
return !!Object.keys(PeerUtilV0._getTokensFromText(string)).length;
}
/**
* @param wrappedServers An array of objects produced by `pInitialiseServers`.
* @param clientsString A string containing one or more textified name/SDP pairs created by `_packToken`. This string can contain other
* junk characters, e.g. copy-pasted from a chat containing timestamps.
* @return {Promise<Array<Object>>} An array of the server metadata for servers which were connected.
*/
static async pConnectClientsToServers (wrappedServers, clientsString) {
const nameToSdpData = PeerUtilV0._getTokensFromText(clientsString);
const connectedServers = [];
await Promise.all(Object.entries(nameToSdpData).map(async ([name, sdpData]) => {
const wrappedServer = wrappedServers.find(server => server.name === name);
if (wrappedServer) {
await wrappedServer.server.pAcceptAnswer(sdpData.sdp);
wrappedServer._tempTokenToDisplay = sdpData.token;
connectedServers.push(wrappedServer);
}
}));
return connectedServers;
}
static async pInitialiseClient (textifiedSdp, msgHandler, errHandler) {
const client = new PeerClientV0();
const unpacked = PeerUtilV0._unpackToken(textifiedSdp);
if (unpacked) {
const {name, sdp} = unpacked;
const sdpClient = await client.pReceiveOfferAndGetAnswer(sdp, msgHandler, errHandler);
return {
name,
textifiedSdp: PeerUtilV0._packToken(name, sdpClient),
client,
};
} else return null;
}
/**
* Performs a basic sanity check on a token.
*/
static isValidToken (token) {
if (!token || typeof token !== "string" || !token.trim()) return false;
return !!/{::[^:]+::}/.exec(token);
}
}
// endregion