• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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