js/js-flipper/src/client.ts (240 lines of code) (raw):
/**
* Copyright (c) Meta Platforms, Inc. and 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 {FlipperConnection} from './connection';
import {FlipperRequest, FlipperResponse} from './message';
import {FlipperPlugin} from './plugin';
import {FlipperResponder} from './responder';
import {assert, detectDevice, detectOS} from './util';
import {RECONNECT_TIMEOUT} from './consts';
// TODO: Share with flipper-server-core
// Borrowed from https://github.com/strong-roots-capital/websocket-close-codes
/**
* IANA WebSocket close code definitions.
*
* @remarks
* https://www.iana.org/assignments/websocket/websocket.xml#close-code-number
*/
export enum WSCloseCode {
/**
* Normal closure; the connection successfully completed whatever
* purpose for which it was created.
*/
NormalClosure = 1000,
/**
* The endpoint is going away, either because of a server failure
* or because the browser is navigating away from the page that
* opened the connection.
*/
GoingAway = 1001,
/**
* The endpoint is terminating the connection due to a protocol
* error.
*/
ProtocolError = 1002,
/**
* The connection is being terminated because the endpoint
* received data of a type it cannot accept (for example, a
* text-only endpoint received binary data).
*/
UnsupportedData = 1003,
/**
* (Reserved.) Indicates that no status code was provided even
* though one was expected.
*/
NoStatusRecvd = 1005,
/**
* (Reserved.) Used to indicate that a connection was closed
* abnormally (that is, with no close frame being sent) when a
* status code is expected.
*/
AbnormalClosure = 1006,
/**
* The endpoint is terminating the connection because a message
* was received that contained inconsistent data (e.g., non-UTF-8
* data within a text message).
*/
InvalidFramePayloadData = 1007,
/**
* The endpoint is terminating the connection because it received
* a message that violates its policy. This is a generic status
* code, used when codes 1003 and 1009 are not suitable.
*/
PolicyViolation = 1008,
/**
* The endpoint is terminating the connection because a data frame
* was received that is too large.
*/
MessageTooBig = 1009,
/**
* The client is terminating the connection because it expected
* the server to negotiate one or more extension, but the server
* didn't.
*/
MissingExtension = 1010,
/**
* The server is terminating the connection because it encountered
* an unexpected condition that prevented it from fulfilling the
* request.
*/
InternalError = 1011,
/**
* The server is terminating the connection because it is
* restarting. [Ref]
*/
ServiceRestart = 1012,
/**
* The server is terminating the connection due to a temporary
* condition, e.g. it is overloaded and is casting off some of its
* clients.
*/
TryAgainLater = 1013,
/**
* The server was acting as a gateway or proxy and received an
* invalid response from the upstream server. This is similar to
* 502 HTTP Status Code.
*/
BadGateway = 1014,
/**
* (Reserved.) Indicates that the connection was closed due to a
* failure to perform a TLS handshake (e.g., the server
* certificate can't be verified).
*/
TLSHandshake = 1015,
}
// global.WebSocket interface is not 100% compatible with ws.WebSocket interface
// We need to support both, so defining our own with only required props
export interface FlipperWebSocket {
onclose: ((ev: {code: WSCloseCode}) => void) | null;
onerror: ((ev: unknown) => void) | null;
onmessage:
| ((ev: {data: Buffer | ArrayBuffer | Buffer[] | string}) => void)
| null;
onopen: (() => void) | null;
close(code?: number): void;
send(data: string): void;
readyState: number;
}
interface FlipperClientOptions {
// Make the client connect to a different URL
urlBase?: string;
// Override WebSocket implementation (Node.js folks, it is for you!)
websocketFactory?: (url: string) => FlipperWebSocket;
// Override how errors are handled (it is simple `console.error` by default)
onError?: (e: unknown) => void;
// Timeout after which client tries to reconnect to Flipper
reconnectTimeout?: number;
}
export class FlipperClient {
private readonly plugins: Map<string, FlipperPlugin> = new Map();
private readonly connections: Map<string, FlipperConnection> = new Map();
private ws?: FlipperWebSocket;
private readonly devicePseudoId = `${Date.now()}.${Math.random()}`;
private readonly os = detectOS();
private readonly device = detectDevice();
private reconnectionTimer?: NodeJS.Timeout;
private resolveStartPromise?: () => void;
private urlBase!: string;
private websocketFactory!: (url: string) => FlipperWebSocket;
private onError!: (e: unknown) => void;
private reconnectTimeout!: number;
private appName!: string;
constructor() {}
addPlugin(plugin: FlipperPlugin) {
this.plugins.set(plugin.getId(), plugin);
if (this.isConnected) {
this.refreshPlugins();
}
}
getPlugin(id: string): FlipperPlugin | undefined {
return this.plugins.get(id);
}
async start(
appName: string,
{
urlBase = 'localhost:8333',
websocketFactory = (url) => new WebSocket(url) as FlipperWebSocket,
onError = (e) => console.error('WebSocket error', e),
reconnectTimeout = RECONNECT_TIMEOUT,
}: FlipperClientOptions = {},
): Promise<void> {
if (this.ws) {
return;
}
this.appName = appName;
this.onError = onError;
this.urlBase = urlBase;
this.websocketFactory = websocketFactory;
this.reconnectTimeout = reconnectTimeout;
return new Promise<void>((resolve) => {
this.resolveStartPromise = resolve;
this.connectToFlipper();
});
}
stop() {
if (!this.ws) {
return;
}
// TODO: Why is it not 1000 by default?
this.ws.close(WSCloseCode.NormalClosure);
this.ws = undefined;
for (const plugin of this.plugins.values()) {
this.disconnectPlugin(plugin);
}
}
sendData(payload: FlipperRequest | FlipperResponse) {
assert(this.ws);
this.ws.send(JSON.stringify(payload));
}
get isConnected() {
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState#value
return !!this.ws && this.ws.readyState === 1;
}
private connectToFlipper() {
const url = `ws://${this.urlBase}?device_id=${this.device}${this.devicePseudoId}&device=${this.device}&app=${this.appName}&os=${this.os}`;
const encodedUrl = encodeURI(url);
this.ws = this.websocketFactory(encodedUrl);
this.ws.onerror = (error) => {
this.onError(error);
};
this.ws.onclose = ({code}) => {
// Some WS implementations do not properly set `wasClean`
if (code !== WSCloseCode.NormalClosure) {
this.reconnect();
}
};
this.ws.onopen = () => {
assert(this.ws);
this.resolveStartPromise?.();
this.resolveStartPromise = undefined;
this.ws.onmessage = ({data}) => {
try {
const message = JSON.parse(data.toString());
this.onMessageReceived(message);
} catch (error) {
this.onError(error);
assert(this.ws);
this.ws.close(WSCloseCode.InternalError);
}
};
};
}
// TODO: Reconnect in a loop with an exponential backoff
private reconnect() {
this.ws = undefined;
if (this.reconnectionTimer) {
clearTimeout(this.reconnectionTimer);
this.reconnectionTimer = undefined;
}
this.reconnectionTimer = setTimeout(() => {
this.connectToFlipper();
}, this.reconnectTimeout);
}
private onMessageReceived(message: {
method: string;
id: number;
params: any;
}) {
let responder: FlipperResponder | undefined;
try {
const {method, params, id} = message;
responder = new FlipperResponder(id, this);
if (method === 'getPlugins') {
responder.success({plugins: [...this.plugins.keys()]});
return;
}
if (method === 'getBackgroundPlugins') {
responder.success({
plugins: [...this.plugins.keys()].filter((key) =>
this.plugins.get(key)?.runInBackground?.(),
),
});
return;
}
if (method === 'init') {
const identifier = params['plugin'] as string;
const plugin = this.plugins.get(identifier);
if (plugin == null) {
const errorMessage = `Plugin ${identifier} not found for method ${method}`;
responder.error({message: errorMessage, name: 'PluginNotFound'});
return;
}
this.connectPlugin(plugin);
return;
}
if (method === 'deinit') {
const identifier = params['plugin'] as string;
const plugin = this.plugins.get(identifier);
if (plugin == null) {
const errorMessage = `Plugin ${identifier} not found for method ${method}`;
responder.error({message: errorMessage, name: 'PluginNotFound'});
return;
}
this.disconnectPlugin(plugin);
return;
}
if (method === 'execute') {
const identifier = params['api'] as string;
const connection = this.connections.get(identifier);
if (connection == null) {
const errorMessage = `Connection ${identifier} not found for plugin identifier`;
responder.error({message: errorMessage, name: 'ConnectionNotFound'});
return;
}
connection.call(
params['method'] as string,
params['params'],
responder,
);
return;
}
if (method == 'isMethodSupported') {
const identifier = params['api'] as string;
const method = params['method'] as string;
const connection = this.connections.get(identifier);
if (connection == null) {
const errorMessage = `Connection ${identifier} not found for plugin identifier`;
responder.error({message: errorMessage, name: 'ConnectionNotFound'});
return;
}
responder.success({isSupported: connection.hasReceiver(method)});
return;
}
const response = {message: 'Received unknown method: ' + method};
responder.error(response);
} catch (e) {
if (responder) {
responder.error({
message: 'Unknown error during ' + JSON.stringify(message),
name: 'Unknown',
});
}
throw e;
}
}
private refreshPlugins() {
this.sendData({method: 'refreshPlugins'});
}
private connectPlugin(plugin: FlipperPlugin): void {
const id = plugin.getId();
const connection = new FlipperConnection(id, this);
plugin.onConnect(connection);
this.connections.set(id, connection);
}
private disconnectPlugin(plugin: FlipperPlugin): void {
const id = plugin.getId();
if (this.connections.has(id)) {
plugin.onDisconnect();
this.connections.delete(id);
}
}
}