microsoft/vscode-react-native

Public

mirrored fromhttps://github.com/microsoft/vscode-react-nativeAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
add-signproj-for-microbuild1

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

src/debugger/appWorker.ts

483lines · modecode

1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT license. See LICENSE file in the project root for details.
3
4import * as path from "path";
5import { EventEmitter } from "events";
6import * as vscode from "vscode";
7import * as WebSocket from "ws";
8import { logger } from "@vscode/debugadapter";
9import * as nls from "vscode-nls";
10import { ensurePackagerRunning } from "../common/packagerStatus";
11import { ErrorHelper } from "../common/error/errorHelper";
12import { ExecutionsLimiter } from "../common/executionsLimiter";
13import { ReactNativeProjectHelper } from "../common/reactNativeProjectHelper";
14import { InternalErrorCode } from "../common/error/internalErrorCode";
15import { FileSystem } from "../common/node/fileSystem";
16import { PromiseUtil } from "../common/node/promise";
17import { ForkedAppWorker } from "./forkedAppWorker";
18import { ScriptImporter } from "./scriptImporter";
19
20nls.config({
21 messageFormat: nls.MessageFormat.bundle,
22 bundleFormat: nls.BundleFormat.standalone,
23})();
24const localize = nls.loadMessageBundle();
25
26export interface RNAppMessage {
27 method: string;
28 url?: string;
29 // These objects have also other properties but that we don't currently use
30}
31
32export interface IDebuggeeWorker {
33 start(): Promise<any>;
34 stop(): void;
35 postMessage(message: RNAppMessage): void;
36}
37
38function printDebuggingError(error: Error, reason: any) {
39 const nestedError = ErrorHelper.getNestedError(
40 error,
41 InternalErrorCode.DebuggingWontWorkReloadJSAndReconnect,
42 reason,
43 );
44
45 logger.error(nestedError.message);
46}
47
48/** This class will create a SandboxedAppWorker that will run the RN App logic, and then create a socket
49 * and send the RN App messages to the SandboxedAppWorker. The only RN App message that this class handles
50 * is the prepareJSRuntime, which we reply to the RN App that the sandbox was created successfully.
51 * When the socket closes, we'll create a new SandboxedAppWorker and a new socket pair and discard the old ones.
52 */
53
54export class MultipleLifetimesAppWorker extends EventEmitter {
55 public static WORKER_BOOTSTRAP = `
56// Initialize some variables before react-native code would access them
57var onmessage=null, self=global;
58// Cache Node's original require as __debug__.require
59global.__debug__={require: require};
60// Prevent leaking process.versions from debugger process to
61// worker because pure React Native doesn't do that and some packages as js-md5 rely on this behavior
62Object.defineProperty(process, "versions", {
63 value: undefined
64});
65
66// TODO: Replace by url.fileURLToPath method when Node 10 LTS become deprecated
67function fileUrlToPath(url) {
68 if (process.platform === 'win32') {
69 return url.toString().replace('file:///', '');
70 } else {
71 return url.toString().replace('file://', '');
72 }
73}
74
75function getNativeModules() {
76 var NativeModules;
77 try {
78 // This approach is for old RN versions
79 NativeModules = global.require('NativeModules');
80 } catch (err) {
81 // ignore error and try another way for more recent RN versions
82 try {
83 var nativeModuleId;
84 var modules = global.__r.getModules();
85 var ids = Object.keys(modules);
86 for (var i = 0; i < ids.length; i++) {
87 if (modules[ids[i]].verboseName) {
88 var packagePath = new String(modules[ids[i]].verboseName);
89 if (packagePath.indexOf('Libraries/BatchedBridge/NativeModules.js') > 0 || packagePath.indexOf('Libraries\\\\BatchedBridge\\\\NativeModules.js') > 0) {
90 nativeModuleId = parseInt(ids[i], 10);
91 break;
92 }
93 }
94 }
95 if (nativeModuleId) {
96 NativeModules = global.__r(nativeModuleId);
97 }
98 }
99 catch (err) {
100 // suppress errors
101 }
102 }
103 return NativeModules;
104}
105
106// Originally, this was made for iOS only
107var vscodeHandlers = {
108 'vscode_reloadApp': function () {
109 var NativeModules = getNativeModules();
110 if (NativeModules && NativeModules.DevSettings) {
111 NativeModules.DevSettings.reload();
112 }
113 },
114 'vscode_showDevMenu': function () {
115 var NativeModules = getNativeModules();
116 if (NativeModules && NativeModules.DevMenu) {
117 NativeModules.DevMenu.show();
118 }
119 }
120};
121
122process.on("message", function (message) {
123 if (message.data && vscodeHandlers[message.data.method]) {
124 vscodeHandlers[message.data.method]();
125 } else if(onmessage) {
126 onmessage(message);
127 }
128});
129
130var postMessage = function(message){
131 process.send(message);
132};
133
134if (!self.postMessage) {
135 self.postMessage = postMessage;
136}
137
138var importScripts = (function(){
139 var fs=require('fs'), vm=require('vm');
140 return function(scriptUrl){
141 scriptUrl = fileUrlToPath(scriptUrl);
142 var scriptCode = fs.readFileSync(scriptUrl, 'utf8');
143 // Add a 'debugger;' statement to stop code execution
144 // to wait for the sourcemaps to be processed by the debug adapter
145 vm.runInThisContext('debugger;' + scriptCode, {filename: scriptUrl});
146 };
147})();
148`;
149
150 public static CONSOLE_TRACE_PATCH = `// Worker is ran as nodejs process, so console.trace() writes to stderr and it leads to error in native app
151// To avoid this console.trace() is overridden to print stacktrace via console.log()
152// Please, see Node JS implementation: https://github.com/nodejs/node/blob/master/lib/internal/console/constructor.js
153console.trace = (function() {
154 return function() {
155 try {
156 var err = {
157 name: 'Trace',
158 message: require('util').format.apply(null, arguments)
159 };
160 // Node uses 10, but usually it's not enough for RN app trace
161 Error.stackTraceLimit = 30;
162 Error.captureStackTrace(err, console.trace);
163 console.log(err.stack);
164 } catch (e) {
165 console.error(e);
166 }
167 };
168})();
169`;
170
171 public static PROCESS_TO_STRING_PATCH = `// As worker is ran in node, it breaks broadcast-channels package approach of identifying if it’s ran in node:
172// https://github.com/pubkey/broadcast-channel/blob/master/src/util.js#L64
173// To avoid it if process.toString() is called if will return empty string instead of [object process].
174var nativeObjectToString = Object.prototype.toString;
175Object.prototype.toString = function() {
176 if (this === process) {
177 return '';
178 } else {
179 return nativeObjectToString.call(this);
180 }
181};
182`;
183
184 public static WORKER_DONE = `// Notify debugger that we're done with loading
185// and started listening for IPC messages
186postMessage({workerLoaded:true});`;
187
188 public static FETCH_STUB = `(function(self) {
189'use strict';
190
191if (self.fetch) {
192 return;
193}
194
195self.fetch = fetch;
196
197function fetch(url) {
198 return new Promise((resolve, reject) => {
199 var data = require('fs').readFileSync(fileUrlToPath(url), 'utf8');
200 resolve(
201 {
202 text: function () {
203 return data;
204 }
205 });
206 });
207}
208})(global);
209`;
210
211 private packagerAddress: string;
212 private packagerPort: number;
213 private sourcesStoragePath: string;
214 private projectRootPath: string;
215 private packagerRemoteRoot?: string;
216 private packagerLocalRoot?: string;
217 private debuggerWorkerUrlPath?: string;
218 private socketToApp: WebSocket;
219 private cancellationToken: vscode.CancellationToken;
220 private singleLifetimeWorker: IDebuggeeWorker | null;
221 private webSocketConstructor: (url: string) => WebSocket;
222
223 private executionLimiter = new ExecutionsLimiter();
224 private nodeFileSystem = new FileSystem();
225 private scriptImporter: ScriptImporter;
226
227 constructor(
228 attachRequestArguments: any,
229 sourcesStoragePath: string,
230 projectRootPath: string,
231 cancellationToken: vscode.CancellationToken,
232 { webSocketConstructor = (url: string) => new WebSocket(url) } = {},
233 ) {
234 super();
235 this.packagerAddress = attachRequestArguments.address || "localhost";
236 this.packagerPort = attachRequestArguments.port;
237 this.packagerRemoteRoot = attachRequestArguments.remoteRoot;
238 this.packagerLocalRoot = attachRequestArguments.localRoot;
239 this.debuggerWorkerUrlPath = attachRequestArguments.debuggerWorkerUrlPath;
240 this.sourcesStoragePath = sourcesStoragePath;
241 this.projectRootPath = projectRootPath;
242 this.cancellationToken = cancellationToken;
243 if (!this.sourcesStoragePath)
244 throw ErrorHelper.getInternalError(InternalErrorCode.SourcesStoragePathIsNullOrEmpty);
245 this.webSocketConstructor = webSocketConstructor;
246 this.scriptImporter = new ScriptImporter(
247 this.packagerAddress,
248 this.packagerPort,
249 sourcesStoragePath,
250 this.packagerRemoteRoot,
251 this.packagerLocalRoot,
252 );
253 }
254
255 public async start(retryAttempt: boolean = false): Promise<void> {
256 const errPackagerNotRunning = ErrorHelper.getInternalError(
257 InternalErrorCode.CannotAttachToPackagerCheckPackagerRunningOnPort,
258 this.packagerPort,
259 );
260
261 await ensurePackagerRunning(this.packagerAddress, this.packagerPort, errPackagerNotRunning);
262 // Don't fetch debugger worker on socket disconnect
263 if (!retryAttempt) {
264 await this.downloadAndPatchDebuggerWorker();
265 }
266 return this.createSocketToApp(retryAttempt);
267 }
268
269 public stop(): void {
270 if (this.socketToApp) {
271 this.socketToApp.removeAllListeners();
272 this.socketToApp.close();
273 }
274
275 if (this.singleLifetimeWorker) {
276 this.singleLifetimeWorker.stop();
277 }
278 }
279
280 public async downloadAndPatchDebuggerWorker(): Promise<void> {
281 const scriptToRunPath = path.resolve(
282 this.sourcesStoragePath,
283 ScriptImporter.DEBUGGER_WORKER_FILENAME,
284 );
285
286 await this.scriptImporter.downloadDebuggerWorker(
287 this.sourcesStoragePath,
288 this.projectRootPath,
289 this.debuggerWorkerUrlPath,
290 );
291 const workerContent = await this.nodeFileSystem.readFile(scriptToRunPath, "utf8");
292 const isHaulProject = ReactNativeProjectHelper.isHaulProject(this.projectRootPath);
293 // Add our customizations to debugger worker to get it working smoothly
294 // in Node env and polyfill WebWorkers API over Node's IPC.
295 const modifiedDebuggeeContent = [
296 MultipleLifetimesAppWorker.WORKER_BOOTSTRAP,
297 MultipleLifetimesAppWorker.CONSOLE_TRACE_PATCH,
298 MultipleLifetimesAppWorker.PROCESS_TO_STRING_PATCH,
299 isHaulProject ? MultipleLifetimesAppWorker.FETCH_STUB : null,
300 workerContent,
301 MultipleLifetimesAppWorker.WORKER_DONE,
302 ].join("\n");
303 return this.nodeFileSystem.writeFile(scriptToRunPath, modifiedDebuggeeContent);
304 }
305
306 public showDevMenuCommand(): void {
307 if (this.singleLifetimeWorker) {
308 this.singleLifetimeWorker.postMessage({
309 method: "vscode_showDevMenu",
310 });
311 }
312 }
313
314 public reloadAppCommand(): void {
315 if (this.singleLifetimeWorker) {
316 this.singleLifetimeWorker.postMessage({
317 method: "vscode_reloadApp",
318 });
319 }
320 }
321
322 private async startNewWorkerLifetime(): Promise<void> {
323 this.singleLifetimeWorker = new ForkedAppWorker(
324 this.packagerAddress,
325 this.packagerPort,
326 this.sourcesStoragePath,
327 this.projectRootPath,
328 message => {
329 this.sendMessageToApp(message);
330 },
331 this.packagerRemoteRoot,
332 this.packagerLocalRoot,
333 );
334 logger.verbose("A new app worker lifetime was created.");
335 const startedEvent = await this.singleLifetimeWorker.start();
336 this.emit("connected", startedEvent);
337 }
338
339 private async createSocketToApp(retryAttempt: boolean = false): Promise<void> {
340 return new Promise((resolve, reject) => {
341 this.socketToApp = this.webSocketConstructor(this.debuggerProxyUrl());
342 this.socketToApp.on("open", () => {
343 this.onSocketOpened();
344 });
345 this.socketToApp.on("close", () => {
346 this.executionLimiter.execute("onSocketClose.msg", /* limitInSeconds*/ 10, () => {
347 /*
348 * It is not the best idea to compare with the message, but this is the only thing React Native gives that is unique when
349 * it closes the socket because it already has a connection to a debugger.
350 * https://github.com/facebook/react-native/blob/588f01e9982775f0699c7bfd56623d4ed3949810/local-cli/server/util/webSocketProxy.js#L38
351 */
352 const msgKey = "_closeMessage";
353 if (this.socketToApp[msgKey] === "Another debugger is already connected") {
354 reject(
355 ErrorHelper.getInternalError(
356 InternalErrorCode.AnotherDebuggerConnectedToPackager,
357 ),
358 );
359 }
360 logger.log(
361 localize(
362 "DisconnectedFromThePackagerToReactNative",
363 "Disconnected from the Proxy (Packager) to the React Native application. Retrying reconnection soon...",
364 ),
365 );
366 });
367 if (!this.cancellationToken.isCancellationRequested) {
368 setTimeout(() => {
369 void this.start(true /* retryAttempt */);
370 }, 100);
371 }
372 });
373 this.socketToApp.on("message", (message: any) => this.onMessage(message));
374 this.socketToApp.on("error", (error: Error) => {
375 if (retryAttempt) {
376 printDebuggingError(
377 ErrorHelper.getInternalError(
378 InternalErrorCode.ReconnectionToPackagerFailedCheckForErrorsOrRestartReactNative,
379 ),
380 error,
381 );
382 }
383
384 reject(error);
385 });
386
387 // In an attempt to catch failures in starting the packager on first attempt,
388 // wait for 300 ms before resolving the promise
389 void PromiseUtil.delay(300).then(() => resolve());
390 });
391 }
392
393 private debuggerProxyUrl() {
394 return `ws://${this.packagerAddress}:${this.packagerPort}/debugger-proxy?role=debugger&name=vscode`;
395 }
396
397 private onSocketOpened() {
398 this.executionLimiter.execute("onSocketOpened.msg", /* limitInSeconds*/ 10, () =>
399 logger.log(
400 localize(
401 "EstablishedConnectionWithPackagerToReactNativeApp",
402 "Established a connection with the Proxy (Packager) to the React Native application",
403 ),
404 ),
405 );
406 }
407
408 private killWorker() {
409 if (!this.singleLifetimeWorker) return;
410 this.singleLifetimeWorker.stop();
411 this.singleLifetimeWorker = null;
412 }
413
414 private onMessage(message: string) {
415 try {
416 logger.verbose(`From RN APP: ${message}`);
417 const object = <RNAppMessage>JSON.parse(message);
418 if (object.method === "prepareJSRuntime") {
419 // In RN 0.40 Android runtime doesn't seem to be sending "$disconnected" event
420 // when user reloads an app, hence we need to try to kill it here either.
421 this.killWorker();
422 // The MultipleLifetimesAppWorker will handle prepareJSRuntime aka create new lifetime
423 this.gotPrepareJSRuntime(object);
424 } else if (object.method === "$disconnected") {
425 // We need to shutdown the current app worker, and create a new lifetime
426 this.killWorker();
427 } else if (object.method) {
428 // All the other messages are handled by the single lifetime worker
429 if (this.singleLifetimeWorker) {
430 this.singleLifetimeWorker.postMessage(object);
431 }
432 } else {
433 // Message doesn't have a method. Ignore it. This is an info message instead of warn because it's normal and expected
434 logger.verbose(
435 `The react-native app sent a message without specifying a method: ${message}`,
436 );
437 }
438 } catch (exception) {
439 printDebuggingError(
440 ErrorHelper.getInternalError(
441 InternalErrorCode.FailedToProcessMessageFromReactNativeApp,
442 message,
443 ),
444 exception,
445 );
446 }
447 }
448
449 private gotPrepareJSRuntime(message: any): void {
450 // Create the sandbox, and replay that we finished processing the message
451 this.startNewWorkerLifetime().then(
452 () => {
453 this.sendMessageToApp({ replyID: parseInt(message.id, 10) });
454 },
455 error =>
456 printDebuggingError(
457 ErrorHelper.getInternalError(
458 InternalErrorCode.FailedToPrepareJSRuntimeEnvironment,
459 message,
460 ),
461 error,
462 ),
463 );
464 }
465
466 private sendMessageToApp(message: any): void {
467 let stringified = "";
468 try {
469 stringified = JSON.stringify(message);
470 logger.verbose(`To RN APP: ${stringified}`);
471 this.socketToApp.send(stringified);
472 } catch (exception) {
473 const messageToShow = stringified || String(message); // Try to show the stringified version, but show the toString if unavailable
474 printDebuggingError(
475 ErrorHelper.getInternalError(
476 InternalErrorCode.FailedToSendMessageToTheReactNativeApp,
477 messageToShow,
478 ),
479 exception,
480 );
481 }
482 }
483}
484