microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
60ad4ec06bdc0f0e5e8c8114e1cc685f1dbc2568

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/debugger/appWorker.ts

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