diff --git a/src/devices/BaseDevice.tsx b/src/devices/BaseDevice.tsx index e1816e13b..a7161cbb1 100644 --- a/src/devices/BaseDevice.tsx +++ b/src/devices/BaseDevice.tsx @@ -52,7 +52,7 @@ export type DeviceExport = { logs: Array; }; -export type OS = 'iOS' | 'Android' | 'Windows' | 'MacOS' | 'JSWebApp'; +export type OS = 'iOS' | 'Android' | 'Windows' | 'MacOS' | 'JSWebApp' | 'Metro'; export default class BaseDevice { constructor(serial: string, deviceType: DeviceType, title: string, os: OS) { diff --git a/src/devices/MetroDevice.tsx b/src/devices/MetroDevice.tsx new file mode 100644 index 000000000..056da9043 --- /dev/null +++ b/src/devices/MetroDevice.tsx @@ -0,0 +1,32 @@ +/** + * 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 BaseDevice from './BaseDevice'; +import ArchivedDevice from './ArchivedDevice'; +import {v4} from 'uuid'; + +export default class MetroDevice extends BaseDevice { + ws: WebSocket; + + constructor(serial: string, ws: WebSocket) { + super(serial, 'emulator', 'React Native', 'Metro'); + this.ws = ws; + this.devicePlugins = []; + } + + archive() { + return new ArchivedDevice( + this.serial + v4(), + this.deviceType, + this.title, + this.os, + [...this.logEntries], + ); + } +} diff --git a/src/dispatcher/index.tsx b/src/dispatcher/index.tsx index f64b8a8dc..0efade1df 100644 --- a/src/dispatcher/index.tsx +++ b/src/dispatcher/index.tsx @@ -8,6 +8,7 @@ */ import androidDevice from './androidDevice'; +import metroDevice from './metroDevice'; import iOSDevice from './iOSDevice'; import desktopDevice from './desktopDevice'; import application from './application'; @@ -28,6 +29,7 @@ export default function(store: Store, logger: Logger): () => Promise { application, store.getState().settingsState.enableAndroid ? androidDevice : null, iOSDevice, + metroDevice, desktopDevice, tracking, server, diff --git a/src/dispatcher/metroDevice.tsx b/src/dispatcher/metroDevice.tsx new file mode 100644 index 000000000..c406c99e7 --- /dev/null +++ b/src/dispatcher/metroDevice.tsx @@ -0,0 +1,141 @@ +/** + * 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 {Store} from '../reducers/index'; +import {Logger} from '../fb-interfaces/Logger'; +import {registerDeviceCallbackOnPlugins} from '../utils/onRegisterDevice'; +import MetroDevice from '../devices/MetroDevice'; +import {ArchivedDevice} from 'flipper'; + +const METRO_PORT = 8081; +const METRO_HOST = 'localhost'; +const METRO_URL = `http://${METRO_HOST}:${METRO_PORT}`; +const METRO_LOGS_ENDPOINT = `ws://${METRO_HOST}:${METRO_PORT}/events`; +const METRO_MESSAGE = ['React Native packager is running', 'Metro is running']; +const QUERY_INTERVAL = 5000; +const METRO_DEVICE_ID = 'metro'; // there is always only one activve + +async function isMetroRunning(): Promise { + try { + const contents = await (await global.fetch(METRO_URL)).text(); + return METRO_MESSAGE.some(msg => contents.includes(msg)); + } catch (e) { + return false; + } +} + +async function registerDevice(ws: WebSocket, store: Store, logger: Logger) { + const metroDevice = new MetroDevice(METRO_DEVICE_ID, ws); + logger.track('usage', 'register-device', { + os: 'Metro', + name: metroDevice.title, + }); + + metroDevice.loadDevicePlugins(store.getState().plugins.devicePlugins); + store.dispatch({ + type: 'REGISTER_DEVICE', + payload: metroDevice, + serial: METRO_DEVICE_ID, + }); + + registerDeviceCallbackOnPlugins( + store, + store.getState().plugins.devicePlugins, + store.getState().plugins.clientPlugins, + metroDevice, + ); +} + +async function unregisterDevices(store: Store, logger: Logger) { + logger.track('usage', 'unregister-device', { + os: 'Metro', + serial: METRO_DEVICE_ID, + }); + + let archivedDevice: ArchivedDevice | undefined = undefined; + const device = store + .getState() + .connections.devices.find(device => device.serial === METRO_DEVICE_ID); + if (device && !device.isArchived) { + archivedDevice = device.archive(); + } + + store.dispatch({ + type: 'UNREGISTER_DEVICES', + payload: new Set([METRO_DEVICE_ID]), + }); + + if (archivedDevice) { + archivedDevice.loadDevicePlugins(store.getState().plugins.devicePlugins); + store.dispatch({ + type: 'REGISTER_DEVICE', + payload: archivedDevice, + }); + } +} + +export default (store: Store, logger: Logger) => { + let timeoutHandle: NodeJS.Timeout; + let ws: WebSocket | undefined; + + async function tryConnectToMetro() { + if (ws) { + return; + } + + if (await isMetroRunning()) { + const _ws = new WebSocket(METRO_LOGS_ENDPOINT); + + _ws.onopen = () => { + clearTimeout(guard); + ws = _ws; + registerDevice(ws, store, logger); + }; + + _ws.onclose = _ws.onerror = () => { + clearTimeout(guard); + ws = undefined; + unregisterDevices(store, logger); + scheduleNext(); + }; + + const guard = setTimeout(() => { + // Metro is running, but didn't respond to /events endpoint + store.dispatch({ + type: 'SERVER_ERROR', + payload: { + message: + "Found a running Metro instance, but couldn't connect to the logs. Probably your React Native version is too old to support Flipper.", + details: `Failed to get a connection to ${METRO_LOGS_ENDPOINT} in a timely fashion`, + urgent: true, + }, + }); + // Note: no scheduleNext, we won't retry until restart + }, 5000); + } else { + scheduleNext(); + } + } + + function scheduleNext() { + timeoutHandle = setTimeout(tryConnectToMetro, QUERY_INTERVAL); + } + + tryConnectToMetro(); + + // cleanup method + return () => { + if (ws) { + ws.close(); + } + if (timeoutHandle) { + clearInterval(timeoutHandle); + } + }; +}; diff --git a/src/index.tsx b/src/index.tsx index d82b59d91..015d49470 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -50,6 +50,7 @@ export {createTablePlugin} from './createTablePlugin'; export {default as DetailSidebar} from './chrome/DetailSidebar'; export {default as Device} from './devices/BaseDevice'; export {default as AndroidDevice} from './devices/AndroidDevice'; +export {default as MetroDevice} from './devices/MetroDevice'; export {default as ArchivedDevice} from './devices/ArchivedDevice'; export {default as IOSDevice} from './devices/IOSDevice'; export {default as KaiOSDevice} from './devices/KaiOSDevice'; diff --git a/src/plugins/metro/index.tsx b/src/plugins/metro/index.tsx new file mode 100644 index 000000000..47e79f9dc --- /dev/null +++ b/src/plugins/metro/index.tsx @@ -0,0 +1,171 @@ +/** + * 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 React from 'react'; +import { + FlipperDevicePlugin, + Device, + View, + Button, + Toolbar, + ButtonGroup, + MetroDevice, +} from 'flipper'; + +type LogEntry = {}; + +export type PersistedState = { + logs: LogEntry[]; +}; + +type State = {}; + +/* +Flow types for events + +/ + A tagged union of all the actions that may happen and we may want to + report to the tool user. + / +export type ReportableEvent = + | { + port: number, + projectRoots: $ReadOnlyArray, + type: 'initialize_started', + ... + } + | {type: 'initialize_done', ...} + | { + type: 'initialize_failed', + port: number, + error: Error, + ... + } + | { + buildID: string, + type: 'bundle_build_done', + ... + } + | { + buildID: string, + type: 'bundle_build_failed', + ... + } + | { + buildID: string, + bundleDetails: BundleDetails, + type: 'bundle_build_started', + ... + } + | { + error: Error, + type: 'bundling_error', + ... + } + | {type: 'dep_graph_loading', ...} + | {type: 'dep_graph_loaded', ...} + | { + buildID: string, + type: 'bundle_transform_progressed', + transformedFileCount: number, + totalFileCount: number, + ... + } + | { + type: 'global_cache_error', + error: Error, + ... + } + | { + type: 'global_cache_disabled', + reason: GlobalCacheDisabledReason, + ... + } + | {type: 'transform_cache_reset', ...} + | { + type: 'worker_stdout_chunk', + chunk: string, + ... + } + | { + type: 'worker_stderr_chunk', + chunk: string, + ... + } + | { + type: 'hmr_client_error', + error: Error, + ... + } + | { + type: 'client_log', + level: + | 'trace' + | 'info' + | 'warn' + | 'error' + | 'log' + | 'group' + | 'groupCollapsed' + | 'groupEnd' + | 'debug', + data: Array, + ... + }; +*/ + +export default class MetroPlugin extends FlipperDevicePlugin< + State, + any, + PersistedState +> { + static supportsDevice(device: Device) { + return device.os === 'Metro'; + } + + get ws(): WebSocket { + return (this.device as MetroDevice).ws; + } + + sendCommand(command: string) { + if (this.ws) { + this.ws.send( + JSON.stringify({ + version: 2, + type: 'command', + command, + }), + ); + } + } + + render() { + return ( + + + + Work-in-progress + + + + + + ); + } +} diff --git a/src/plugins/metro/package.json b/src/plugins/metro/package.json new file mode 100644 index 000000000..678b91ba1 --- /dev/null +++ b/src/plugins/metro/package.json @@ -0,0 +1,18 @@ +{ + "name": "Metro", + "version": "0.1.0", + "description": "A plugin to manage React Native applications served trough Metro", + "main": "index.tsx", + "repository": "https://github.com/facebook/flipper", + "license": "MIT", + "keywords": [ + "flipper-plugin", + "react-native", + "metro" + ], + "title": "Metro Bundler", + "bugs": { + "email": "mweststrate@fb.com" + }, + "dependencies": {} +} diff --git a/src/reducers/__tests__/connections.node.tsx b/src/reducers/__tests__/connections.node.tsx index ef4d765c2..563917642 100644 --- a/src/reducers/__tests__/connections.node.tsx +++ b/src/reducers/__tests__/connections.node.tsx @@ -127,3 +127,19 @@ test('selectPlugin sets deepLinkPayload correctly', () => { ); expect(state.deepLinkPayload).toBe('myPayload'); }); + +test('UNREGISTER_DEVICE removes device', () => { + const device = new BaseDevice('serial', 'physical', 'title', 'Android'); + const initialState: State = reducer(undefined, { + type: 'REGISTER_DEVICE', + payload: new BaseDevice('serial', 'physical', 'title', 'Android'), + }); + + expect(initialState.devices).toEqual([device]); + const endState = reducer(initialState, { + type: 'UNREGISTER_DEVICES', + payload: new Set(['serial']), + }); + + expect(endState.devices).toEqual([]); +});