microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
34472878f9e8d227bd5d0902161c571864c5d12d

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/debugger/appWorker.ts

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