mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
v1.198.1
This commit is contained in:
451
js/utils-p2p.js
Normal file
451
js/utils-p2p.js
Normal file
@@ -0,0 +1,451 @@
|
||||
"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
|
||||
Reference in New Issue
Block a user