WebSocket-based server, base class and browser

Summary:
Introduce a base class for WebSocket based servers and a Browser-based implementation which is in use by Kite.

The implementation for the Browser-based one is basically taken as is from ServerController but slightly adapted to match the existing interface.

As with the RSocket-based implementation, this diff doesn't put this implementation into use but is a good opportunity to revisit the existing implementation.

Reviewed By: passy

Differential Revision: D29985886

fbshipit-source-id: 32abba37ec31478b6497ef5cfe90bb9aedc282d3
This commit is contained in:
Lorenzo Blasa
2021-07-30 02:00:11 -07:00
committed by Facebook GitHub Bot
parent e2fc1339f4
commit 5c99170abd
2 changed files with 275 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {IncomingMessage} from 'http';
import {SecureServerConfig} from '../utils/CertificateProvider';
import ServerAdapter, {ServerEventsListener} from './ServerAdapter';
import ws from 'ws';
import WebSocket from 'ws';
import https from 'https';
import http from 'http';
/**
* It serves as a base class for WebSocket based servers. It delegates the 'connection'
* event to subclasses as a customisation point.
*/
abstract class ServerWebSocketBase extends ServerAdapter {
rawServer_: ws.Server | null;
constructor(listener: ServerEventsListener) {
super(listener);
this.rawServer_ = null;
}
/**
* WebSocket client verification. Usually used to validate the origin.
*
* Base implementation simply returns true, but this can be overriden by subclasses
* that require verification.
*
* @returns Return true if the client was successfully verified, otherwise
* returns false.
*/
verifyClient(): ws.VerifyClientCallbackSync {
return (_info: {origin: string; req: IncomingMessage; secure: boolean}) => {
return false;
};
}
start(port: number, sslConfig?: SecureServerConfig): Promise<boolean> {
const self = this;
return new Promise((resolve, reject) => {
let server: http.Server | https.Server | undefined;
if (sslConfig) {
server = https.createServer({
key: sslConfig.key,
cert: sslConfig.cert,
ca: sslConfig.ca,
// Client to provide a certificate to authenticate.
requestCert: sslConfig.requestCert,
// As specified as "true", so no unauthenticated traffic
// will make it to the specified route specified
rejectUnauthorized: sslConfig.rejectUnauthorized,
});
} else {
server = http.createServer();
}
const handleRequest = sslConfig
? self.onSecureConnection
: self.onConnection;
const rawServer = new WebSocket.Server({
server,
verifyClient: this.verifyClient(),
});
rawServer.on('connection', (ws: WebSocket, message: any) => {
handleRequest.apply(self, [ws, message]);
});
rawServer.on('error', (_ws: WebSocket, error: any) => {
console.warn('Server found connection error: ' + error);
reject(error);
});
if (server) {
server.listen(port, () => {
console.debug(
`${
sslConfig ? 'Secure' : 'Certificate'
} server started on port ${port}`,
'server',
);
self.listener.onListening(port);
self.rawServer_ = rawServer;
resolve(true);
});
} else {
reject(new Error(`Unable to start server at port ${port}`));
}
});
}
stop(): Promise<void> {
if (this.rawServer_) {
return Promise.resolve(this.rawServer_.close());
}
return Promise.resolve();
}
onConnection(_ws: WebSocket, _message: any): void {
throw new Error('Method not implemented.');
}
onSecureConnection(_ws: WebSocket, _message: any): void {
throw new Error('Method not implemented.');
}
}
export default ServerWebSocketBase;

View File

