microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
e23d18413023bf3cf53b60f216b32f9b33263a28

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/debugger/appWorker.ts

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