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:
committed by
Facebook GitHub Bot
parent
e2fc1339f4
commit
5c99170abd
113
desktop/app/src/comms/ServerWebSocketBase.tsx
Normal file
113
desktop/app/src/comms/ServerWebSocketBase.tsx
Normal 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;
|
||||||
162
desktop/app/src/comms/ServerWebSocketBrowser.tsx
Normal file
162
desktop/app/src/comms/ServerWebSocketBrowser.tsx
Normal 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;
|
||||||
Reference in New Issue
Block a user