microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1.7.1

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/debugger/appWorker.ts

482lines · 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.DevSettings) {
110 NativeModules.DevSettings.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 async start(retryAttempt: boolean = false): Promise<void> {
255 const errPackagerNotRunning = ErrorHelper.getInternalError(
256 InternalErrorCode.CannotAttachToPackagerCheckPackagerRunningOnPort,
257 this.packagerPort,
258 );
259
260 await ensurePackagerRunning(this.packagerAddress, this.packagerPort, errPackagerNotRunning);
261 // Don't fetch debugger worker on socket disconnect
262 if (!retryAttempt) {
263 await this.downloadAndPatchDebuggerWorker();
264 }
265 return 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 async downloadAndPatchDebuggerWorker(): Promise<void> {
280 let scriptToRunPath = path.resolve(
281 this.sourcesStoragePath,
282 ScriptImporter.DEBUGGER_WORKER_FILENAME,
283 );
284
285 await this.scriptImporter.downloadDebuggerWorker(
286 this.sourcesStoragePath,
287 this.projectRootPath,
288 this.debuggerWorkerUrlPath,
289 );
290 const workerContent = await this.nodeFileSystem.readFile(scriptToRunPath, "utf8");
291 const isHaulProject = ReactNativeProjectHelper.isHaulProject(this.projectRootPath);
292 // Add our customizations to debugger worker to get it working smoothly
293 // in Node env and polyfill WebWorkers API over Node's IPC.
294 const modifiedDebuggeeContent = [
295 MultipleLifetimesAppWorker.WORKER_BOOTSTRAP,
296 MultipleLifetimesAppWorker.CONSOLE_TRACE_PATCH,
297 MultipleLifetimesAppWorker.PROCESS_TO_STRING_PATCH,
298 isHaulProject ? MultipleLifetimesAppWorker.FETCH_STUB : null,
299 workerContent,
300 MultipleLifetimesAppWorker.WORKER_DONE,
301 ].join("\n");
302 return this.nodeFileSystem.writeFile(scriptToRunPath, modifiedDebuggeeContent);
303 }
304
305 public showDevMenuCommand(): void {
306 if (this.singleLifetimeWorker) {
307 this.singleLifetimeWorker.postMessage({
308 method: "vscode_showDevMenu",
309 });
310 }
311 }
312
313 public reloadAppCommand(): void {
314 if (this.singleLifetimeWorker) {
315 this.singleLifetimeWorker.postMessage({
316 method: "vscode_reloadApp",
317 });
318 }
319 }
320
321 private async startNewWorkerLifetime(): Promise<void> {
322 this.singleLifetimeWorker = new ForkedAppWorker(
323 this.packagerAddress,
324 this.packagerPort,
325 this.sourcesStoragePath,
326 this.projectRootPath,
327 message => {
328 this.sendMessageToApp(message);
329 },
330 this.packagerRemoteRoot,
331 this.packagerLocalRoot,
332 );
333 logger.verbose("A new app worker lifetime was created.");
334 const startedEvent = await this.singleLifetimeWorker.start();
335 this.emit("connected", startedEvent);
336 }
337
338 private async createSocketToApp(retryAttempt: boolean = false): Promise<void> {
339 return new Promise((resolve, reject) => {
340 this.socketToApp = this.webSocketConstructor(this.debuggerProxyUrl());
341 this.socketToApp.on("open", () => {
342 this.onSocketOpened();
343 });
344 this.socketToApp.on("close", () => {
345 this.executionLimiter.execute("onSocketClose.msg", /*limitInSeconds*/ 10, () => {
346 /*
347 * It is not the best idea to compare with the message, but this is the only thing React Native gives that is unique when
348 * it closes the socket because it already has a connection to a debugger.
349 * https://github.com/facebook/react-native/blob/588f01e9982775f0699c7bfd56623d4ed3949810/local-cli/server/util/webSocketProxy.js#L38
350 */
351 let msgKey = "_closeMessage";
352 if (this.socketToApp[msgKey] === "Another debugger is already connected") {
353 reject(
354 ErrorHelper.getInternalError(
355 InternalErrorCode.AnotherDebuggerConnectedToPackager,
356 ),
357 );
358 }
359 logger.log(
360 localize(
361 "DisconnectedFromThePackagerToReactNative",
362 "Disconnected from the Proxy (Packager) to the React Native application. Retrying reconnection soon...",
363 ),
364 );
365 });
366 if (!this.cancellationToken.isCancellationRequested) {
367 setTimeout(() => {
368 this.start(true /* retryAttempt */);
369 }, 100);
370 }
371 });
372 this.socketToApp.on("message", (message: any) => this.onMessage(message));
373 this.socketToApp.on("error", (error: Error) => {
374 if (retryAttempt) {
375 printDebuggingError(
376 ErrorHelper.getInternalError(
377 InternalErrorCode.ReconnectionToPackagerFailedCheckForErrorsOrRestartReactNative,
378 ),
379 error,
380 );
381 }
382
383 reject(error);
384 });
385
386 // In an attempt to catch failures in starting the packager on first attempt,
387 // wait for 300 ms before resolving the promise
388 PromiseUtil.delay(300).then(() => resolve());
389 });
390 }
391
392 private debuggerProxyUrl() {
393 return `ws://${this.packagerAddress}:${this.packagerPort}/debugger-proxy?role=debugger&name=vscode`;
394 }
395
396 private onSocketOpened() {
397 this.executionLimiter.execute("onSocketOpened.msg", /*limitInSeconds*/ 10, () =>
398 logger.log(
399 localize(
400 "EstablishedConnectionWithPackagerToReactNativeApp",
401 "Established a connection with the Proxy (Packager) to the React Native application",
402 ),
403 ),
404 );
405 }
406
407 private killWorker() {
408 if (!this.singleLifetimeWorker) return;
409 this.singleLifetimeWorker.stop();
410 this.singleLifetimeWorker = null;
411 }
412
413 private onMessage(message: string) {
414 try {
415 logger.verbose("From RN APP: " + message);
416 let object = <RNAppMessage>JSON.parse(message);
417 if (object.method === "prepareJSRuntime") {
418 // In RN 0.40 Android runtime doesn't seem to be sending "$disconnected" event
419 // when user reloads an app, hence we need to try to kill it here either.
420 this.killWorker();
421 // The MultipleLifetimesAppWorker will handle prepareJSRuntime aka create new lifetime
422 this.gotPrepareJSRuntime(object);
423 } else if (object.method === "$disconnected") {
424 // We need to shutdown the current app worker, and create a new lifetime
425 this.killWorker();
426 } else if (object.method) {
427 // All the other messages are handled by the single lifetime worker
428 if (this.singleLifetimeWorker) {
429 this.singleLifetimeWorker.postMessage(object);
430 }
431 } else {
432 // Message doesn't have a method. Ignore it. This is an info message instead of warn because it's normal and expected
433 logger.verbose(
434 `The react-native app sent a message without specifying a method: ${message}`,
435 );
436 }
437 } catch (exception) {
438 printDebuggingError(
439 ErrorHelper.getInternalError(
440 InternalErrorCode.FailedToProcessMessageFromReactNativeApp,
441 message,
442 ),
443 exception,
444 );
445 }
446 }
447
448 private gotPrepareJSRuntime(message: any): void {
449 // Create the sandbox, and replay that we finished processing the message
450 this.startNewWorkerLifetime().then(
451 () => {
452 this.sendMessageToApp({ replyID: parseInt(message.id, 10) });
453 },
454 error =>
455 printDebuggingError(
456 ErrorHelper.getInternalError(
457 InternalErrorCode.FailedToPrepareJSRuntimeEnvironment,
458 message,
459 ),
460 error,
461 ),
462 );
463 }
464
465 private sendMessageToApp(message: any): void {
466 let stringified: string = "";
467 try {
468 stringified = JSON.stringify(message);
469 logger.verbose(`To RN APP: ${stringified}`);
470 this.socketToApp.send(stringified);
471 } catch (exception) {
472 let messageToShow = stringified || "" + message; // Try to show the stringified version, but show the toString if unavailable
473 printDebuggingError(
474 ErrorHelper.getInternalError(
475 InternalErrorCode.FailedToSendMessageToTheReactNativeApp,
476 messageToShow,
477 ),
478 exception,
479 );
480 }
481 }
482}
483