1/* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17'use strict'; 18 19// The public elements in this file implement the Server Connector Interface, 20// part of the contract between the signaling server and the webrtc client. 21// No changes that break backward compatibility are allowed here. Any new 22// features must be added as a new function/class in the interface. Any 23// additions to the interface must be checked for existence by the client before 24// using it. 25 26// The id of the device the client is supposed to connect to. 27// The List Devices page in the signaling server may choose any way to pass the 28// device id to the client page, this function retrieves that information once 29// the client loaded. 30// In this case the device id is passed as a parameter in the url. 31export function deviceId() { 32 const urlParams = new URLSearchParams(window.location.search); 33 return urlParams.get('deviceId'); 34} 35 36// Creates a connector capable of communicating with the signaling server. 37export async function createConnector() { 38 try { 39 let ws = await connectWs(); 40 console.debug(`Connected to ${ws.url}`); 41 return new WebsocketConnector(ws); 42 } catch (e) { 43 console.error('WebSocket error:', e); 44 } 45 console.warn('Failed to connect websocket, trying polling instead'); 46 47 return new PollingConnector(); 48} 49 50// A connector object provides high level functions for communicating with the 51// signaling server, while hiding away implementation details. 52// This class is an interface and shouldn't be instantiated direclty. 53// Only the public methods present in this class form part of the Server 54// Connector Interface, any implementations of the interface are considered 55// internal and not accessible to client code. 56class Connector { 57 constructor() { 58 if (this.constructor == Connector) { 59 throw new Error('Connector is an abstract class'); 60 } 61 } 62 63 // Selects a particular device in the signaling server and opens the signaling 64 // channel with it (but doesn't send any message to the device). Returns a 65 // promise to an object with the following properties: 66 // - deviceInfo: The info object provided by the device when it registered 67 // with the server. 68 // - infraConfig: The server's infrastructure configuration (mainly STUN and 69 // TURN servers) 70 // The promise may take a long time to resolve if, for example, the server 71 // decides to wait for a device with the provided id to register with it. The 72 // promise may be rejected if there are connectivity issues, a device with 73 // that id doesn't exist or this client doesn't have rights to access that 74 // device. 75 async requestDevice(deviceId) { 76 throw 'Not implemented!'; 77 } 78 79 // Sends a message to the device selected with requestDevice. It's an error to 80 // call this function before the promise from requestDevice() has resolved. 81 // Returns an empty promise that is rejected when the message can not be 82 // delivered, either because the device has not been requested yet or because 83 // of connectivity issues. 84 async sendToDevice(msg) { 85 throw 'Not implemented!'; 86 } 87} 88 89// End of Server Connector Interface. 90 91// The following code is internal and shouldn't be accessed outside this file. 92 93function httpUrl(path) { 94 return location.protocol + '//' + location.host + '/' + path; 95} 96 97function websocketUrl(path) { 98 return ((location.protocol == 'http:') ? 'ws://' : 'wss://') + location.host + 99 '/' + path; 100} 101 102const kPollConfigUrl = httpUrl('infra_config'); 103const kPollConnectUrl = httpUrl('connect'); 104const kPollForwardUrl = httpUrl('forward'); 105const kPollMessagesUrl = httpUrl('poll_messages'); 106 107async function connectWs() { 108 return new Promise((resolve, reject) => { 109 let url = websocketUrl('connect_client'); 110 let ws = new WebSocket(url); 111 ws.onopen = () => { 112 resolve(ws); 113 }; 114 ws.onerror = evt => { 115 reject(evt); 116 }; 117 }); 118} 119 120async function ajaxPostJson(url, data) { 121 const response = await fetch(url, { 122 method: 'POST', 123 cache: 'no-cache', 124 headers: {'Content-Type': 'application/json'}, 125 redirect: 'follow', 126 body: JSON.stringify(data), 127 }); 128 return response.json(); 129} 130 131// Implementation of the connector interface using websockets 132class WebsocketConnector extends Connector { 133 #websocket; 134 #futures = {}; 135 #onDeviceMsgCb = msg => 136 console.error('Received device message without registered listener'); 137 138 onDeviceMsg(cb) { 139 this.#onDeviceMsgCb = cb; 140 } 141 142 constructor(ws) { 143 super(); 144 ws.onmessage = e => { 145 let data = JSON.parse(e.data); 146 this.#onWebsocketMessage(data); 147 }; 148 this.#websocket = ws; 149 } 150 151 async requestDevice(deviceId) { 152 return new Promise((resolve, reject) => { 153 this.#futures.onDeviceAvailable = (device) => resolve(device); 154 this.#futures.onConnectionFailed = (error) => reject(error); 155 this.#wsSendJson({ 156 message_type: 'connect', 157 device_id: deviceId, 158 }); 159 }); 160 } 161 162 async sendToDevice(msg) { 163 return this.#wsSendJson({message_type: 'forward', payload: msg}); 164 } 165 166 #onWebsocketMessage(message) { 167 const type = message.message_type; 168 if (message.error) { 169 console.error(message.error); 170 this.#futures.onConnectionFailed(message.error); 171 return; 172 } 173 switch (type) { 174 case 'config': 175 this.#futures.infraConfig = message; 176 break; 177 case 'device_info': 178 if (this.#futures.onDeviceAvailable) { 179 this.#futures.onDeviceAvailable({ 180 deviceInfo: message.device_info, 181 infraConfig: this.#futures.infraConfig, 182 }); 183 delete this.#futures.onDeviceAvailable; 184 } else { 185 console.error('Received unsolicited device info'); 186 } 187 break; 188 case 'device_msg': 189 this.#onDeviceMsgCb(message.payload); 190 break; 191 default: 192 console.error('Unrecognized message type from server: ', type); 193 this.#futures.onConnectionFailed( 194 'Unrecognized message type from server: ' + type); 195 console.error(message); 196 } 197 } 198 199 async #wsSendJson(obj) { 200 return this.#websocket.send(JSON.stringify(obj)); 201 } 202} 203 204// Implementation of the Connector interface using HTTP long polling 205class PollingConnector extends Connector { 206 #connId = undefined; 207 #config = undefined; 208 #pollerSchedule; 209 #onDeviceMsgCb = msg => 210 console.error('Received device message without registered listener'); 211 212 onDeviceMsg(cb) { 213 this.#onDeviceMsgCb = cb; 214 } 215 216 constructor() { 217 super(); 218 } 219 220 async requestDevice(deviceId) { 221 let config = await this.#getConfig(); 222 let response = await ajaxPostJson(kPollConnectUrl, {device_id: deviceId}); 223 this.#connId = response.connection_id; 224 225 this.#startPolling(); 226 227 return { 228 deviceInfo: response.device_info, 229 infraConfig: config, 230 }; 231 } 232 233 async sendToDevice(msg) { 234 // Forward messages act like polling messages as well 235 let device_messages = await this.#forward(msg); 236 for (const message of device_messages) { 237 this.#onDeviceMsgCb(message); 238 } 239 } 240 241 async #getConfig() { 242 if (this.#config === undefined) { 243 this.#config = await (await fetch(kPollConfigUrl, { 244 method: 'GET', 245 redirect: 'follow', 246 })).json(); 247 } 248 return this.#config; 249 } 250 251 async #forward(msg) { 252 return await ajaxPostJson(kPollForwardUrl, { 253 connection_id: this.#connId, 254 payload: msg, 255 }); 256 } 257 258 async #pollMessages() { 259 return await ajaxPostJson(kPollMessagesUrl, { 260 connection_id: this.#connId, 261 }); 262 } 263 264 #startPolling() { 265 if (this.#pollerSchedule !== undefined) { 266 return; 267 } 268 269 let currentPollDelay = 1000; 270 let pollerRoutine = async () => { 271 let messages = await this.#pollMessages(); 272 273 // Do exponential backoff on the polling up to 60 seconds 274 currentPollDelay = Math.min(60000, 2 * currentPollDelay); 275 for (const message of messages) { 276 this.#onDeviceMsgCb(message); 277 // There is at least one message, poll sooner 278 currentPollDelay = 1000; 279 } 280 this.#pollerSchedule = setTimeout(pollerRoutine, currentPollDelay); 281 }; 282 283 this.#pollerSchedule = setTimeout(pollerRoutine, currentPollDelay); 284 } 285} 286