main
js 288 lines 8.01 KB
Raw
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 /* global chrome, ExtensionRuntimePort */
10
11 'use strict';
12
13 import {
14 EXTENSION_BRIDGE_CONNECTION_DISCONNECTED,
15 EXTENSION_BRIDGE_CONNECTION_READY,
16 getExtensionBridgeConnectionType,
17 } from '../constants';
18
19 function injectProxy() {
20 isTransportActive = true;
21
22 // Firefox's behaviour for injecting this content script can be unpredictable
23 // While navigating the history, some content scripts might not be re-injected and still be alive
24 if (!window.__REACT_DEVTOOLS_PROXY_INJECTED__) {
25 window.__REACT_DEVTOOLS_PROXY_INJECTED__ = true;
26
27 listenToMessagesFromBackend();
28 connectPort();
29 sayHelloToBackendManager();
30
31 // The backend waits to install the global hook until notified by the content script.
32 // In the event of a page reload, the content script might be loaded before the backend manager is injected.
33 // Because of this we need to poll the backend manager until it has been initialized.
34 backendManagerHelloIntervalID = setInterval(() => {
35 if (backendInitialized) {
36 stopPollingForBackendManager();
37 } else {
38 sayHelloToBackendManager();
39 }
40 }, 500);
41 }
42 }
43
44 function handlePageShow() {
45 if (document.prerendering) {
46 // React DevTools can't handle multiple documents being connected to the same extension port.
47 // However, browsers are firing pageshow events while prerendering (https://issues.chromium.org/issues/489633225).
48 // We need to wait until prerendering is finished before injecting the proxy.
49 // In browsers with pagereveal support, listening to pagereveal would be sufficient.
50 // Waiting for prerenderingchange is a workaround to support browsers that
51 // have speculationrules but not pagereveal.
52 document.addEventListener('prerenderingchange', injectProxy, {once: true});
53 } else {
54 injectProxy();
55 }
56 }
57
58 window.addEventListener('pagereveal', injectProxy);
59 // For backwards compat with browsers not implementing `pagereveal` which is a fairly new event.
60 window.addEventListener('pageshow', handlePageShow);
61
62 window.addEventListener('pagehide', function ({target}) {
63 if (target !== window.document) {
64 return;
65 }
66
67 isTransportActive = false;
68 backendInitialized = false;
69 isBridgeConnected = false;
70 pendingMessages.length = 0;
71 stopPollingForBackendManager();
72
73 delete window.__REACT_DEVTOOLS_PROXY_INJECTED__;
74 });
75
76 let port: ExtensionRuntimePort | null = null;
77 let isTransportActive: boolean = true;
78 let backendInitialized: boolean = false;
79 let isBridgeConnected: boolean = false;
80 let isListeningToMessagesFromBackend: boolean = false;
81 const pendingMessages: Array<mixed> = [];
82 let backendManagerHelloIntervalID: IntervalID | null = null;
83
84 function stopPollingForBackendManager() {
85 if (backendManagerHelloIntervalID !== null) {
86 clearInterval(backendManagerHelloIntervalID);
87 backendManagerHelloIntervalID = null;
88 }
89 }
90
91 function listenToMessagesFromBackend() {
92 if (!isListeningToMessagesFromBackend) {
93 window.addEventListener('message', handleMessageFromPage);
94 isListeningToMessagesFromBackend = true;
95 }
96 }
97
98 function flushPendingMessages(): boolean {
99 const currentPort = port;
100 if (!isBridgeConnected || currentPort === null) {
101 return false;
102 }
103
104 let sentCount = 0;
105 while (sentCount < pendingMessages.length) {
106 try {
107 currentPort.postMessage(pendingMessages[sentCount]);
108 sentCount++;
109 } catch (error) {
110 isBridgeConnected = false;
111 break;
112 }
113 }
114
115 if (sentCount > 0) {
116 pendingMessages.splice(0, sentCount);
117 }
118
119 return isBridgeConnected && pendingMessages.length === 0;
120 }
121
122 function sayHelloToBackendManager() {
123 window.postMessage(
124 {
125 source: 'react-devtools-content-script',
126 hello: true,
127 },
128 '*',
129 );
130 }
131
132 function handleMessageFromDevtools(
133 sourcePort: ExtensionRuntimePort,
134 message: mixed,
135 ) {
136 if (!isTransportActive || port !== sourcePort) {
137 return;
138 }
139
140 switch (getExtensionBridgeConnectionType(message)) {
141 case EXTENSION_BRIDGE_CONNECTION_READY:
142 isBridgeConnected = true;
143 if (flushPendingMessages()) {
144 const currentPort = port;
145 if (currentPort === null) {
146 // The port may disconnect synchronously while its queue is flushed.
147 return;
148 }
149 try {
150 // This travels through the forwarding pipe after all queued backend
151 // messages, so the frontend can safely flush its command queue.
152 currentPort.postMessage(message);
153 } catch (error) {
154 isBridgeConnected = false;
155 }
156 }
157 return;
158 case EXTENSION_BRIDGE_CONNECTION_DISCONNECTED:
159 isBridgeConnected = false;
160 return;
161 }
162
163 window.postMessage(
164 {
165 source: 'react-devtools-content-script',
166 payload: message,
167 },
168 '*',
169 );
170 }
171
172 function handleMessageFromPage(event: any) {
173 if (!isTransportActive || event.source !== window || !event.data) {
174 return;
175 }
176
177 switch (event.data.source) {
178 // This is a message from a bridge (initialized by a devtools backend)
179 case 'react-devtools-bridge': {
180 backendInitialized = true;
181
182 pendingMessages.push(event.data.payload);
183 flushPendingMessages();
184 break;
185 }
186
187 // This is a message from the backend manager, which runs in ExecutionWorld.MAIN
188 // and can't use `chrome.runtime.sendMessage`
189 case 'react-devtools-backend-manager': {
190 const {source, payload} = event.data;
191
192 chrome.runtime.sendMessage({
193 source,
194 payload,
195 });
196 break;
197 }
198 }
199 }
200
201 function handleDisconnect(disconnectedPort: ExtensionRuntimePort) {
202 if (port !== disconnectedPort) {
203 return;
204 }
205
206 isBridgeConnected = false;
207 port = null;
208
209 // Mirrors the guard in handlePageShow(): the background script can evict/
210 // replace a tab's proxy port (see registerProxyPort in background/index.js),
211 // which disconnects us while still prerendering. Reconnecting immediately
212 // in that case causes an unbounded connect/disconnect cycle for as long as
213 // the document stays in the prerendering state (https://crbug.com/478909972).
214 if (document.prerendering) {
215 document.addEventListener('prerenderingchange', connectPort, {
216 once: true,
217 });
218 } else {
219 connectPort();
220 }
221 }
222
223 // Creates port from application page to the React DevTools' service worker
224 // Which then connects it with extension port
225 function connectPort() {
226 if (!isTransportActive) {
227 return;
228 }
229
230 isBridgeConnected = false;
231 const nextPort = chrome.runtime.connect({
232 name: 'proxy',
233 });
234 port = nextPort;
235
236 listenToMessagesFromBackend();
237
238 nextPort.onMessage.addListener(message =>
239 handleMessageFromDevtools(nextPort, message),
240 );
241 nextPort.onDisconnect.addListener(() => handleDisconnect(nextPort));
242 }
243
244 let evalRequestId = 0;
245 const evalRequestCallbacks = new Map<number, Function>();
246
247 chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
248 switch (msg?.source) {
249 case 'devtools-page-eval': {
250 const {scriptId, args} = msg.payload;
251 const requestId = evalRequestId++;
252 window.postMessage(
253 {
254 source: 'react-devtools-content-script-eval',
255 payload: {
256 requestId,
257 scriptId,
258 args,
259 },
260 },
261 '*',
262 );
263 evalRequestCallbacks.set(requestId, sendResponse);
264 return true; // Indicate we will respond asynchronously
265 }
266 }
267 });
268
269 window.addEventListener('message', event => {
270 if (event.data?.source === 'react-devtools-content-script-eval-response') {
271 const {requestId, response} = event.data.payload;
272 const callback = evalRequestCallbacks.get(requestId);
273 try {
274 if (!callback)
275 throw new Error(
276 `No eval request callback for id "${requestId}" exists.`,
277 );
278 callback(response);
279 } catch (e) {
280 console.warn(
281 'React DevTools Content Script eval response error occurred:',
282 e,
283 );
284 } finally {
285 evalRequestCallbacks.delete(requestId);
286 }
287 }
288 });