Add AbstractClient to flipper-frontend-core
Summary: AbstractClient provides basic interaction with Flipper Server and manages plugins Reviewed By: mweststrate Differential Revision: D36129750 fbshipit-source-id: 056589666c45821d0af276e3a1572708e0a3b903
This commit is contained in:
committed by
Facebook GitHub Bot
parent
f0b5e7cadb
commit
658cd1544d
514
desktop/flipper-frontend-core/src/AbstractClient.tsx
Normal file
514
desktop/flipper-frontend-core/src/AbstractClient.tsx
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
|
// We're using `deviceSync` here on purpose which is triggering a lot of warnings.
|
||||||
|
/* eslint-disable node/no-sync */
|
||||||
|
|
||||||
|
import BaseDevice from './devices/BaseDevice';
|
||||||
|
import {Logger, FlipperServer, ServerAddOnControls} from 'flipper-common';
|
||||||
|
import {
|
||||||
|
reportPluginFailures,
|
||||||
|
NoLongerConnectedToClientError,
|
||||||
|
} from 'flipper-common';
|
||||||
|
import EventEmitter from 'eventemitter3';
|
||||||
|
import {getPluginKey} from './utils/pluginKey';
|
||||||
|
import {freeze} from 'immer';
|
||||||
|
import {
|
||||||
|
timeout,
|
||||||
|
ClientQuery,
|
||||||
|
ClientResponseType,
|
||||||
|
ClientErrorType,
|
||||||
|
} from 'flipper-common';
|
||||||
|
import {
|
||||||
|
createState,
|
||||||
|
_SandyPluginInstance,
|
||||||
|
getFlipperLib,
|
||||||
|
_SandyPluginDefinition,
|
||||||
|
} from 'flipper-plugin';
|
||||||
|
import {createServerAddOnControls} from './utils/createServerAddOnControls';
|
||||||
|
import isProduction from './utils/isProduction';
|
||||||
|
|
||||||
|
type Plugins = Set<string>;
|
||||||
|
type PluginsArr = Array<string>;
|
||||||
|
|
||||||
|
export type ClientExport = {
|
||||||
|
id: string;
|
||||||
|
query: ClientQuery;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Params = {
|
||||||
|
api: string;
|
||||||
|
method: string;
|
||||||
|
params?: Object;
|
||||||
|
};
|
||||||
|
export type RequestMetadata = {
|
||||||
|
method: string;
|
||||||
|
id: number;
|
||||||
|
params: Params | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ClientConnection {
|
||||||
|
send(data: any): void;
|
||||||
|
sendExpectResponse(data: any): Promise<ClientResponseType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default abstract class AbstractClient extends EventEmitter {
|
||||||
|
connected = createState(false);
|
||||||
|
id: string;
|
||||||
|
query: ClientQuery;
|
||||||
|
sdkVersion: number;
|
||||||
|
messageIdCounter: number;
|
||||||
|
plugins: Plugins;
|
||||||
|
backgroundPlugins: Plugins;
|
||||||
|
connection: ClientConnection | null | undefined;
|
||||||
|
activePlugins: Set<string>;
|
||||||
|
|
||||||
|
device: BaseDevice;
|
||||||
|
logger: Logger;
|
||||||
|
|
||||||
|
sandyPluginStates = new Map<string /*pluginID*/, _SandyPluginInstance>();
|
||||||
|
private readonly serverAddOnControls: ServerAddOnControls;
|
||||||
|
private readonly flipperServer: FlipperServer;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id: string,
|
||||||
|
query: ClientQuery,
|
||||||
|
conn: ClientConnection | null | undefined,
|
||||||
|
logger: Logger,
|
||||||
|
plugins: Plugins | null | undefined,
|
||||||
|
device: BaseDevice,
|
||||||
|
flipperServer: FlipperServer,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.connected.set(!!conn);
|
||||||
|
this.plugins = plugins ? plugins : new Set();
|
||||||
|
this.backgroundPlugins = new Set();
|
||||||
|
this.connection = conn;
|
||||||
|
this.id = id;
|
||||||
|
this.query = query;
|
||||||
|
this.sdkVersion = query.sdk_version || 0;
|
||||||
|
this.messageIdCounter = 0;
|
||||||
|
this.logger = logger;
|
||||||
|
this.activePlugins = new Set();
|
||||||
|
this.device = device;
|
||||||
|
this.flipperServer = flipperServer;
|
||||||
|
this.serverAddOnControls = createServerAddOnControls(this.flipperServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
isBackgroundPlugin(pluginId: string) {
|
||||||
|
return this.backgroundPlugins.has(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract shouldConnectAsBackgroundPlugin(pluginId: string): boolean;
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadPlugins();
|
||||||
|
await Promise.all(
|
||||||
|
[...this.plugins].map(async (pluginId) =>
|
||||||
|
this.startPluginIfNeeded(await this.getPlugin(pluginId)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.backgroundPlugins = new Set(await this.getBackgroundPlugins());
|
||||||
|
this.backgroundPlugins.forEach((plugin) => {
|
||||||
|
if (this.shouldConnectAsBackgroundPlugin(plugin)) {
|
||||||
|
this.initPlugin(plugin);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.emit('plugins-change');
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the supported plugins
|
||||||
|
protected async loadPlugins(): Promise<Plugins> {
|
||||||
|
const {plugins} = await timeout(
|
||||||
|
30 * 1000,
|
||||||
|
this.rawCall<{plugins: Plugins}>('getPlugins', false),
|
||||||
|
'Fetch plugin timeout for ' + this.id,
|
||||||
|
);
|
||||||
|
this.plugins = new Set(plugins);
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected loadPlugin(
|
||||||
|
plugin: _SandyPluginDefinition,
|
||||||
|
initialState?: Record<string, any>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
this.sandyPluginStates.set(
|
||||||
|
plugin.id,
|
||||||
|
new _SandyPluginInstance(
|
||||||
|
this.serverAddOnControls,
|
||||||
|
getFlipperLib(),
|
||||||
|
plugin,
|
||||||
|
this,
|
||||||
|
getPluginKey(this.id, {serial: this.query.device_id}, plugin.id),
|
||||||
|
initialState,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to start plugin '${plugin.id}': `, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startPluginIfNeeded(plugin: _SandyPluginDefinition | undefined) {
|
||||||
|
if (plugin && !this.sandyPluginStates.has(plugin.id)) {
|
||||||
|
this.loadPlugin(plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPluginIfNeeded(pluginId: string, _force = false) {
|
||||||
|
const instance = this.sandyPluginStates.get(pluginId);
|
||||||
|
if (instance) {
|
||||||
|
instance.destroy();
|
||||||
|
this.sandyPluginStates.delete(pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connection lost, but Client might live on
|
||||||
|
disconnect() {
|
||||||
|
this.sandyPluginStates.forEach((instance) => {
|
||||||
|
instance.disconnect();
|
||||||
|
});
|
||||||
|
this.emit('close');
|
||||||
|
this.connected.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean up this client
|
||||||
|
destroy() {
|
||||||
|
this.disconnect();
|
||||||
|
this.plugins.forEach((pluginId) => this.stopPluginIfNeeded(pluginId, true));
|
||||||
|
this.serverAddOnControls.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// gets a plugin definition by pluginId
|
||||||
|
protected abstract getPlugin(
|
||||||
|
pluginId: string,
|
||||||
|
): Promise<_SandyPluginDefinition | undefined>;
|
||||||
|
|
||||||
|
// get the supported background plugins
|
||||||
|
protected async getBackgroundPlugins(): Promise<PluginsArr> {
|
||||||
|
if (this.sdkVersion < 4) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const data = await timeout(
|
||||||
|
30 * 1000,
|
||||||
|
this.rawCall<{plugins: PluginsArr}>('getBackgroundPlugins', false),
|
||||||
|
'Fetch background plugins timeout for ' + this.id,
|
||||||
|
);
|
||||||
|
return data.plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the plugins, and update the UI
|
||||||
|
protected async refreshPlugins() {
|
||||||
|
const oldBackgroundPlugins = this.backgroundPlugins;
|
||||||
|
await this.loadPlugins();
|
||||||
|
await Promise.all(
|
||||||
|
[...this.plugins].map(async (pluginId) =>
|
||||||
|
this.startPluginIfNeeded(await this.getPlugin(pluginId)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const newBackgroundPlugins = await this.getBackgroundPlugins();
|
||||||
|
this.backgroundPlugins = new Set(newBackgroundPlugins);
|
||||||
|
// diff the background plugin list, disconnect old, connect new ones
|
||||||
|
oldBackgroundPlugins.forEach((plugin) => {
|
||||||
|
if (!this.backgroundPlugins.has(plugin)) {
|
||||||
|
this.deinitPlugin(plugin);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
newBackgroundPlugins.forEach((plugin) => {
|
||||||
|
if (
|
||||||
|
!oldBackgroundPlugins.has(plugin) &&
|
||||||
|
this.shouldConnectAsBackgroundPlugin(plugin)
|
||||||
|
) {
|
||||||
|
this.initPlugin(plugin);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.emit('plugins-change');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage(msg: string) {
|
||||||
|
if (typeof msg !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawData;
|
||||||
|
try {
|
||||||
|
rawData = freeze(JSON.parse(msg), true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Invalid JSON: ${msg}`, 'clientMessage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: Params;
|
||||||
|
success?: Object;
|
||||||
|
error?: ClientErrorType;
|
||||||
|
} = rawData;
|
||||||
|
|
||||||
|
const {id, method} = data;
|
||||||
|
|
||||||
|
this.emit('flipper-debug-message', {
|
||||||
|
device: this.device?.displayTitle(),
|
||||||
|
app: this.query.app,
|
||||||
|
flipperInternalMethod: method,
|
||||||
|
plugin: data.params?.api,
|
||||||
|
pluginMethod: data.params?.method,
|
||||||
|
payload: data.params?.params,
|
||||||
|
direction: 'toFlipper:message',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (id == null) {
|
||||||
|
const {error} = data;
|
||||||
|
if (error != null) {
|
||||||
|
console.error(
|
||||||
|
`Error received from device ${
|
||||||
|
method ? `when calling ${method}` : ''
|
||||||
|
}: ${error.message} + \nDevice Stack Trace: ${error.stacktrace}`,
|
||||||
|
'deviceError',
|
||||||
|
);
|
||||||
|
this.emit('error', error);
|
||||||
|
} else if (method === 'refreshPlugins') {
|
||||||
|
this.refreshPlugins();
|
||||||
|
} else if (method === 'execute') {
|
||||||
|
if (!data.params) {
|
||||||
|
throw new Error('expected params');
|
||||||
|
}
|
||||||
|
const params: Params = data.params;
|
||||||
|
const bytes = msg.length * 2; // string lengths are measured in UTF-16 units (not characters), so 2 bytes per char
|
||||||
|
this.emit('bytes-received', params.api, bytes);
|
||||||
|
if (bytes > 5 * 1024 * 1024) {
|
||||||
|
console.warn(
|
||||||
|
`Plugin '${params.api}' received excessively large message for '${
|
||||||
|
params.method
|
||||||
|
}': ${Math.round(bytes / 1024)}kB`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginInstance = this.getPluginInstanceForExecuteMessage(params);
|
||||||
|
|
||||||
|
let handled = false; // This is just for analysis
|
||||||
|
if (pluginInstance) {
|
||||||
|
handled = true;
|
||||||
|
pluginInstance.receiveMessages([params]);
|
||||||
|
}
|
||||||
|
if (!handled && !isProduction()) {
|
||||||
|
console.warn(`Unhandled message ${params.api}.${params.method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: Warn about unknown method?
|
||||||
|
return; // method === 'execute'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getPluginInstanceForExecuteMessage(
|
||||||
|
params: Params,
|
||||||
|
): _SandyPluginInstance | undefined {
|
||||||
|
return this.sandyPluginStates.get(params.api);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onResponse(
|
||||||
|
data: ClientResponseType,
|
||||||
|
resolve: ((a: any) => any) | undefined,
|
||||||
|
reject: (error: ClientErrorType) => any,
|
||||||
|
) {
|
||||||
|
if (data.success) {
|
||||||
|
resolve && resolve(data.success);
|
||||||
|
} else if (data.error) {
|
||||||
|
reject(data.error);
|
||||||
|
const {error} = data;
|
||||||
|
if (error) {
|
||||||
|
this.emit('error', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ???
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected rawCall<T>(
|
||||||
|
method: string,
|
||||||
|
fromPlugin: boolean,
|
||||||
|
params?: Params,
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const id = this.messageIdCounter++;
|
||||||
|
const metadata: RequestMetadata = {
|
||||||
|
method,
|
||||||
|
id,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
|
||||||
|
const plugin = params ? params.api : undefined;
|
||||||
|
|
||||||
|
console.debug(data, 'message:call');
|
||||||
|
|
||||||
|
const mark = this.getPerformanceMark(metadata);
|
||||||
|
performance.mark(mark);
|
||||||
|
if (!this.connected.get()) {
|
||||||
|
// TODO: display warning in the UI
|
||||||
|
reject(new NoLongerConnectedToClientError());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fromPlugin || this.isAcceptingMessagesFromPlugin(plugin)) {
|
||||||
|
try {
|
||||||
|
const response = await this.connection!.sendExpectResponse(data);
|
||||||
|
if (!fromPlugin || this.isAcceptingMessagesFromPlugin(plugin)) {
|
||||||
|
const logEventName = this.getLogEventName(data);
|
||||||
|
this.logger.trackTimeSince(mark, logEventName);
|
||||||
|
this.emit(
|
||||||
|
'bytes-received',
|
||||||
|
plugin || 'unknown',
|
||||||
|
response.length * 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.onResponse(response, resolve, reject);
|
||||||
|
|
||||||
|
this.emit('flipper-debug-message', {
|
||||||
|
device: this.device?.displayTitle(),
|
||||||
|
app: this.query.app,
|
||||||
|
flipperInternalMethod: method,
|
||||||
|
payload: response,
|
||||||
|
plugin,
|
||||||
|
pluginMethod: params?.method,
|
||||||
|
direction: 'toFlipper:response',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// This is only called if the connection is dead. Not in expected
|
||||||
|
// and recoverable cases like a missing receiver/method.
|
||||||
|
this.disconnect();
|
||||||
|
reject(new Error('Unable to send, connection error: ' + error));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Cannot send ${method}, client is not accepting messages for plugin ${plugin}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('flipper-debug-message', {
|
||||||
|
device: this.device?.displayTitle(),
|
||||||
|
app: this.query.app,
|
||||||
|
flipperInternalMethod: method,
|
||||||
|
plugin: params?.api,
|
||||||
|
pluginMethod: params?.method,
|
||||||
|
payload: params?.params,
|
||||||
|
direction: 'toClient:call',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isAcceptingMessagesFromPlugin(plugin: string | null | undefined) {
|
||||||
|
return this.connection && (!plugin || this.activePlugins.has(plugin));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getPerformanceMark(data: RequestMetadata): string {
|
||||||
|
const {method, id} = data;
|
||||||
|
return `request_response_${method}_${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getLogEventName(data: RequestMetadata): string {
|
||||||
|
const {method, params} = data;
|
||||||
|
return params && params.api && params.method
|
||||||
|
? `request_response_${method}_${params.api}_${params.method}`
|
||||||
|
: `request_response_${method}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
initPlugin(pluginId: string) {
|
||||||
|
this.activePlugins.add(pluginId);
|
||||||
|
const instance = this.sandyPluginStates.get(pluginId);
|
||||||
|
if (this.connected.get() && instance) {
|
||||||
|
this.rawSend('init', {plugin: pluginId});
|
||||||
|
instance.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinitPlugin(pluginId: string) {
|
||||||
|
this.activePlugins.delete(pluginId);
|
||||||
|
const instance = this.sandyPluginStates.get(pluginId);
|
||||||
|
instance?.disconnect();
|
||||||
|
if (this.connected.get() && instance) {
|
||||||
|
this.rawSend('deinit', {plugin: pluginId});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected rawSend(method: string, params?: Object): void {
|
||||||
|
const data = {
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
console.debug(data, 'message:send');
|
||||||
|
if (this.connection) {
|
||||||
|
this.connection.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('flipper-debug-message', {
|
||||||
|
device: this.device?.displayTitle(),
|
||||||
|
app: this.query.app,
|
||||||
|
flipperInternalMethod: method,
|
||||||
|
payload: params,
|
||||||
|
direction: 'toClient:send',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
call(
|
||||||
|
api: string,
|
||||||
|
method: string,
|
||||||
|
fromPlugin: boolean,
|
||||||
|
params?: Object,
|
||||||
|
): Promise<Object> {
|
||||||
|
return reportPluginFailures(
|
||||||
|
this.rawCall<Object>('execute', fromPlugin, {
|
||||||
|
api,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
}).catch((err: Error) => {
|
||||||
|
// We only throw errors if the connection is still alive
|
||||||
|
// as connection-related ones aren't recoverable from
|
||||||
|
// user code.
|
||||||
|
if (this.connected.get()) {
|
||||||
|
// This is a special case where we a send failed because of
|
||||||
|
// a disconnect "mid-air". This can happen, for instance,
|
||||||
|
// when you pull the plug from a connected phone. We can
|
||||||
|
// still handle this gracefully.
|
||||||
|
if (err.toString().includes('Socket closed unexpectedly')) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to call device due to unexpected disconnect: ${err}`,
|
||||||
|
);
|
||||||
|
this.disconnect();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// This effectively preserves the previous behavior
|
||||||
|
// of ignoring disconnection-related call failures.
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
`Call-${method}`,
|
||||||
|
api,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async supportsMethod(api: string, method: string): Promise<boolean> {
|
||||||
|
const response = await this.rawCall<{
|
||||||
|
isSupported: boolean;
|
||||||
|
}>('isMethodSupported', true, {
|
||||||
|
api,
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
return response.isSupported;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
desktop/flipper-frontend-core/src/utils/isProduction.tsx
Normal file
14
desktop/flipper-frontend-core/src/utils/isProduction.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* 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 {getRenderHostInstance} from '../RenderHost';
|
||||||
|
|
||||||
|
export default function isProduction() {
|
||||||
|
return getRenderHostInstance().serverConfig.environmentInfo.isProduction;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user