diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fff55743..f1514c045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This changelog records changes to stable releases since 1.50.2. "TBA" changes he ## Nightly (only) +- feat: add basic network view, support experimental networking for node ([#2051](https://github.com/microsoft/vscode-js-debug/issues/2051)) - feat: support "debug url" in terminals created through the `node-terminal` launch type ([#2049](https://github.com/microsoft/vscode-js-debug/issues/2049)) - fix: hover evaluation incorrectly showing undefined ([vscode#221503](https://github.com/microsoft/vscode/issues/221503)) diff --git a/OPTIONS.md b/OPTIONS.md index 97f4e6c1e..16c9bc721 100644 --- a/OPTIONS.md +++ b/OPTIONS.md @@ -68,7 +68,8 @@
Default value:
true

enableDWARF

Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the ms-vscode.wasm-dwarf-debugging extension to function.

Default value:
true

env

Environment variables passed to the program. The value null removes the variable from the environment.

Default value:
{}

envFile

Absolute path to a file containing environment variable definitions.

-
Default value:
null

killBehavior

Configures how debug processes are killed when stopping the session. Can be:

- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or taskkill.exe /F on Windows.
- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or taskkill.exe with no /F (force) flag on Windows.
- none: no termination will happen.

+
Default value:
null

experimentalNetworking

Enable experimental inspection in Node.js. When set to auto this is enabled for versions of Node.js that support it. It can be set to on or off to enable or disable it explicitly.

+
Default value:
"auto"

killBehavior

Configures how debug processes are killed when stopping the session. Can be:

- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or taskkill.exe /F on Windows.
- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or taskkill.exe with no /F (force) flag on Windows.
- none: no termination will happen.

Default value:
"forceful"

localRoot

Path to the local directory containing the program.

Default value:
null

nodeVersionHint

Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.

Default value:
undefined

outFiles

If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with ! the files are excluded. If not specified, the generated code is expected in the same directory as its source.

