/** * 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 './ServerAdapter'; 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 = 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;