Path: blob/main/extensions/debug-server-ready/src/extension.ts
3291 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as vscode from 'vscode';6import * as util from 'util';7import { randomUUID } from 'crypto';89const PATTERN = 'listening on.* (https?://\\S+|[0-9]+)'; // matches "listening on port 3000" or "Now listening on: https://localhost:5001"10const URI_PORT_FORMAT = 'http://localhost:%s';11const URI_FORMAT = '%s';12const WEB_ROOT = '${workspaceFolder}';1314interface ServerReadyAction {15pattern: string;16action?: 'openExternally' | 'debugWithChrome' | 'debugWithEdge' | 'startDebugging';17uriFormat?: string;18webRoot?: string;19name?: string;20config?: vscode.DebugConfiguration;21killOnServerStop?: boolean;22}2324// From src/vs/base/common/strings.ts25const CSI_SEQUENCE = /(?:\x1b\[|\x9b)[=?>!]?[\d;:]*["$#'* ]?[a-zA-Z@^`{}|~]/;26const OSC_SEQUENCE = /(?:\x1b\]|\x9d).*?(?:\x1b\\|\x07|\x9c)/;27const ESC_SEQUENCE = /\x1b(?:[ #%\(\)\*\+\-\.\/]?[a-zA-Z0-9\|}~@])/;28const CONTROL_SEQUENCES = new RegExp('(?:' + [29CSI_SEQUENCE.source,30OSC_SEQUENCE.source,31ESC_SEQUENCE.source,32].join('|') + ')', 'g');3334/**35* Froms vs/base/common/strings.ts in core36* @see https://github.com/microsoft/vscode/blob/22a2a0e833175c32a2005b977d7fbd355582e416/src/vs/base/common/strings.ts#L73637*/38function removeAnsiEscapeCodes(str: string): string {39if (str) {40str = str.replace(CONTROL_SEQUENCES, '');41}4243return str;44}4546class Trigger {47private _fired = false;4849public get hasFired() {50return this._fired;51}5253public fire() {54this._fired = true;55}56}5758class ServerReadyDetector extends vscode.Disposable {5960private static detectors = new Map<vscode.DebugSession, ServerReadyDetector>();61private static terminalDataListener: vscode.Disposable | undefined;6263private readonly stoppedEmitter = new vscode.EventEmitter<void>();64private readonly onDidSessionStop = this.stoppedEmitter.event;65private readonly disposables = new Set<vscode.Disposable>([]);66private trigger: Trigger;67private shellPid?: number;68private regexp: RegExp;6970static start(session: vscode.DebugSession): ServerReadyDetector | undefined {71if (session.configuration.serverReadyAction) {72let detector = ServerReadyDetector.detectors.get(session);73if (!detector) {74detector = new ServerReadyDetector(session);75ServerReadyDetector.detectors.set(session, detector);76}77return detector;78}79return undefined;80}8182static stop(session: vscode.DebugSession): void {83const detector = ServerReadyDetector.detectors.get(session);84if (detector) {85ServerReadyDetector.detectors.delete(session);86detector.sessionStopped();87detector.dispose();88}89}9091static rememberShellPid(session: vscode.DebugSession, pid: number) {92const detector = ServerReadyDetector.detectors.get(session);93if (detector) {94detector.shellPid = pid;95}96}9798static async startListeningTerminalData() {99if (!this.terminalDataListener) {100this.terminalDataListener = vscode.window.onDidWriteTerminalData(async e => {101102// first find the detector with a matching pid103const pid = await e.terminal.processId;104const str = removeAnsiEscapeCodes(e.data);105for (const [, detector] of this.detectors) {106if (detector.shellPid === pid) {107detector.detectPattern(str);108return;109}110}111112// if none found, try all detectors until one matches113for (const [, detector] of this.detectors) {114if (detector.detectPattern(str)) {115return;116}117}118});119}120}121122private constructor(private session: vscode.DebugSession) {123super(() => this.internalDispose());124125// Re-used the triggered of the parent session, if one exists126if (session.parentSession) {127this.trigger = ServerReadyDetector.start(session.parentSession)?.trigger ?? new Trigger();128} else {129this.trigger = new Trigger();130}131132this.regexp = new RegExp(session.configuration.serverReadyAction.pattern || PATTERN, 'i');133}134135private internalDispose() {136this.disposables.forEach(d => d.dispose());137this.disposables.clear();138}139140public sessionStopped() {141this.stoppedEmitter.fire();142}143144detectPattern(s: string): boolean {145if (!this.trigger.hasFired) {146const matches = this.regexp.exec(s);147if (matches && matches.length >= 1) {148this.openExternalWithString(this.session, matches.length > 1 ? matches[1] : '');149this.trigger.fire();150return true;151}152}153return false;154}155156private openExternalWithString(session: vscode.DebugSession, captureString: string) {157const args: ServerReadyAction = session.configuration.serverReadyAction;158159let uri;160if (captureString === '') {161// nothing captured by reg exp -> use the uriFormat as the target uri without substitution162// verify that format does not contain '%s'163const format = args.uriFormat || '';164if (format.indexOf('%s') >= 0) {165const errMsg = vscode.l10n.t("Format uri ('{0}') uses a substitution placeholder but pattern did not capture anything.", format);166vscode.window.showErrorMessage(errMsg, { modal: true }).then(_ => undefined);167return;168}169uri = format;170} else {171// if no uriFormat is specified guess the appropriate format based on the captureString172const format = args.uriFormat || (/^[0-9]+$/.test(captureString) ? URI_PORT_FORMAT : URI_FORMAT);173// verify that format only contains a single '%s'174const s = format.split('%s');175if (s.length !== 2) {176const errMsg = vscode.l10n.t("Format uri ('{0}') must contain exactly one substitution placeholder.", format);177vscode.window.showErrorMessage(errMsg, { modal: true }).then(_ => undefined);178return;179}180uri = util.format(format, captureString);181}182183this.openExternalWithUri(session, uri);184}185186private async openExternalWithUri(session: vscode.DebugSession, uri: string) {187188const args: ServerReadyAction = session.configuration.serverReadyAction;189switch (args.action || 'openExternally') {190191case 'openExternally':192await vscode.env.openExternal(vscode.Uri.parse(uri));193break;194195case 'debugWithChrome':196await this.debugWithBrowser('pwa-chrome', session, uri);197break;198199case 'debugWithEdge':200await this.debugWithBrowser('pwa-msedge', session, uri);201break;202203case 'startDebugging':204if (args.config) {205await this.startDebugSession(session, args.config.name, args.config);206} else {207await this.startDebugSession(session, args.name || 'unspecified');208}209break;210211default:212// not supported213break;214}215}216217private async debugWithBrowser(type: string, session: vscode.DebugSession, uri: string) {218const args = session.configuration.serverReadyAction as ServerReadyAction;219if (!args.killOnServerStop) {220await this.startBrowserDebugSession(type, session, uri);221return;222}223224const trackerId = randomUUID();225const cts = new vscode.CancellationTokenSource();226const newSessionPromise = this.catchStartedDebugSession(session => session.configuration._debugServerReadySessionId === trackerId, cts.token);227228if (!await this.startBrowserDebugSession(type, session, uri, trackerId)) {229cts.cancel();230cts.dispose();231return;232}233234const createdSession = await newSessionPromise;235cts.dispose();236237if (!createdSession) {238return;239}240241const stopListener = this.onDidSessionStop(async () => {242stopListener.dispose();243this.disposables.delete(stopListener);244await vscode.debug.stopDebugging(createdSession);245});246this.disposables.add(stopListener);247}248249private startBrowserDebugSession(type: string, session: vscode.DebugSession, uri: string, trackerId?: string) {250return vscode.debug.startDebugging(session.workspaceFolder, {251type,252name: 'Browser Debug',253request: 'launch',254url: uri,255webRoot: session.configuration.serverReadyAction.webRoot || WEB_ROOT,256_debugServerReadySessionId: trackerId,257});258}259260/**261* Starts a debug session given a debug configuration name (saved in launch.json) or a debug configuration object.262*263* @param session The parent debugSession264* @param name The name of the configuration to launch. If config it set, it assumes it is the same as config.name.265* @param config [Optional] Instead of starting a debug session by debug configuration name, use a debug configuration object instead.266*/267private async startDebugSession(session: vscode.DebugSession, name: string, config?: vscode.DebugConfiguration) {268const args = session.configuration.serverReadyAction as ServerReadyAction;269if (!args.killOnServerStop) {270await vscode.debug.startDebugging(session.workspaceFolder, config ?? name);271return;272}273274const cts = new vscode.CancellationTokenSource();275const newSessionPromise = this.catchStartedDebugSession(x => x.name === name, cts.token);276277if (!await vscode.debug.startDebugging(session.workspaceFolder, config ?? name)) {278cts.cancel();279cts.dispose();280return;281}282283const createdSession = await newSessionPromise;284cts.dispose();285286if (!createdSession) {287return;288}289290const stopListener = this.onDidSessionStop(async () => {291stopListener.dispose();292this.disposables.delete(stopListener);293await vscode.debug.stopDebugging(createdSession);294});295this.disposables.add(stopListener);296}297298private catchStartedDebugSession(predicate: (session: vscode.DebugSession) => boolean, cancellationToken: vscode.CancellationToken): Promise<vscode.DebugSession | undefined> {299return new Promise<vscode.DebugSession | undefined>(_resolve => {300const done = (value?: vscode.DebugSession) => {301listener.dispose();302cancellationListener.dispose();303this.disposables.delete(listener);304this.disposables.delete(cancellationListener);305_resolve(value);306};307308const cancellationListener = cancellationToken.onCancellationRequested(done);309const listener = vscode.debug.onDidStartDebugSession(session => {310if (predicate(session)) {311done(session);312}313});314315// In case the debug session of interest was never caught anyhow.316this.disposables.add(listener);317this.disposables.add(cancellationListener);318});319}320}321322export function activate(context: vscode.ExtensionContext) {323324context.subscriptions.push(vscode.debug.onDidStartDebugSession(session => {325if (session.configuration.serverReadyAction) {326const detector = ServerReadyDetector.start(session);327if (detector) {328ServerReadyDetector.startListeningTerminalData();329}330}331}));332333context.subscriptions.push(vscode.debug.onDidTerminateDebugSession(session => {334ServerReadyDetector.stop(session);335}));336337const trackers = new Set<string>();338339context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider('*', {340resolveDebugConfigurationWithSubstitutedVariables(_folder: vscode.WorkspaceFolder | undefined, debugConfiguration: vscode.DebugConfiguration) {341if (debugConfiguration.type && debugConfiguration.serverReadyAction) {342if (!trackers.has(debugConfiguration.type)) {343trackers.add(debugConfiguration.type);344startTrackerForType(context, debugConfiguration.type);345}346}347return debugConfiguration;348}349}));350}351352function startTrackerForType(context: vscode.ExtensionContext, type: string) {353354// scan debug console output for a PORT message355context.subscriptions.push(vscode.debug.registerDebugAdapterTrackerFactory(type, {356createDebugAdapterTracker(session: vscode.DebugSession) {357const detector = ServerReadyDetector.start(session);358if (detector) {359let runInTerminalRequestSeq: number | undefined;360return {361onDidSendMessage: m => {362if (m.type === 'event' && m.event === 'output' && m.body) {363switch (m.body.category) {364case 'console':365case 'stderr':366case 'stdout':367if (m.body.output) {368detector.detectPattern(m.body.output);369}370break;371default:372break;373}374}375if (m.type === 'request' && m.command === 'runInTerminal' && m.arguments) {376if (m.arguments.kind === 'integrated') {377runInTerminalRequestSeq = m.seq; // remember this to find matching response378}379}380},381onWillReceiveMessage: m => {382if (runInTerminalRequestSeq && m.type === 'response' && m.command === 'runInTerminal' && m.body && runInTerminalRequestSeq === m.request_seq) {383runInTerminalRequestSeq = undefined;384ServerReadyDetector.rememberShellPid(session, m.body.shellProcessId);385}386}387};388}389return undefined;390}391}));392}393394395