UNPKG

networked-aframe

Version:

A web framework for building multi-user virtual reality experiences.

332 lines (271 loc) 9.31 kB
/* global NAF */ class UWSAdapter { constructor() { this.app = 'default'; this.room = 'default'; this.occupantListener = null; this.myRoomJoinTime = null; this.myId = null; this.packet = { from: undefined, to: undefined, type: undefined, data: undefined }; this.occupants = {}; // id -> joinTimestamp this.connectedClients = []; this.serverTimeRequests = 0; this.timeOffsets = []; this.avgTimeOffset = 0; this.ws = null; this.onWebsocketOpen = this.onWebsocketOpen.bind(this); this.onWebsocketClose = this.onWebsocketClose.bind(this); this.onWebsocketMessage = this.onWebsocketMessage.bind(this); // In the event the server restarts and all clients lose connection, reconnect with // some random jitter added to prevent simultaneous reconnection requests. this.initialReconnectionDelay = 1000 * Math.random(); this.reconnectionDelay = this.initialReconnectionDelay; this.reconnectionTimeout = null; this.maxReconnectionAttempts = 10; this.reconnectionAttempts = 0; this.isReconnecting = false; window.addEventListener('offline', () => { console.log('Browser went offline - closing WebSocket'); this.reconnect(); }); } setServerUrl(wsUrl) { this.wsUrl = wsUrl; } setApp(appName) { this.app = appName; } setRoom(roomName) { this.room = roomName; } setWebRtcOptions(options) { // No WebRTC support } setServerConnectListeners(successListener, failureListener) { this.connectSuccess = successListener; this.connectFailure = failureListener; } setRoomOccupantListener(occupantListener) { this.occupantListener = occupantListener; } setDataChannelListeners(openListener, closedListener, messageListener) { this.openListener = openListener; this.closedListener = closedListener; this.messageListener = messageListener; } connect() { return Promise.all([this.connectToServer(), this.updateTimeOffset()]); } connectToServer() { if (!this.wsUrl || this.wsUrl === '/') { if (location.protocol === 'https:') { this.wsUrl = 'wss://' + location.host; } else { this.wsUrl = 'ws://' + location.host; } } NAF.log.write('Connecting to WebSocket', this.wsUrl); const websocketConnection = new Promise((resolve, reject) => { this.ws = new WebSocket(this.wsUrl); this.websocketConnectionPromise = {}; this.websocketConnectionPromise.resolve = resolve; this.websocketConnectionPromise.reject = reject; this.ws.addEventListener('open', this.onWebsocketOpen); this.ws.addEventListener('close', this.onWebsocketClose); this.ws.addEventListener('message', this.onWebsocketMessage); }); return websocketConnection; } onWebsocketOpen() { console.log('WebSocket connected'); this.joinRoom(); } onWebsocketClose(event) { // The connection was closed successfully. Don't try to reconnect. if (event.code === 1000 || event.code === 1005) { return; } this.websocketConnectionPromise.reject(event); if (!this.isReconnecting) { this.isReconnecting = true; console.warn('WebSocket closed unexpectedly.'); if (this.onReconnecting) { this.onReconnecting(this.reconnectionDelay); } this.reconnectionTimeout = setTimeout(() => this.reconnect(), this.reconnectionDelay); } } reconnect() { // Dispose of all networked entities and other resources tied to the session. this.disconnect(); this.connectToServer() .then(() => { this.reconnectionDelay = this.initialReconnectionDelay; this.reconnectionAttempts = 0; this.isReconnecting = false; if (this.onReconnected) { this.onReconnected(); } }) .catch((error) => { this.reconnectionDelay += 1000; this.reconnectionAttempts++; if (this.reconnectionAttempts > this.maxReconnectionAttempts) { const error = new Error( 'Connection could not be reestablished, exceeded maximum number of reconnection attempts.' ); if (this.onReconnectionError) { return this.onReconnectionError(error); } else { console.warn(error); return; } } console.warn('Error during reconnect, retrying.'); console.warn(error); if (this.onReconnecting) { this.onReconnecting(this.reconnectionDelay); } this.reconnectionTimeout = setTimeout(() => this.reconnect(), this.reconnectionDelay); }); } onWebsocketMessage(event) { const message = JSON.parse(event.data); const { event: eventName, data } = message; switch (eventName) { case 'connectSuccess': { const { joinedTime, socketId } = data; this.myRoomJoinTime = joinedTime; this.myId = socketId; this.websocketConnectionPromise.resolve(); this.connectSuccess(this.myId); break; } case 'occupantsChanged': { const { occupants } = data; this.receivedOccupants(occupants); break; } case 'send': case 'broadcast': { const { from, type, data: messageData } = data; this.messageListener(from, type, messageData); break; } } } joinRoom() { NAF.log.write('Joining room', this.room); this.send('joinRoom', { room: this.room }); } receivedOccupants(occupants) { delete occupants[this.myId]; this.occupants = occupants; this.occupantListener(occupants); } shouldStartConnectionTo(client) { return true; } startStreamConnection(remoteId) { this.connectedClients.push(remoteId); this.openListener(remoteId); } closeStreamConnection(clientId) { this.connectedClients = this.connectedClients.filter((c) => c !== clientId); this.closedListener(clientId); } getConnectStatus(clientId) { const connected = this.connectedClients.indexOf(clientId) !== -1; if (connected) { return NAF.adapters.IS_CONNECTED; } else { return NAF.adapters.NOT_CONNECTED; } } send(event, data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ event, data })); } } sendData(to, type, data) { this.sendDataGuaranteed(to, type, data); } sendDataGuaranteed(to, type, data) { this.packet.from = this.myId; this.packet.to = to; this.packet.type = type; this.packet.data = data; this.send('send', this.packet); } broadcastData(type, data) { this.broadcastDataGuaranteed(type, data); } broadcastDataGuaranteed(type, data) { this.packet.from = this.myId; this.packet.to = undefined; this.packet.type = type; this.packet.data = data; this.send('broadcast', this.packet); } getMediaStream(clientId) { return Promise.reject('Interface method not implemented: getMediaStream'); } updateTimeOffset() { const clientSentTime = Date.now() + this.avgTimeOffset; return fetch(document.location.href, { method: 'HEAD', cache: 'no-cache' }).then((res) => { const precision = 1000; const serverReceivedTime = new Date(res.headers.get('Date')).getTime() + precision / 2; const clientReceivedTime = Date.now(); const serverTime = serverReceivedTime + (clientReceivedTime - clientSentTime) / 2; const timeOffset = serverTime - clientReceivedTime; this.serverTimeRequests++; if (this.serverTimeRequests <= 10) { this.timeOffsets.push(timeOffset); } else { this.timeOffsets[this.serverTimeRequests % 10] = timeOffset; } this.avgTimeOffset = this.timeOffsets.reduce((acc, offset) => (acc += offset), 0) / this.timeOffsets.length; if (this.serverTimeRequests > 10) { setTimeout(() => this.updateTimeOffset(), 5 * 60 * 1000); // Sync clock every 5 minutes. } else { this.updateTimeOffset(); } }); } getServerTime() { return Date.now() + this.avgTimeOffset; } onDisconnect() { if (NAF.clientId === '') return; // Properly remove connected clients and remote entities this.receivedOccupants({}); // For entities I'm the creator, reset to empty owner and register // again the onConnected callback to send my entities to all // the participants upon reconnect. for (const entity of Object.values(NAF.entities.entities)) { if (entity.components.networked.data.creator === NAF.clientId) { // The creator and owner will be set to the new NAF.clientId upon reconnect entity.setAttribute('networked', { owner: '', creator: '' }); document.body.addEventListener('connected', entity.components.networked.onConnected, false); } } NAF.clientId = ''; } disconnect() { clearTimeout(this.reconnectionTimeout); this.onDisconnect(); if (this.ws) { this.ws.removeEventListener('open', this.onWebsocketOpen); this.ws.removeEventListener('close', this.onWebsocketClose); this.ws.removeEventListener('message', this.onWebsocketMessage); this.ws.close(); this.ws = null; } } } module.exports = UWSAdapter;