microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
dev/v-peq/remove-ios-relative-project-path

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/debugger/appWorker.ts

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