Repository: microsoft/vscode-edge-devtools
Pull request #443 (2021-08-05 21:16:12)

Add new DevTools with Frame support to webview host - Localhost only
This PR adds a new host bundle that allows the VSCode extension to host compatible devtools from a localhost edge-watch environment. This adds a new host bundle and Webview implementation that supports routing messages between the extension and the DevTools inside an iframe. Not all currently supported features have been migrated, and currently only localhost connections are supported. Future PRs will add the ability to target remote provided DevTools and add remaining functionality before shipping new version of the extension

![image](https://user-images.githubusercontent.com/42152559/128558808-94c5cb3a-c91d-4a70-8797-595dab01b045.png)


mliao95: Still looking through it, but I think a simple architecture diagram can help paint a bigger picture of the changes in this PR.  

Something like this:
![image](https://user-images.githubusercontent.com/14304598/128434513-fae5d026-8dc7-40e5-98ff-a41b0f50427c.png)

You can steal this diagram to work off of here: https://microsoft.sharepoint.com/:p:/t/Edge/EUL2tWFeek9FggsmuGtNSAUBlmBuaJ6Q3JUb5_B-fbWHTg?e=PQPgRJ&nav=eyJzSWQiOjI5MCwiY0lkIjozMzY2NDEyOTIzfQ

bgoddar: > Still looking through it, but I think a simple architecture diagram can help paint a bigger picture of the changes in this PR.
> 
> Something like this:
> ![image](https://user-images.githubusercontent.com/14304598/128434513-fae5d026-8dc7-40e5-98ff-a41b0f50427c.png)
> 
> You can steal this diagram to work off of here: https://microsoft.sharepoint.com/:p:/t/Edge/EUL2tWFeek9FggsmuGtNSAUBlmBuaJ6Q3JUb5_B-fbWHTg?e=PQPgRJ&nav=eyJzSWQiOjI5MCwiY0lkIjozMzY2NDEyOTIzfQ

Sounds good! I'll post that here when I publish the review. Good news is that it isn't too different from what we have now

Patch:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 407f755..1e305a8 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -16,6 +16,21 @@
             ],
             "preLaunchTask": "npm: build"
         },
+        {
+            "name": "Launch Extension Edge Watch",
+            "type": "extensionHost",
+            "request": "launch",
+            "runtimeExecutable": "${execPath}",
+            "args": [
+                "--extensionDevelopmentPath=${workspaceFolder}"
+            ],
+            "stopOnEntry": false,
+            "sourceMaps": true,
+            "outFiles": [
+                "${workspaceFolder}/out/**/*.js"
+            ],
+            "preLaunchTask": "npm: build-edge-watch"
+        },
         {
             "name": "Launch Extension Without Build",
             "type": "extensionHost",
diff --git a/package.json b/package.json
index f7285af..a36bd41 100644
--- a/package.json
+++ b/package.json
@@ -589,7 +589,11 @@
             "jsx",
             "json",
             "node"
-        ]
+        ],
+        "globals": {
+            "DEBUG": false,
+            "DEVTOOLS_BASE_URI": "https://test.invalid"
+          }
     },
     "scripts": {
         "package": "vsce package --out vscode-edge-devtools.vsix",
@@ -597,6 +601,7 @@
         "build": "npm run build-wp && npm run build-post",
         "build-min": "npm run build-wp && npm run build-post && npm run minify-devtools",
         "build-debug": "npm run build-wp && ts-node postBuildStep.ts debug",
+        "build-edge-watch": "webpack --env debug",
         "build-wp": "webpack",
         "build-post": "ts-node postBuildStep.ts",
         "build-watch": "npm run build && npm run watch",
diff --git a/src/common/webviewEvents.ts b/src/common/webviewEvents.ts
index afdf3f9..587fc5c 100644
--- a/src/common/webviewEvents.ts
+++ b/src/common/webviewEvents.ts
@@ -19,6 +19,17 @@ export const webviewEventNames: WebviewEvent[] = [
     'consoleOutput',
 ];
 
+export type FrameToolsEvent = 'sendMessageToBackend' | 'openInNewTab' | 'recordEnumeratedHistogram' |
+'recordPerformanceHistogram' | 'reportError' | 'openInEditor';
+export const FrameToolsEventNames: FrameToolsEvent[] = [
+    'sendMessageToBackend',
+    'openInNewTab',
+    'openInEditor',
+    'recordEnumeratedHistogram',
+    'recordPerformanceHistogram',
+    'reportError',
+];
+
 export type WebSocketEvent = 'open' | 'close' | 'error' | 'message';
 export const webSocketEventNames: WebSocketEvent[] = [
     'open',
diff --git a/src/devtoolsPanel.ts b/src/devtoolsPanel.ts
index a06d759..0bc2ed3 100644
--- a/src/devtoolsPanel.ts
+++ b/src/devtoolsPanel.ts
@@ -320,7 +320,7 @@ export class DevToolsPanel {
     }
 
     private update() {
-        this.panel.webview.html = this.getHtmlForWebview();
+        this.panel.webview.html = this.config.isCdnHostedTools ? this.getCdnHtmlForWebview() : this.getHtmlForWebview();
     }
 
     private getHtmlForWebview() {
@@ -365,6 +365,44 @@ export class DevToolsPanel {
             `;
     }
 
+    private getCdnHtmlForWebview() {
+        let cdnBaseUrl = this.config.devtoolsBaseUri;
+        if (!cdnBaseUrl) {
+            // Not provided, calculate based on config
+            // TODO: CDP call to determine actual hash and stuff
+            cdnBaseUrl = this.config.useLocalEdgeWatch ? 'http://localhost:3000/vscode_app.html' : 'https://devtools.invalid';
+        }
+        const hostPath = vscode.Uri.file(path.join(this.extensionPath, 'out', 'host_beta', 'host.bundle.js'));
+        const hostUri = this.panel.webview.asWebviewUri(hostPath);
+
+        const stylesPath = vscode.Uri.file(path.join(this.extensionPath, 'out', 'common', 'styles.css'));
+        const stylesUri = this.panel.webview.asWebviewUri(stylesPath);
+
+        // the added fields for "Content-Security-Policy" allow resource loading for other file types
+        return `
+            <!doctype html>
+            <html>
+            <head>
+                <meta http-equiv="content-type" content="text/html; charset=utf-8">
+                <meta name="referrer" content="no-referrer">
+                <link href="${stylesUri}" rel="stylesheet"/>
+                <script src="${hostUri}"></script>
+                <meta http-equiv="Content-Security-Policy"
+                    content="default-src;
+                    img-src 'self' data: ${this.panel.webview.cspSource};
+                    style-src 'self' 'unsafe-inline' ${this.panel.webview.cspSource};
+                    script-src 'self' 'unsafe-eval' ${this.panel.webview.cspSource};
+                    frame-src 'self' ${this.panel.webview.cspSource} ${cdnBaseUrl};
+                    connect-src 'self' data: ${this.panel.webview.cspSource};
+                ">
+            </head>
+            <body>
+                <iframe id="devtools-frame" frameBorder="0" src="${cdnBaseUrl}?experiments=true&edgeThemes=true"></iframe>
+            </body>
+            </html>
+            `;
+    }
+
     static createOrShow(
         context: vscode.ExtensionContext,
         telemetryReporter: Readonly<TelemetryReporter>,
diff --git a/src/host_beta/mainHost.ts b/src/host_beta/mainHost.ts
new file mode 100644
index 0000000..e5f5ba5
--- /dev/null
+++ b/src/host_beta/mainHost.ts
@@ -0,0 +1,5 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+import { MessageRouter } from './messageRouter';
+export const messageRouter = new MessageRouter(window);
diff --git a/src/host_beta/messageRouter.ts b/src/host_beta/messageRouter.ts
new file mode 100644
index 0000000..89236fc
--- /dev/null
+++ b/src/host_beta/messageRouter.ts
@@ -0,0 +1,162 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+import {
+    encodeMessageForChannel,
+    FrameToolsEvent,
+    IOpenEditorData,
+    parseMessageFromChannel,
+    TelemetryData,
+    WebSocketEvent,
+    WebviewEvent,
+} from '../common/webviewEvents';
+
+declare const acquireVsCodeApi: () => {postMessage(message: unknown, args?: any|undefined): void};
+const vscode = acquireVsCodeApi();
+
+/**
+ * Both the DevTools iframe and the Extension (which owns the ws connection to the browser) will
+ * post messages to the Webview window for communication between the two. This class routes message
+ * to the correct location based on the origin and type of message posted.
+ *
+ */
+export class MessageRouter {
+    private toolsFrameWindow: Window | null | undefined;
+
+    constructor(webviewWindow: Window) {
+        webviewWindow.addEventListener('DOMContentLoaded', () => {
+            this.toolsFrameWindow = (document.getElementById('devtools-frame') as HTMLIFrameElement).contentWindow;
+        });
+
+        const extensionMessageCallback = this.onMessageFromChannel.bind(this);
+
+        // Both the DevTools iframe and the extension will post messages to the webview
+        // Listen for messages and forward to correct recipient based on origin
+        webviewWindow.addEventListener('message', messageEvent => {
+            const fromExtension = messageEvent.origin.startsWith('vscode-webview://');
+            if (!fromExtension) {
+                // Send message from DevTools to Extension
+                this.onMessageFromFrame(messageEvent.data.method as FrameToolsEvent, messageEvent.data.args as any[]);
+            } else if (this.toolsFrameWindow) {
+                // Send message from Extension to DevTools
+                parseMessageFromChannel(
+                    messageEvent.data,
+                    extensionMessageCallback,
+                );
+                messageEvent.preventDefault();
+                messageEvent.stopImmediatePropagation();
+            }
+        }, true);
+
+        // Inform the extension we are ready to receive messages
+        this.sendReady();
+    }
+
+    onMessageFromFrame(e: FrameToolsEvent, args: any[]): boolean {
+        switch(e) {
+            case 'openInEditor':
+                const [url, line, column, ignoreTabChanges] = args;
+                this.openInEditor(url, line, column, ignoreTabChanges);
+                return true;
+            case 'openInNewTab':
+                this.openInNewTab(args[0]);
+                return true;
+            case 'recordEnumeratedHistogram':
+                const [actionName, actionCode, bucketSize] = args;
+                this.recordEnumeratedHistogram(actionName, actionCode, bucketSize);
+                return true;
+            case 'recordPerformanceHistogram':
+                const [histogramName, duration] = args;
+                this.recordPerformanceHistogram(histogramName, duration);
+                return true;
+            case 'reportError':
+                const [type, message, stack, filename, sourceUrl, lineno, colno] = args;
+                this.reportError(type, message, stack, filename, sourceUrl, lineno, colno);
+                return true;
+            case 'sendMessageToBackend':
+                const [cdpMessage] = args;
+                this.sendMessageToBackend(cdpMessage);
+                return true;
+            default:
+                // TODO: handle other types of messages from devtools
+                return false;
+        }
+    }
+
+    onMessageFromChannel(e: WebviewEvent, args: string): boolean {
+        if (e !== 'websocket') {
+            return false;
+        }
+        const { event, message } = JSON.parse(args) as {event: WebSocketEvent, message: string};
+        this.fireWebSocketCallback(event, message);
+        return true;
+    }
+
+    private sendReady() {
+        // Inform the extension we are ready to receive messages
+        encodeMessageForChannel(msg => vscode.postMessage(msg, '*'), 'ready');
+    }
+
+    private recordEnumeratedHistogram(actionName: string, actionCode: number, _bucketSize: number): void {
+        // Inform the extension of the DevTools telemetry event
+        this.sendTelemetry({
+            data: actionCode,
+            event: 'enumerated',
+            name: actionName,
+        });
+    }
+
+    private recordPerformanceHistogram(histogramName: string, duration: number): void {
+        // Inform the extension of the DevTools telemetry event
+        this.sendTelemetry({
+            data: duration,
+            event: 'performance',
+            name: histogramName,
+        });
+    }
+
+    private reportError(
+        type: string,
+        message: string,
+        stack: string,
+        filename: string,
+        sourceUrl: string,
+        lineno: number,
+        colno: number): void {
+        // Package up the error info to send to the extension
+        const data = { message, stack, filename, sourceUrl, lineno, colno };
+
+        // Inform the extension of the DevTools telemetry event
+        this.sendTelemetry({
+            data,
+            event: 'error',
+            name: type,
+        });
+    }
+
+    private openInEditor(url: string, line: number, column: number, ignoreTabChanges: boolean): void {
+        // Forward the data to the extension
+        const request: IOpenEditorData = { column, line, url, ignoreTabChanges };
+        encodeMessageForChannel(msg => vscode.postMessage(msg, '*'), 'openInEditor', request);
+    }
+
+    private openInNewTab(url: string): void {
+        encodeMessageForChannel(msg => vscode.postMessage(msg, '*'), 'openUrl', {url});
+    }
+
+    private sendMessageToBackend(message: string): void {
+        encodeMessageForChannel(msg => vscode.postMessage(msg, '*'), 'websocket', { message });
+    }
+
+    private sendTelemetry(telemetry: TelemetryData) {
+        // Forward the data to the extension
+        encodeMessageForChannel(msg => vscode.postMessage(msg, '*'), 'telemetry', telemetry);
+    }
+
+    private fireWebSocketCallback(e: WebSocketEvent, message: string) {
+        // Send response message to DevTools
+        if (this.toolsFrameWindow && e === 'message') {
+            this.toolsFrameWindow.postMessage({method: 'dispatchMessage', args: [message]}, '*');
+        }
+    }
+}
diff --git a/src/utils.ts b/src/utils.ts
index 4304964..3dd35c4 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -56,6 +56,9 @@ export interface IRuntimeConfig {
     sourceMaps: boolean;
     webRoot: string;
     isJsDebugProxiedCDPConnection: boolean;
+    isCdnHostedTools: boolean;
+    useLocalEdgeWatch: boolean;
+    devtoolsBaseUri?: string;
 }
 export interface IStringDictionary<T> {
     [name: string]: T;
@@ -101,6 +104,10 @@ export const SETTINGS_DEFAULT_ATTACH_INTERVAL = 200;
 const WIN_APP_DATA = process.env.LOCALAPPDATA || '/';
 const msEdgeBrowserMapping: Map<BrowserFlavor, IBrowserPath> = new Map<BrowserFlavor, IBrowserPath>();
 
+/** Build-specified flags. */
+declare const DEBUG: boolean;
+declare const DEVTOOLS_BASE_URI: string | undefined;
+
 export interface IRemoteTargetJson {
     [index: string]: string;
     description: string;
@@ -449,6 +456,9 @@ export function getRuntimeConfig(config: Partial<IUserConfig> = {}): IRuntimeCon
         sourceMaps,
         webRoot: resolvedWebRoot,
         isJsDebugProxiedCDPConnection: false,
+        isCdnHostedTools: DEBUG || false,
+        useLocalEdgeWatch: DEBUG,
+        devtoolsBaseUri: DEVTOOLS_BASE_URI,
     };
 }
 
diff --git a/webpack.config.ts b/webpack.config.ts
index c1ca308..a5f6ab7 100644
--- a/webpack.config.ts
+++ b/webpack.config.ts
@@ -1,5 +1,6 @@
 import copyPlugin from 'copy-webpack-plugin';
 import path from 'path';
+import { DefinePlugin } from 'webpack';
 
 const commonConfig = {
     devtool: 'source-map',
@@ -18,43 +19,61 @@ const commonConfig = {
     },
 };
 
-module.exports = [
-    {
-        ...commonConfig,
-        entry: {
-            host: './src/host/mainHost.ts',
-        },
-        name: 'host',
-        output: {
-            filename: '[name].bundle.js',
-            path: path.resolve(__dirname, 'out/host'),
-        },
-    },
-    {
-        ...commonConfig,
-        entry: {
-            extension: './src/extension.ts',
+module.exports = (env: any) => {
+    return [
+        {
+            ...commonConfig,
+            entry: {
+                host: './src/host/mainHost.ts',
+            },
+            name: 'host',
+            output: {
+                filename: '[name].bundle.js',
+                path: path.resolve(__dirname, 'out/host'),
+            },
         },
-        externals: {
-            vscode: 'commonjs vscode',
+        {
+            ...commonConfig,
+            entry: {
+                host: './src/host_beta/mainHost.ts',
+            },
+            name: 'host',
+            output: {
+                filename: '[name].bundle.js',
+                path: path.resolve(__dirname, 'out/host_beta'),
+            },
         },
-        name: 'extension',
-        output: {
-            devtoolModuleFilenameTemplate: '../[resource-path]',
-            filename: '[name].js',
-            libraryTarget: 'commonjs2',
-            path: path.resolve(__dirname, 'out'),
+        {
+            ...commonConfig,
+            entry: {
+                extension: './src/extension.ts',
+            },
+            externals: {
+                vscode: 'commonjs vscode',
+            },
+            name: 'extension',
+            output: {
+                devtoolModuleFilenameTemplate: '../[resource-path]',
+                filename: '[name].js',
+                libraryTarget: 'commonjs2',
+                path: path.resolve(__dirname, 'out'),
+            },
+            stats: 'errors-only', // Bug ws package includes dev-dependencies which webpack will report as warnings
+            target: 'node',
+            // Copy startpage html to output bundle
+            plugins: [
+                new copyPlugin({
+                    patterns: [
+                    { from: 'startpage', to: 'startpage'},
+                    { from: 'icon.png', to: 'icon.png'},
+                    ],
+                }),
+                // These must also be defined in the jest section of package.json for tests to pass
+                new DefinePlugin({
+                    DEBUG: JSON.stringify(env && env.debug || false),
+                    DEVTOOLS_BASE_URI: JSON.stringify(env && env.devtoolsBaseUri || undefined),
+                })
+            ],
         },
-        stats: 'errors-only', // Bug ws package includes dev-dependencies which webpack will report as warnings
-        target: 'node',
-        // Copy startpage html to output bundle
-        plugins: [
-            new copyPlugin({
-                patterns: [
-                  { from: 'startpage', to: 'startpage'},
-                  { from: 'icon.png', to: 'icon.png'},
-                ],
-            }),
-        ],
-    },
-];
+    ]
+};


Test patch:
diff --git a/test/devtoolsPanel.test.ts b/test/devtoolsPanel.test.ts
index 809c289..6bba2e0 100644
--- a/test/devtoolsPanel.test.ts
+++ b/test/devtoolsPanel.test.ts
@@ -43,6 +43,8 @@ describe("devtoolsPanel", () => {
             sourceMaps: true,
             webRoot: "",
             isJsDebugProxiedCDPConnection: false,
+            isCdnHostedTools: false,
+            useLocalEdgeWatch: false,
         };
 
         mockPanel = {
@@ -102,7 +104,7 @@ describe("devtoolsPanel", () => {
         it("switches targets with active session", async () => {
             const dtp = await import("../src/devtoolsPanel");
             const mockVsCode = jest.requireMock("vscode");
-            
+
             dtp.DevToolsPanel.createOrShow(context, mockTelemetry, "https://www.bing.com/", mockRuntimeConfig);
             dtp.DevToolsPanel.createOrShow(context, mockTelemetry, "https://www.microsoft.com/", mockRuntimeConfig);
             expect(mockPanel.onDidDispose).toHaveBeenCalledTimes(2);