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

@@ -1,34 +0,0 @@
# flipper-sdk-api
SDK to build Flipper clients for JS based apps
## Installation
`yarn add flipper-client-sdk`
## Usage
## Example
```TypeScript
class SeaMammalPlugin extends AbsctractFlipperPlugin {
getId(): string {
return 'sea-mammals';
}
runInBackground(): boolean {
return true;
}
newRow(row: {id: string, url: string, title: string}) {
this.connection?.send("newRow", row)
}
}
const flipperClient = newWebviewClient();
cosnt plugin = new SeaMammalPlugin();
flipperClient.addPlugin();
flipperClient.start('Example JS App');
plugin.newRow({id: '1', title: 'Dolphin', url: 'example.com'})
```

View File

@@ -1,249 +0,0 @@
/**
* 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 FlipperPluginID = string;
export type FlipperMethodID = string;
export class FlipperResponder {
messageID?: number;
private client: FlipperClient;
constructor(messageID: number, client: FlipperClient) {
this.messageID = messageID;
this.client = client;
}
success(response?: any) {
this.client.sendData({id: this.messageID, success: response});
}
error(response?: any) {
this.client.sendData({id: this.messageID, error: response});
}
}
export type FlipperReceiver = (
params: any,
responder: FlipperResponder,
) => void;
export class FlipperConnection {
pluginId: FlipperPluginID;
private client: FlipperClient;
private subscriptions: Map<FlipperMethodID, FlipperReceiver> = new Map();
constructor(pluginId: FlipperPluginID, client: FlipperClient) {
this.pluginId = pluginId;
this.client = client;
}
send(method: FlipperMethodID, params: any) {
this.client.sendData({
method: 'execute',
params: {
api: this.pluginId,
method,
params,
},
});
}
receive(method: FlipperMethodID, receiver: FlipperReceiver) {
this.subscriptions.set(method, receiver);
}
call(method: FlipperMethodID, params: any, 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: FlipperMethodID): boolean {
return this.subscriptions.has(method);
}
}
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: FlipperConnection): 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;
}
export abstract class FlipperClient {
private _isConnected: boolean = false;
protected plugins: Map<FlipperPluginID, FlipperPlugin> = new Map();
protected connections: Map<FlipperPluginID, FlipperConnection> = new Map();
addPlugin(plugin: FlipperPlugin) {
if (this._isConnected) {
this.connectPlugin(plugin);
}
this.plugins.set(plugin.getId(), plugin);
}
getPlugin(id: FlipperPluginID): FlipperPlugin | undefined {
return this.plugins.get(id);
}
onConnect() {
if (this._isConnected) {
return;
}
this._isConnected = true;
}
onDisconnect() {
this._isConnected = false;
for (const plugin of this.plugins.values()) {
this.disconnectPlugin(plugin);
}
}
abstract start(appName: string): void;
abstract stop(): void;
abstract sendData(payload: any): void;
abstract isAvailable(): boolean;
protected 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'].getString();
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;
}
const isSupported = connection.hasReceiver(
params['method'].getString(),
);
responder.success({isSupported: isSupported});
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',
});
}
}
}
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();
plugin.onDisconnect();
this.connections.delete(id);
}
}

View File

@@ -1,58 +0,0 @@
/**
* 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, FlipperConnection, FlipperPlugin} from './api';
import {newWebviewClient} from './webviewImpl';
export class SeaMammalPlugin implements FlipperPlugin {
protected connection: FlipperConnection | null | undefined;
onConnect(connection: FlipperConnection): void {
this.connection = connection;
}
onDisconnect(): void {
this.connection = null;
}
getId(): string {
return 'sea-mammals';
}
runInBackground(): boolean {
return true;
}
newRow(row: {id: string; url: string; title: string}) {
this.connection?.send('newRow', row);
}
}
class FlipperManager {
flipperClient: FlipperClient;
seaMammalPlugin: SeaMammalPlugin;
constructor() {
this.flipperClient = newWebviewClient();
this.seaMammalPlugin = new SeaMammalPlugin();
this.flipperClient.addPlugin(this.seaMammalPlugin);
this.flipperClient.start('Example JS App');
}
}
let flipperManager: FlipperManager | undefined;
export function init() {
if (!flipperManager) {
flipperManager = new FlipperManager();
}
}
export function flipper(): FlipperManager | undefined {
return flipperManager;
}

View File

@@ -1,39 +0,0 @@
/**
* 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} from './api';
class FlipperWebviewClient extends FlipperClient {
_subscriptions: Map<string, (message: any) => void> = new Map();
_client: FlipperClient | null = null;
start = (appName: string) => {
const bridge = (window as any).FlipperWebviewBridge;
bridge?.registerPlugins(this.plugins);
bridge?.start(appName);
};
stop = () => {
const bridge = (window as any).FlipperWebviewBridge;
bridge?.FlipperWebviewBridge.stop();
};
sendData = (data: any) => {
const bridge = (window as any).FlipperWebviewBridge;
bridge && bridge.sendFlipperObject(data);
};
isAvailable = () => {
return (window as any).FlipperWebviewBridge != null;
};
}
export function newWebviewClient(): FlipperClient {
return new FlipperWebviewClient();
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,23 +8,10 @@
*/ */
const fbjs = require('eslint-config-fbjs'); const fbjs = require('eslint-config-fbjs');
const prettierConfig = require('./prettierrc.json')
// enforces copyright header and @format directive to be present in every file
const pattern = /^\*\r?\n[\S\s]*Facebook[\S\s]* \* @format\r?\n/; const pattern = /^\*\r?\n[\S\s]*Facebook[\S\s]* \* @format\r?\n/;
const prettierConfig = {
// arrowParens=always is the default for Prettier 2.0, but other configs
// at Facebook appear to be leaking into this file, which is still on
// Prettier 1.x at the moment, so it is best to be explicit.
arrowParens: 'always',
requirePragma: true,
singleQuote: true,
trailingComma: 'all',
bracketSpacing: false,
jsxBracketSameLine: true,
parser: '',
};
module.exports = { module.exports = {
parser: 'babel-eslint', parser: 'babel-eslint',
root: true, root: true,
@@ -74,6 +61,8 @@ module.exports = {
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
rules: { rules: {
'prettier/prettier': [2, {...prettierConfig, parser: 'typescript'}], 'prettier/prettier': [2, {...prettierConfig, parser: 'typescript'}],
// following rules are disabled because TS already handles it
'no-undef': 0,
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
1, 1,
{ {

View File

@@ -1,4 +1,4 @@
lib/ /lib
node_modules/ node_modules/
*.tsbuildinfo *.tsbuildinfo
/coverage /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
}

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

@@ -1,21 +1,23 @@
{ {
"name": "flipper-client-sdk", "name": "js-flipper",
"version": "0.0.3", "title": "JS Flipper Bindings for Web-Socket based clients",
"version": "0.0.4",
"main": "lib/index.js", "main": "lib/index.js",
"types": "lib/index.d.ts", "types": "lib/index.d.ts",
"title": "Flipper SDK API", "description": "Flipper bindings for Node.js and web",
"scripts": { "scripts": {
"reset": "rimraf lib *.tsbuildinfo", "reset": "rimraf index.js *.tsbuildinfo",
"build": "tsc -b", "build": "tsc -b",
"fix": "eslint . --fix --ext .js,.ts,.tsx", "fix": "eslint . --fix --ext .js,.ts,.tsx",
"lint:tsc": "tsc --noemit", "lint:tsc": "tsc --noemit",
"lint:eslint": "eslint . --ext .js,.ts,.tsx", "lint:eslint": "eslint . --ext .js,.ts,.tsx",
"lint": "yarn lint:eslint && yarn lint:tsc" "lint": "yarn lint:eslint && yarn lint:tsc",
"test": "cross-env TZ=Pacific/Pohnpei jest"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/facebook/flipper.git", "url": "git+https://github.com/facebook/flipper.git",
"baseUrl": "https://github.com/facebook/flipper/tree/main/flipper-js-client-sdk" "baseUrl": "https://github.com/facebook/flipper/tree/main/js/js-flipper"
}, },
"keywords": [ "keywords": [
"flipper" "flipper"
@@ -27,10 +29,14 @@
"licenseFilename": "LICENSE", "licenseFilename": "LICENSE",
"readmeFilename": "README.md", "readmeFilename": "README.md",
"devDependencies": { "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/eslint-plugin": "^4.30.0",
"@typescript-eslint/parser": "^4.30.0", "@typescript-eslint/parser": "^4.30.0",
"ansi-to-html": "^0.7.2", "ansi-to-html": "^0.7.2",
"babel-eslint": "^10.0.1", "babel-eslint": "^10.0.1",
"cross-env": "^7.0.3",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-config-fbjs": "^3.1.1", "eslint-config-fbjs": "^3.1.1",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
@@ -44,7 +50,11 @@
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.26.1", "eslint-plugin-react": "^7.26.1",
"eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-react-hooks": "^4.2.0",
"jest": "^27.3.1",
"prettier": "^2.4.1", "prettier": "^2.4.1",
"typescript": "^4.4.2" "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

@@ -7,4 +7,4 @@
* @format * @format
*/ */
export * from './api'; 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

@@ -1,9 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"outDir": "lib", "outDir": "lib",
"rootDir": "src",
"esModuleInterop": true, "esModuleInterop": true,
"target": "ES2017", "target": "ES5",
"removeComments": true, "removeComments": true,
"preserveConstEnums": true, "preserveConstEnums": true,
"sourceMap": true, "sourceMap": true,
@@ -11,15 +10,10 @@
"moduleResolution": "node", "moduleResolution": "node",
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"strictFunctionTypes": false,
"downlevelIteration": true, "downlevelIteration": true,
"module": "commonjs", "module": "commonjs",
"lib": [ "baseUrl": "."
"es7",
"dom",
"es2017"
],
"baseUrl": ".",
"allowJs": true
}, },
"include": [ "include": [
"src" "src"

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

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@ declare namespace Flipper {
/** /**
* Returns true if the plugin is meant to be run in background too, otherwise it returns false. * Returns true if the plugin is meant to be run in background too, otherwise it returns false.
*/ */
runInBackground(): boolean; runInBackground?(): boolean;
} }
export interface FlipperResponder { export interface FlipperResponder {