Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CDP Proxy: Allows Other Extensions to Reuse CDP Connection #964

Merged
merged 4 commits into from
Apr 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"source-map-support": "^0.5.19",
"split2": "^3.1.1",
"vscode-js-debug-browsers": "^1.0.4",
"vscode-js-debug-cdp-proxy-api": "0.0.3",
"vscode-nls": "^4.1.2",
"ws": "^7.2.3"
},
Expand Down
189 changes: 189 additions & 0 deletions src/adapter/cdpProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/

import * as ProxyProtocol from 'vscode-js-debug-cdp-proxy-api';
import WebSocket from 'ws';
import { Cdp } from '../cdp/api';
import { DisposableList, IDisposable } from '../common/disposable';

export class CDPProxyServer implements IDisposable {
private server?: WebSocket.Server;
private readonly disposables = new DisposableList();

constructor(private readonly cdp: Cdp.Api) {}

address(): { host: string; port: number } | undefined {
const address = this.server?.address();
if (address && typeof address !== 'string') {
return {
host: address.address,
port: address.port,
};
}
return;
}

async proxy(): Promise<void> {
if (!this.server) {
const server = new WebSocket.Server({ port: 0 });

server.on('connection', client => {
const clientHandle = new ClientHandle(client);

client.on('close', () => {
this.disposables.disposeObject(clientHandle);
});

client.on('message', async d => {
const request = parseRequest(d.toString());

if (request) {
try {
switch (request.operation) {
case ProxyProtocol.Operation.Subscribe:
const subscribeResult = await this.subscribeToCDP(request, clientHandle);
this.sendResultResponse(clientHandle, request, subscribeResult);
break;
case ProxyProtocol.Operation.Send:
const sendResult = await this.sendToCDP(request);
this.sendResultResponse(clientHandle, request, sendResult);
break;
}
} catch (e) {
this.sendErrorResponse(clientHandle, request, e.toString());
}
}
});
});

this.server = server;
}
}

dispose() {
this.disposables.dispose();
this.server?.close();
}

private async sendToCDP({
domain,
method,
params,
}: ProxyProtocol.SendRequest): Promise<Record<string, unknown>> {
const agent = this.cdp[domain as keyof Cdp.Api];

if (agent) {
const fn = (agent as any)[method]; // eslint-disable-line @typescript-eslint/no-explicit-any

if (typeof fn === 'function') {
return await fn(params);
} else {
throw new Error(`Unknown method for domain "${method}"`);
}
} else {
throw new Error(`Unknown domain "${domain}"`);
}
}

private sendResultResponse<O extends ProxyProtocol.Operation>(
{ webSocket }: ClientHandle,
request: ProxyProtocol.RequestMessage<O>,
result: ProxyProtocol.IResponsePayload[O],
): void {
const response: ProxyProtocol.ResponseMessage<O> = {
requestId: request.requestId,
result,
};
webSocket.send(JSON.stringify(response));
}

private sendErrorResponse<O extends ProxyProtocol.Operation>(
{ webSocket }: ClientHandle,
request: ProxyProtocol.Request,
error: string,
): void {
const response: ProxyProtocol.ResponseMessage<O> = {
requestId: request.requestId,
error,
};
webSocket.send(JSON.stringify(response));
}

private subscribeToCDP(
{ domain, event }: ProxyProtocol.SubscribeRequest,
clientHandle: ClientHandle,
) {
if (!event) {
throw new Error('Subscription of complete domain not implemented!');
}

const agent = this.cdp[domain as keyof Cdp.Api];
if (agent) {
const on = (agent as any).on; // eslint-disable-line @typescript-eslint/no-explicit-any

if (typeof on === 'function') {
clientHandle.pushDisposable(
on(event, (data: Record<string, unknown>) =>
this.sendEvent(clientHandle.webSocket, domain, event, data),
),
);
} else {
throw new Error(`Domain "${domain}" does not provide event subscriptions.`);
}
} else {
throw new Error(`Unknown domain "${domain}"`);
}
}

private sendEvent(
socket: WebSocket,
domain: string,
event: string,
data: Record<string, unknown>,
) {
const message: ProxyProtocol.IEvent = {
domain,
event,
data,
};
socket.send(JSON.stringify(message));
}
}

function parseRequest(raw: string): ProxyProtocol.Request | undefined {
try {
const json = JSON.parse(raw);
const { operation } = json;

if (typeof operation !== 'string') {
return;
}

// TODO do proper parsing of the incoming requests JSON?
switch (operation) {
case ProxyProtocol.Operation.Subscribe:
return json as ProxyProtocol.RequestMessage<ProxyProtocol.Operation.Subscribe>;
case ProxyProtocol.Operation.Send:
return json as ProxyProtocol.RequestMessage<ProxyProtocol.Operation.Send>;
}
} catch (e) {
// Ignore requests which cannot be parsed
}
return;
}

