blob: ff19a0a86c888341dfbbb76ec14f0194b7a5e165 [file] [log] [blame]
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
// The public elements in this file implement the Server Connector Interface,
// part of the contract between the signaling server and the webrtc client.
// No changes that break backward compatibility are allowed here. Any new
// features must be added as a new function/class in the interface. Any
// additions to the interface must be checked for existence by the client before
// using it.
// The id of the device the client is supposed to connect to.
// The List Devices page in the signaling server may choose any way to pass the
// device id to the client page, this function retrieves that information once
// the client loaded.
// In this case the device id is passed as a parameter in the url.
export function deviceId() {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('deviceId');
}
// Creates a connector capable of communicating with the signaling server.
export async function createConnector() {
try {
let ws = await connectWs();
console.debug(`Connected to ${ws.url}`);
return new WebsocketConnector(ws);
} catch (e) {
console.error('WebSocket error:', e);
}
console.warn('Failed to connect websocket, trying polling instead');
return new PollingConnector();
}
// A connector object provides high level functions for communicating with the
// signaling server, while hiding away implementation details.
// This class is an interface and shouldn't be instantiated direclty.
// Only the public methods present in this class form part of the Server
// Connector Interface, any implementations of the interface are considered
// internal and not accessible to client code.
class Connector {
constructor() {
if (this.constructor == Connector) {
throw new Error('Connector is an abstract class');
}
}
// Selects a particular device in the signaling server and opens the signaling
// channel with it (but doesn't send any message to the device). Returns a
// promise to an object with the following properties:
// - deviceInfo: The info object provided by the device when it registered
// with the server.
// - infraConfig: The server's infrastructure configuration (mainly STUN and
// TURN servers)
// The promise may take a long time to resolve if, for example, the server
// decides to wait for a device with the provided id to register with it. The
// promise may be rejected if there are connectivity issues, a device with
// that id doesn't exist or this client doesn't have rights to access that
// device.
async requestDevice(deviceId) {
throw 'Not implemented!';
}
// Sends a message to the device selected with requestDevice. It's an error to
// call this function before the promise from requestDevice() has resolved.
// Returns an empty promise that is rejected when the message can not be
// delivered, either because the device has not been requested yet or because
// of connectivity issues.
async sendToDevice(msg) {
throw 'Not implemented!';
}
}
// End of Server Connector Interface.
// The following code is internal and shouldn't be accessed outside this file.
function httpUrl(path) {
return location.protocol + '//' + location.host + '/' + path;
}
function websocketUrl(path) {
return ((location.protocol == 'http:') ? 'ws://' : 'wss://') + location.host +
'/' + path;
}
const kPollConfigUrl = httpUrl('infra_config');
const kPollConnectUrl = httpUrl('connect');
const kPollForwardUrl = httpUrl('forward');
const kPollMessagesUrl = httpUrl('poll_messages');
async function connectWs() {
return new Promise((resolve, reject) => {
let url = websocketUrl('connect_client');
let ws = new WebSocket(url);
ws.onopen = () => {
resolve(ws);
};
ws.onerror = evt => {
reject(evt);
};
});
}
async function ajaxPostJson(url, data) {
const response = await fetch(url, {
method: 'POST',
cache: 'no-cache',
headers: {'Content-Type': 'application/json'},
redirect: 'follow',
body: JSON.stringify(data),
});
return response.json();
}
// Implementation of the connector interface using websockets
class WebsocketConnector extends Connector {
#websocket;
#futures = {};
#onDeviceMsgCb = msg =>
console.error('Received device message without registered listener');
onDeviceMsg(cb) {
this.#onDeviceMsgCb = cb;
}
constructor(ws) {
super();
ws.onmessage = e => {
let data = JSON.parse(e.data);
this.#onWebsocketMessage(data);
};
this.#websocket = ws;
}
async requestDevice(deviceId) {
return new Promise((resolve, reject) => {
this.#futures.onDeviceAvailable = (device) => resolve(device);
this.#futures.onConnectionFailed = (error) => reject(error);
this.#wsSendJson({
message_type: 'connect',
device_id: deviceId,
});
});
}
async sendToDevice(msg) {
return this.#wsSendJson({message_type: 'forward', payload: msg});
}
#onWebsocketMessage(message) {
const type = message.message_type;
if (message.error) {
console.error(message.error);
this.#futures.onConnectionFailed(message.error);
return;
}
switch (type) {
case 'config':
this.#futures.infraConfig = message;
break;
case 'device_info':
if (this.#futures.onDeviceAvailable) {
this.#futures.onDeviceAvailable({
deviceInfo: message.device_info,
infraConfig: this.#futures.infraConfig,
});
delete this.#futures.onDeviceAvailable;
} else {
console.error('Received unsolicited device info');
}
break;
case 'device_msg':
this.#onDeviceMsgCb(message.payload);
break;
default:
console.error('Unrecognized message type from server: ', type);
this.#futures.onConnectionFailed(
'Unrecognized message type from server: ' + type);
console.error(message);
}
}
async #wsSendJson(obj) {
return this.#websocket.send(JSON.stringify(obj));
}
}
// Implementation of the Connector interface using HTTP long polling
class PollingConnector extends Connector {
#connId = undefined;
#config = undefined;
#pollerSchedule;
#onDeviceMsgCb = msg =>
console.error('Received device message without registered listener');
onDeviceMsg(cb) {
this.#onDeviceMsgCb = cb;
}
constructor() {
super();
}
async requestDevice(deviceId) {
let config = await this.#getConfig();
let response = await ajaxPostJson(kPollConnectUrl, {device_id: deviceId});
this.#connId = response.connection_id;
this.#startPolling();
return {
deviceInfo: response.device_info,
infraConfig: config,
};
}
async sendToDevice(msg) {
// Forward messages act like polling messages as well
let device_messages = await this.#forward(msg);
for (const message of device_messages) {
this.#onDeviceMsgCb(message);
}
}
async #getConfig() {
if (this.#config === undefined) {
this.#config = await (await fetch(kPollConfigUrl, {
method: 'GET',
redirect: 'follow',
})).json();
}
return this.#config;
}
async #forward(msg) {
return await ajaxPostJson(kPollForwardUrl, {
connection_id: this.#connId,
payload: msg,
});
}
async #pollMessages() {
return await ajaxPostJson(kPollMessagesUrl, {
connection_id: this.#connId,
});
}
#startPolling() {
if (this.#pollerSchedule !== undefined) {
return;
}
let currentPollDelay = 1000;
let pollerRoutine = async () => {
let messages = await this.#pollMessages();
// Do exponential backoff on the polling up to 60 seconds
currentPollDelay = Math.min(60000, 2 * currentPollDelay);
for (const message of messages) {
this.#onDeviceMsgCb(message);
// There is at least one message, poll sooner
currentPollDelay = 1000;
}
this.#pollerSchedule = setTimeout(pollerRoutine, currentPollDelay);
};
this.#pollerSchedule = setTimeout(pollerRoutine, currentPollDelay);
}
}