UNPKG

@game-vir/multiplayer

Version:

Client side definitions and types for @game-vir/multiplayer-server

150 lines (149 loc) 6.36 kB
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; } }