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:
committed by
Facebook GitHub Bot
parent
2be631ea4d
commit
9a47f41056
@@ -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'})
|
||||
|
||||
```
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -8,23 +8,10 @@
|
||||
*/
|
||||
|
||||
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 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 = {
|
||||
parser: 'babel-eslint',
|
||||
root: true,
|
||||
@@ -74,6 +61,8 @@ module.exports = {
|
||||
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,
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
lib/
|
||||
/lib
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
/coverage
|
||||
4
js/js-flipper/.npmignore
Normal file
4
js/js-flipper/.npmignore
Normal file
@@ -0,0 +1,4 @@
|
||||
/src
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
/coverage
|
||||
8
js/js-flipper/.prettierrc.json
Normal file
8
js/js-flipper/.prettierrc.json
Normal 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
68
js/js-flipper/README.md
Normal 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!).
|
||||
15
js/js-flipper/jest.config.js
Normal file
15
js/js-flipper/jest.config.js
Normal 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)'],
|
||||
};
|
||||
@@ -1,21 +1,23 @@
|
||||
{
|
||||
"name": "flipper-client-sdk",
|
||||
"version": "0.0.3",
|
||||
"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",
|
||||
"title": "Flipper SDK API",
|
||||
"description": "Flipper bindings for Node.js and web",
|
||||
"scripts": {
|
||||
"reset": "rimraf lib *.tsbuildinfo",
|
||||
"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"
|
||||
"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/flipper-js-client-sdk"
|
||||
"baseUrl": "https://github.com/facebook/flipper/tree/main/js/js-flipper"
|
||||
},
|
||||
"keywords": [
|
||||
"flipper"
|
||||
@@ -27,10 +29,14 @@
|
||||
"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",
|
||||
@@ -44,7 +50,11 @@
|
||||
"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",
|
||||
"typescript": "^4.4.2"
|
||||
}
|
||||
"ts-jest": "^27.0.7",
|
||||
"typescript": "^4.4.2",
|
||||
"ws": "^8.2.3"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
170
js/js-flipper/src/__tests__/client.spec.ts
Normal file
170
js/js-flipper/src/__tests__/client.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
44
js/js-flipper/src/__tests__/utils.ts
Normal file
44
js/js-flipper/src/__tests__/utils.ts
Normal 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
366
js/js-flipper/src/client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
js/js-flipper/src/connection.ts
Normal file
74
js/js-flipper/src/connection.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,4 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
export * from './api';
|
||||
export const RECONNECT_TIMEOUT = 1000;
|
||||
16
js/js-flipper/src/index.ts
Normal file
16
js/js-flipper/src/index.ts
Normal 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();
|
||||
36
js/js-flipper/src/message.ts
Normal file
36
js/js-flipper/src/message.ts
Normal 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;
|
||||
}
|
||||
68
js/js-flipper/src/plugin.ts
Normal file
68
js/js-flipper/src/plugin.ts
Normal 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;
|
||||
}
|
||||
29
js/js-flipper/src/responder.ts
Normal file
29
js/js-flipper/src/responder.ts
Normal 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
70
js/js-flipper/src/util.ts
Normal 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)
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"esModuleInterop": true,
|
||||
"target": "ES2017",
|
||||
"target": "ES5",
|
||||
"removeComments": true,
|
||||
"preserveConstEnums": true,
|
||||
"sourceMap": true,
|
||||
@@ -11,15 +10,10 @@
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": false,
|
||||
"downlevelIteration": true,
|
||||
"module": "commonjs",
|
||||
"lib": [
|
||||
"es7",
|
||||
"dom",
|
||||
"es2017"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"allowJs": true
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
4248
js/js-flipper/yarn.lock
Normal file
4248
js/js-flipper/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
2
react-native/react-native-flipper/index.d.ts
vendored
2
react-native/react-native-flipper/index.d.ts
vendored
@@ -38,7 +38,7 @@ declare namespace Flipper {
|
||||
/**
|
||||
* Returns true if the plugin is meant to be run in background too, otherwise it returns false.
|
||||
*/
|
||||
runInBackground(): boolean;
|
||||
runInBackground?(): boolean;
|
||||
}
|
||||
|
||||
export interface FlipperResponder {
|
||||
|
||||
Reference in New Issue
Block a user