comms: app-connectivity

Summary: It doesn't get more generic than 'comms'. So, narrow it down: app-connectivity.

Reviewed By: passy

Differential Revision: D47185255

fbshipit-source-id: 87e9c2487c9b07603d14e856de670757078c0da1
This commit is contained in:
Lorenzo Blasa
2023-07-03 09:40:26 -07:00
committed by Facebook GitHub Bot
parent 48495c906e
commit 62cb33b763
22 changed files with 8 additions and 8 deletions

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
ClientResponseType,
GetBackgroundPluginsMessage,
GetPluginsMessage,
} from 'flipper-common';
import WebSocket from 'ws';
import WebSocketClientConnection from './WebSocketClientConnection';
/**
* @deprecated
* Default `WebSocketClientConnection` should be used instead.
* See BrowserServerWebSocket.handleMessage.
*/
export class BrowserClientConnection extends WebSocketClientConnection {
public legacyFormat = false;
private static isGetPluginsCall(data: object): data is GetPluginsMessage {
return (data as GetPluginsMessage).method === 'getPlugins';
}
private static isGetBackgroundPluginsCall(
data: object,
): data is GetBackgroundPluginsMessage {
return (
(data as GetBackgroundPluginsMessage).method === 'getBackgroundPlugins'
);
}
constructor(ws: WebSocket, public plugins?: string[]) {
super(ws);
}
async sendExpectResponse(data: object): Promise<ClientResponseType> {
if (BrowserClientConnection.isGetPluginsCall(data) && this.plugins) {
return {
success: {plugins: this.plugins},
length: 0,
};
}
if (
BrowserClientConnection.isGetBackgroundPluginsCall(data) &&
this.plugins
) {
return {
success: {plugins: []},
length: 0,
};
}
return super.sendExpectResponse(data);
}
protected serializeData(data: object): string {
return super.serializeData(this.legacyFormat ? {payload: data} : data);
}
}

View File

@@ -0,0 +1,174 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ParsedUrlQuery} from 'querystring';
import {BrowserClientConnection} from './BrowserClientConnection';
import {getFlipperServerConfig} from '../FlipperServerConfig';
import ws from 'ws';
import {IncomingMessage} from 'http';
import {assertNotNull, parseClientQuery} from './Utilities';
import SecureServerWebSocket, {
SecureConnectionCtx,
} from './SecureServerWebSocket';
import {SecureClientQuery} from './ServerWebSocketBase';
import {ClientDescription, DeviceOS} from 'flipper-common';
import {URL} from 'url';
import {isFBBuild} from '../fb-stubs/constants';
interface BrowserConnectionCtx extends SecureConnectionCtx {
clientConnection?: BrowserClientConnection;
}
type LegacyWsMessage =
| {
app: string;
payload: object;
type?: never;
plugins?: never;
}
| {
app: string;
payload?: never;
type: 'connect';
plugins?: string[];
};
function isLegacyMessage(message: object): message is LegacyWsMessage {
return typeof (message as LegacyWsMessage).app === 'string';
}
/**
* WebSocket-based server over an insecure channel that does not support the certificate exchange flow. E.g. web browser.
*/
class BrowserServerWebSocket extends SecureServerWebSocket {
protected handleConnectionAttempt(ctx: BrowserConnectionCtx): void {
const {clientQuery, ws} = ctx;
assertNotNull(clientQuery);
console.info(
`[conn] Local websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}.`,
);
this.listener.onConnectionAttempt(clientQuery);
this.listener.onSecureConnectionAttempt(clientQuery);
// Mock an initial empty list of plugins
// Read more o the reasoning in `handleMessage`
const clientConnection = new BrowserClientConnection(ws);
const client: Promise<ClientDescription> =
this.listener.onConnectionCreated(clientQuery, clientConnection);
ctx.clientConnection = clientConnection;
ctx.clientPromise = client;
}
protected async handleMessage(
ctx: BrowserConnectionCtx,
parsedMessage: object,
rawMessage: string,
) {
const {clientQuery, clientConnection} = ctx;
assertNotNull(clientQuery);
assertNotNull(clientConnection);
// Remove this part once our current customers migrate to the new message structure
if (isLegacyMessage(parsedMessage)) {
if (parsedMessage.type === 'connect') {
// TODO: Add a link to a blog post when it is ready.
console.warn(
'[conn] Legacy WebSocket connection. Please, upgrade. See https://github.com/facebook/flipper/tree/main/js/js-flipper for references.',
);
// Legacy protocol supported passing an optional list of plugins with a 'connect' message.
// Clients that pass the list of plugins this way might not suport `getPlugins` call.
// We create a special BrowserClientConnection that intercepts any `getPlugings` call if the list was passed and fakes a client reply using the list of plugins from the `connect` message.
const plugins = parsedMessage.plugins;
clientConnection.plugins = plugins;
clientConnection.legacyFormat = true;
if (plugins) {
// Client connection was initialized without a list of plugins.
// Upon initialization it sent a `getPlugins` request.
// We find that request and resolve it with the list of plugins we received from the `connect` message
const getPluginsCallbacks = clientConnection.matchPendingRequest(0);
if (!getPluginsCallbacks) {
return;
}
getPluginsCallbacks.resolve({
success: {plugins},
length: rawMessage.length,
});
}
return;
}
// Legacy messages wrap the actual message content with { app: string, payload: object }.
// This way we normalize them to the current message format which does not require that wrapper.
parsedMessage = parsedMessage.payload;
rawMessage = JSON.stringify(parsedMessage);
}
super.handleMessage(ctx, parsedMessage, rawMessage);
}
protected parseClientQuery(
query: ParsedUrlQuery,
): SecureClientQuery | undefined {
// Some legacy clients send only deviceId and device
// Remove it once they fix it
// P463066994
const fallbackOS: DeviceOS = 'MacOS';
const fallbackApp = query.device;
const fallbackDeviceId = query.deviceId;
const fallbackSdkVersion = '4';
query = {
app: fallbackApp,
os: fallbackOS,
device_id: fallbackDeviceId,
sdk_version: fallbackSdkVersion,
...query,
};
const parsedBaseQuery = parseClientQuery(query);
if (!parsedBaseQuery) {
return;
}
return {...parsedBaseQuery, medium: 'NONE'};
}
protected verifyClient(): ws.VerifyClientCallbackSync {
return (info: {origin: string; req: IncomingMessage; secure: boolean}) => {
if (isFBBuild) {
try {
const urlObj = new URL(info.origin);
if (urlObj.hostname.endsWith('.facebook.com')) {
return true;
}
} catch {}
}
const ok = getFlipperServerConfig().validWebSocketOrigins.some(
(validPrefix) => info.origin.startsWith(validPrefix),
);
if (!ok) {
console.warn(
`[conn] Refused webSocket connection from ${info.origin} (secure: ${info.secure})`,
);
}
return ok;
};
}
}
export default BrowserServerWebSocket;

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ClientResponseType} from 'flipper-common';
export interface PendingRequestResolvers {
resolve: (data: ClientResponseType) => void;
reject: (err: Error) => void;
}
export enum ConnectionStatus {
ERROR = 'error',
CLOSED = 'closed',
CONNECTED = 'connected',
NOT_CONNECTED = 'not_connected',
CONNECTING = 'connecting',
}
export type ConnectionStatusChange = (status: ConnectionStatus) => void;
export interface ClientConnection {
subscribeToEvents(subscriber: ConnectionStatusChange): void;
close(): void;
send(data: object): void;
sendExpectResponse(data: object): Promise<ClientResponseType>;
}

View File