@@ -0,0 +1,162 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import ServerWebSocketBase from './ServerWebSocketBase';
import WebSocket from 'ws';
import querystring from 'querystring';
import Client, {ClientQuery} from '../Client';
import {BrowserClientFlipperConnection} from './BrowserClientFlipperConnection';
import {ServerEventsListener} from './ServerAdapter';
import constants from '../fb-stubs/constants';
import ws from 'ws';
import {IncomingMessage} from 'http';
/**
* WebSocket-based server which uses a connect/disconnect handshake over an insecure channel.
*/
class ServerWebSocketBrowser extends ServerWebSocketBase {
constructor(listener: ServerEventsListener) {
super(listener);
}
verifyClient(): ws.VerifyClientCallbackSync {
return (info: {origin: string; req: IncomingMessage; secure: boolean}) => {
return constants.VALID_WEB_SOCKET_REQUEST_ORIGIN_PREFIXES.some(
(validPrefix) => info.origin.startsWith(validPrefix),
);
};
}
/**
* A connection has been established between the server and a client.
* @param ws An active WebSocket.
* @param message Incoming request message.
*/
onConnection(ws: WebSocket, message: any): void {
const clients: {
[app: string]: Promise<Client>;
} = {};
/**
* Any required arguments to construct a ClientQuery come
* embedded in the query string.
*/
const query = querystring.decode(message.url.split('?')[1]);
const deviceId: string =
typeof query.deviceId === 'string' ? query.deviceId : 'webbrowser';
const device =
typeof query.device === 'string' ? query.device : 'WebSocket';
const clientQuery: ClientQuery = {
device_id: deviceId,
device,
app: device,
os: 'JSWebApp',
};
this.listener.onConnectionAttempt(clientQuery);
const cleanup = () => {
Object.values(clients).map((p) =>
p.then((c) => this.listener.onConnectionClosed(c.id)),
);
// TODO: destroy device.
// This seems to be the only case in which a device gets destroyed when there's a disconnection
// or error on the transport layer.
//
// destroyDevice(this.store, this.logger, deviceId);
};
/**
* Subscribe to the 'message' event. Initially, a handshake should take place in the form of a
* 'connect' message. Once received, a client connection will be established and registered. This
* is followed by another subscription to the 'message' event, again. Effectively, two listeners
* are now attached to that event. The former will continue to check for 'connect' and 'disconnect'
* messages. The latter will deliver messages to the client.
*/
ws.on('message', (rawMessage: any) => {
let message: any | undefined;
try {
message = JSON.parse(rawMessage.toString());
} catch (error) {
// Throws a SyntaxError exception if the string to parse is not valid JSON.
console.log('Received message is not valid.', error);
return;
}
switch (message.type) {
case 'connect': {
const app = message.app;
const plugins = message.plugins;
const clientConnection = new BrowserClientFlipperConnection(
ws,
app,
plugins,
);
const extendedClientQuery = {...clientQuery, medium: 1};
extendedClientQuery.sdk_version = plugins == null ? 4 : 1;
let resolvedClient: Client | null = null;
const client: Promise<Client> = this.listener.onConnectionCreated(
extendedClientQuery,
clientConnection,
);
client.then((client) => (resolvedClient = client)).catch((_) => {});
clients[app] = client;
ws.on('message', (m: any) => {
let parsed: any | undefined;
try {
parsed = JSON.parse(m.toString());
} catch (error) {
// Throws a SyntaxError exception if the string to parse is not valid JSON.
console.log('Received message is not valid.', error);
return;
}
// non-null payload id means response to prev request, it's handled in connection
if (parsed.app === app && parsed.payload?.id == null) {
const message = JSON.stringify(parsed.payload);
if (resolvedClient) {
resolvedClient.onMessage(message);
} else {
client.then((c) => c.onMessage(message)).catch((_) => {});
}
}
});
break;
}
case 'disconnect': {
const app = message.app;
(clients[app] || Promise.resolve())
.then((c) => {
this.listener.onConnectionClosed(c.id);
delete clients[app];
})
.catch((_) => {});
break;
}
}
});
/** Close event from the existing client connection. */
ws.on('close', () => {
cleanup();
});
/** Error event from the existing client connection. */
ws.on('error', (error) => {
console.warn('Server found connection error: ' + error);
cleanup();
});
}
}
export default ServerWebSocketBrowser;