107 lines
3.3 KiB
JavaScript
107 lines
3.3 KiB
JavaScript
// @ts-check
|
|||
|
|
/**
|
||
|
|
* The connection to the relay. It knows nothing about pages or encryption:
|
||
|
|
* it delivers bytes to peers and reports who is present. It reconnects by
|
||
|
|
* itself when the connection drops.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const RETRY_DELAYS_MS = [500, 1000, 2000, 5000];
|
||
|
|
|
||
|
|
/** Close codes after which retrying is pointless (see the relay). */
|
||
|
|
const FINAL_CLOSE_CODES = new Map([
|
||
|
|
[4001, 'The session is full.'],
|
||
|
|
[4002, 'The relay is full.'],
|
||
|
|
]);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @typedef {object} RelayHandlers
|
||
|
|
* @property {(peer: number, peers: number[]) => void} onWelcome
|
||
|
|
* Connected (or reconnected): our peer number and the peers present.
|
||
|
|
* @property {(peer: number) => void} onJoin
|
||
|
|
* @property {(peer: number) => void} onLeave
|
||
|
|
* @property {(peer: number, body: Uint8Array<ArrayBuffer>) => void} onMessage
|
||
|
|
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
|
||
|
|
*/
|
||
|
|
|
||
|
|
export class RelayConnection {
|
||
|
|
/**
|
||
|
|
* @param {string} relay Address of the relay, ws:// or wss://.
|
||
|
|
* @param {string} roomId
|
||
|
|
* @param {RelayHandlers} handlers
|
||
|
|
*/
|
||
|
|
constructor(relay, roomId, handlers) {
|
||
|
|
this.url = `${relay}/room/${roomId}`;
|
||
|
|
this.handlers = handlers;
|
||
|
|
/** @type {WebSocket | null} */
|
||
|
|
this.socket = null;
|
||
|
|
this.attempt = 0;
|
||
|
|
this.closed = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
connect() {
|
||
|
|
if (this.closed) return;
|
||
|
|
this.handlers.onStatus('connecting');
|
||
|
|
const socket = new WebSocket(this.url);
|
||
|
|
socket.binaryType = 'arraybuffer';
|
||
|
|
this.socket = socket;
|
||
|
|
|
||
|
|
socket.onmessage = (event) => {
|
||
|
|
if (socket !== this.socket) return;
|
||
|
|
if (typeof event.data === 'string') {
|
||
|
|
const control = JSON.parse(event.data);
|
||
|
|
if (control.type === 'welcome') {
|
||
|
|
this.attempt = 0;
|
||
|
|
this.handlers.onStatus('connected');
|
||
|
|
this.handlers.onWelcome(control.peer, control.peers);
|
||
|
|
} else if (control.type === 'join') {
|
||
|
|
this.handlers.onJoin(control.peer);
|
||
|
|
} else if (control.type === 'leave') {
|
||
|
|
this.handlers.onLeave(control.peer);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
const bytes = new Uint8Array(event.data);
|
||
|
|
if (bytes.length < 4) return;
|
||
|
|
const peer = new DataView(event.data).getUint32(0);
|
||
|
|
this.handlers.onMessage(peer, bytes.subarray(4));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
socket.onclose = (event) => {
|
||
|
|
if (socket !== this.socket) return;
|
||
|
|
this.socket = null;
|
||
|
|
if (this.closed) return;
|
||
|
|
const final = FINAL_CLOSE_CODES.get(event.code);
|
||
|
|
if (final) {
|
||
|
|
this.closed = true;
|
||
|
|
this.handlers.onStatus('failed', final);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.handlers.onStatus('connecting');
|
||
|
|
const delay = RETRY_DELAYS_MS[Math.min(this.attempt, RETRY_DELAYS_MS.length - 1)];
|
||
|
|
this.attempt += 1;
|
||
|
|
setTimeout(() => this.connect(), delay);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @returns {boolean} */
|
||
|
|
get connected() {
|
||
|
|
return this.socket !== null && this.socket.readyState === WebSocket.OPEN;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {number} destination A peer number, or 0 for everyone else.
|
||
|
|
* @param {Uint8Array} body
|
||
|
|
*/
|
||
|
|
send(destination, body) {
|
||
|
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
||
|
|
const message = new Uint8Array(4 + body.length);
|
||
|
|
new DataView(message.buffer).setUint32(0, destination);
|
||
|
|
message.set(body, 4);
|
||
|
|
this.socket.send(message);
|
||
|
|
}
|
||
|
|
|
||
|
|
close() {
|
||
|
|
this.closed = true;
|
||
|
|
if (this.socket) this.socket.close();
|
||
|
|
}
|
||
|
|
}
|