@@ -0,0 +1,133 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import ServerWebSocket, {ConnectionCtx} from './ServerWebSocket';
import {SecureClientQuery} from './ServerWebSocketBase';
import {ParsedUrlQuery} from 'querystring';
import {ClientDescription} from 'flipper-common';
import {
isWsResponseMessage,
parseSecureClientQuery,
assertNotNull,
} from './Utilities';
import WebSocketClientConnection from './WebSocketClientConnection';
import {serializeError} from 'serialize-error';
import {WSCloseCode} from '../utils/WSCloseCode';
export interface SecureConnectionCtx extends ConnectionCtx {
clientQuery?: SecureClientQuery;
clientConnection?: WebSocketClientConnection;
clientPromise?: Promise<ClientDescription>;
client?: ClientDescription;
}
/**
* WebSocket-based server.
* A secure connection has been established between the server and a client. Once a client
* has a valid certificate, it can use a secure connection with Flipper and start exchanging
* messages.
* https://fbflipper.com/docs/extending/new-clients
* https://fbflipper.com/docs/extending/establishing-a-connection
*/
class SecureServerWebSocket extends ServerWebSocket {
protected handleConnectionAttempt(ctx: SecureConnectionCtx): void {
const {clientQuery, ws} = ctx;
assertNotNull(clientQuery);
console.info(
`[conn] Secure websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}. Medium ${clientQuery.medium}. CSR: ${clientQuery.csr_path}`,
);
this.listener.onSecureConnectionAttempt(clientQuery);
const clientConnection = new WebSocketClientConnection(ws);
// TODO: Could we just await it here? How much time could it be, potentially?
// DRI: @aigoncharov
const clientPromise: Promise<ClientDescription> = this.listener
.onConnectionCreated(clientQuery, clientConnection)
.then((client) => {
ctx.client = client;
return client;
})
.catch((e) => {
throw new Error(
`Failed to resolve client ${clientQuery.app} on ${
clientQuery.device_id
} medium ${clientQuery.medium}. Reason: ${JSON.stringify(
serializeError(e),
)}`,
);
});
ctx.clientConnection = clientConnection;
ctx.clientPromise = clientPromise;
}
protected async handleMessage(
ctx: SecureConnectionCtx,
parsedMessage: object,
rawMessage: string,
) {
const {clientQuery, clientConnection, clientPromise, client, ws} = ctx;
assertNotNull(clientQuery);
assertNotNull(clientConnection);
assertNotNull(clientPromise);
// We can recieve either "execute" messages from the client or "responses" to our messages
// https://fbflipper.com/docs/extending/new-clients#responding-to-messages
// Received a response message
if (isWsResponseMessage(parsedMessage)) {
const callbacks = clientConnection.matchPendingRequest(parsedMessage.id);
if (!callbacks) {
return;
}
callbacks.resolve({
...parsedMessage,
length: rawMessage.length,
});
return;
}
// Received an "execute" message
if (client) {
this.listener.onClientMessage(client.id, rawMessage);
} else {
// Client promise is not resolved yet
// So we schedule the execution for when it is resolved
clientPromise
.then((client) => {
this.listener.onClientMessage(client.id, rawMessage);
})
.catch((error) => {
// It is an async action, which might run after the socket is closed
if (ws.readyState === ws.OPEN) {
// See the reasoning in the error handler for a `connection` event in ServerWebSocket
ws.emit('error', error);
ws.close(WSCloseCode.InternalError);
}
});
}
}
/**
* Parse and extract a SecureClientQuery instance from a message. The ClientQuery
* data will be contained in the message url query string.
* @param message An incoming web socket message.
*/
protected parseClientQuery(
query: ParsedUrlQuery,
): SecureClientQuery | undefined {
return parseSecureClientQuery(query);
}
}
export default SecureServerWebSocket;

View File

