Implement JS flipper client

Summary:
Standardize WS implementation for JS environments.

Why do we need a separate server implementation for browsers?
Browser targets cannot authenticate via the default certificate exchange flow. We need a dedicated client for them that works over an insecure channel (without the cert exchange).

Major changes:
1. Renamed `flipper-js-client-sdk` to `js-flipper` for consistency with `react-native-flipper`
2. Updated `js-flipper` implementation to match our other existing clients

Documentation will be updated in a separate subsequent PR.

https://fb.quip.com/2mboA0xbgoxl

Reviewed By: mweststrate

Differential Revision: D31688105

fbshipit-source-id: 418aa80e0fd86361c089cf54b0d44a8b4f748efa
This commit is contained in:
Andrey Goncharov
2021-10-21 04:26:59 -07:00
committed by Facebook GitHub Bot
parent 2be631ea4d
commit 9a47f41056
27 changed files with 5243 additions and 2517 deletions

View File

@@ -0,0 +1,4 @@
*.bundle.js
node_modules
lib
!.eslintrc.js

View File

@@ -0,0 +1,78 @@
/**
* 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
*/
const fbjs = require('eslint-config-fbjs');
const prettierConfig = require('./prettierrc.json')
const pattern = /^\*\r?\n[\S\s]*Facebook[\S\s]* \* @format\r?\n/;
module.exports = {
parser: 'babel-eslint',
root: true,
extends: ['fbjs', 'prettier'],
plugins: [
...fbjs.plugins,
'header',
'prettier',
'@typescript-eslint',
'import',
],
rules: {
// disable rules from eslint-config-fbjs
'no-new': 0, // new keyword needed e.g. new Notification
'no-catch-shadow': 0, // only relevant for IE8 and below
'no-bitwise': 0, // bitwise operations needed in some places
'consistent-return': 0,
'no-var': 2,
'prefer-const': [2, {destructuring: 'all'}],
'prefer-spread': 1,
'prefer-rest-params': 1,
'no-console': 0, // we're setting window.console in App.js
'no-multi-spaces': 2,
'prefer-promise-reject-errors': 1,
'no-throw-literal': 'error',
'no-extra-boolean-cast': 2,
'no-extra-semi': 2,
'no-unsafe-negation': 2,
'no-useless-computed-key': 2,
'no-useless-rename': 2,
// additional rules for this project
'header/header': [2, 'block', {pattern}],
'prettier/prettier': [2, prettierConfig],
'import/no-unresolved': [2, {commonjs: true, amd: true}],
},
settings: {
'import/resolver': {
typescript: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
overrides: [
{
files: ['*.tsx', '*.ts'],
parser: '@typescript-eslint/parser',
rules: {
'prettier/prettier': [2, {...prettierConfig, parser: 'typescript'}],
// following rules are disabled because TS already handles it
'no-undef': 0,
'@typescript-eslint/no-unused-vars': [
1,
{
ignoreRestSiblings: true,
varsIgnorePattern: '^_',
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
],
};

4
js/js-flipper/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/lib
node_modules/
*.tsbuildinfo
/coverage

4
js/js-flipper/.npmignore Normal file
View File

@@ -0,0 +1,4 @@
/src
node_modules/
*.tsbuildinfo
/coverage

View File

@@ -0,0 +1,8 @@
{
"arrowParens": "always",
"requirePragma": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": false,
"bracketSameLine": true
}

21
js/js-flipper/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) Facebook, Inc. and its affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

68
js/js-flipper/README.md Normal file
View File

@@ -0,0 +1,68 @@
# flipper-sdk-api
This package exposes JavaScript bindings to talk from web / Node.js directly to
flipper.
## Installation
`yarn add js-flipper`
## Usage
How to build Flipper plugins is explained in the flipper documentation:
[Creating a Flipper plugin](https://fbflipper.com/docs/extending/index).
Building a Flipper plugin involves building a plugin for the Desktop app, and a
plugin that runs on a Device (web or Node.js). This package is only needed for
the plugin that runs on the device (web / Node.js), and wants to use the
WebSocket connection to communicate to Flipper.
This package exposes a `flipperClient`. It has:
- `addPlugin` method. It accepts a `plugin`
parameter, that registers a client plugin and will fire the relevant callbacks
if the corresponding desktop plugin is selected in the Flipper Desktop. The full
plugin API is documented
[here](https://fbflipper.com/docs/extending/create-plugin).
- `start` method. It starts the client.
- `appName` setter to set the app name displayed in Flipper
- `onError` setter to override how errors are handled (it is simple `console.error` by default)
- `websocketFactory` setter to override WebSocket implementation (Node.js folks, it is for you!)
- `urlBase` setter to make the client connect to a different URL
## Example (web)
An example plugin can be found in
[FlipperTicTacToe.js](https://github.com/facebook/flipper/blob/main/js/react-flipper-example/src/FlipperTicTacToe.tsx).
The corresponding Desktop plugin ships by default in Flipper, so importing the
above file and dropping the `<FlipperTicTacToe />` component somewhere in your
application should work out of the box.
The sources of the corresponding Desktop plugin can be found
[here](https://github.com/facebook/flipper/tree/main/desktop/plugins/rn-tic-tac-toe).
## Node.js
Node.js does not have a built-in WebSocket implementation. You need to install
any implmentation of WebSockets for Node.js that is compatible with the
interface of the
[web version](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket).
```ts
import {setWebSocketImplementation, start} from 'js-flipper';
// Say, you decided to go with 'ws'
// https://github.com/websockets/ws
import WebSocket from 'ws';
// You need to let the flipper client know about it.
setWebSocketImplementation(url => new WebSocket(url, {origin: 'localhost:'}));
// You might ask yourself why there is the second argument `{ origin: 'localhost:' }`
// Flipper Desktop verifies the `Origin` header for every WS connection. You need to set it to one of the whitelisted values (see `VALID_WEB_SOCKET_REQUEST_ORIGIN_PREFIXES`).
// Now, the last bit. You need to start the client.
start();
```
An example plugin should be somewhat similar to
[what we have for React](https://github.com/facebook/flipper/blob/main/js/react-flipper-example/src/FlipperTicTacToe.tsx).
It is currently WIP (do not confuse with RIP!).

View File

@@ -0,0 +1,15 @@
/**
* 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
*/
module.exports = {
preset: "ts-jest",
clearMocks: true,
coverageReporters: ['json-summary', 'lcov', 'html'],
testMatch: ['**/**.spec.(js|jsx|ts|tsx)'],
};

View File

@@ -0,0 +1,60 @@
{
"name": "js-flipper",
"title": "JS Flipper Bindings for Web-Socket based clients",
"version": "0.0.4",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"description": "Flipper bindings for Node.js and web",
"scripts": {
"reset": "rimraf index.js *.tsbuildinfo",
"build": "tsc -b",
"fix": "eslint . --fix --ext .js,.ts,.tsx",
"lint:tsc": "tsc --noemit",
"lint:eslint": "eslint . --ext .js,.ts,.tsx",
"lint": "yarn lint:eslint && yarn lint:tsc",
"test": "cross-env TZ=Pacific/Pohnpei jest"
},
"repository": {
"type": "git",
"url": "git+https://github.com/facebook/flipper.git",
"baseUrl": "https://github.com/facebook/flipper/tree/main/js/js-flipper"
},
"keywords": [
"flipper"
],
"author": {
"name": "Facebook Inc"
},
"license": "MIT",
"licenseFilename": "LICENSE",
"readmeFilename": "README.md",
"devDependencies": {
"@types/jest": "^27.0.2",
"@types/node": "^16.10.9",
"@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^4.30.0",
"@typescript-eslint/parser": "^4.30.0",
"ansi-to-html": "^0.7.2",
"babel-eslint": "^10.0.1",
"cross-env": "^7.0.3",
"eslint": "^7.32.0",
"eslint-config-fbjs": "^3.1.1",
"eslint-config-prettier": "^8.3.0",
"eslint-import-resolver-typescript": "^2.4.0",
"eslint-plugin-babel": "^5.3.0",
"eslint-plugin-flowtype": "^5.10.0",
"eslint-plugin-header": "^3.0.0",
"eslint-plugin-import": "^2.24.2",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.26.1",
"eslint-plugin-react-hooks": "^4.2.0",
"jest": "^27.3.1",
"prettier": "^2.4.1",
"ts-jest": "^27.0.7",
"typescript": "^4.4.2",
"ws": "^8.2.3"
},
"dependencies": {}
}

View File

@@ -0,0 +1,170 @@
/**
* 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 {AddressInfo} from 'net';
import {WebSocketServer, WebSocket} from 'ws';
import {FlipperClient, FlipperWebSocket} from '../client';
import {RECONNECT_TIMEOUT} from '../consts';
import {WSMessageAccumulator} from './utils';
describe('client', () => {
let port: number;
let wsServer: WebSocketServer;
let client: FlipperClient;
let allowConnection = true;
const verifyClient = jest.fn().mockImplementation(() => allowConnection);
beforeEach(() => {
allowConnection = true;
});
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(async () => {
wsServer = new WebSocketServer({
port: 0,
verifyClient,
});
await new Promise((resolve) => wsServer.on('listening', resolve));
port = (wsServer.address() as AddressInfo).port;
client = new FlipperClient();
// TODO: Figure out why we need to convert ot unknown first
client.websocketFactory = (url) =>
new WebSocket(url) as unknown as FlipperWebSocket;
client.urlBase = `localhost:${port}`;
});
afterEach(async () => {
client.stop();
await new Promise((resolve) => wsServer.close(resolve));
});
describe('message handling', () => {
describe('getPlugins', () => {
it('returns a list of registered plugins', async () => {
const serverReceivedMessages = new WSMessageAccumulator();
wsServer.on('connection', (ws) => {
ws.send(JSON.stringify({method: 'getPlugins', id: 0}));
ws.on('message', (message) =>
serverReceivedMessages.add(message.toString()),
);
});
client.addPlugin({
getId: () => '42',
onConnect: () => undefined,
onDisconnect: () => undefined,
});
await client.start();
const expectedGetPluginsResponse = {
id: 0,
success: {
plugins: ['42'],
},
};
const actualGetPluginsReponse = await serverReceivedMessages.newMessage;
expect(actualGetPluginsReponse).toBe(
JSON.stringify(expectedGetPluginsResponse),
);
});
});
it('onError is called if message handling has failed, connection is closed, client reconnects', async () => {
const onError = jest.fn();
client.onError = onError;
let resolveFirstConnectionPromise: () => void;
const firstConnectionPromise = new Promise<void>((resolve) => {
resolveFirstConnectionPromise = resolve;
});
wsServer.on('connection', (ws) => {
resolveFirstConnectionPromise();
// Send a malformed message to cause a failure
ws.send('{{{');
});
// Capturing a moment when the client received an error
const receivedErrorPromise = new Promise<void>((resolve) =>
onError.mockImplementationOnce((e) => {
resolve();
}),
);
await client.start();
// Capturing a moment when the client was closed because of the error
const closedPromise = new Promise<void>((resolve) => {
const originalOnclose = (client as any).ws.onclose;
(client as any).ws.onclose = (data: unknown) => {
originalOnclose(data);
resolve();
};
});
await receivedErrorPromise;
expect(onError).toBeCalledTimes(1);
// Make sure that the connection went through
await firstConnectionPromise;
wsServer.removeAllListeners('connection');
let resolveSecondConnectionPromise: () => void;
const secondConnectionPromise = new Promise<void>((resolve) => {
resolveSecondConnectionPromise = resolve;
});
wsServer.on('connection', () => {
resolveSecondConnectionPromise();
});
// Make sure the current client is closed
// When it closes, it schedules a reconnection
await closedPromise;
// Now, once the reconnection is scheduled, we can advance timers to do the actual reconnection
jest.advanceTimersByTime(RECONNECT_TIMEOUT);
// Make sure that the client reconnects
await secondConnectionPromise;
});
});
describe('connection', () => {
it('onError is called if connection has failed, it is called every time Flipper fails to reconnect', async () => {
allowConnection = false;
const onError = jest.fn();
client.onError = onError;
expect(onError).toBeCalledTimes(0);
client.start();
// Expect connection request to fail
await new Promise((resolve) => onError.mockImplementationOnce(resolve));
expect(onError).toBeCalledTimes(1);
// Checking that the request went through to the server
expect(verifyClient).toBeCalledTimes(1);
// Exepect reconnection attempts to fail
for (let i = 2; i < 10; i++) {
jest.advanceTimersByTime(RECONNECT_TIMEOUT);
await new Promise((resolve) => onError.mockImplementationOnce(resolve));
expect(onError).toBeCalledTimes(i);
expect(verifyClient).toBeCalledTimes(i);
}
});
});
});

View File

@@ -0,0 +1,44 @@
/**
* 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
*/
// TODO: Share with desktop/flipper-server-core/src/comms/__tests__/utils.ts
export class WSMessageAccumulator {
private messages: unknown[] = [];
private newMessageSubscribers: ((newMessageContent: unknown) => void)[] = [];
constructor(private readonly timeout = 1000) {}
get newMessage(): Promise<unknown> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Timeout exceeded'));
}, this.timeout);
this.newMessageSubscribers.push((newMessageContent: unknown) => {
clearTimeout(timer);
resolve(newMessageContent);
});
this.consume();
});
}
add(newMessageContent: unknown) {
this.messages.push(newMessageContent);
this.consume();
}
private consume() {
if (this.messages.length && this.newMessageSubscribers.length) {
const message = this.messages.shift();
const subscriber = this.newMessageSubscribers.shift();
subscriber!(message);
}
}
}

366
js/js-flipper/src/client.ts Normal file
View File

@@ -0,0 +1,366 @@
/**
* 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 {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
/**
* 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;
}
export class FlipperClient {
protected plugins: Map<string, FlipperPlugin> = new Map();
protected connections: Map<string, FlipperConnection> = new Map();
private ws?: FlipperWebSocket;
private devicePseudoId = `${Date.now()}.${Math.random()}`;
private os = detectOS();
private device = detectDevice();
private _appName = 'JS App';
private reconnectionTimer?: NodeJS.Timeout;
private resolveStartPromise?: () => void;
public urlBase = `localhost:8333`;
public websocketFactory: (url: string) => FlipperWebSocket = (url) =>
new WebSocket(url) as FlipperWebSocket;
public onError: (e: unknown) => void = (e: unknown) =>
console.error('WebSocket error', e);
constructor(public readonly reconnectTimeout = RECONNECT_TIMEOUT) {}
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(): Promise<void> {
if (this.ws) {
return;
}
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;
}
get appName() {
return this._appName;
}
set appName(newAppName: string) {
this._appName = newAppName;
this.ws?.close(WSCloseCode.NormalClosure);
this.reconnect(true);
}
private connectToFlipper() {
const url = `ws://${this.urlBase}?device_id=${this.device}${this.devicePseudoId}&device=${this.device}&app=${this.appName}&os=${this.os}`;
this.ws = this.websocketFactory(url);
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(false);
}
};
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(now?: boolean) {
this.ws = undefined;
if (this.reconnectionTimer) {
clearTimeout(this.reconnectionTimer);
this.reconnectionTimer = undefined;
}
this.reconnectionTimer = setTimeout(
() => {
this.connectToFlipper();
},
now ? 0 : 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);
}
}
}

View File

@@ -0,0 +1,74 @@
/**
* 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 {FlipperErrorMessage, FlipperMessageBus} from './message';
import {FlipperPluginConnection, FlipperPluginReceiver} from './plugin';
import {FlipperResponder} from './responder';
import {isPromise, safeJSONStringify} from './util';
type FlipperReceiver = (data: unknown, responder: FlipperResponder) => void;
export class FlipperConnection implements FlipperPluginConnection {
pluginId: string;
private client: FlipperMessageBus;
private subscriptions: Map<string, FlipperReceiver> = new Map();
constructor(pluginId: string, client: FlipperMessageBus) {
this.pluginId = pluginId;
this.client = client;
}
send(method: string, params?: unknown) {
this.client.sendData({
method: 'execute',
params: {
api: this.pluginId,
method,
params,
},
});
}
receive(method: string, receiver: FlipperPluginReceiver) {
const wrappedReceiver: FlipperReceiver = (data, responder) => {
const handleError = (e: unknown) => {
const errorMessage: FlipperErrorMessage =
e instanceof Error
? {name: e.name, message: e.message, stacktrace: e.stack}
: {name: 'Unknown', message: safeJSONStringify(e)};
responder.error(errorMessage);
};
try {
const response = receiver(data);
if (isPromise(response)) {
response.then((data) => responder.success(data)).catch(handleError);
return;
}
responder.success(response);
} catch (e) {
handleError(e);
}
};
this.subscriptions.set(method, wrappedReceiver);
}
call(method: string, params: unknown, responder: FlipperResponder) {
const receiver = this.subscriptions.get(method);
if (receiver == null) {
const errorMessage = `Receiver ${method} not found.`;
responder.error({message: errorMessage});
return;
}
receiver.call(receiver, params, responder);
}
hasReceiver(method: string): boolean {
return this.subscriptions.has(method);
}
}

View File

@@ -0,0 +1,10 @@
/**
* 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
*/
export const RECONNECT_TIMEOUT = 1000;

View File

@@ -0,0 +1,16 @@
/**
* 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 {FlipperClient, FlipperWebSocket, WSCloseCode} from './client';
import {FlipperPlugin} from './plugin';
export * from './plugin';
export {FlipperWebSocket};
export const flipperClient = new FlipperClient();

View File

@@ -0,0 +1,36 @@
/**
* 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
*/
export interface FlipperRequest {
method: string;
params?: {
method: string;
api: string;
params?: unknown;
};
}
export type FlipperResponse =
| {
id: number;
success: object | string | number | boolean | null;
error?: never;
}
| {
id: number;
success?: never;
error: FlipperErrorMessage;
};
export interface FlipperErrorMessage {
message: string;
stacktrace?: string;
name?: string;
}
export interface FlipperMessageBus {
sendData(data: FlipperRequest | FlipperResponse): void;
}

View File

@@ -0,0 +1,68 @@
/**
* 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
*/
export type FlipperPluginReceiverRes =
| object
| string
| number
| boolean
| null
| undefined
| void;
export type FlipperPluginReceiver = (
data: any,
) => FlipperPluginReceiverRes | Promise<FlipperPluginReceiverRes>;
export interface FlipperPluginConnection {
/**
* Send an `execute` message to Flipper.
* Here is what client sends over the wire:
* { method: 'execute', params: { api: pluginID, method, params } }
*
* @param method Method name that needs to be executed by Flipper
* @param params Any extra params required for the method execution
*/
send(method: string, params?: unknown): void;
/**
* Listen to messages for the method provided and execute a callback when one arrives.
* Send response back to Flipper.
* Read more about responses at https://fbflipper.com/docs/extending/new-clients#responding-to-messages
*
* @param method Method name that Flipper sent to the client
* @param receiver A callback executed by the client when a message with the specified method arrives.
* If this callback throws or returns a rejected Promise, client send an error message to Flipper.
* If this callback returns any value (even undefined) synchronously, client sends it as a success message to Flipper.
* If this callback returns a Promise, clients sends the value it is resolved with as a success message to Flipper.
*/
receive(method: string, receiver: FlipperPluginReceiver): void;
}
export interface FlipperPlugin {
/**
* @return The id of this plugin. This is the namespace which Flipper desktop plugins will call
* methods on to route them to your plugin. This should match the id specified in your React
* plugin.
*/
getId(): string;
/**
* Called when a connection has been established. The connection passed to this method is valid
* until {@link FlipperPlugin#onDisconnect()} is called.
*/
onConnect(connection: FlipperPluginConnection): void;
/**
* Called when the connection passed to `FlipperPlugin#onConnect(FlipperConnection)` is no
* longer valid. Do not try to use the connection in or after this method has been called.
*/
onDisconnect(): void;
/**
* Returns true if the plugin is meant to be run in background too, otherwise it returns false.
*/
runInBackground?(): boolean;
}

View File

@@ -0,0 +1,29 @@
/**
* 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 {FlipperErrorMessage, FlipperMessageBus} from './message';
import {FlipperPluginReceiverRes} from './plugin';
export class FlipperResponder {
constructor(
public readonly responderId: number,
private client: FlipperMessageBus,
) {}
success(response?: FlipperPluginReceiverRes) {
this.client.sendData({
id: this.responderId,
success: response == null ? null : response,
});
}
error(response: FlipperErrorMessage) {
this.client.sendData({id: this.responderId, error: response});
}
}

70
js/js-flipper/src/util.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* 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
*/
// https://github.com/microsoft/TypeScript/issues/36931#issuecomment-846131999
type Assert = (condition: unknown) => asserts condition;
export const assert: Assert = (condition) => {
if (!condition) {
throw new Error();
}
};
export const safeJSONStringify = (data: unknown): string => {
try {
return JSON.stringify(data);
} catch {
return 'Unable to serialize';
}
};
export const isPromise = (val: unknown): val is Promise<unknown> =>
typeof val === 'object' &&
val !== null &&
typeof (val as Promise<unknown>).then === 'function' &&
typeof (val as Promise<unknown>).catch === 'function';
// TODO: Share types wiht desktop
type OS =
| 'iOS'
| 'Android'
| 'Metro'
| 'Windows'
| 'MacOS'
| 'Browser'
| 'Linux';
// https://stackoverflow.com/a/31456668
const detectDeviceType = () =>
typeof process === 'object' &&
typeof process.versions === 'object' &&
typeof process.versions.node !== 'undefined'
? 'Node.js'
: 'Browser';
export const detectOS = (): OS => {
if (detectDeviceType() === 'Browser') {
return 'Browser';
}
switch (require('os').type()) {
case 'Linux':
return 'Linux';
case 'Darwin':
return 'MacOS';
default:
return 'Windows';
}
};
export const detectDevice = (): string => {
if (detectDeviceType() === 'Browser') {
return window.navigator.userAgent;
}
return require('os').release();
};
export const awaitTimeout = (timeout: number) => new Promise(resolve => setTimeout)

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"outDir": "lib",
"esModuleInterop": true,
"target": "ES5",
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"declaration": true,
"moduleResolution": "node",
"skipLibCheck": true,
"strict": true,
"strictFunctionTypes": false,
"downlevelIteration": true,
"module": "commonjs",
"baseUrl": "."
},
"include": [
"src"
],
"exclude": [
"node_modules",
"**/__tests__/*"
]
}

4248
js/js-flipper/yarn.lock Normal file

File diff suppressed because it is too large Load Diff