@game-vir/multiplayer
Version:
Client side definitions and types for @game-vir/multiplayer-server
150 lines (149 loc) • 6.36 kB
JavaScript
import { assert, check } from '@augment-vir/assert';
import { addPrefix, DeferredPromise, makeWritable, wrapInTry, } from '@augment-vir/common';
import { assertValidShape } from 'object-shape-tester';
import { defineTypedCustomEvent, ListenTarget } from 'typed-event-target';
import { webrtcAnswerShape, webrtcOfferShape, } from './web-rtc-communication.js';
/**
* An event that is omitted from {@link WebrtcController} when a WebRTC message is received.
*
* @category Internal
*/
export class WebrtcMessageEvent extends defineTypedCustomEvent()('webrtc-message') {
}
/**
* An event that is omitted from {@link WebrtcController} when the WebRTC connection is made or lost.
* `event.detail` is `true` if the connection has been made; `false` if the connection was lost.
*
* @category Internal
*/
export class WebrtcConnectEvent extends defineTypedCustomEvent()('webrtc-connect') {
}
function formatStunServerUrls(stunServerUrls) {
return stunServerUrls.map((stunServerUrl) => {
return {
urls: addPrefix({ value: stunServerUrl, prefix: 'stun:' }),
};
});
}
/**
* A single peer-to-peer WebRTC controller. It is to be used like this:
*
* 1. Call {@link WebrtcController.createOffer} to start off a WebRTC handshake. Send this offer to
* another WebRTC connection or {@link WebrtcController} instance.
* 2. Call {@link WebrtcController.createAnswer} to accept someone else's WebRTC handshake offer and
* create a WebRTC handshake answer. Send this answer back to whoever sent the WebRTC offer.
* 3. Call {@link WebrtcController.acceptAnswer} on the {@link WebrtcController} instance that first
* created the offer.
*
* If everything went well, after those 3 steps you'll now have a live WebRTC connection!
*
* @category Internal
*/
export class WebrtcController extends ListenTarget {
clientId;
dataChannel;
connection;
/** Indicates whether the WebRTC connection is live or not. */
isConnected = false;
constructor(clientId) {
super();
this.clientId = clientId;
}
/** Create a WebRTC offer. This is the first step in the WebRTC handshake process. */
async createOffer(stunServerUrls) {
const candidatePromise = this.createConnection(stunServerUrls);
assert.isDefined(this.connection);
this.handleDataChannel(this.connection.createDataChannel('chat'));
await this.connection.setLocalDescription(await this.connection.createOffer());
await candidatePromise;
const offer = this.connection.localDescription;
assertValidShape(offer, webrtcOfferShape);
return offer;
}
/** Accepts a WebRTC answer. This is the third (and last) step in the WebRTC handshake process. */
async acceptAnswer(rawAnswer) {
const answer = check.isString(rawAnswer) ? JSON.parse(rawAnswer) : rawAnswer;
assert.isDefined(this.connection);
assertValidShape(answer, webrtcAnswerShape);
await this.connection.setRemoteDescription(answer);
}
/**
* Accepts a WebRTC offer and creates a WebRTC answer. This is the second step in the WebRTC
* handshake process.
*/
async createAnswer(rawOffer, stunServerUrls) {
const offer = check.isString(rawOffer)
? JSON.parse(rawOffer)
: rawOffer;
const candidatePromise = this.createConnection(stunServerUrls);
assert.isDefined(this.connection);
this.connection.addEventListener('datachannel', (event) => {
this.handleDataChannel(event.channel);
});
await this.connection.setRemoteDescription(offer);
await this.connection.setLocalDescription(await this.connection.createAnswer());
if (stunServerUrls.length) {
await candidatePromise;
}
const answer = this.connection.localDescription;
assertValidShape(answer, webrtcAnswerShape);
return answer;
}
/**
* Send a message to the other peer in the WebRTC connection. This will throw an error if the
* connection has not been established yet.
*/
sendMessage(data) {
assert.isTrue(this.isConnected, `There is no WebRTC connection to send a message to from ${this.clientId}.`);
assert.isDefined(this.dataChannel, `There is no WebRTC connection to send a message to from ${this.clientId}.`);
this.dataChannel.send(JSON.stringify(data));
}
destroy() {
this.dataChannel?.close();
this.connection?.close();
super.destroy();
}
handleDataChannel(dataChannel) {
this.dataChannel?.close();
this.dataChannel = dataChannel;
this.dataChannel.addEventListener('open', () => {
makeWritable(this).isConnected = true;
this.dispatch(new WebrtcConnectEvent({ detail: true }));
});
this.dataChannel.addEventListener('closing', () => {
makeWritable(this).isConnected = false;
this.dispatch(new WebrtcConnectEvent({ detail: false }));
});
this.dataChannel.addEventListener('message', (event) => {
const detail = wrapInTry(() => (check.isString(event.data) ? JSON.parse(event.data) : event.data), {
fallbackValue: event.data,
});
this.dispatch(new WebrtcMessageEvent({
detail,
}));
});
}
createConnection(stunServerUrls) {
if (this.connection) {
throw new Error('Connection already created!');
}
const deferredIceCandidatePromise = new DeferredPromise();
const iceCandidateListener = (event) => {
// all candidates are done
if (!event.candidate) {
assert.isDefined(this.connection);
deferredIceCandidatePromise.resolve();
this.connection.removeEventListener('icecandidate', iceCandidateListener);
}
};
this.connection = new RTCPeerConnection({
iceServers: formatStunServerUrls(stunServerUrls),
});
this.connection.addEventListener('icecandidate', iceCandidateListener);
/**
* This must be awaited so the candidate list can finish populating before we present the
* offer to the user.
*/
return deferredIceCandidatePromise.promise;
}
}