Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
Signaler.js 4.31 KiB
import {SocketDataChannel} from "./DataChannel.js";

export const baseURL = `ws://${document.location.hostname}:443/skribbl`;
/**
 * @typedef {{connection:RTCPeerConnection,dataChannel:RTCDataChannel}} ConnectionData 
 */
/**
 * A connection to the signaling server used to start and join games.
 */
export default class Signaler {

	/**
	 * Starts a new game, returning a promise with its ID. After that, calls the given callback with a new RTCPeerConnection whenever someone joins the game.
	 * @param {(connectionData:ConnectionData)=>void} onConnect
	 */
	static async host(onConnect){
		const socket = new SocketDataChannel(baseURL+"/host");
		//await socket.waitUntilReady();
		let response = await socket.next();
		console.log("response:",response);
		if (!response.startsWith("started ")){
			throw new Error(`Unexpected first response or something: "${response}"`);
		}
		let id = response.split(" ")[1];
		socket.onMessage(async message=>{
			if (message.startsWith("join ")){
				let playerID = message.split(" ",2)[1];
				console.log("new player joined: ",playerID);
				/** @type {((message:string)=>void)[]} */
				let callbacks = [];
				socket.onMessage(message=>{
					if (message.startsWith("message "+playerID+" ")){
						let data = message.split(" ").slice(2).join(" ");
						console.log("message from player "+playerID+": ",data);
						callbacks.forEach(callback=>callback(data));
					}
				});
				onConnect(await perfectNegotiation(callback=>callbacks.push(callback),message=>{
					console.log("message to player "+playerID+": ",message);
					socket.send("message "+playerID+" "+message);
				},false));
			}
		});
		return id;
	}

	/**
	 * Joins a new game, returning an RTCPeerConnection to its host.
	 * @param {string} id
	 */
	static async join(id){
		const socket = new SocketDataChannel(baseURL+"/join/"+id);
		await socket.waitUntilReady();
		/** @type {((message:string)=>void)[]} */
		let callbacks = [];
		socket.onMessage(message=>{
			console.log("new message: ",message);
			callbacks.forEach(callback=>callback(message.split(" ").slice(1).join(" ")));
		});
		return await perfectNegotiation(callback=>callbacks.push(callback),message=>{
			console.log("send message:",message);
			socket.send("message "+message);
		},true);
	}
}
/** 
 * An implementation of "perfect negotiaton" as described [here](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation).
 * @param {(callback:(message:string)=>void)=>void} onMessage
 * @param {(message:string)=>void} sendMessage
 * @param {boolean} polite
 * @returns {Promise<ConnectionData>}
 */
export async function perfectNegotiation(onMessage,sendMessage,polite){
	const connection = new RTCPeerConnection();
	const dataChannel = connection.createDataChannel("channel",{id:0,negotiated:true});
	let makingOffer = false;
	connection.onnegotiationneeded = async()=>{
		makingOffer = true;
		// @ts-ignore
		await connection.setLocalDescription();
		//console.log("local description:",connection.localDescription)
		sendMessage(JSON.stringify({description:connection.localDescription}));
		makingOffer = false;
	};
	connection.onicecandidate = ({candidate})=>{
		//console.log("candidate:",candidate);
		sendMessage(JSON.stringify({candidate}));
	};
	let ignoreOffer = false;
	onMessage(async message=>{
		console.log("message to negotiator:",message);
		/** @type {{description:RTCSessionDescriptionInit,candidate:undefined}|{candidate:RTCIceCandidateInit,description:undefined}} */
		let data = JSON.parse(message);
		if (data.description){
			let description = data.description;
			ignoreOffer = !polite&&(description.type=="offer"&&(makingOffer||connection.signalingState!="stable"));
			if(!ignoreOffer){
				await connection.setRemoteDescription(description);
				if (description.type=="offer"){
					// @ts-ignore
					await connection.setLocalDescription();
					sendMessage(JSON.stringify({description:connection.localDescription}));
				}
			}
		}else if(data.candidate){
			try {
				await connection.addIceCandidate(data.candidate);
			}catch(e){
				if (!ignoreOffer){
					throw e;
				}
			}
		}
	});
	/** @type {Promise<ConnectionData>} */
	return new Promise(resolve=>{
		connection.onsignalingstatechange = ()=>{
			console.log("Signaling state changed to:",connection.signalingState);
			if (connection.signalingState=="stable"){
				resolve({connection,dataChannel});
			}
		};
	});
}