class ClientHandle implements IDisposable {
private readonly disposables: DisposableList = new DisposableList();

constructor(readonly webSocket: WebSocket) {}

pushDisposable(d: IDisposable): void {
this.disposables.push(d);
}

dispose() {
this.disposables.dispose();
this.webSocket.close();
}
}
19 changes: 19 additions & 0 deletions src/adapter/debugAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { disposeContainer } from '../ioc-extras';
import { ITelemetryReporter } from '../telemetry/telemetryReporter';
import { IAsyncStackPolicy } from './asyncStackPolicy';
import { BreakpointManager } from './breakpoints';
import { CDPProxyServer } from './cdpProxy';
import { ICompletions } from './completions';
import { IConsole } from './console';
import { Diagnostics } from './diagnosics';
Expand Down Expand Up @@ -45,6 +46,7 @@ export class DebugAdapter implements IDisposable {
private _thread: Thread | undefined;
private _configurationDoneDeferred: IDeferred<void>;
private lastBreakpointId = 0;
private _cdpProxyServer: CDPProxyServer | undefined;

constructor(
dap: Dap.Api,
Expand Down Expand Up @@ -104,6 +106,7 @@ export class DebugAdapter implements IDisposable {
})),
);
this.dap.on('createDiagnostics', () => this._dumpDiagnostics());
this.dap.on('requestCDPProxy', () => this._requestCDPProxy());
}

public async launchBlocker(): Promise<void> {
Expand Down Expand Up @@ -418,6 +421,22 @@ export class DebugAdapter implements IDisposable {
return { file: await this._services.get(Diagnostics).generateHtml() };
}

async _requestCDPProxy() {
if (!this._cdpProxyServer) {
const cdp = this._thread?.cdp();

if (cdp) {
const cdpProxy = new CDPProxyServer(cdp);
cdpProxy.proxy();

this._disposables.push(cdpProxy);
this._cdpProxyServer = cdpProxy;
}
}

return this._cdpProxyServer?.address() || {};
}

dispose() {
this._disposables.dispose();
disposeContainer(this._services);
Expand Down
20 changes: 19 additions & 1 deletion src/build/dapCustom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,12 +473,30 @@ const dapCustom: JSONSchema4 = {
required: ['file'],
},
),

...makeEvent(
'suggestDiagnosticTool',
"Shows a prompt to the user suggesting they use the diagnostic tool if breakpoints don't bind.",
{},
),
...makeRequest(
'requestCDPProxy',
'Request WebSocket connection information on a proxy for this debug sessions CDP connection.',
undefined,
{
properties: {
host: {
type: 'string',
description:
'Name of the host, on which the CDP proxy is available through a WebSocket.',
},
port: {
type: 'number',
description:
'Port on the host, under which the CDP proxy is available through a WebSocket.',
},
},
},
),
},
};

Expand Down
10 changes: 10 additions & 0 deletions src/build/generate-contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1240,6 +1240,11 @@ const commands: ReadonlyArray<{
title: refString('startWithStopOnEntry.label'),
category: 'Debug',
},
{
command: Commands.RequestCDPProxy,
title: refString('requestCDPProxy.label'),
category: 'Debug',
},
];

const menus: Menus = {
Expand Down Expand Up @@ -1280,6 +1285,11 @@ const menus: Menus = {
group: 'navigation',
when: forBrowserDebugType('debugType', `callStackItemType == 'session'`),
},
{
command: Commands.RequestCDPProxy,
group: 'navigation',
when: forAnyDebugType('debugType', `callStackItemType == 'session'`),
},
{
command: Commands.ToggleSkipping,
group: 'navigation',
Expand Down
1 change: 1 addition & 0 deletions src/build/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ A common case to disable certificate verification can be done by passing \`{ "ht
'debugLink.label': 'Open Link',
'createDiagnostics.label': 'Diagnose Breakpoint Problems',
'startWithStopOnEntry.label': 'Start Debugging and Stop on Entry',
'requestCDPProxy.label': 'Request CDP Proxy for Debug Session',
};

export default strings;
Expand Down
5 changes: 5 additions & 0 deletions src/common/contributionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const enum Commands {
RemoveAllCustomBreakpoints = 'extension.js-debug.removeAllCustomBreakpoints',
RemoveCustomBreakpoint = 'extension.js-debug.removeCustomBreakpoint',
RevealPage = 'extension.js-debug.revealPage',
RequestCDPProxy = 'extension.js-debug.requestCDPProxy',
/** Use node-debug's command so existing keybindings work */
StartWithStopOnEntry = 'extension.node-debug.startWithStopOnEntry',
StartProfile = 'extension.js-debug.startProfile',
Expand Down Expand Up @@ -77,6 +78,7 @@ const commandsObj: { [K in Commands]: null } = {
[Commands.StopProfile]: null,
[Commands.ToggleSkipping]: null,
[Commands.StartWithStopOnEntry]: null,
[Commands.RequestCDPProxy]: null,
};

/**
Expand Down Expand Up @@ -163,6 +165,9 @@ export interface ICommandTypes {
[Commands.RevealPage](sessionId: string): void;
[Commands.DebugLink](link?: string): void;
[Commands.StartWithStopOnEntry](): void;
[Commands.RequestCDPProxy](
sessionId: string,
): { address: string; port: number; family: string } | null;
}

/**
Expand Down
Loading