| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @flow |
| 8 | */ |
| 9 | |
| 10 | import Agent from 'react-devtools-shared/src/backend/agent'; |
| 11 | import Bridge from 'react-devtools-shared/src/bridge'; |
| 12 | import {installHook} from 'react-devtools-shared/src/hook'; |
| 13 | import {initBackend} from 'react-devtools-shared/src/backend'; |
| 14 | import {__DEBUG__} from 'react-devtools-shared/src/constants'; |
| 15 | import setupNativeStyleEditor from 'react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor'; |
| 16 | import { |
| 17 | getDefaultComponentFilters, |
| 18 | getIsReloadAndProfileSupported, |
| 19 | } from 'react-devtools-shared/src/utils'; |
| 20 | |
| 21 | import type {BackendBridge} from 'react-devtools-shared/src/bridge'; |
| 22 | import type { |
| 23 | ComponentFilter, |
| 24 | Wall, |
| 25 | } from 'react-devtools-shared/src/frontend/types'; |
| 26 | import type { |
| 27 | DevToolsHook, |
| 28 | DevToolsHookSettings, |
| 29 | ProfilingSettings, |
| 30 | } from 'react-devtools-shared/src/backend/types'; |
| 31 | import type {ResolveNativeStyle} from 'react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor'; |
| 32 | |
| 33 | type ConnectOptions = { |
| 34 | host?: string, |
| 35 | nativeStyleEditorValidAttributes?: $ReadOnlyArray<string>, |
| 36 | path?: string, |
| 37 | port?: number, |
| 38 | useHttps?: boolean, |
| 39 | resolveRNStyle?: ResolveNativeStyle, |
| 40 | retryConnectionDelay?: number, |
| 41 | isAppActive?: () => boolean, |
| 42 | websocket?: ?WebSocket, |
| 43 | onSettingsUpdated?: (settings: $ReadOnly<DevToolsHookSettings>) => void, |
| 44 | isReloadAndProfileSupported?: boolean, |
| 45 | isProfiling?: boolean, |
| 46 | onReloadAndProfile?: (recordChangeDescriptions: boolean) => void, |
| 47 | onReloadAndProfileFlagsReset?: () => void, |
| 48 | }; |
| 49 | |
| 50 | let savedComponentFilters: Array<ComponentFilter> = |
| 51 | getDefaultComponentFilters(); |
| 52 | |
| 53 | function debug(methodName: string, ...args: Array<mixed>) { |
| 54 | // $FlowFixMe[constant-condition] |
| 55 | if (__DEBUG__) { |
| 56 | console.log( |
| 57 | `%c[core/backend] %c${methodName}`, |
| 58 | 'color: teal; font-weight: bold;', |
| 59 | 'font-weight: bold;', |
| 60 | ...args, |
| 61 | ); |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | export function initialize( |
| 66 | maybeSettingsOrSettingsPromise?: |
| 67 | | DevToolsHookSettings |
| 68 | | Promise<DevToolsHookSettings>, |
| 69 | shouldStartProfilingNow: boolean = false, |
| 70 | profilingSettings?: ProfilingSettings, |
| 71 | maybeComponentFiltersOrComponentFiltersPromise?: |
| 72 | | Array<ComponentFilter> |
| 73 | | Promise<Array<ComponentFilter>>, |
| 74 | ) { |
| 75 | const componentFiltersOrComponentFiltersPromise = |
| 76 | maybeComponentFiltersOrComponentFiltersPromise |
| 77 | ? maybeComponentFiltersOrComponentFiltersPromise |
| 78 | : savedComponentFilters; |
| 79 | installHook( |
| 80 | window, |
| 81 | componentFiltersOrComponentFiltersPromise, |
| 82 | maybeSettingsOrSettingsPromise, |
| 83 | shouldStartProfilingNow, |
| 84 | profilingSettings, |
| 85 | ); |
| 86 | } |
| 87 | |
| 88 | export function connectToDevTools(options: ?ConnectOptions) { |
| 89 | const hook: ?DevToolsHook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; |
| 90 | if (hook == null) { |
| 91 | // DevTools didn't get injected into this page (maybe b'c of the contentType). |
| 92 | return; |
| 93 | } |
| 94 | |
| 95 | const { |
| 96 | host = 'localhost', |
| 97 | nativeStyleEditorValidAttributes, |
| 98 | path = '', |
| 99 | useHttps = false, |
| 100 | port = 8097, |
| 101 | websocket, |
| 102 | resolveRNStyle = null as $FlowFixMe, |
| 103 | retryConnectionDelay = 2000, |
| 104 | isAppActive = () => true, |
| 105 | onSettingsUpdated, |
| 106 | isReloadAndProfileSupported = getIsReloadAndProfileSupported(), |
| 107 | isProfiling, |
| 108 | onReloadAndProfile, |
| 109 | onReloadAndProfileFlagsReset, |
| 110 | } = options || {}; |
| 111 | |
| 112 | const protocol = useHttps ? 'wss' : 'ws'; |
| 113 | const prefixedPath = path !== '' && !path.startsWith('/') ? '/' + path : path; |
| 114 | let retryTimeoutID: TimeoutID | null = null; |
| 115 | |
| 116 | function scheduleRetry() { |
| 117 | if (retryTimeoutID === null) { |
| 118 | // Two seconds because RN had issues with quick retries. |
| 119 | retryTimeoutID = setTimeout( |
| 120 | () => connectToDevTools(options), |
| 121 | retryConnectionDelay, |
| 122 | ); |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | if (!isAppActive()) { |
| 127 | // If the app is in background, maybe retry later. |
| 128 | // Don't actually attempt to connect until we're in foreground. |
| 129 | scheduleRetry(); |
| 130 | return; |
| 131 | } |
| 132 | |
| 133 | let bridge: BackendBridge | null = null; |
| 134 | |
| 135 | function shutdownBridge(): void { |
| 136 | const bridgeToShutdown = bridge; |
| 137 | if (bridgeToShutdown !== null) { |
| 138 | // Clear the active reference before shutdown flushes its final message |
| 139 | // through a potentially closed socket. |
| 140 | bridge = null; |
| 141 | bridgeToShutdown.shutdown(); |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | const messageListeners = []; |
| 146 | const uri = protocol + '://' + host + ':' + port + prefixedPath; |
| 147 | |
| 148 | // If existing websocket is passed, use it. |
| 149 | // This is necessary to support our custom integrations. |
| 150 | // See D6251744. |
| 151 | const ws = websocket ? websocket : new window.WebSocket(uri); |
| 152 | ws.onclose = handleClose; |
| 153 | ws.onerror = handleFailed; |
| 154 | ws.onmessage = handleMessage; |
| 155 | ws.onopen = function () { |
| 156 | bridge = new Bridge({ |
| 157 | listen(fn) { |
| 158 | messageListeners.push(fn); |
| 159 | return () => { |
| 160 | const index = messageListeners.indexOf(fn); |
| 161 | if (index >= 0) { |
| 162 | messageListeners.splice(index, 1); |
| 163 | } |
| 164 | }; |
| 165 | }, |
| 166 | send( |
| 167 | event: string, |
| 168 | payload: mixed, |
| 169 | transferable?: $ReadOnlyArray<mixed>, |
| 170 | ) { |
| 171 | if (ws.readyState === ws.OPEN) { |
| 172 | // $FlowFixMe[constant-condition] |
| 173 | if (__DEBUG__) { |
| 174 | debug('wall.send()', event, payload); |
| 175 | } |
| 176 | |
| 177 | ws.send(JSON.stringify({event, payload})); |
| 178 | } else { |
| 179 | // $FlowFixMe[constant-condition] |
| 180 | if (__DEBUG__) { |
| 181 | debug( |
| 182 | 'wall.send()', |
| 183 | 'Shutting down bridge because of closed WebSocket connection', |
| 184 | ); |
| 185 | } |
| 186 | |
| 187 | shutdownBridge(); |
| 188 | scheduleRetry(); |
| 189 | } |
| 190 | }, |
| 191 | }); |
| 192 | bridge.addListener( |
| 193 | 'updateComponentFilters', |
| 194 | (componentFilters: Array<ComponentFilter>) => { |
| 195 | // Save filter changes in memory, in case DevTools is reloaded. |
| 196 | // In that case, the renderer will already be using the updated values. |
| 197 | // We'll lose these in between backend reloads but that can't be helped. |
| 198 | savedComponentFilters = componentFilters; |
| 199 | }, |
| 200 | ); |
| 201 | |
| 202 | // TODO (npm-packages) Warn if "isBackendStorageAPISupported" |
| 203 | // $FlowFixMe[incompatible-type] found when upgrading Flow |
| 204 | const agent = new Agent(bridge, isProfiling, onReloadAndProfile); |
| 205 | if (typeof onReloadAndProfileFlagsReset === 'function') { |
| 206 | onReloadAndProfileFlagsReset(); |
| 207 | } |
| 208 | |
| 209 | if (onSettingsUpdated != null) { |
| 210 | agent.addListener('updateHookSettings', onSettingsUpdated); |
| 211 | } |
| 212 | agent.addListener('shutdown', () => { |
| 213 | if (onSettingsUpdated != null) { |
| 214 | agent.removeListener('updateHookSettings', onSettingsUpdated); |
| 215 | } |
| 216 | |
| 217 | // If we received 'shutdown' from `agent`, we assume the `bridge` is already shutting down, |
| 218 | // and that caused the 'shutdown' event on the `agent`, so we don't need to call `bridge.shutdown()` here. |
| 219 | hook.emit('shutdown'); |
| 220 | }); |
| 221 | |
| 222 | initBackend(hook, agent, window, isReloadAndProfileSupported); |
| 223 | |
| 224 | // Setup React Native style editor if the environment supports it. |
| 225 | if (resolveRNStyle != null || hook.resolveRNStyle != null) { |
| 226 | setupNativeStyleEditor( |
| 227 | // $FlowFixMe[incompatible-type] found when upgrading Flow |
| 228 | bridge, |
| 229 | agent, |
| 230 | // $FlowFixMe[constant-condition] |
| 231 | (resolveRNStyle || hook.resolveRNStyle) as any as ResolveNativeStyle, |
| 232 | nativeStyleEditorValidAttributes || |
| 233 | hook.nativeStyleEditorValidAttributes || |
| 234 | null, |
| 235 | ); |
| 236 | } else { |
| 237 | // Otherwise listen to detect if the environment later supports it. |
| 238 | // For example, Flipper does not eagerly inject these values. |
| 239 | // Instead it relies on the React Native Inspector to lazily inject them. |
| 240 | let lazyResolveRNStyle; |
| 241 | let lazyNativeStyleEditorValidAttributes; |
| 242 | |
| 243 | const initAfterTick = () => { |
| 244 | if (bridge !== null) { |
| 245 | setupNativeStyleEditor( |
| 246 | bridge, |
| 247 | agent, |
| 248 | lazyResolveRNStyle, |
| 249 | lazyNativeStyleEditorValidAttributes, |
| 250 | ); |
| 251 | } |
| 252 | }; |
| 253 | |
| 254 | if (!hook.hasOwnProperty('resolveRNStyle')) { |
| 255 | Object.defineProperty(hook, 'resolveRNStyle', { |
| 256 | enumerable: false, |
| 257 | get() { |
| 258 | return lazyResolveRNStyle; |
| 259 | }, |
| 260 | set(value: $FlowFixMe) { |
| 261 | lazyResolveRNStyle = value; |
| 262 | initAfterTick(); |
| 263 | }, |
| 264 | } as Object); |
| 265 | } |
| 266 | if (!hook.hasOwnProperty('nativeStyleEditorValidAttributes')) { |
| 267 | Object.defineProperty(hook, 'nativeStyleEditorValidAttributes', { |
| 268 | enumerable: false, |
| 269 | get() { |
| 270 | return lazyNativeStyleEditorValidAttributes; |
| 271 | }, |
| 272 | set(value: $FlowFixMe) { |
| 273 | lazyNativeStyleEditorValidAttributes = value; |
| 274 | initAfterTick(); |
| 275 | }, |
| 276 | } as Object); |
| 277 | } |
| 278 | } |
| 279 | }; |
| 280 | |
| 281 | function handleClose() { |
| 282 | // $FlowFixMe[constant-condition] |
| 283 | if (__DEBUG__) { |
| 284 | debug('WebSocket.onclose'); |
| 285 | } |
| 286 | |
| 287 | shutdownBridge(); |
| 288 | scheduleRetry(); |
| 289 | } |
| 290 | |
| 291 | function handleFailed() { |
| 292 | // $FlowFixMe[constant-condition] |
| 293 | if (__DEBUG__) { |
| 294 | debug('WebSocket.onerror'); |
| 295 | } |
| 296 | |
| 297 | scheduleRetry(); |
| 298 | } |
| 299 | |
| 300 | function handleMessage(event: MessageEvent<>) { |
| 301 | let data; |
| 302 | try { |
| 303 | if (typeof event.data === 'string') { |
| 304 | data = JSON.parse(event.data); |
| 305 | // $FlowFixMe[constant-condition] |
| 306 | if (__DEBUG__) { |
| 307 | debug('WebSocket.onmessage', data); |
| 308 | } |
| 309 | } else { |
| 310 | throw Error(); |
| 311 | } |
| 312 | } catch (e) { |
| 313 | console.error( |
| 314 | '[React DevTools] Failed to parse JSON: ' + (event.data as any), |
| 315 | ); |
| 316 | return; |
| 317 | } |
| 318 | messageListeners.forEach(fn => { |
| 319 | try { |
| 320 | fn(data); |
| 321 | } catch (error) { |
| 322 | // jsc doesn't play so well with tracebacks that go into eval'd code, |
| 323 | // so the stack trace here will stop at the `eval()` call. Getting the |
| 324 | // message that caused the error is the best we can do for now. |
| 325 | console.log('[React DevTools] Error calling listener', data); |
| 326 | console.log('error:', error); |
| 327 | throw error; |
| 328 | } |
| 329 | }); |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | type ConnectWithCustomMessagingOptions = { |
| 334 | onSubscribe: (cb: (message: mixed) => void) => void, |
| 335 | onUnsubscribe: (cb: (message: mixed) => void) => void, |
| 336 | onMessage: (event: string, payload: mixed) => void, |
| 337 | nativeStyleEditorValidAttributes?: $ReadOnlyArray<string>, |
| 338 | resolveRNStyle?: ResolveNativeStyle, |
| 339 | onSettingsUpdated?: (settings: $ReadOnly<DevToolsHookSettings>) => void, |
| 340 | isReloadAndProfileSupported?: boolean, |
| 341 | isProfiling?: boolean, |
| 342 | onReloadAndProfile?: (recordChangeDescriptions: boolean) => void, |
| 343 | onReloadAndProfileFlagsReset?: () => void, |
| 344 | }; |
| 345 | |
| 346 | export function connectWithCustomMessagingProtocol({ |
| 347 | onSubscribe, |
| 348 | onUnsubscribe, |
| 349 | onMessage, |
| 350 | nativeStyleEditorValidAttributes, |
| 351 | resolveRNStyle, |
| 352 | onSettingsUpdated, |
| 353 | isReloadAndProfileSupported = getIsReloadAndProfileSupported(), |
| 354 | isProfiling, |
| 355 | onReloadAndProfile, |
| 356 | onReloadAndProfileFlagsReset, |
| 357 | }: ConnectWithCustomMessagingOptions): Function { |
| 358 | const hook: ?DevToolsHook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; |
| 359 | if (hook == null) { |
| 360 | // DevTools didn't get injected into this page (maybe b'c of the contentType). |
| 361 | return; |
| 362 | } |
| 363 | |
| 364 | const wall: Wall = { |
| 365 | listen(fn: (message: mixed) => void) { |
| 366 | onSubscribe(fn); |
| 367 | |
| 368 | return () => { |
| 369 | onUnsubscribe(fn); |
| 370 | }; |
| 371 | }, |
| 372 | send(event: string, payload: mixed) { |
| 373 | onMessage(event, payload); |
| 374 | }, |
| 375 | }; |
| 376 | |
| 377 | const bridge: BackendBridge = new Bridge(wall); |
| 378 | |
| 379 | bridge.addListener( |
| 380 | 'updateComponentFilters', |
| 381 | (componentFilters: Array<ComponentFilter>) => { |
| 382 | // Save filter changes in memory, in case DevTools is reloaded. |
| 383 | // In that case, the renderer will already be using the updated values. |
| 384 | // We'll lose these in between backend reloads but that can't be helped. |
| 385 | savedComponentFilters = componentFilters; |
| 386 | }, |
| 387 | ); |
| 388 | |
| 389 | const agent = new Agent(bridge, isProfiling, onReloadAndProfile); |
| 390 | if (typeof onReloadAndProfileFlagsReset === 'function') { |
| 391 | onReloadAndProfileFlagsReset(); |
| 392 | } |
| 393 | |
| 394 | if (onSettingsUpdated != null) { |
| 395 | agent.addListener('updateHookSettings', onSettingsUpdated); |
| 396 | } |
| 397 | agent.addListener('shutdown', () => { |
| 398 | if (onSettingsUpdated != null) { |
| 399 | agent.removeListener('updateHookSettings', onSettingsUpdated); |
| 400 | } |
| 401 | |
| 402 | // If we received 'shutdown' from `agent`, we assume the `bridge` is already shutting down, |
| 403 | // and that caused the 'shutdown' event on the `agent`, so we don't need to call `bridge.shutdown()` here. |
| 404 | hook.emit('shutdown'); |
| 405 | }); |
| 406 | |
| 407 | const unsubscribeBackend = initBackend( |
| 408 | hook, |
| 409 | agent, |
| 410 | window, |
| 411 | isReloadAndProfileSupported, |
| 412 | ); |
| 413 | |
| 414 | const nativeStyleResolver: ResolveNativeStyle | void = |
| 415 | resolveRNStyle || hook.resolveRNStyle; |
| 416 | |
| 417 | if (nativeStyleResolver != null) { |
| 418 | const validAttributes = |
| 419 | nativeStyleEditorValidAttributes || |
| 420 | hook.nativeStyleEditorValidAttributes || |
| 421 | null; |
| 422 | |
| 423 | setupNativeStyleEditor(bridge, agent, nativeStyleResolver, validAttributes); |
| 424 | } |
| 425 | |
| 426 | return unsubscribeBackend; |
| 427 | } |