diff --git a/package.nls.json b/package.nls.json index af1e3a65b..a534623f9 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,8 +1,8 @@ { "add.eventListener.breakpoint": "Toggle Event Listener Breakpoints", "add.xhr.breakpoint": "Add XHR/fetch Breakpoint", - "breakpoint.xhr.contains":"Break when URL contains:", - "breakpoint.xhr.any":"Any XHR/fetch", + "breakpoint.xhr.contains": "Break when URL contains:", + "breakpoint.xhr.any": "Any XHR/fetch", "edit.xhr.breakpoint": "Edit XHR/fetch Breakpoint", "attach.node.process": "Attach to Node Process", "base.cascadeTerminateToConfigurations.label": "A list of debug sessions which, when this debug session is terminated, will also be stopped.", @@ -198,6 +198,7 @@ "node.versionHint.description": "Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.", "node.websocket.address.description": "Exact websocket address to attach to. If unspecified, it will be discovered from the address and port.", "node.remote.host.header.description": "Explicit Host header to use when connecting to the websocket of inspector. If unspecified, the host header will be set to 'localhost'. This is useful when the inspector is running behind a proxy that only accept particular Host header.", + "node.experimentalNetworking.description": "Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.", "openEdgeDevTools.label": "Open Browser Devtools", "outFiles.description": "If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.", "pretty.print.script": "Pretty print for debugging", @@ -222,5 +223,11 @@ "trace.description": "Configures what diagnostic output is produced.", "trace.logFile.description": "Configures where on disk logs are written.", "trace.stdio.description": "Whether to return trace data from the launched application or browser.", - "workspaceTrust.description": "Trust is required to debug code in this workspace." + "workspaceTrust.description": "Trust is required to debug code in this workspace.", + "commands.networkViewRequest.label": "View Request as cURL", + "commands.networkOpenBody.label": "Open Response Body", + "commands.networkOpenBodyInHexEditor.label": "Open Response Body in Hex Editor", + "commands.networkReplayXHR.label": "Replay Request", + "commands.networkCopyURI.label": "Copy Request URL", + "commands.networkClear.label": "Clear Network Log" } diff --git a/src/adapter/debugAdapter.ts b/src/adapter/debugAdapter.ts index 4e6b196d9..4bb01ac46 100644 --- a/src/adapter/debugAdapter.ts +++ b/src/adapter/debugAdapter.ts @@ -119,6 +119,21 @@ export class DebugAdapter implements IDisposable { this.dap.on('stepInTargets', params => this._stepInTargets(params)); this.dap.on('setDebuggerProperty', params => this._setDebuggerProperty(params)); this.dap.on('setSymbolOptions', params => this._setSymbolOptions(params)); + this.dap.on('networkCall', params => this._doNetworkCall(params)); + } + + private async _doNetworkCall({ method, params }: Dap.NetworkCallParams) { + if (!this._thread) { + return Promise.resolve({}); + } + + // ugly casts :( + const networkDomain = this._thread.cdp().Network as unknown as Record< + string, + (method: unknown) => Promise + >; + + return networkDomain[method](params); } private _setDebuggerProperty( diff --git a/src/adapter/threads.ts b/src/adapter/threads.ts index b6ba97367..e0a2da1fa 100644 --- a/src/adapter/threads.ts +++ b/src/adapter/threads.ts @@ -9,6 +9,7 @@ import { DebugType } from '../common/contributionUtils'; import { EventEmitter } from '../common/events'; import { HrTime } from '../common/hrnow'; import { ILogger, LogTag } from '../common/logging'; +import { mirroredNetworkEvents } from '../common/networkEvents'; import { isInstanceOf, truthy } from '../common/objUtils'; import { Base0Position, Base1Position, Range } from '../common/positions'; import { IDeferred, delay, getDeferred } from '../common/promiseUtil'; @@ -739,6 +740,26 @@ export class Thread implements IVariableStoreLocationProvider { } else this._revealObject(event.object); }); + if (!this.launchConfig.noDebug) { + // Use whether we can make a cookies request to feature-request the + // availability of networking. + this._cdp.Network.enable({}).then(r => { + if (!r) { + return; + } + + this._dap.with(dap => { + dap.networkAvailable({}); + for (const event of mirroredNetworkEvents) { + // the types don't work well with the overloads on Network.on, a cast is needed: + (this._cdp.Network.on as (ev: string, fn: (d: object) => void) => void)(event, data => + dap.networkEvent({ data, event }), + ); + } + }); + }); + } + this._cdp.Debugger.on('paused', async event => this._onPaused(event)); this._cdp.Debugger.on('resumed', () => this.onResumed()); this._cdp.Debugger.on('scriptParsed', event => this._onScriptParsed(event)); diff --git a/src/build/dapCustom.ts b/src/build/dapCustom.ts index 18941ed71..e075b50a2 100644 --- a/src/build/dapCustom.ts +++ b/src/build/dapCustom.ts @@ -729,6 +729,51 @@ const dapCustom: JSONSchema4 = { description: 'Arguments for "setSymbolOptions" request. Properties are determined by debugger.', }, + + ...makeEvent( + 'networkAvailable', + 'Fired when we successfully enable CDP networking on the session.', + {}, + ), + + ...makeEvent( + 'networkEvent', + 'A wrapped CDP network event. There is little abstraction here because UI interacts literally with CDP at the moment.', + { + properties: { + event: { + type: 'string', + description: 'The CDP network event name', + }, + data: { + type: 'object', + description: 'The CDP network data', + }, + }, + required: ['event', 'data'], + }, + ), + + ...makeRequest( + 'networkCall', + 'Makes a network call. There is little abstraction here because UI interacts literally with CDP at the moment.', + { + properties: { + method: { + type: 'string', + description: 'The HTTP method', + }, + params: { + type: 'object', + description: 'The CDP call parameters', + }, + }, + required: ['method', 'params'], + }, + { + type: 'object', + }, + ), }, }; diff --git a/src/build/generate-contributions.ts b/src/build/generate-contributions.ts index 8488b6c04..ba9765459 100644 --- a/src/build/generate-contributions.ts +++ b/src/build/generate-contributions.ts @@ -13,6 +13,7 @@ import { IConfigurationTypes, allCommands, allDebugTypes, + networkFilesystemScheme, preferredDebugTypes, } from '../common/contributionUtils'; import { knownToolToken } from '../common/knownTools'; @@ -79,7 +80,7 @@ type Menus = { command: Commands; title?: MappedReferenceString; when?: string; - group?: 'navigation' | 'inline'; + group?: 'navigation' | 'inline' | string; }[]; }; @@ -653,6 +654,12 @@ const nodeLaunchConfig: IDebugger = { default: KillBehavior.Forceful, markdownDescription: refString('node.killBehavior.description'), }, + experimentalNetworking: { + type: 'string', + default: 'auto', + description: refString('node.experimentalNetworking.description'), + enum: ['auto', 'on', 'off'], + }, }, defaults: nodeLaunchConfigDefaults, }; @@ -1350,6 +1357,32 @@ const commands: ReadonlyArray<{ title: refString('commands.disableSourceMapStepping.label'), icon: '$(compass)', }, + { + command: Commands.NetworkViewRequest, + title: refString('commands.networkViewRequest.label'), + icon: '$(arrow-right)', + }, + { + command: Commands.NetworkClear, + title: refString('commands.networkClear.label'), + icon: '$(clear-all)', + }, + { + command: Commands.NetworkOpenBody, + title: refString('commands.networkOpenBody.label'), + }, + { + command: Commands.NetworkOpenBodyHex, + title: refString('commands.networkOpenBodyInHexEditor.label'), + }, + { + command: Commands.NetworkReplayXHR, + title: refString('commands.networkReplayXHR.label'), + }, + { + command: Commands.NetworkCopyUri, + title: refString('commands.networkCopyURI.label'), + }, ]; const menus: Menus = { @@ -1406,6 +1439,30 @@ const menus: Menus = { command: Commands.CallersGoToTarget, when: 'false', }, + { + command: Commands.NetworkCopyUri, + when: 'false', + }, + { + command: Commands.NetworkOpenBody, + when: 'false', + }, + { + command: Commands.NetworkOpenBodyHex, + when: 'false', + }, + { + command: Commands.NetworkReplayXHR, + when: 'false', + }, + { + command: Commands.NetworkViewRequest, + when: 'false', + }, + { + command: Commands.NetworkClear, + when: 'false', + }, { command: Commands.EnableSourceMapStepping, when: ContextKey.IsMapSteppingDisabled, @@ -1497,6 +1554,11 @@ const menus: Menus = { `view == workbench.debug.callStackView && ${ContextKey.IsMapSteppingDisabled}`, ), }, + { + command: Commands.NetworkClear, + group: 'navigation', + when: `view == ${CustomViews.Network}`, + }, ], 'view/item/context': [ { @@ -1541,6 +1603,31 @@ const menus: Menus = { group: 'inline', when: `view == ${CustomViews.ExcludedCallers}`, }, + { + command: Commands.NetworkViewRequest, + group: 'inline@1', + when: `view == ${CustomViews.Network}`, + }, + { + command: Commands.NetworkOpenBody, + group: 'body@1', + when: `view == ${CustomViews.Network}`, + }, + { + command: Commands.NetworkOpenBodyHex, + group: 'body@2', + when: `view == ${CustomViews.Network}`, + }, + { + command: Commands.NetworkCopyUri, + group: 'other@1', + when: `view == ${CustomViews.Network}`, + }, + { + command: Commands.NetworkReplayXHR, + group: 'other@2', + when: `view == ${CustomViews.Network}`, + }, ], 'editor/title': [ { @@ -1594,12 +1681,18 @@ const views = { name: 'Excluded Callers', when: forAnyDebugType('debugType', 'jsDebugHasExcludedCallers'), }, + { + id: CustomViews.Network, + name: 'Network', + when: ContextKey.NetworkAvailable, + }, ], }; const activationEvents = new Set([ 'onDebugDynamicConfigurations', 'onDebugInitialConfigurations', + `onFileSystem:${networkFilesystemScheme}`, ...[...debuggers.map(dbg => dbg.type), ...preferredDebugTypes.values()].map( t => `onDebugResolve:${t}`, ), diff --git a/src/common/contributionUtils.ts b/src/common/contributionUtils.ts index 84f524ca4..445536fdc 100644 --- a/src/common/contributionUtils.ts +++ b/src/common/contributionUtils.ts @@ -13,6 +13,7 @@ import type Dap from '../dap/api'; import type { IAutoAttachInfo } from '../targets/node/bootloader/environment'; import type { ExcludedCaller } from '../ui/excludedCallersUI'; import type { IStartProfileArguments } from '../ui/profiling/uiProfileManager'; +import type { NetworkRequest } from '../ui/networkTree'; export const enum Contributions { BrowserBreakpointsView = 'jsBrowserBreakpoints', @@ -24,6 +25,7 @@ export const enum CustomViews { EventListenerBreakpoints = 'jsBrowserBreakpoints', XHRFetchBreakpoints = 'jsXHRBreakpoints', ExcludedCallers = 'jsExcludedCallers', + Network = 'jsDebugNetworkTree', } export const enum Commands { @@ -60,6 +62,14 @@ export const enum Commands { CallersRemoveAll = 'extension.js-debug.callers.removeAll', CallersAdd = 'extension.js-debug.callers.add', //#endregion + //#region Network view + NetworkViewRequest = 'extension.js-debug.network.viewRequest', + NetworkCopyUri = 'extension.js-debug.network.copyUri', + NetworkOpenBody = 'extension.js-debug.network.openBody', + NetworkOpenBodyHex = 'extension.js-debug.network.openBodyInHex', + NetworkReplayXHR = 'extension.js-debug.network.replayXHR', + NetworkClear = 'extension.js-debug.network.clear', + //#endregion } export const enum DebugType { @@ -120,6 +130,12 @@ const commandsObj: { [K in Commands]: null } = { [Commands.CallersRemoveAll]: null, [Commands.EnableSourceMapStepping]: null, [Commands.DisableSourceMapStepping]: null, + [Commands.NetworkViewRequest]: null, + [Commands.NetworkCopyUri]: null, + [Commands.NetworkOpenBody]: null, + [Commands.NetworkOpenBodyHex]: null, + [Commands.NetworkReplayXHR]: null, + [Commands.NetworkClear]: null, }; /** @@ -223,8 +239,16 @@ export interface ICommandTypes { [Commands.CallersRemoveAll](): void; [Commands.EnableSourceMapStepping](): void; [Commands.DisableSourceMapStepping](): void; + [Commands.NetworkViewRequest](request: NetworkRequest): void; + [Commands.NetworkCopyUri](request: NetworkRequest): void; + [Commands.NetworkOpenBody](request: NetworkRequest): void; + [Commands.NetworkOpenBodyHex](request: NetworkRequest): void; + [Commands.NetworkReplayXHR](request: NetworkRequest): void; + [Commands.NetworkClear](): void; } +export const networkFilesystemScheme = 'jsDebugNetworkFs'; + /** * Typed guard for registering a command. */ @@ -278,6 +302,7 @@ export const enum ContextKey { CanPrettyPrint = 'jsDebugCanPrettyPrint', IsProfiling = 'jsDebugIsProfiling', IsMapSteppingDisabled = 'jsDebugIsMapSteppingDisabled', + NetworkAvailable = 'jsDebugNetworkAvailable', } export interface IContextKeyTypes { @@ -285,6 +310,7 @@ export interface IContextKeyTypes { [ContextKey.CanPrettyPrint]: string[]; [ContextKey.IsProfiling]: boolean; [ContextKey.IsMapSteppingDisabled]: boolean; + [ContextKey.NetworkAvailable]: boolean; } export const setContextKey = async ( diff --git a/src/common/disposable.ts b/src/common/disposable.ts index c328de13e..ef89a75ad 100644 --- a/src/common/disposable.ts +++ b/src/common/disposable.ts @@ -99,6 +99,15 @@ export class DisposableList { d.dispose(); } + /** + * Clears all items without disposing them + */ + public clear() { + const r = Promise.all(this.items.map(i => i.dispose())); + this.items = []; + return r; + } + /** * @inheritdoc */ diff --git a/src/common/networkEvents.ts b/src/common/networkEvents.ts new file mode 100644 index 000000000..d56041ebf --- /dev/null +++ b/src/common/networkEvents.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import Cdp from '../cdp/api'; + +/** + * Network events mirrored over DAP. + */ +export interface IMirroredNetworkEvents { + requestWillBeSent: Cdp.Network.RequestWillBeSentEvent; + responseReceived: Cdp.Network.ResponseReceivedEvent; + responseReceivedExtraInfo: Cdp.Network.ResponseReceivedExtraInfoEvent; + loadingFailed: Cdp.Network.LoadingFailedEvent; + loadingFinished: Cdp.Network.LoadingFinishedEvent; +} + +export const mirroredNetworkEvents = Object.keys({ + requestWillBeSent: 0, + responseReceived: 0, + responseReceivedExtraInfo: 0, + loadingFailed: 0, + loadingFinished: 0, +} satisfies { [K in keyof IMirroredNetworkEvents]: unknown }); diff --git a/src/configuration.ts b/src/configuration.ts index 5ba1a166d..6db987b64 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -439,6 +439,11 @@ export interface INodeLaunchConfiguration extends INodeBaseConfiguration, IConfi * - none: no termination will happen. */ killBehavior: KillBehavior; + + /** + * Whether to automatically add the `--experimental-network-inspection` flag. + */ + experimentalNetworking: 'on' | 'off' | 'auto'; } /** @@ -906,6 +911,7 @@ export const nodeLaunchConfigDefaults: INodeLaunchConfiguration = { runtimeArgs: [], profileStartup: false, attachSimplePort: null, + experimentalNetworking: 'auto', killBehavior: KillBehavior.Forceful, }; diff --git a/src/dap/api.d.ts b/src/dap/api.d.ts index 88d5ad58e..3e86c8783 100644 --- a/src/dap/api.d.ts +++ b/src/dap/api.d.ts @@ -688,7 +688,7 @@ export namespace Dap { loadedSourcesRequest(params: LoadedSourcesParams): Promise; /** - * Evaluates the given expression in the context of the topmost stack frame. + * Evaluates the given expression in the context of a stack frame. * The expression has access to any variables and arguments that are in scope. */ on( @@ -696,7 +696,7 @@ export namespace Dap { handler: (params: EvaluateParams) => Promise, ): () => void; /** - * Evaluates the given expression in the context of the topmost stack frame. + * Evaluates the given expression in the context of a stack frame. * The expression has access to any variables and arguments that are in scope. */ evaluateRequest(params: EvaluateParams): Promise; @@ -1144,6 +1144,28 @@ export namespace Dap { * Sets options for locating symbols. */ setSymbolOptionsRequest(params: SetSymbolOptionsParams): Promise; + + /** + * Fired when we successfully enable CDP networking on the session. + */ + networkAvailable(params: NetworkAvailableEventParams): void; + + /** + * A wrapped CDP network event. There is little abstraction here because UI interacts literally with CDP at the moment. + */ + networkEvent(params: NetworkEventEventParams): void; + + /** + * Makes a network call. There is little abstraction here because UI interacts literally with CDP at the moment. + */ + on( + request: 'networkCall', + handler: (params: NetworkCallParams) => Promise, + ): () => void; + /** + * Makes a network call. There is little abstraction here because UI interacts literally with CDP at the moment. + */ + networkCallRequest(params: NetworkCallParams): Promise; } export interface TestApi { @@ -1591,7 +1613,7 @@ export namespace Dap { loadedSources(params: LoadedSourcesParams): Promise; /** - * Evaluates the given expression in the context of the topmost stack frame. + * Evaluates the given expression in the context of a stack frame. * The expression has access to any variables and arguments that are in scope. */ evaluate(params: EvaluateParams): Promise; @@ -1899,6 +1921,31 @@ export namespace Dap { * Sets options for locating symbols. */ setSymbolOptions(params: SetSymbolOptionsParams): Promise; + + /** + * Fired when we successfully enable CDP networking on the session. + */ + on(request: 'networkAvailable', handler: (params: NetworkAvailableEventParams) => void): void; + off(request: 'networkAvailable', handler: (params: NetworkAvailableEventParams) => void): void; + once( + request: 'networkAvailable', + filter?: (event: NetworkAvailableEventParams) => boolean, + ): Promise; + + /** + * A wrapped CDP network event. There is little abstraction here because UI interacts literally with CDP at the moment. + */ + on(request: 'networkEvent', handler: (params: NetworkEventEventParams) => void): void; + off(request: 'networkEvent', handler: (params: NetworkEventEventParams) => void): void; + once( + request: 'networkEvent', + filter?: (event: NetworkEventEventParams) => boolean, + ): Promise; + + /** + * Makes a network call. There is little abstraction here because UI interacts literally with CDP at the moment. + */ + networkCall(params: NetworkCallParams): Promise; } export interface AttachParams { @@ -2841,6 +2888,34 @@ export namespace Dap { totalModules?: integer; } + export interface NetworkAvailableEventParams {} + + export interface NetworkCallParams { + /** + * The HTTP method + */ + method: string; + + /** + * The CDP call parameters + */ + params: object; + } + + export interface NetworkCallResult {} + + export interface NetworkEventEventParams { + /** + * The CDP network event name + */ + event: string; + + /** + * The CDP network data + */ + data: object; + } + export interface NextParams { /** * Specifies the thread for which to resume execution for one step (of the given granularity). @@ -2944,7 +3019,7 @@ export namespace Dap { name: string; /** - * The system process id of the debugged process. This property is missing for non-system processes. + * The process ID of the debugged process, as assigned by the operating system. This property should be omitted for logical processes that do not map to operating system processes on the machine. */ systemProcessId?: integer; @@ -3474,6 +3549,8 @@ export namespace Dap { /** * If `variablesReference` is > 0, the new value is structured and its children can be retrieved by passing `variablesReference` to the `variables` request as long as execution remains suspended. See 'Lifetime of Object References' in the Overview section for details. + * + * If this property is included in the response, any `variablesReference` previously associated with the updated variable, and those of its children, are no longer valid. */ variablesReference?: integer; @@ -4306,7 +4383,7 @@ export namespace Dap { /** * A hint for how to present this scope in the UI. If this attribute is missing, the scope is shown with a generic UI. */ - presentationHint?: 'arguments' | 'locals' | 'registers'; + presentationHint?: 'arguments' | 'locals' | 'registers' | 'returnValue'; /** * The variables of this scope can be retrieved by passing the value of `variablesReference` to the `variables` request as long as execution remains suspended. See 'Lifetime of Object References' in the Overview section for details. diff --git a/src/dap/telemetryClassification.d.ts b/src/dap/telemetryClassification.d.ts index 20d07b7b4..b8c756c20 100644 --- a/src/dap/telemetryClassification.d.ts +++ b/src/dap/telemetryClassification.d.ts @@ -138,4 +138,6 @@ interface IDAPOperationClassification { '!evaluationoptions.errors': { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth' }; setsymboloptions: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth' }; '!setsymboloptions.errors': { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth' }; + networkcall: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth' }; + '!networkcall.errors': { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth' }; } diff --git a/src/targets/node/nodeBinaryProvider.ts b/src/targets/node/nodeBinaryProvider.ts index 9834538c9..52668841e 100644 --- a/src/targets/node/nodeBinaryProvider.ts +++ b/src/targets/node/nodeBinaryProvider.ts @@ -29,6 +29,7 @@ export const INodeBinaryProvider = Symbol('INodeBinaryProvider'); export const enum Capability { UseSpacesInRequirePath, UseInspectPublishUid, + UseExperimentalNetworking, } /** @@ -129,6 +130,14 @@ export class NodeBinary { if (version.gte(new Semver(12, 6, 0))) { this.capabilities.add(Capability.UseInspectPublishUid); } + + // todo@connor4312: the current API we get in Node.js is pretty tiny and + // I don't want to ship it by default in its current version, ref + // https://github.com/nodejs/node/pull/53593#issuecomment-2276367389 + // Users can still turn it on by setting `experimentalNetworking: on`. + // if (version.gte(new Semver(22, 6, 0)) && version.lt(new Semver(24, 0, 0))) { + // this.capabilities.add(Capability.UseExperimentalNetworking); + // } } /** diff --git a/src/targets/node/nodeLauncher.ts b/src/targets/node/nodeLauncher.ts index c0dde842c..fc4fbc26c 100644 --- a/src/targets/node/nodeLauncher.ts +++ b/src/targets/node/nodeLauncher.ts @@ -19,7 +19,12 @@ import { fixInspectFlags } from '../../ui/configurationUtils'; import { retryGetNodeEndpoint } from '../browser/spawn/endpoints'; import { ISourcePathResolverFactory } from '../sourcePathResolverFactory'; import { CallbackFile } from './callback-file'; -import { INodeBinaryProvider, getRunScript, hideDebugInfoFromConsole } from './nodeBinaryProvider'; +import { + Capability, + INodeBinaryProvider, + getRunScript, + hideDebugInfoFromConsole, +} from './nodeBinaryProvider'; import { IProcessTelemetry, IRunData, NodeLauncherBase } from './nodeLauncherBase'; import { INodeTargetLifecycleHooks } from './nodeTarget'; import { IPackageJsonProvider } from './packageJsonProvider'; @@ -143,6 +148,16 @@ export class NodeLauncher extends NodeLauncherBase { } const options: INodeLaunchConfiguration = { ...runData.params, env: env.value }; + const experimentalNetworkFlag = '--experimental-network-inspection'; + if (runData.params.experimentalNetworking === 'off') { + // no-op + } else if ( + binary.has(Capability.UseExperimentalNetworking) || + runData.params.experimentalNetworking === 'on' + ) { + options.runtimeArgs = [experimentalNetworkFlag, ...options.runtimeArgs]; + } + const launcher = this.launchers.find(l => l.canLaunch(options)); if (!launcher) { throw new Error('Cannot find an appropriate launcher for the given set of options'); diff --git a/src/ui/debugSessionTracker.ts b/src/ui/debugSessionTracker.ts index 84f76203e..0eb390808 100644 --- a/src/ui/debugSessionTracker.ts +++ b/src/ui/debugSessionTracker.ts @@ -43,9 +43,16 @@ export class DebugSessionTracker implements vscode.Disposable { private _onSessionAddedEmitter = new vscode.EventEmitter(); private _onSessionEndedEmitter = new vscode.EventEmitter(); + private _onNetworkAvailabilityChangeEmitter = new vscode.EventEmitter(); private _disposables: vscode.Disposable[] = []; + private readonly networkAvailable = new WeakSet(); private readonly sessions = new Map(); + /** + * Fired when networking becomes available for a debug session. + */ + public onNetworkAvailabilityChanged = this._onNetworkAvailabilityChangeEmitter.event; + /** * Fires when any new js-debug session comes in. */ @@ -70,6 +77,13 @@ export class DebugSessionTracker implements vscode.Disposable { return this.sessions.get(id); } + /** + * Gets whether networkign is available in the session. + */ + public isNetworkAvailable(session: vscode.DebugSession) { + return this.networkAvailable.has(session); + } + /** * Gets whether the js-debug session is still running. */ @@ -137,13 +151,12 @@ export class DebugSessionTracker implements vscode.Disposable { options.selection = new vscode.Range(position, position); } vscode.window.showTextDocument(uri, options); - return; - } - - if (event.event === 'copyRequested') { + } else if (event.event === 'copyRequested') { const params = event.body as Dap.CopyRequestedEventParams; vscode.env.clipboard.writeText(params.text); - return; + } else if (event.event === 'networkAvailable') { + this.networkAvailable.add(event.session); + this._onNetworkAvailabilityChangeEmitter.fire(event.session); } }, undefined, diff --git a/src/ui/networkTree.ts b/src/ui/networkTree.ts new file mode 100644 index 000000000..eac97c27b --- /dev/null +++ b/src/ui/networkTree.ts @@ -0,0 +1,499 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { isUtf8 } from 'buffer'; +import { inject, injectable } from 'inversify'; +import * as vscode from 'vscode'; +import Cdp from '../cdp/api'; +import { + Commands, + ContextKey, + CustomViews, + DebugType, + networkFilesystemScheme, + registerCommand, + setContextKey, +} from '../common/contributionUtils'; +import { DisposableList, noOpDisposable } from '../common/disposable'; +import { IMirroredNetworkEvents } from '../common/networkEvents'; +import { assertNever, once } from '../common/objUtils'; +import Dap from '../dap/api'; +import { IExtensionContribution } from '../ioc-extras'; +import { DebugSessionTracker } from './debugSessionTracker'; + +type NetworkNode = NetworkRequest; + +@injectable() +export class NetworkTree implements IExtensionContribution, vscode.TreeDataProvider { + private readonly disposables = new DisposableList(); + private readonly activeListeners = new DisposableList(); + private readonly treeDataChangeEmitter = new vscode.EventEmitter< + void | NetworkNode | NetworkNode[] | null | undefined + >(); + private readonly models = new Map(); + private current: NetworkModel | undefined; + + constructor( + @inject(DebugSessionTracker) private readonly debugSessionTracker: DebugSessionTracker, + ) { + this.disposables.push( + this.debugSessionTracker.onNetworkAvailabilityChanged(session => { + this.models.set(session.id, new NetworkModel(session)); + if (session === vscode.debug.activeDebugSession) { + this.listenToActiveSession(); + } + }), + vscode.debug.onDidChangeActiveDebugSession(() => { + this.listenToActiveSession(); + }), + this.debugSessionTracker.onSessionEnded(session => { + this.models.delete(session.id); + }), + vscode.debug.onDidReceiveDebugSessionCustomEvent(event => { + if (event.event === 'networkEvent') { + this.models.get(event.session.id)?.append([event.body.event, event.body.data]); + } + }), + + registerCommand( + vscode.commands, + Commands.NetworkViewRequest, + async (request: NetworkRequest) => { + const doc = await vscode.workspace.openTextDocument(request.fsUri); + await vscode.window.showTextDocument(doc); + }, + ), + registerCommand(vscode.commands, Commands.NetworkCopyUri, async (request: NetworkRequest) => { + await vscode.env.clipboard.writeText(request.init.request.url); + }), + registerCommand( + vscode.commands, + Commands.NetworkOpenBody, + async (request: NetworkRequest) => { + const doc = await vscode.workspace.openTextDocument(request.fsBodyUri); + await vscode.window.showTextDocument(doc); + }, + ), + registerCommand( + vscode.commands, + Commands.NetworkOpenBodyHex, + async (request: NetworkRequest) => { + vscode.commands.executeCommand('vscode.openWith', request.fsBodyUri, 'hexEditor.hexedit'); + }, + ), + registerCommand( + vscode.commands, + Commands.NetworkReplayXHR, + async (request: NetworkRequest) => { + await request.session.customRequest('networkCall', { + method: 'replayXHR', + params: { requestId: request.id }, + } satisfies Dap.NetworkCallParams); + }, + ), + registerCommand(vscode.commands, Commands.NetworkClear, async () => { + for (const model of this.models.values()) { + model.clear(); + } + this.treeDataChangeEmitter.fire(); + }), + ); + } + + /** @inheritdoc */ + onDidChangeTreeData = this.treeDataChangeEmitter.event; + + /** @inheritdoc */ + getTreeItem(element: NetworkNode): vscode.TreeItem | Thenable { + return element.toTreeItem(); + } + + /** @inheritdoc */ + getChildren(element?: NetworkNode | undefined): vscode.ProviderResult { + if (!element && this.current) { + return this.current.allRequests; + } + + return []; + } + + /** @inheritdoc */ + register(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.window.registerTreeDataProvider(CustomViews.Network, this), + vscode.workspace.registerFileSystemProvider( + networkFilesystemScheme, + new FilesystemProvider(this.debugSessionTracker, this.models), + { isCaseSensitive: true, isReadonly: true }, + ), + ); + } + + private listenToActiveSession() { + this.activeListeners.clear(); + const model = (this.current = + vscode.debug.activeDebugSession && this.models.get(vscode.debug.activeDebugSession.id)); + let hasRequests = !!model && model.hasRequests; + if (model) { + this.activeListeners.push( + model.onDidChange(ev => { + this.treeDataChangeEmitter.fire(ev.isNew ? undefined : ev.request); + + if (model.hasRequests && !hasRequests) { + hasRequests = true; + setContextKey(vscode.commands, ContextKey.NetworkAvailable, true); + } + }), + ); + } + + setContextKey(vscode.commands, ContextKey.NetworkAvailable, hasRequests); + this.treeDataChangeEmitter.fire(undefined); + } +} + +class FilesystemProvider implements vscode.FileSystemProvider { + private readonly changeFileEmitter = new vscode.EventEmitter(); + + /** @inheritdoc */ + public readonly onDidChangeFile = this.changeFileEmitter.event; + + constructor( + private tracker: DebugSessionTracker, + private readonly models: Map, + ) {} + + /** @inheritdoc */ + watch(watchUri: vscode.Uri): vscode.Disposable { + const [sessionId, requestId] = watchUri.path.split('/').slice(1); + const model = this.models.get(sessionId); + if (!model) { + return noOpDisposable; + } + + return model.onDidChange(({ request, isNew }) => { + const uri = watchUri.with({ path: `${sessionId}/${request.id}` }); + if (isNew && !requestId) { + this.changeFileEmitter.fire([{ type: vscode.FileChangeType.Created, uri }]); + } else if (requestId === request.id) { + this.changeFileEmitter.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } + }); + } + + /** @inheritdoc */ + async stat(uri: vscode.Uri): Promise { + const [sessionId, requestId] = uri.path.split('/').slice(1); + const model = this.models.get(sessionId); + if (!model) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + if (!requestId) { + return { type: vscode.FileType.Directory, ctime: 0, mtime: 0, size: 0 }; + } + + const request = model.getRequest(requestId); + if (!request) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + return { + type: vscode.FileType.File, + ctime: request.ctime, + mtime: request.mtime, + size: request.isComplete ? await request.body().then(b => b?.length || 0) : 0, + }; + } + + /** @inheritdoc */ + readDirectory(): [string, vscode.FileType][] { + return []; + } + + /** @inheritdoc */ + createDirectory(): void { + // no-op + } + + /** @inheritdoc */ + async readFile(uri: vscode.Uri): Promise { + const [sessionId, requestId, aspect] = uri.path.split('/').slice(1); + const request = this.models.get(sessionId)?.getRequest(requestId); + if (!request) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + if (aspect === 'body') { + if (!request.isComplete) { + // we'll fire a watcher change event as this updates: + return Buffer.from('Response is still loading...'); + } + + return (await request.body()) || Buffer.from('Body not available'); + } + + return Buffer.from(await request.toCurl(this.tracker.getById(sessionId))); + } + + /** @inheritdoc */ + writeFile(): void { + // no-op + } + + /** @inheritdoc */ + delete(): void { + // no-op + } + + /** @inheritdoc */ + rename(): void { + // no-op + } +} + +class NetworkModel { + private readonly requests = new Map(); + + private readonly didChangeEmitter = new vscode.EventEmitter<{ + request: NetworkRequest; + isNew: boolean; + }>(); + public readonly onDidChange = this.didChangeEmitter.event; + + constructor(private readonly session: vscode.DebugSession) {} + + public get allRequests() { + return [...this.requests.values()]; + } + + public get hasRequests() { + return this.requests.size > 0; + } + + public getRequest(id: string) { + return this.requests.get(id); + } + + public clear() { + this.requests.clear(); + } + + public append([key, event]: KeyValue) { + if (key === 'requestWillBeSent') { + const request = new NetworkRequest(event, this.session); + this.requests.set(event.requestId, request); + this.didChangeEmitter.fire({ request, isNew: true }); + } else if ( + key === 'responseReceived' || + key === 'loadingFailed' || + key === 'loadingFinished' || + key === 'responseReceivedExtraInfo' + ) { + const request = this.requests.get(event.requestId); + if (!request) { + return; + } + + if (key === 'responseReceived') { + request.response = event.response || {}; // node.js response is just empty right now + } else if (key === 'loadingFailed') { + request.failed = event; + } else if (key === 'loadingFinished') { + request.finished = event; + } else if (key === 'responseReceivedExtraInfo') { + request.responseExtra = event; + } + request.mtime = Date.now(); + this.didChangeEmitter.fire({ request, isNew: false }); + } else { + assertNever(key, 'unexpected network event'); + } + } +} + +export class NetworkRequest { + public readonly ctime = Date.now(); + public mtime = Date.now(); + public response?: Cdp.Network.Response; + public responseExtra?: Cdp.Network.ResponseReceivedExtraInfoEvent; + public failed?: Cdp.Network.LoadingFailedEvent; + public finished?: Cdp.Network.LoadingFinishedEvent; + + public get isComplete() { + return !!(this.finished || this.failed); + } + + public get id() { + return this.init.requestId; + } + + public get fsUri() { + return vscode.Uri.from({ + scheme: networkFilesystemScheme, + path: `/${this.session.id}/${this.id}`, + }); + } + + public get fsBodyUri() { + return vscode.Uri.from({ + scheme: networkFilesystemScheme, + path: `/${this.session.id}/${this.id}/body`, + }); + } + + constructor( + public readonly init: Cdp.Network.RequestWillBeSentEvent, + public readonly session: vscode.DebugSession, + ) {} + + /** Returns a tree-item representation of the request. */ + public toTreeItem() { + let icon: vscode.ThemeIcon; + if (!this.isComplete) { + icon = new vscode.ThemeIcon( + 'sync~spin', + new vscode.ThemeColor('notebookStatusRunningIcon.foreground'), + ); + } else if (this.failed) { + icon = new vscode.ThemeIcon( + 'error', + new vscode.ThemeColor('notebookStatusErrorIcon.foreground'), + ); + } else if (this.response && this.response.status >= 400) { + icon = new vscode.ThemeIcon('warning'); + } else { + icon = new vscode.ThemeIcon( + 'check', + new vscode.ThemeColor('notebookStatusSuccessIcon.foreground'), + ); + } + + let label = ''; + if (this.failed) { + label += `[${this.failed.errorText}] `; + } else if (this.response) { + label += `[${this.response.status}] `; + } + + let host: string | undefined; + let path: string; + try { + const url = new URL(this.init.request.url); + host = url.host; + path = url.pathname; + } catch { + path = this.init.request.url; + } + + label += `${this.init.request.method.toUpperCase()} ${path}`; + const treeItem = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Collapsed); + treeItem.iconPath = icon; + treeItem.description = host; + treeItem.tooltip = this.init.request.url; + treeItem.id = this.init.requestId; + treeItem.collapsibleState = vscode.TreeItemCollapsibleState.None; + return treeItem; + } + + /** Converts the request to a curl-style command. */ + public async toCurl(session: vscode.DebugSession | undefined) { + const command = this.toCurlCommand(); + if (!this.response) { + return command; + } + + const parts = [command]; + parts.push(`< HTTP ${this.responseExtra?.statusCode || this.response.status || 'UNKOWN'}`); + for (const header of Object.entries( + this.responseExtra?.headers || this.response.headers || {}, + )) { + parts.push(`< ${header[0]}: ${header[1]}`); + } + parts.push('<'); + + if (this.failed) { + parts.push('', `${this.failed.errorText}`); + if (this.failed.blockedReason) { + parts.push(`Blocked: ${this.failed.blockedReason}`); + } else if (this.failed.corsErrorStatus) { + parts.push(`CORS error: ${this.failed.corsErrorStatus.corsError}`); + } + } + + if (!this.isComplete || !session) { + return parts.join('\n'); + } + + const body = (await this.body()) || Buffer.from(''); + if (!isUtf8(body)) { + parts.push(`[binary data as base64]: ${body.toString('base64')}`); + } else { + const str = body.toString(); + try { + const parsed = JSON.parse(str); + parts.push(JSON.stringify(parsed, null, 2)); + } catch { + parts.push(str); + } + } + + return parts.join('\n'); + } + + private toCurlCommand() { + const args = ['curl', '-v']; + if (this.init.request.method !== 'GET') { + args.push(`-X ${this.init.request.method}`); + } + + // note: although headers is required by CDP types, it's undefined in Node.js right now (22.6.0) + for (const [headerName, headerValue] of Object.entries(this.init.request.headers || {})) { + args.push(`-H '${headerName}: ${headerValue}'`); + } + + if (this.init.request.postDataEntries?.length) { + const parts = this.init.request.postDataEntries.map(e => e.bytes || '').join(''); + const bytes = Buffer.from(parts, 'base64'); + args.push(isUtf8(bytes) ? `-d '${bytes.toString()}'` : `--data-binary ''`); + } + + args.push(`'${this.init.request.url}'`); + + return args.join(' '); + } + + /** Gets the response body. */ + public body = once(async () => { + try { + const res: Cdp.Network.GetResponseBodyResult = await this.session.customRequest( + 'networkCall', + { + method: 'getResponseBody', + params: { requestId: this.init.requestId }, + } satisfies Dap.NetworkCallParams, + ); + + if (!res.body) { + // only say this on failure so that we gracefully support it once available: + if (this.session.type === DebugType.Node) { + return Buffer.from('Response body inspection is not supported in Node.js yet.'); + } + return undefined; + } + if (res.base64Encoded) { + return Buffer.from(res.body, 'base64'); + } + return Buffer.from(res.body); + } catch { + return undefined; + } + }); +} + +type KeyValue = keyof T extends infer K + ? K extends keyof T + ? [key: K, value: T[K]] + : never + : never; diff --git a/src/ui/ui-ioc.extensionOnly.ts b/src/ui/ui-ioc.extensionOnly.ts index af0a06698..34e6a336e 100644 --- a/src/ui/ui-ioc.extensionOnly.ts +++ b/src/ui/ui-ioc.extensionOnly.ts @@ -39,6 +39,7 @@ import { SettingRequestOptionsProvider } from './settingRequestOptionsProvider'; import { SourceSteppingUI } from './sourceSteppingUI'; import { StartDebugingAndStopOnEntry } from './startDebuggingAndStopOnEntry'; import { TerminalLinkHandler } from './terminalLinkHandler'; +import { NetworkTree } from './networkTree'; export const registerUiComponents = (container: Container) => { container.bind(VSCodeApi).toConstantValue(require('vscode')); @@ -66,6 +67,7 @@ export const registerUiComponents = (container: Container) => { container.bind(IExtensionContribution).to(ExcludedCallersUI).inSingletonScope(); container.bind(IExtensionContribution).to(PrettyPrintUI).inSingletonScope(); container.bind(IExtensionContribution).to(SourceSteppingUI).inSingletonScope(); + container.bind(IExtensionContribution).to(NetworkTree).inSingletonScope(); container.bind(ILinkedBreakpointLocation).to(LinkedBreakpointLocationUI).inSingletonScope(); container.bind(DebugSessionTracker).toSelf().inSingletonScope().onActivation(trackDispose); container.bind(UiProfileManager).toSelf().inSingletonScope().onActivation(trackDispose);