@@ -0,0 +1,598 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
ClientDescription,
ClientQuery,
isTest,
buildClientId,
Logger,
UninitializedClient,
reportPlatformFailures,
FlipperServerEvents,
} from 'flipper-common';
import CertificateProvider from '../utils/CertificateProvider';
import {ClientConnection, ConnectionStatus} from './ClientConnection';
import {EventEmitter} from 'events';
import invariant from 'invariant';
import DummyDevice from '../devices/DummyDevice';
import {
appNameWithUpdateHint,
assertNotNull,
cloneClientQuerySafeForLogging,
} from './Utilities';
import ServerWebSocketBase, {
SecureClientQuery,
ServerEventsListener,
} from './ServerWebSocketBase';
import {
createBrowserServer,
createServer,
TransportType,
} from './ServerFactory';
import {FlipperServerImpl} from '../FlipperServerImpl';
import {
getServerPortsConfig,
getFlipperServerConfig,
} from '../FlipperServerConfig';
import {
extractAppNameFromCSR,
loadSecureServerConfig,
} from '../utils/certificateUtils';
import DesktopCertificateProvider from '../devices/desktop/DesktopCertificateProvider';
import WWWCertificateProvider from '../fb-stubs/WWWCertificateProvider';
import {tracker} from '../utils/tracker';
type ClientTimestampTracker = {
insecureStart?: number;
secureStart?: number;
};
type ClientInfo = {
connection: ClientConnection | null | undefined;
client: ClientDescription;
};
type ClientCsrQuery = {
csr?: string | undefined;
csr_path?: string | undefined;
};
/**
* Responsible of creating and managing the actual underlying servers:
* - Insecure (used for certificate exchange)
* - Secure (used for secure communication between the client and server)
* - Browser (only ever used between Desktop and a local Browser)
*
* Additionally, it manages client connections.
*/
export class ServerController
extends EventEmitter
implements ServerEventsListener
{
connections: Map<string, ClientInfo> = new Map();
timestamps: Map<string, ClientTimestampTracker> = new Map();
secureServer: ServerWebSocketBase | null = null;
insecureServer: ServerWebSocketBase | null = null;
altSecureServer: ServerWebSocketBase | null = null;
altInsecureServer: ServerWebSocketBase | null = null;
browserServer: ServerWebSocketBase | null = null;
connectionTracker: ConnectionTracker;
flipperServer: FlipperServerImpl;
timeHandlers: Map<string, NodeJS.Timeout> = new Map();
constructor(flipperServer: FlipperServerImpl) {
super();
this.flipperServer = flipperServer;
this.connectionTracker = new ConnectionTracker(this.logger);
}
onClientMessage(clientId: string, payload: string): void {
this.flipperServer.emit('client-message', {
id: clientId,
message: payload,
});
}
get logger(): Logger {
return this.flipperServer.logger;
}
/**
* Loads the secure server configuration and starts any necessary servers.
* Initialisation is complete once the initialized promise is fullfilled at
* which point Flipper is accepting connections.
*/
async init() {
if (isTest()) {
throw new Error('Spawing new server is not supported in test');
}
const {insecure, secure} = getServerPortsConfig().serverPorts;
const options = await loadSecureServerConfig();
console.info('[conn] secure server listening at port: ', secure);
this.secureServer = await createServer(secure, this, options);
const {secure: altSecure} = getServerPortsConfig().altServerPorts;
console.info('[conn] secure server (ws) listening at port: ', altSecure);
this.altSecureServer = await createServer(
altSecure,
this,
options,
TransportType.WebSocket,
);
console.info('[conn] insecure server listening at port: ', insecure);
this.insecureServer = await createServer(insecure, this);
const {insecure: altInsecure} = getServerPortsConfig().altServerPorts;
console.info(
'[conn] insecure server (ws) listening at port: ',
altInsecure,
);
this.altInsecureServer = await createServer(
altInsecure,
this,
undefined,
TransportType.WebSocket,
);
const browserPort = getServerPortsConfig().browserPort;
console.info('[conn] Browser server (ws) listening at port: ', browserPort);
this.browserServer = await createBrowserServer(browserPort, this);
}
/**
* If initialized, it stops any started servers.
*/
async close() {
await Promise.all([
this.insecureServer?.stop(),
this.secureServer?.stop(),
this.altInsecureServer?.stop(),
this.altSecureServer?.stop(),
this.browserServer?.stop(),
]);
}
onConnectionCreated(
clientQuery: SecureClientQuery,
clientConnection: ClientConnection,
downgrade?: boolean,
): Promise<ClientDescription> {
const {
app,
os,
device,
device_id,
sdk_version,
csr,
csr_path,
medium,
rsocket,
} = clientQuery;
console.info(
`[conn] Connection established: ${app} on ${device_id}. Medium ${medium}. CSR: ${csr_path}`,
cloneClientQuerySafeForLogging(clientQuery),
);
tracker.track('app-connection-created', {
app,
os,
device,
device_id,
medium,
});
return this.addConnection(
clientConnection,
{
app,
os,
device,
device_id,
sdk_version,
medium,
rsocket,
},
{csr, csr_path},
downgrade,
);
}
onConnectionClosed(clientId: string) {
this.removeConnection(clientId);
}
onListening(port: number): void {
this.emit('listening', port);
}
onSecureConnectionAttempt(clientQuery: SecureClientQuery): void {
const strippedClientQuery = (({device_id, ...o}) => o)(clientQuery);
let id = buildClientId({device_id: 'unknown', ...strippedClientQuery});
const timestamp = this.timestamps.get(id);
if (timestamp) {
this.timestamps.delete(id);
}
id = buildClientId(clientQuery);
this.timestamps.set(id, {
secureStart: Date.now(),
...timestamp,
});
tracker.track('app-connection-secure-attempt', {
app: clientQuery.app,
os: clientQuery.os,
device: clientQuery.device,
device_id: clientQuery.device_id,
medium: clientQuery.medium,
});
const {os, app, device_id} = clientQuery;
// without these checks, the user might see a connection timeout error instead, which would be much harder to track down
if (os === 'iOS' && !getFlipperServerConfig().settings.enableIOS) {
console.error(
`Refusing connection from ${app} on ${device_id}, since iOS support is disabled in settings`,
);
return;
}
if (os === 'Android' && !getFlipperServerConfig().settings.enableAndroid) {
console.error(
`Refusing connection from ${app} on ${device_id}, since Android support is disabled in settings`,
);
return;
}
this.connectionTracker.logConnectionAttempt(clientQuery);
const timeout = this.timeHandlers.get(clientQueryToKey(clientQuery));
if (timeout) {
clearTimeout(timeout);
}
if (clientQuery.medium === 'WWW' || clientQuery.medium === 'NONE') {
this.flipperServer.registerDevice(
new DummyDevice(
this.flipperServer,
clientQuery.device_id,
clientQuery.app +
(clientQuery.medium === 'WWW' ? ' Server Exchanged' : ''),
clientQuery.os,
),
);
}
}
onConnectionAttempt(clientQuery: ClientQuery): void {
const strippedClientQuery = (({device_id, ...o}) => o)(clientQuery);
const id = buildClientId({device_id: 'unknown', ...strippedClientQuery});
this.timestamps.set(id, {
insecureStart: Date.now(),
});
tracker.track('app-connection-insecure-attempt', clientQuery);
this.connectionTracker.logConnectionAttempt(clientQuery);
const client: UninitializedClient = {
os: clientQuery.os,
deviceName: clientQuery.device,
appName: appNameWithUpdateHint(clientQuery),
};
this.emit('start-client-setup', client);
}
onProcessCSR(
unsanitizedCSR: string,
clientQuery: ClientQuery,
appDirectory: string,
): Promise<{deviceId: string}> {
let certificateProvider: CertificateProvider;
switch (clientQuery.os) {
case 'Android': {
assertNotNull(
this.flipperServer.android,
'Android settings have not been provided / enabled',
);
certificateProvider = this.flipperServer.android.certificateProvider;
break;
}
case 'iOS': {
assertNotNull(
this.flipperServer.ios,
'iOS settings have not been provided / enabled',
);
certificateProvider = this.flipperServer.ios.certificateProvider;
if (clientQuery.medium === 'WWW') {
certificateProvider = new WWWCertificateProvider(
this.flipperServer.keytarManager,
);
}
break;
}
// Used by Spark AR studio (search for SkylightFlipperClient)
// See D30992087
case 'MacOS':
case 'Windows': {
certificateProvider = new DesktopCertificateProvider();
break;
}
default: {
throw new Error(
`ServerController.onProcessCSR -> os ${clientQuery.os} does not support certificate exchange.`,
);
}
}
certificateProvider.verifyMedium(clientQuery.medium);
return new Promise((resolve, reject) => {
reportPlatformFailures(
certificateProvider.processCertificateSigningRequest(
unsanitizedCSR,
clientQuery.os,
appDirectory,
),
'processCertificateSigningRequest',
)
.then((response) => {
const client: UninitializedClient = {
os: clientQuery.os,
deviceName: clientQuery.device,
appName: appNameWithUpdateHint(clientQuery),
};
this.timeHandlers.set(
// In the original insecure connection request, `device_id` is set to "unknown".
// Flipper queries adb/idb to learn the device ID and provides it back to the app.
// Once app knows it, it starts using the correct device ID for its subsequent secure connections.
// When the app re-connects securely after the cert exchange process, we need to cancel this timeout.
// Since the original clientQuery has `device_id` set to "unknown", we update it here with the correct `device_id` to find it and cancel it later.
clientQueryToKey({...clientQuery, device_id: response.deviceId}),
setTimeout(() => {
this.emit('client-unresponsive-error', {
client,
medium: clientQuery.medium,
deviceID: response.deviceId,
});
}, 30 * 1000),
);
tracker.track('app-connection-certificate-exchange', {
...clientQuery,
successful: true,
device_id: response.deviceId,
});
resolve(response);
})
.catch((error: Error) => {
tracker.track('app-connection-certificate-exchange', {
...clientQuery,
successful: false,
error: error.message,
});
reject(error);
});
});
}
onError(error: Error): void {
this.emit('error', error);
}
toJSON() {
return null;
}
onClientSetupError(clientQuery: ClientQuery, e: any) {
console.warn(
`[conn] Failed to exchange certificate with ${clientQuery.app} on ${
clientQuery.device || clientQuery.device_id
}`,
e,
);
const client: UninitializedClient = {
os: clientQuery.os,
deviceName: clientQuery.device,
appName: appNameWithUpdateHint(clientQuery),
};
this.emit('client-setup-error', {
client,
error: `[conn] Failed to exchange certificate with ${
clientQuery.app
} on ${clientQuery.device || clientQuery.device_id}: ${e}`,
});
}
/**
* Creates a Client and sets the underlying connection.
* @param connection A client connection to communicate between server and client.
* @param query The client query created from the initial handshake.
* @param csrQuery The CSR query which contains CSR related information.
*/
async addConnection(
connection: ClientConnection,
query: ClientQuery,
csrQuery: ClientCsrQuery,
silentReplace?: boolean,
): Promise<ClientDescription> {
invariant(query, 'expected query');
// try to get id by comparing giving `csr` to file from `csr_path`
// otherwise, use given device_id
const {csr_path, csr} = csrQuery;
// For Android, device id might change
if (csr_path && csr && query.os === 'Android') {
const app_name = await extractAppNameFromCSR(csr);
assertNotNull(this.flipperServer.android);
// TODO: allocate new object, kept now as is to keep changes minimal
(query as any).device_id =
await this.flipperServer.android.certificateProvider.getTargetDeviceId(
app_name,
csr_path,
csr,
);
console.info(
`[conn] Detected ${app_name} on ${query.device_id} in certificate`,
query,
);
}
// TODO: allocate new object, kept now as is to keep changes minimal
(query as any).app = appNameWithUpdateHint(query);
const id = buildClientId(query);
console.info(
`[conn] Matching device for ${query.app} on ${query.device_id}...`,
query,
);
const client: ClientDescription = {
id,
query,
};
const info = {
client,
connection: connection,
};
console.info(
`[conn] Initializing client ${query.app} on ${query.device_id}...`,
query,
);
connection.subscribeToEvents((status: ConnectionStatus) => {
if (
status === ConnectionStatus.CLOSED ||
status === ConnectionStatus.ERROR
) {
this.onConnectionClosed(client.id);
}
});
console.debug(`[conn] Device client initialized: ${id}.`, 'server', query);
/* If a device gets disconnected without being cleaned up properly,
* Flipper won't be aware until it attempts to reconnect.
* When it does we need to terminate the zombie connection.
*/
if (this.connections.has(id)) {
const connectionInfo = this.connections.get(id);
if (connectionInfo) {
if (
connectionInfo.connection &&
connectionInfo.connection !== connection
) {
if (!silentReplace) {
connectionInfo.connection.close();
}
this.removeConnection(id);
}
}
}
this.connections.set(id, info);
this.flipperServer.emit('client-connected', client);
const tracker = this.timestamps.get(id);
if (tracker) {
const end = Date.now();
const start = tracker.insecureStart
? tracker.insecureStart
: tracker.secureStart;
const elapsed = Math.round(end - start!);
this.logger.track('performance', 'client-connection-tracker', {
'time-to-connection': elapsed,
...query,
});
this.timestamps.delete(id);
}
return client;
}
attachFakeClient(client: ClientDescription) {
this.connections.set(client.id, {
client,
connection: null,
});
}
/**
* Removes a client connection by disconnecting it, if still connected
* and then deleting it from the tracked connections.
* @param id The client connection identifier.
*/
removeConnection(id: string) {
const info = this.connections.get(id);
if (info) {
console.info(
`[conn] Disconnected: ${info.client.query.app} on ${info.client.query.device_id}.`,
info.client.query,
);
this.flipperServer.emit('client-disconnected', {id});
this.connections.delete(id);
this.flipperServer.pluginManager.stopAllServerAddOns(id);
}
}
onDeprecationNotice(message: string) {
const notification: FlipperServerEvents['notification'] = {
type: 'warning',
title: 'Deprecation notice',
description: message,
};
this.flipperServer.emit('notification', notification);
}
}
class ConnectionTracker {
timeWindowMillis = 20 * 1000;
connectionProblemThreshold = 4;
// "${device}.${app}" -> [timestamp1, timestamp2...]
connectionAttempts: Map<string, Array<number>> = new Map();
logger: Logger;
constructor(logger: Logger) {
this.logger = logger;
}
logConnectionAttempt(client: ClientQuery) {
const key = `${client.os}-${client.device}-${client.app}`;
const time = Date.now();
let entry = this.connectionAttempts.get(key) || [];
entry.push(time);
entry = entry.filter((t) => t >= time - this.timeWindowMillis);
this.connectionAttempts.set(key, entry);
if (entry.length >= this.connectionProblemThreshold) {
console.warn(
`[conn] Connection loop detected with ${key}. Connected ${
this.connectionProblemThreshold
} times within ${this.timeWindowMillis / 1000}s.`,
'server',
client,
);
}
}
}
function clientQueryToKey(clientQuery: ClientQuery): string {
return `${clientQuery.app}/${clientQuery.os}/${clientQuery.device}/${clientQuery.device_id}`;
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {SecureServerConfig} from '../utils/certificateUtils';
import ServerWebSocketBase, {ServerEventsListener} from './ServerWebSocketBase';
import ServerRSocket from './ServerRSocket';
import SecureServerWebSocket from './SecureServerWebSocket';
import BrowserServerWebSocket from './BrowserServerWebSocket';
import ServerWebSocket from './ServerWebSocket';
export enum TransportType {
RSocket,
WebSocket,
}
/**
* Creates a server to be used by Flipper. The created server will be set into
* the promise once it has started and bound to the specified port.
* @param port A port number in which to listen for incoming connections.
* @param listener An object implementing the ServerEventsListener interface.
* @param sslConfig An SSL configuration for TLS servers.
*/
export async function createServer(
port: number,
listener: ServerEventsListener,
sslConfig?: SecureServerConfig,
transportType: TransportType = TransportType.RSocket,
): Promise<ServerWebSocketBase> {
let server: ServerWebSocketBase;
if (transportType === TransportType.RSocket) {
server = new ServerRSocket(listener);
} else if (sslConfig) {
server = new SecureServerWebSocket(listener);
} else {
server = new ServerWebSocket(listener);
}
await server.start(port, sslConfig);
return server;
}
/**
* Creates a server to be used by Flipper to allow Browser connections.
* The protocol is slightly different for Browser connections hence a different
* factory method. The created server will be set into the promise
* once it has started and bound to the specified port.
* @param port A port number in which to listen for incoming connections.
* @param listener An object implementing the ServerEventsListener interface.
* @returns
*/
export async function createBrowserServer(
port: number,
listener: ServerEventsListener,
): Promise<ServerWebSocketBase> {
const server = new BrowserServerWebSocket(listener);
await server.start(port);
return server;
}

View File

@@ -0,0 +1,296 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {SecureServerConfig} from '../utils/certificateUtils';
import ServerWebSocketBase, {
SecureClientQuery,
ServerEventsListener,
} from './ServerWebSocketBase';
import tls from 'tls';
import net, {AddressInfo, Socket} from 'net';
import {RSocketServer} from 'rsocket-core';
import RSocketTCPServer from 'rsocket-tcp-server';
import {Payload, ReactiveSocket, Responder} from 'rsocket-types';
import {Single} from 'rsocket-flowable';
import {
ClientConnection,
ConnectionStatusChange,
ConnectionStatus,
} from './ClientConnection';
import {
ClientDescription,
ClientQuery,
ClientResponseType,
} from 'flipper-common';
import {transformCertificateExchangeMediumToType} from './Utilities';
/**
* RSocket based server. RSocket uses its own protocol for communication between
* client and server.
*/
class ServerRSocket extends ServerWebSocketBase {
rawServer_: RSocketServer<any, any> | null | undefined;
constructor(listener: ServerEventsListener) {
super(listener);
this.rawServer_ = null;
}
/**
* Start the server bound to the specified port. It configures
* the RSocket server factory and request handler based on the optional
* sslConfig argument.
*/
start(port: number, sslConfig?: SecureServerConfig): Promise<number> {
const self = this;
return new Promise((resolve, reject) => {
// eslint-disable-next-line prefer-const
let rawServer: RSocketServer<any, any> | undefined;
const serverFactory = (onConnect: (socket: Socket) => void) => {
const transportServer = sslConfig
? tls.createServer(sslConfig, (socket) => {
onConnect(socket);
})
: net.createServer(onConnect);
transportServer.on('error', reject).on('listening', () => {
console.debug(
`${
sslConfig ? 'Secure' : 'Certificate'
} server started on port ${port}`,
'server',
);
self.listener.onListening(port);
self.rawServer_ = rawServer;
resolve((transportServer.address() as AddressInfo).port);
});
return transportServer;
};
rawServer = new RSocketServer({
getRequestHandler: sslConfig
? this._trustedRequestHandler
: this._untrustedRequestHandler,
transport: new RSocketTCPServer({
port: port,
serverFactory: serverFactory,
}),
});
rawServer.start();
});
}
stop(): Promise<void> {
if (this.rawServer_) {
return Promise.resolve(this.rawServer_.stop());
}
return Promise.resolve();
}
/**
* Handle an incoming connection request over TLS.
* @param socket Underlying socket connection.
* @param payload Payload or message received.
* @returns Returns a valid RSocket responder which will handle further
* communication from the client.
*/
_trustedRequestHandler = (
socket: ReactiveSocket<string, any>,
payload: Payload<string, any>,
): Partial<Responder<string, any>> => {
if (!payload.data) {
return {};
}
const query = JSON.parse(payload.data);
const clientQuery: SecureClientQuery = {
...query,
medium: transformCertificateExchangeMediumToType(query.medium),
rsocket: true,
};
this.listener.onDeprecationNotice(
`[conn] RSockets are being deprecated at Flipper. Please, use the latest Flipper client in your app to migrate to WebSockets. App: ${clientQuery.app}. Device: ${clientQuery.device}.`,
);
this.listener.onSecureConnectionAttempt(clientQuery);
console.info(
`[conn] Secure rsocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}. Medium ${clientQuery.medium}. CSR: ${clientQuery.csr_path}`,
);
const clientConnection: ClientConnection = {
subscribeToEvents(subscriber: ConnectionStatusChange): void {
socket.connectionStatus().subscribe({
onNext(payload) {
let status = ConnectionStatus.CONNECTED;
if (payload.kind == 'ERROR') status = ConnectionStatus.ERROR;
else if (payload.kind == 'CLOSED') status = ConnectionStatus.CLOSED;
else if (payload.kind == 'CONNECTED')
status = ConnectionStatus.CONNECTED;
else if (payload.kind == 'NOT_CONNECTED')
status = ConnectionStatus.NOT_CONNECTED;
else if (payload.kind == 'CONNECTING')
status = ConnectionStatus.CONNECTING;
subscriber(status);
},
onSubscribe(subscription) {
subscription.request(Number.MAX_SAFE_INTEGER);
},
onError(payload) {
console.error('[client] connection status error ', payload);
},
});
},
close(): void {
socket.close();
},
send(data: any): void {
socket.fireAndForget({data: JSON.stringify(data)});
},
sendExpectResponse(data: any): Promise<any> {
return new Promise<any>((resolve, reject) => {
socket
.requestResponse({
data: JSON.stringify(data),
})
.subscribe({
onComplete: (payload: Payload<any, any>) => {
const response: ClientResponseType = JSON.parse(payload.data);
response.length = payload.data.length;
resolve(response);
},
onError: (e) => {
reject(e);
},
});
});
},
};
let resolvedClient: ClientDescription | undefined;
const client: Promise<ClientDescription> =
this.listener.onConnectionCreated(clientQuery, clientConnection);
client
.then((client) => {
console.info(
`[conn] Client connected: ${clientQuery.app} on ${clientQuery.device_id}. Medium ${clientQuery.medium}. CSR: ${clientQuery.csr_path}`,
);
resolvedClient = client;
})
.catch((e) => {
console.error('[conn] Failed to resolve new client', e);
});
return {
fireAndForget: (payload: {data: string}) => {
if (resolvedClient) {
this.listener.onClientMessage(resolvedClient.id, payload.data);
} else {
client &&
client
.then((client) => {
this.listener.onClientMessage(client.id, payload.data);
})
.catch((e) => {
console.error('Could not deliver message: ', e);
});
}
},
};
};
/**
* Handle an incoming connection request over an insecure connection.
* @param socket Underlying socket connection.
* @param payload Payload or message received.
* @returns Returns a valid RSocket responder which will handle further
* communication from the client.
*/
_untrustedRequestHandler = (
_socket: ReactiveSocket<string, any>,
payload: Payload<string, any>,
): Partial<Responder<string, any>> => {
if (!payload.data) {
return {};
}
const query = JSON.parse(payload.data);
const clientQuery: ClientQuery = {
...query,
medium: transformCertificateExchangeMediumToType(query.medium),
rsocket: true,
};
this.listener.onConnectionAttempt(clientQuery);
return {
requestResponse: (
payload: Payload<string, any>,
): Single<Payload<string, any>> => {
if (typeof payload.data !== 'string') {
return new Single((_) => {});
}
let rawData: any;
try {
rawData = JSON.parse(payload.data);
} catch (err) {
console.error(
`[conn] Invalid JSON: ${payload.data}`,
'clientMessage',
'server',
);
return new Single((_) => {});
}
return new Single((subscriber) => {
subscriber.onSubscribe(undefined);
this._onHandleUntrustedMessage(clientQuery, rawData)
.then((response) => {
subscriber.onComplete({
data: response,
metadata: '',
});
})
.catch((err) => {
subscriber.onError(err);
});
});
},
// Can probably refactor this out
// Leaving this here for a while for backwards compatibility,
// but for up to date SDKs it will no longer used.
fireAndForget: (payload: Payload<string, any>) => {
if (typeof payload.data !== 'string') {
return;
}
let rawData: any;
try {
rawData = JSON.parse(payload.data);
} catch (err) {
console.error(`Invalid JSON: ${payload.data}`, 'server');
return;
}
if (rawData && rawData.method === 'signCertificate') {
console.debug('CSR received from device', 'server');
this._onHandleUntrustedMessage(clientQuery, rawData)
.then((_) => {})
.catch((err) => {
console.error(
'[conn] Unable to process CSR, failed with error.',
err,
);
});
}
},
};
};
}
export default ServerRSocket;

View File

@@ -0,0 +1,287 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {IncomingMessage} from 'http';
import ServerWebSocketBase from './ServerWebSocketBase';
import WebSocket, {
AddressInfo,
Server as WSServer,
VerifyClientCallbackSync,
} from 'ws';
import {createServer as createHttpsServer} from 'https';
import {createServer as createHttpServer} from 'http';
import querystring from 'querystring';
import {ClientQuery, UnableToExtractClientQueryError} from 'flipper-common';
import {
assertNotNull,
parseClientQuery,
parseMessageToJson,
verifyClientQueryComesFromCertExchangeSupportedOS,
} from './Utilities';
import {SecureServerConfig} from '../utils/certificateUtils';
import {Server} from 'net';
import {serializeError} from 'serialize-error';
import {WSCloseCode} from '../utils/WSCloseCode';
export interface ConnectionCtx {
clientQuery?: ClientQuery;
ws: WebSocket;
request: IncomingMessage;
}
// Based on https://github.com/websockets/ws/blob/master/lib/websocket-server.js#L40,
// exposed to share with socket.io defaults.
export const WEBSOCKET_MAX_MESSAGE_SIZE = 100 * 1024 * 1024;
/**
* It serves as a base class for WebSocket based servers. It delegates the 'connection'
* event to subclasses as a customisation point.
*/
class ServerWebSocket extends ServerWebSocketBase {
protected wsServer?: WSServer;
private httpServer?: Server;
async start(port: number, sslConfig?: SecureServerConfig): Promise<number> {
const assignedPort = await new Promise<number>((resolve, reject) => {
const server = sslConfig
? createHttpsServer(sslConfig)
: createHttpServer();
const wsServer = new WSServer({
server,
verifyClient: this.verifyClient(),
maxPayload: WEBSOCKET_MAX_MESSAGE_SIZE,
});
// We do not need to listen to http server's `error` because it is propagated to WS
// https://github.com/websockets/ws/blob/a3a22e4ed39c1a3be8e727e9c630dd440edc61dd/lib/websocket-server.js#L109
const onConnectionError = (error: Error) => {
reject(
new Error(
`Unable to start server at port ${port} due to ${JSON.stringify(
serializeError(error),
)}`,
),
);
};
wsServer.once('error', onConnectionError);
server.listen(port, () => {
console.debug(
`${sslConfig ? 'Secure' : 'Insecure'} server started on port ${port}`,
'server',
);
// Unsubscribe connection error listener.
// We'll attach a permanent error listener later.
wsServer.off('error', onConnectionError);
this.listener.onListening(port);
this.wsServer = wsServer;
this.httpServer = server;
resolve((server.address() as AddressInfo).port);
});
});
assertNotNull(this.wsServer);
assertNotNull(this.httpServer);
this.wsServer.on(
'connection',
(ws: WebSocket, request: IncomingMessage) => {
ws.on('error', (error) => {
console.error('[conn] WS connection error:', error);
this.listener.onError(error);
});
try {
this.onConnection(ws, request);
} catch (error) {
// TODO: Investigate if we need to close the socket in the `error` listener
// DRI: @aigoncharov
ws.close(WSCloseCode.InternalError);
if (error instanceof UnableToExtractClientQueryError) {
// If we are unable to extract the client query, do not emit an error.
// It cannot be determined if the client is legitimately trying to establish
// a connection with Flipper or some other process is trying to connect to
// the port currently used by Flipper.
return;
}
// If an exception is thrown, an `error` event is not emitted automatically.
// We need to explicitly handle the error and emit an error manually.
// If we leave it unhanled, the process just dies
// https://replit.com/@aigoncharov/WS-error-handling#index.js
ws.emit('error', error);
}
},
);
this.wsServer.on('error', (error) => {
console.error('[conn] WS server error:', error);
this.listener.onError(error);
});
return assignedPort;
}
async stop(): Promise<void> {
if (!this.wsServer) {
return;
}
await new Promise<void>((resolve, reject) => {
console.info('[conn] Stopping WS server');
assertNotNull(this.wsServer);
this.wsServer.close((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
await new Promise<void>((resolve, reject) => {
console.info('[conn] Stopping HTTP server');
assertNotNull(this.httpServer);
this.httpServer.close((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
/**
* A connection has been established between the server and a client. Only ever used for
* certificate exchange.
*
* @param ws An active WebSocket.
* @param request Incoming request message.
*/
onConnection(ws: WebSocket, request: IncomingMessage): void {
const ctx: ConnectionCtx = {ws, request};
this.extractClientQuery(ctx);
this.handleConnectionAttempt(ctx);
ws.on('message', async (message: WebSocket.RawData) => {
const messageString = message.toString();
try {
const parsedMessage = this.handleMessageDeserialization(messageString);
// Successful deserialization is a proof that the message is a string
this.handleMessage(ctx, parsedMessage, messageString);
} catch (error) {
// If handling an individual message failes, we don't necessarily kill the connection,
// all other plugins might still be working correctly. So let's just report it.
// This avoids ping-ponging connections if an individual plugin sends garbage (e.g. T129428800)
// or throws an error when handling messages
console.error('[conn] Failed to handle message', messageString, error);
}
});
}
/**
* Extract and create a ClientQuery from the request URL. This method will throw if:
* @param ctx The connection context.
* @returns It doesn't return anything, if the client query
* is extracted, this one is set into the connection context.
*/
protected extractClientQuery(ctx: ConnectionCtx): void {
const {request} = ctx;
if (!request.url) {
return;
}
const query = querystring.decode(request.url.split('?')[1]);
const clientQuery = this.parseClientQuery(query);
if (!clientQuery) {
console.warn(
'[conn] Unable to extract the client query from the request URL.',
request.url,
);
throw new UnableToExtractClientQueryError(
'[conn] Unable to extract the client query from the request URL.',
);
}
ctx.clientQuery = clientQuery;
}
protected handleConnectionAttempt(ctx: ConnectionCtx): void {
const {clientQuery} = ctx;
assertNotNull(clientQuery);
console.info(
`[conn] Insecure websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}.`,
);
this.listener.onConnectionAttempt(clientQuery);
}
protected handleMessageDeserialization(message: unknown): object {
const parsedMessage = parseMessageToJson(message);
if (!parsedMessage) {
console.error('[conn] Failed to parse message', message);
throw new Error(`[conn] Failed to parse message`);
}
return parsedMessage;
}
protected async handleMessage(
ctx: ConnectionCtx,
parsedMessage: object,
// Not used in this method, but left as a reference for overriding classes.
_rawMessage: string,
) {
const {clientQuery, ws} = ctx;
assertNotNull(clientQuery);
const response = await this._onHandleUntrustedMessage(
clientQuery,
parsedMessage,
);
if (response) {
ws.send(response);
}
}
/**
* Parse and extract a ClientQuery instance from a message. The ClientQuery
* data will be contained in the message url query string.
* @param message An incoming web socket message.
*/
protected parseClientQuery(
query: querystring.ParsedUrlQuery,
): ClientQuery | undefined {
return verifyClientQueryComesFromCertExchangeSupportedOS(
parseClientQuery(query),
);
}
/**
* WebSocket client verification. Usually used to validate the origin.
*
* Base implementation simply returns true, but this can be overriden by subclasses
* that require verification.
*
* @returns Return true if the client was successfully verified, otherwise
* returns false.
*/
protected verifyClient(): VerifyClientCallbackSync {
return (_info: {origin: string; req: IncomingMessage; secure: boolean}) => {
// Client verification is not necessary. The connected client has
// already been verified using its certificate signed by the server.
return true;
};
}
}
export default ServerWebSocket;

View File

@@ -0,0 +1,188 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ClientConnection} from './ClientConnection';
import {
ClientDescription,
ClientQuery,
SignCertificateMessage,
} from 'flipper-common';
import {SecureServerConfig} from '../utils/certificateUtils';
/**
* ClientCsrQuery defines a client query with CSR
* information.
*/
export type ClientCsrQuery = {
csr?: string | undefined;
csr_path?: string | undefined;
};
/**
* SecureClientQuery combines a ClientQuery with
* ClientCsrQuery. It also adds medium information.
*/
export type SecureClientQuery = ClientQuery & ClientCsrQuery;
/**
* Defines an interface for events triggered by a running server interacting
* with a client.
*/
export interface ServerEventsListener {
/**
* Server started and listening at the specified port.
* @param port The port in which the server is listening to.
*/
onListening(port: number): void;
/**
* An insecure connection attempt has been made by a client. At this
* point, a connection should be already be available but needs to be
* validated by the server.
* @param clientQuery A ClientQuery instance containing metadata about
* the client e.g. OS, device, app, etc.
*/
onConnectionAttempt(clientQuery: ClientQuery): void;
/**
* A TLS connection attempt has been made by a client. At this
* point, a connection should be already be available but needs to be
* validated by the server.
* @param clientQuery A SecureClientQuery instance containing metadata about
* the client and CSR information as exchanged on the previously
* established insecure connection.
*/
onSecureConnectionAttempt(clientQuery: SecureClientQuery): void;
/**
* CSR received by the server and needs to be processed. If successfully
* processed, it should return a generated device identifier.
* @param unsanitizedCSR CSR as sent by the client, will need to be sanitized
* before usage.
* @param clientQuery A ClientQuery instance containing metadata about
* the client e.g. OS, device, app, etc.
* @param appDirectory App directory in which to deploy the CA and client
* certificates.
* @param medium Certificate exchange medium type e.g. FS_ACCESS, WWW.
*/
onProcessCSR(
unsanitizedCSR: string,
clientQuery: ClientQuery,
appDirectory: string,
): Promise<{deviceId: string}>;
/**
* A secure connection has been established with a validated client.
* A promise to a Client instance needs to be returned.
* @param clientQuery A SecureClientQuery instance containing metadata about
* the client and CSR information as exchanged on the previously
* established insecure connection.
* @param clientConnection A valid client connection.
*/
onConnectionCreated(
clientQuery: SecureClientQuery,
clientConnection: ClientConnection,
downgrade?: boolean,
): Promise<ClientDescription>;
/**
* A connection with a client has been closed.
* @param id The client identifier.
*/
onConnectionClosed(id: string): void;
/**
* An error has occurred.
* @param error An Error instance.
*/
onError(error: Error): void;
/**
* A message was received for a specif client
* // TODO: payload should become JSON
*/
onClientMessage(clientId: string, payload: string): void;
onClientSetupError(clientQuery: ClientQuery, error: any): void;
onDeprecationNotice: (message: string) => void;
}
/**
* Defines the base class to be used by any server implementation e.g.
* RSocket, WebSocket, etc.
*/
abstract class ServerWebSocketBase {
constructor(protected listener: ServerEventsListener) {}
/**
* Start and bind server to the specified port.
* @param port A port number. Pass 0 to get a random free port.
* https://stackoverflow.com/a/28050404
* @param sslConfig An optional SSL configuration to be used for
* TLS servers.
*
* @returns An assigned port number
*/
abstract start(port: number, sslConfig?: SecureServerConfig): Promise<number>;
/**
* Stop the server.
*/
abstract stop(): Promise<void>;
/**
* Handle a message received over an insecure connection. The only
* supported message is to sign certificates.
* @param clientQuery A ClientQuery instance containing metadata about
* the client e.g. OS, device, app, etc.
* @param rawData Raw data as sent by the client.
* @returns The response to be sent back to the client. If the received
* request is to sign a certificate and no errors were found, the response
* should contain the device identifier to use by the client.
*/
protected async _onHandleUntrustedMessage(
clientQuery: ClientQuery,
rawData: any,
): Promise<string | undefined> {
// OSS's older Client SDK might not send medium information.
// This is not an issue for internal FB users, as Flipper release
// is insync with client SDK through launcher.
const message: SignCertificateMessage = rawData;
console.info(
`[conn] Connection attempt: ${clientQuery.app} on ${clientQuery.device}, medium: ${message.medium}, cert: ${message.destination}`,
clientQuery,
);
if (message.method === 'signCertificate') {
console.debug('CSR received from device', 'server');
const {csr, destination} = message;
console.info(
`[conn] Starting certificate exchange: ${clientQuery.app} on ${clientQuery.device}`,
);
try {
const result = await this.listener.onProcessCSR(
csr,
clientQuery,
destination,
);
console.info(
`[conn] Exchanged certificate: ${clientQuery.app} on ${result.deviceId}`,
);
const response = JSON.stringify({
deviceId: result.deviceId,
});
return response;
} catch (e) {
this.listener.onClientSetupError(clientQuery, e);
}
}
return undefined;
}
}
export default ServerWebSocketBase;

View File

@@ -0,0 +1,224 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
CertificateExchangeMedium,
ClientQuery,
DeviceOS,
ResponseMessage,
} from 'flipper-common';
import {ParsedUrlQuery} from 'querystring';
import {SecureClientQuery} from './ServerWebSocketBase';
/**
* Transforms the certificate exchange medium type as number to the
* CertificateExchangeMedium type.
* @param medium A number representing the certificate exchange medium type.
*/
export function transformCertificateExchangeMediumToType(
medium: number | undefined,
): CertificateExchangeMedium {
switch (medium) {
case undefined:
case 1:
return 'FS_ACCESS';
case 2:
return 'WWW';
case 3:
return 'NONE';
default:
throw new Error('Unknown Certificate exchange medium: ' + medium);
}
}
/**
* Returns the app name from a ClientQuery instance. In most cases it should be
* the app name as given in the query. On Android, and for old SDK versions (<3) it
* will returned the app name suffixed by '(Outdated SDK)'.
*
* Reason is, in previous version (<3), app may not appear in correct device
* section because it refers to the name given by client which is not fixed
* for android emulators, so it is indicated as outdated so that developers
* might want to update SDK to get rid of this connection swap problem
* @param query A ClientQuery object.
*/
export function appNameWithUpdateHint(query: ClientQuery): string {
if (query.os === 'Android' && (!query.sdk_version || query.sdk_version < 3)) {
return query.app + ' (Outdated SDK)';
}
return query.app;
}
export function parseMessageToJson<T extends object = object>(
message: any,
): T | undefined {
try {
return JSON.parse(message.toString());
} catch (err) {
console.warn(`Invalid JSON: ${message}`, 'clientMessage');
return;
}
}
export function isWsResponseMessage(
message: object,
): message is ResponseMessage {
return typeof (message as ResponseMessage).id === 'number';
}
const certExchangeSupportedOSes = new Set<DeviceOS>([
'Android',
'iOS',
'MacOS',
'Metro',
'Windows',
]);
/**
* Validates a string as being one of those defined as valid OS.
* @param str An input string.
*/
export function verifyClientQueryComesFromCertExchangeSupportedOS(
query: ClientQuery | undefined,
): ClientQuery | undefined {
if (!query || !certExchangeSupportedOSes.has(query.os)) {
return;
}
return query;
}
/**
* Parse and extract a ClientQuery instance from a message. The ClientQuery
* data will be contained in the message url query string.
* @param message An incoming web socket message.
*/
export function parseClientQuery(
query: ParsedUrlQuery,
): ClientQuery | undefined {
/** Any required arguments to construct a ClientQuery come
* embedded in the query string.
*/
let device_id: string | undefined;
if (typeof query.device_id === 'string') {
device_id = query.device_id;
} else {
return;
}
let device: string | undefined;
if (typeof query.device === 'string') {
device = query.device;
} else {
return;
}
let app: string | undefined;
if (typeof query.app === 'string') {
app = query.app;
} else {
return;
}
let os: DeviceOS | undefined;
if (typeof query.os === 'string') {
os = query.os as DeviceOS;
} else {
return;
}
let medium: number | undefined;
if (typeof query.medium === 'string') {
medium = parseInt(query.medium, 10);
} else if (typeof query.medium === 'number') {
medium = query.medium;
}
if (medium !== undefined && (medium < 1 || medium > 3)) {
throw new Error('Unsupported exchange medium: ' + medium);
}
const clientQuery: ClientQuery = {
device_id,
device,
app,
os,
medium: transformCertificateExchangeMediumToType(medium),
};
if (typeof query.sdk_version === 'string') {
const sdk_version = parseInt(query.sdk_version, 10);
if (sdk_version) {
// TODO: allocate new object, kept now as is to keep changes minimal
(clientQuery as any).sdk_version = sdk_version;
}
}
return clientQuery;
}
/**
* Parse and extract a SecureClientQuery instance from a message. The ClientQuery
* data will be contained in the message url query string.
* @param message An incoming web socket message.
*/
export function parseSecureClientQuery(
query: ParsedUrlQuery,
): SecureClientQuery | undefined {
/** Any required arguments to construct a SecureClientQuery come
* embedded in the query string.
*/
const clientQuery = verifyClientQueryComesFromCertExchangeSupportedOS(
parseClientQuery(query),
);
if (!clientQuery) {
return;
}
let csr: string | undefined;
if (typeof query.csr === 'string') {
const buffer = Buffer.from(query.csr, 'base64');
if (buffer) {
csr = buffer.toString('ascii');
}
}
let csr_path: string | undefined;
if (typeof query.csr_path === 'string') {
csr_path = query.csr_path;
}
let medium: number | undefined;
if (typeof query.medium === 'string') {
medium = parseInt(query.medium, 10);
} else if (typeof query.medium === 'number') {
medium = query.medium;
}
if (medium !== undefined && (medium < 1 || medium > 3)) {
throw new Error('Unsupported exchange medium: ' + medium);
}
return {
...clientQuery,
csr,
csr_path,
medium: transformCertificateExchangeMediumToType(medium),
};
}
export function cloneClientQuerySafeForLogging(clientQuery: SecureClientQuery) {
return {...clientQuery, csr: !clientQuery.csr ? clientQuery.csr : '<hidden>'};
}
// TODO: Merge with the same fn in desktop/app/src/utils
export function assertNotNull<T extends any>(
value: T,
message: string = 'Unexpected null/undefined value found',
): asserts value is Exclude<T, undefined | null> {
if (value === null || value === undefined) {
throw new Error(message);
}
}

View File

@@ -0,0 +1,57 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ClientResponseType} from 'flipper-common';
import WebSocket from 'ws';
import {WSCloseCode} from '../utils/WSCloseCode';
import {
ClientConnection,
ConnectionStatus,
ConnectionStatusChange,
PendingRequestResolvers,
} from './ClientConnection';
export default class WebSocketClientConnection implements ClientConnection {
protected pendingRequests: Map<number, PendingRequestResolvers> = new Map();
constructor(protected ws: WebSocket) {}
subscribeToEvents(subscriber: ConnectionStatusChange): void {
this.ws.on('close', () => subscriber(ConnectionStatus.CLOSED));
this.ws.on('error', () => subscriber(ConnectionStatus.ERROR));
}
close(): void {
this.ws.close(WSCloseCode.NormalClosure);
}
send(data: any): void {
this.ws.send(this.serializeData(data));
}
sendExpectResponse(data: any): Promise<ClientResponseType> {
return new Promise((resolve, reject) => {
this.pendingRequests.set(data.id, {reject, resolve});
this.ws.send(this.serializeData(data));
});
}
matchPendingRequest(id: number): PendingRequestResolvers | undefined {
const callbacks = this.pendingRequests.get(id);
if (!callbacks) {
console.debug(`[conn] Pending request ${id} is not found. Ignore.`);
// It must be a response for a message from the older connection. Ignore.
// TODO: When we decide to bump sdk_version, make `id` a string equal to `connectionId:messageId`. Ignore messages only from other conections. Raise an error for missing mesages from this connection.
return;
}
this.pendingRequests.delete(id);
return callbacks;
}
protected serializeData(data: object): string {
return JSON.stringify(data);
}
}

View File

@@ -0,0 +1,254 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
DeviceOS,
ExecuteMessage,
GetPluginsMessage,
ResponseMessage,
} from 'flipper-common';
import WebSocket from 'ws';
import {BrowserClientConnection} from '../BrowserClientConnection';
import {getFlipperServerConfig} from '../../FlipperServerConfig';
import BrowserServerWebSocket from '../BrowserServerWebSocket';
import {SecureClientQuery} from '../ServerWebSocketBase';
import {createMockSEListener, WSMessageAccumulator} from './utils';
jest.mock('../../FlipperServerConfig');
(getFlipperServerConfig as jest.Mock).mockImplementation(() => ({
validWebSocketOrigins: ['http://localhost'],
}));
describe('BrowserServerWebSocket', () => {
let server: BrowserServerWebSocket | undefined;
let wsClient: WebSocket | undefined;
afterEach(async () => {
wsClient?.close();
wsClient = undefined;
await server?.stop();
server = undefined;
});
test('handles a modern execute message', async () => {
const deviceId = 'yoda42';
const device = 'yoda';
const os: DeviceOS = 'MacOS';
const app = 'deathstar';
const sdkVersion = 4;
const mockSEListener = createMockSEListener();
server = new BrowserServerWebSocket(mockSEListener);
const serverReceivedMessages = new WSMessageAccumulator();
(mockSEListener.onClientMessage as jest.Mock).mockImplementation(
(_, parsedMessage) => serverReceivedMessages.add(parsedMessage),
);
expect(mockSEListener.onListening).toBeCalledTimes(0);
const port = await server.start(0);
expect(mockSEListener.onListening).toBeCalledTimes(1);
expect(mockSEListener.onConnectionAttempt).toBeCalledTimes(0);
expect(mockSEListener.onSecureConnectionAttempt).toBeCalledTimes(0);
expect(mockSEListener.onConnectionCreated).toBeCalledTimes(0);
const clientReceivedMessages = new WSMessageAccumulator();
wsClient = new WebSocket(
`ws://localhost:${port}?device_id=${deviceId}&device=${device}&app=${app}&os=${os}&sdk_version=${sdkVersion}`,
{origin: 'http://localhost'},
);
wsClient.onmessage = ({data}) => clientReceivedMessages.add(data);
await new Promise<void>((resolve, reject) => {
wsClient!.onopen = () => resolve();
wsClient!.onerror = (e) => reject(e);
wsClient!.onmessage = ({data}) => {
clientReceivedMessages.add(data);
};
});
expect(mockSEListener.onConnectionAttempt).toBeCalledTimes(1);
expect(mockSEListener.onSecureConnectionAttempt).toBeCalledTimes(1);
expect(mockSEListener.onConnectionCreated).toBeCalledTimes(1);
const expectedClientQuery: SecureClientQuery = {
device_id: deviceId,
device,
os,
app,
sdk_version: sdkVersion,
medium: 'NONE',
};
expect(mockSEListener.onConnectionAttempt).toBeCalledWith(
expectedClientQuery,
);
expect(mockSEListener.onSecureConnectionAttempt).toBeCalledWith(
expectedClientQuery,
);
expect(mockSEListener.onConnectionCreated).toBeCalledWith(
expectedClientQuery,
expect.anything(),
);
const connection: BrowserClientConnection = (
mockSEListener.onConnectionCreated as jest.Mock
).mock.calls[0][1];
expect(connection).toBeInstanceOf(BrowserClientConnection);
// When client connects, server requests a list of plugins and a list of background plugins
const getPluginsMessage: GetPluginsMessage = {
id: 0,
method: 'getPlugins',
};
const actualGetPluginsResponsePromise =
connection.sendExpectResponse(getPluginsMessage);
const actualGetPluginsMessage = await clientReceivedMessages.newMessage;
expect(actualGetPluginsMessage).toBe(JSON.stringify(getPluginsMessage));
// Client sends a response to the server
const getPluginsResponse: ResponseMessage = {
id: 0,
success: {
plugins: ['fbrocks'],
},
};
wsClient.send(JSON.stringify(getPluginsResponse));
// Server receives the response
const actualGetPluginsResponse = await actualGetPluginsResponsePromise;
expect(actualGetPluginsResponse).toMatchObject(getPluginsResponse);
// Now client can send an execute message
// Note: In real world, the server should have sent a getBackgroundPluginsRequest as well
const executeMessage: ExecuteMessage = {
method: 'execute',
params: {
method: 'admire',
api: 'flipper',
params: 'constantly',
},
};
wsClient.send(JSON.stringify(executeMessage));
const actualExecuteMessage = await serverReceivedMessages.newMessage;
expect(actualExecuteMessage).toEqual(JSON.stringify(executeMessage));
expect(mockSEListener.onClientMessage).toBeCalledTimes(1);
expect(mockSEListener.onProcessCSR).toBeCalledTimes(0);
expect(mockSEListener.onConnectionClosed).toBeCalledTimes(0);
expect(mockSEListener.onError).toBeCalledTimes(0);
});
test('handles a legacy execute message', async () => {
const deviceId = 'yoda42';
const device = 'yoda';
const mockSEListener = createMockSEListener();
server = new BrowserServerWebSocket(mockSEListener);
const serverReceivedMessages = new WSMessageAccumulator();
(mockSEListener.onClientMessage as jest.Mock).mockImplementation(
(_, parsedMessage) => serverReceivedMessages.add(parsedMessage),
);
expect(mockSEListener.onListening).toBeCalledTimes(0);
const port = await server.start(0);
expect(mockSEListener.onListening).toBeCalledTimes(1);
expect(mockSEListener.onConnectionAttempt).toBeCalledTimes(0);
expect(mockSEListener.onSecureConnectionAttempt).toBeCalledTimes(0);
expect(mockSEListener.onConnectionCreated).toBeCalledTimes(0);
const clientReceivedMessages = new WSMessageAccumulator();
wsClient = new WebSocket(
`ws://localhost:${port}?deviceId=${deviceId}&device=${device}`,
{origin: 'http://localhost'},
);
wsClient.onmessage = ({data}) => clientReceivedMessages.add(data);
await new Promise<void>((resolve, reject) => {
wsClient!.onopen = () => resolve();
wsClient!.onerror = (e) => reject(e);
wsClient!.onmessage = ({data}) => {
clientReceivedMessages.add(data);
};
});
expect(mockSEListener.onConnectionAttempt).toBeCalledTimes(1);
expect(mockSEListener.onSecureConnectionAttempt).toBeCalledTimes(1);
expect(mockSEListener.onConnectionCreated).toBeCalledTimes(1);
const expectedClientQuery: SecureClientQuery = {
device_id: deviceId,
device,
os: 'MacOS',
app: device,
sdk_version: 4,
medium: 'NONE',
};
expect(mockSEListener.onConnectionAttempt).toBeCalledWith(
expectedClientQuery,
);
expect(mockSEListener.onSecureConnectionAttempt).toBeCalledWith(
expectedClientQuery,
);
expect(mockSEListener.onConnectionCreated).toBeCalledWith(
expectedClientQuery,
expect.anything(),
);
const connection: BrowserClientConnection = (
mockSEListener.onConnectionCreated as jest.Mock
).mock.calls[0][1];
expect(connection).toBeInstanceOf(BrowserClientConnection);
// When client connects, server requests a list of plugins and a list of background plugins
const getPluginsMessage: GetPluginsMessage = {
id: 0,
method: 'getPlugins',
};
const actualGetPluginsResponsePromise =
connection.sendExpectResponse(getPluginsMessage);
// In teh legacy flow, client sends a connect message
// Client sends a response to the server
const connectMessage = {
app: device,
type: 'connect',
plugins: ['fbrockslegacy'],
};
wsClient.send(JSON.stringify(connectMessage));
// Even thoug the client did not send a response for get plugins, the servers gets this information from the connect message
const actualGetPluginsResponse = await actualGetPluginsResponsePromise;
const expectedGetPluginsResponse = {
success: {
plugins: ['fbrockslegacy'],
},
};
expect(actualGetPluginsResponse).toMatchObject(expectedGetPluginsResponse);
// Now client can send an execute message
// Note: In real world, the server should have sent a getBackgroundPluginsRequest as well
const executeMessage: ExecuteMessage = {
method: 'execute',
params: {
method: 'admire',
api: 'flipper',
params: 'constantly',
},
};
// In the legacy mode, the client is going to use the legacy format
const legacyExecuteMessage = {
app: device,
payload: executeMessage,
};
wsClient.send(JSON.stringify(legacyExecuteMessage));
const actualExecuteMessage = await serverReceivedMessages.newMessage;
// Server is smart enough to handle the legacy format and normalize the message
expect(actualExecuteMessage).toEqual(JSON.stringify(executeMessage));
expect(mockSEListener.onClientMessage).toBeCalledTimes(1);
expect(mockSEListener.onProcessCSR).toBeCalledTimes(0);
expect(mockSEListener.onConnectionClosed).toBeCalledTimes(0);
expect(mockSEListener.onError).toBeCalledTimes(0);
});
});

View File

@@ -0,0 +1,141 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
DeviceOS,
ExecuteMessage,
GetPluginsMessage,
ResponseMessage,
} from 'flipper-common';
import {toBase64} from 'js-base64';
import WebSocket from 'ws';
import SecureServerWebSocket from '../SecureServerWebSocket';
import {SecureClientQuery} from '../ServerWebSocketBase';
import {transformCertificateExchangeMediumToType} from '../Utilities';
import WebSocketClientConnection from '../WebSocketClientConnection';
import {createMockSEListener, WSMessageAccumulator} from './utils';
describe('SecureServerWebSocket', () => {
let server: SecureServerWebSocket | undefined;
let wsClient: WebSocket | undefined;
afterEach(async () => {
wsClient?.close();
wsClient = undefined;
await server?.stop();
server = undefined;
});
test('handles an execute message', async () => {
const deviceId = 'yoda42';
const device = 'yoda';
const os: DeviceOS = 'MacOS';
const app = 'deathstar';
const sdkVersion = 4;
const medium = 2;
const csr = 'luke';
const csrPath = 'notearth';
const mockSEListener = createMockSEListener();
server = new SecureServerWebSocket(mockSEListener);
const serverReceivedMessages = new WSMessageAccumulator();
(mockSEListener.onClientMessage as jest.Mock).mockImplementation(
(_, parsedMessage) => serverReceivedMessages.add(parsedMessage),
);
expect(mockSEListener.onListening).toBeCalledTimes(0);
const port = await server.start(0);
expect(mockSEListener.onListening).toBeCalledTimes(1);
expect(mockSEListener.onSecureConnectionAttempt).toBeCalledTimes(0);
expect(mockSEListener.onConnectionCreated).toBeCalledTimes(0);
const clientReceivedMessages = new WSMessageAccumulator();
wsClient = new WebSocket(
`ws://localhost:${port}?device_id=${deviceId}&device=${device}&app=${app}&os=${os}&sdk_version=${sdkVersion}&csr=${toBase64(
csr,
)}&csr_path=${csrPath}&medium=${medium}`,
);
wsClient.onmessage = ({data}) => clientReceivedMessages.add(data);
await new Promise<void>((resolve, reject) => {
wsClient!.onopen = () => resolve();
wsClient!.onerror = (e) => reject(e);
wsClient!.onmessage = ({data}) => {
clientReceivedMessages.add(data);
};
});
expect(mockSEListener.onSecureConnectionAttempt).toBeCalledTimes(1);
expect(mockSEListener.onConnectionCreated).toBeCalledTimes(1);
const expectedClientQuery: SecureClientQuery = {
device_id: deviceId,
device,
os,
app,
sdk_version: sdkVersion,
csr,
csr_path: csrPath,
medium: transformCertificateExchangeMediumToType(medium),
};
expect(mockSEListener.onSecureConnectionAttempt).toBeCalledWith(
expectedClientQuery,
);
expect(mockSEListener.onConnectionCreated).toBeCalledWith(
expectedClientQuery,
expect.anything(),
);
const connection: WebSocketClientConnection = (
mockSEListener.onConnectionCreated as jest.Mock
).mock.calls[0][1];
expect(connection).toBeInstanceOf(WebSocketClientConnection);
// When client connects, server requests a list of plugins and a list of background plugins
const getPluginsMessage: GetPluginsMessage = {
id: 0,
method: 'getPlugins',
};
const actualGetPluginsResponsePromise =
connection.sendExpectResponse(getPluginsMessage);
const actualGetPluginsMessage = await clientReceivedMessages.newMessage;
expect(actualGetPluginsMessage).toBe(JSON.stringify(getPluginsMessage));
// Client sends a response to the server
const getPluginsResponse: ResponseMessage = {
id: 0,
success: {
plugins: ['fbrocks'],
},
};
wsClient.send(JSON.stringify(getPluginsResponse));
// Server receives the response
const actualGetPluginsResponse = await actualGetPluginsResponsePromise;
expect(actualGetPluginsResponse).toMatchObject(getPluginsResponse);
// Now client can send an execute message
// Note: In real world, the server should have sent a getBackgroundPluginsRequest as well
const executeMessage: ExecuteMessage = {
method: 'execute',
params: {
method: 'admire',
api: 'flipper',
params: 'constantly',
},
};
wsClient.send(JSON.stringify(executeMessage));
const actualExecuteMessage = await serverReceivedMessages.newMessage;
expect(actualExecuteMessage).toEqual(JSON.stringify(executeMessage));
expect(mockSEListener.onClientMessage).toBeCalledTimes(1);
expect(mockSEListener.onProcessCSR).toBeCalledTimes(0);
expect(mockSEListener.onConnectionClosed).toBeCalledTimes(0);
expect(mockSEListener.onError).toBeCalledTimes(0);
});
});

View File

@@ -0,0 +1,125 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ClientQuery, DeviceOS, SignCertificateMessage} from 'flipper-common';
import WebSocket from 'ws';
import ServerWebSocket from '../ServerWebSocket';
import {
createMockSEListener,
WSMessageAccumulator,
processCSRResponse,
} from './utils';
describe('ServerWebSocket', () => {
let server: ServerWebSocket | undefined;
let wsClient: WebSocket | undefined;
afterEach(async () => {
wsClient?.close();
wsClient = undefined;
await server?.stop();
server = undefined;
});
const deviceId = 'yoda42';
const device = 'yoda';
const os: DeviceOS = 'MacOS';
const app = 'deathstar';
const sdkVersion = 4;
test('accepts new connections and handles a signCertificate message', async () => {
const mockSEListener = createMockSEListener();
server = new ServerWebSocket(mockSEListener);
expect(mockSEListener.onListening).toBeCalledTimes(0);
const port = await server.start(0);
expect(mockSEListener.onListening).toBeCalledTimes(1);
expect(mockSEListener.onConnectionAttempt).toBeCalledTimes(0);
wsClient = new WebSocket(
`ws://localhost:${port}?device_id=${deviceId}&device=${device}&app=${app}&os=${os}&sdk_version=${sdkVersion}&medium=2`,
);
const receivedMessages = new WSMessageAccumulator();
await new Promise<void>((resolve, reject) => {
wsClient!.onopen = () => resolve();
wsClient!.onerror = (e) => reject(e);
wsClient!.onmessage = ({data}) => receivedMessages.add(data);
});
expect(mockSEListener.onConnectionAttempt).toBeCalledTimes(1);
const expectedClientQuery: ClientQuery = {
device_id: deviceId,
device,
os,
app,
sdk_version: sdkVersion,
medium: 'WWW',
};
expect(mockSEListener.onConnectionAttempt).toBeCalledWith(
expectedClientQuery,
);
expect(mockSEListener.onProcessCSR).toBeCalledTimes(0);
const signCertMessage: SignCertificateMessage = {
method: 'signCertificate',
csr: 'ilovepancakes',
destination: 'mars',
medium: 2,
};
wsClient.send(JSON.stringify(signCertMessage));
const response = await receivedMessages.newMessage;
expect(response).toBe(JSON.stringify(processCSRResponse));
expect(mockSEListener.onProcessCSR).toBeCalledTimes(1);
expect(mockSEListener.onConnectionCreated).toBeCalledTimes(0);
expect(mockSEListener.onConnectionClosed).toBeCalledTimes(0);
expect(mockSEListener.onError).toBeCalledTimes(0);
expect(mockSEListener.onClientMessage).toBeCalledTimes(0);
});
test('throws if starts on the same port', async () => {
const mockSEListener = createMockSEListener();
server = new ServerWebSocket(mockSEListener);
const assignedPort = await server.start(0);
expect(mockSEListener.onListening).toBeCalledTimes(1);
expect(mockSEListener.onError).toBeCalledTimes(0);
const conflictingServer = new ServerWebSocket(mockSEListener);
await expect(conflictingServer.start(assignedPort)).rejects.toThrow(
/EADDRINUSE/,
);
expect(mockSEListener.onListening).toBeCalledTimes(1);
expect(mockSEListener.onError).toBeCalledTimes(0); // no onError triggered, as start throws already
});
test('does NOT call listener onError if individual message handling fails', async () => {
const mockSEListener = createMockSEListener();
server = new ServerWebSocket(mockSEListener);
const port = await server.start(0);
wsClient = new WebSocket(
`ws://localhost:${port}?device_id=${deviceId}&device=${device}&app=${app}&os=${os}&sdk_version=${sdkVersion}`,
);
await new Promise<void>((resolve) => {
wsClient!.onopen = () => resolve();
});
expect(mockSEListener.onError).toBeCalledTimes(0);
// Sending invalid JSON to cause a parsing error
wsClient.send(`{{{{`);
// Server must close the connection on error
expect(mockSEListener.onError).toBeCalledTimes(0);
expect(mockSEListener.onListening).toBeCalledTimes(1);
});
});

View File

@@ -0,0 +1,67 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ClientQuery, ClientDescription} from 'flipper-common';
import {ServerEventsListener} from '../ServerWebSocketBase';
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);
}
}
}
export const processCSRResponse = {deviceId: 'dagobah'};
export const createMockSEListener = (): ServerEventsListener => ({
onListening: jest.fn(),
onConnectionAttempt: jest.fn(),
onSecureConnectionAttempt: jest.fn(),
onProcessCSR: jest.fn().mockImplementation(() => processCSRResponse),
onConnectionCreated: jest.fn().mockImplementation(
(query: ClientQuery): Promise<ClientDescription> =>
Promise.resolve({
id: 'tatooine',
query,
}),
),
onConnectionClosed: jest.fn(),
onError: jest.fn(),
onClientMessage: jest.fn(),
onClientSetupError: jest.fn(),
onDeprecationNotice: jest.fn(),
});