Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/debug-server-ready/src/extension.ts
3291 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as vscode from 'vscode';
7
import * as util from 'util';
8
import { randomUUID } from 'crypto';
9
10
const PATTERN = 'listening on.* (https?://\\S+|[0-9]+)'; // matches "listening on port 3000" or "Now listening on: https://localhost:5001"
11
const URI_PORT_FORMAT = 'http://localhost:%s';
12
const URI_FORMAT = '%s';
13
const WEB_ROOT = '${workspaceFolder}';
14
15
interface ServerReadyAction {
16
pattern: string;
17
action?: 'openExternally' | 'debugWithChrome' | 'debugWithEdge' | 'startDebugging';
18
uriFormat?: string;
19
webRoot?: string;
20
name?: string;
21
config?: vscode.DebugConfiguration;
22
killOnServerStop?: boolean;
23
}
24
25
// From src/vs/base/common/strings.ts
26
const CSI_SEQUENCE = /(?:\x1b\[|\x9b)[=?>!]?[\d;:]*["$#'* ]?[a-zA-Z@^`{}|~]/;
27
const OSC_SEQUENCE = /(?:\x1b\]|\x9d).*?(?:\x1b\\|\x07|\x9c)/;
28
const ESC_SEQUENCE = /\x1b(?:[ #%\(\)\*\+\-\.\/]?[a-zA-Z0-9\|}~@])/;
29
const CONTROL_SEQUENCES = new RegExp('(?:' + [
30
CSI_SEQUENCE.source,
31
OSC_SEQUENCE.source,
32
ESC_SEQUENCE.source,
33
].join('|') + ')', 'g');
34
35
/**
36
* Froms vs/base/common/strings.ts in core
37
* @see https://github.com/microsoft/vscode/blob/22a2a0e833175c32a2005b977d7fbd355582e416/src/vs/base/common/strings.ts#L736
38
*/
39
function removeAnsiEscapeCodes(str: string): string {
40
if (str) {
41
str = str.replace(CONTROL_SEQUENCES, '');
42
}
43
44
return str;
45
}
46
47
class Trigger {
48
private _fired = false;
49
50
public get hasFired() {
51
return this._fired;
52
}
53
54
public fire() {
55
this._fired = true;
56
}
57
}
58
59
class ServerReadyDetector extends vscode.Disposable {
60
61
private static detectors = new Map<vscode.DebugSession, ServerReadyDetector>();
62
private static terminalDataListener: vscode.Disposable | undefined;
63
64
private readonly stoppedEmitter = new vscode.EventEmitter<void>();
65
private readonly onDidSessionStop = this.stoppedEmitter.event;
66
private readonly disposables = new Set<vscode.Disposable>([]);
67
private trigger: Trigger;
68
private shellPid?: number;
69
private regexp: RegExp;
70
71
static start(session: vscode.DebugSession): ServerReadyDetector | undefined {
72
if (session.configuration.serverReadyAction) {
73
let detector = ServerReadyDetector.detectors.get(session);
74
if (!detector) {
75
detector = new ServerReadyDetector(session);
76
ServerReadyDetector.detectors.set(session, detector);
77
}
78
return detector;
79
}
80
return undefined;
81
}
82
83
static stop(session: vscode.DebugSession): void {
84
const detector = ServerReadyDetector.detectors.get(session);
85
if (detector) {
86
ServerReadyDetector.detectors.delete(session);
87
detector.sessionStopped();
88
detector.dispose();
89
}
90
}
91
92
static rememberShellPid(session: vscode.DebugSession, pid: number) {
93
const detector = ServerReadyDetector.detectors.get(session);
94
if (detector) {
95
detector.shellPid = pid;
96
}
97
}
98
99
static async startListeningTerminalData() {
100
if (!this.terminalDataListener) {
101
this.terminalDataListener = vscode.window.onDidWriteTerminalData(async e => {
102
103
// first find the detector with a matching pid
104
const pid = await e.terminal.processId;
105
const str = removeAnsiEscapeCodes(e.data);
106
for (const [, detector] of this.detectors) {
107
if (detector.shellPid === pid) {
108
detector.detectPattern(str);
109
return;
110
}
111
}
112
113
// if none found, try all detectors until one matches
114
for (const [, detector] of this.detectors) {
115
if (detector.detectPattern(str)) {
116
return;
117
}
118
}
119
});
120
}
121
}
122
123
private constructor(private session: vscode.DebugSession) {
124
super(() => this.internalDispose());
125
126
// Re-used the triggered of the parent session, if one exists
127
if (session.parentSession) {
128
this.trigger = ServerReadyDetector.start(session.parentSession)?.trigger ?? new Trigger();
129
} else {
130
this.trigger = new Trigger();
131
}
132
133
this.regexp = new RegExp(session.configuration.serverReadyAction.pattern || PATTERN, 'i');
134
}
135
136
private internalDispose() {
137
this.disposables.forEach(d => d.dispose());
138
this.disposables.clear();
139
}
140
141
public sessionStopped() {
142
this.stoppedEmitter.fire();
143
}
144
145
detectPattern(s: string): boolean {
146
if (!this.trigger.hasFired) {
147
const matches = this.regexp.exec(s);
148
if (matches && matches.length >= 1) {
149
this.openExternalWithString(this.session, matches.length > 1 ? matches[1] : '');
150
this.trigger.fire();
151
return true;
152
}
153
}
154
return false;
155
}
156
157
private openExternalWithString(session: vscode.DebugSession, captureString: string) {
158
const args: ServerReadyAction = session.configuration.serverReadyAction;
159
160
let uri;
161
if (captureString === '') {
162
// nothing captured by reg exp -> use the uriFormat as the target uri without substitution
163
// verify that format does not contain '%s'
164
const format = args.uriFormat || '';
165
if (format.indexOf('%s') >= 0) {
166
const errMsg = vscode.l10n.t("Format uri ('{0}') uses a substitution placeholder but pattern did not capture anything.", format);
167
vscode.window.showErrorMessage(errMsg, { modal: true }).then(_ => undefined);
168
return;
169
}
170
uri = format;
171
} else {
172
// if no uriFormat is specified guess the appropriate format based on the captureString
173
const format = args.uriFormat || (/^[0-9]+$/.test(captureString) ? URI_PORT_FORMAT : URI_FORMAT);
174
// verify that format only contains a single '%s'
175
const s = format.split('%s');
176
if (s.length !== 2) {
177
const errMsg = vscode.l10n.t("Format uri ('{0}') must contain exactly one substitution placeholder.", format);
178
vscode.window.showErrorMessage(errMsg, { modal: true }).then(_ => undefined);
179
return;
180
}
181
uri = util.format(format, captureString);
182
}
183
184
this.openExternalWithUri(session, uri);
185
}
186
187
private async openExternalWithUri(session: vscode.DebugSession, uri: string) {
188
189
const args: ServerReadyAction = session.configuration.serverReadyAction;
190
switch (args.action || 'openExternally') {
191
192
case 'openExternally':
193
await vscode.env.openExternal(vscode.Uri.parse(uri));
194
break;
195
196
case 'debugWithChrome':
197
await this.debugWithBrowser('pwa-chrome', session, uri);
198
break;
199
200
case 'debugWithEdge':
201
await this.debugWithBrowser('pwa-msedge', session, uri);
202
break;
203
204
case 'startDebugging':
205
if (args.config) {
206
await this.startDebugSession(session, args.config.name, args.config);
207
} else {
208
await this.startDebugSession(session, args.name || 'unspecified');
209
}
210
break;
211
212
default:
213
// not supported
214
break;
215
}
216
}
217
218
private async debugWithBrowser(type: string, session: vscode.DebugSession, uri: string) {
219
const args = session.configuration.serverReadyAction as ServerReadyAction;
220
if (!args.killOnServerStop) {
221
await this.startBrowserDebugSession(type, session, uri);
222
return;
223
}
224
225
const trackerId = randomUUID();
226
const cts = new vscode.CancellationTokenSource();
227
const newSessionPromise = this.catchStartedDebugSession(session => session.configuration._debugServerReadySessionId === trackerId, cts.token);
228
229
if (!await this.startBrowserDebugSession(type, session, uri, trackerId)) {
230
cts.cancel();
231
cts.dispose();
232
return;
233
}
234
235
const createdSession = await newSessionPromise;
236
cts.dispose();
237
238
if (!createdSession) {
239
return;
240
}
241
242
const stopListener = this.onDidSessionStop(async () => {
243
stopListener.dispose();
244
this.disposables.delete(stopListener);
245
await vscode.debug.stopDebugging(createdSession);
246
});
247
this.disposables.add(stopListener);
248
}
249
250
private startBrowserDebugSession(type: string, session: vscode.DebugSession, uri: string, trackerId?: string) {
251
return vscode.debug.startDebugging(session.workspaceFolder, {
252
type,
253
name: 'Browser Debug',
254
request: 'launch',
255
url: uri,
256
webRoot: session.configuration.serverReadyAction.webRoot || WEB_ROOT,
257
_debugServerReadySessionId: trackerId,
258
});
259
}
260
261
/**
262
* Starts a debug session given a debug configuration name (saved in launch.json) or a debug configuration object.
263
*
264
* @param session The parent debugSession
265
* @param name The name of the configuration to launch. If config it set, it assumes it is the same as config.name.
266
* @param config [Optional] Instead of starting a debug session by debug configuration name, use a debug configuration object instead.
267
*/
268
private async startDebugSession(session: vscode.DebugSession, name: string, config?: vscode.DebugConfiguration) {
269
const args = session.configuration.serverReadyAction as ServerReadyAction;
270
if (!args.killOnServerStop) {
271
await vscode.debug.startDebugging(session.workspaceFolder, config ?? name);
272
return;
273
}
274
275
const cts = new vscode.CancellationTokenSource();
276
const newSessionPromise = this.catchStartedDebugSession(x => x.name === name, cts.token);
277
278
if (!await vscode.debug.startDebugging(session.workspaceFolder, config ?? name)) {
279
cts.cancel();
280
cts.dispose();
281
return;
282
}
283
284
const createdSession = await newSessionPromise;
285
cts.dispose();
286
287
if (!createdSession) {
288
return;
289
}
290
291
const stopListener = this.onDidSessionStop(async () => {
292
stopListener.dispose();
293
this.disposables.delete(stopListener);
294
await vscode.debug.stopDebugging(createdSession);
295
});
296
this.disposables.add(stopListener);
297
}
298
299
private catchStartedDebugSession(predicate: (session: vscode.DebugSession) => boolean, cancellationToken: vscode.CancellationToken): Promise<vscode.DebugSession | undefined> {
300
return new Promise<vscode.DebugSession | undefined>(_resolve => {
301
const done = (value?: vscode.DebugSession) => {
302
listener.dispose();
303
cancellationListener.dispose();
304
this.disposables.delete(listener);
305
this.disposables.delete(cancellationListener);
306
_resolve(value);
307
};
308
309
const cancellationListener = cancellationToken.onCancellationRequested(done);
310
const listener = vscode.debug.onDidStartDebugSession(session => {
311
if (predicate(session)) {
312
done(session);
313
}
314
});
315
316
// In case the debug session of interest was never caught anyhow.
317
this.disposables.add(listener);
318
this.disposables.add(cancellationListener);
319
});
320
}
321
}
322
323
export function activate(context: vscode.ExtensionContext) {
324
325
context.subscriptions.push(vscode.debug.onDidStartDebugSession(session => {
326
if (session.configuration.serverReadyAction) {
327
const detector = ServerReadyDetector.start(session);
328
if (detector) {
329
ServerReadyDetector.startListeningTerminalData();
330
}
331
}
332
}));
333
334
context.subscriptions.push(vscode.debug.onDidTerminateDebugSession(session => {
335
ServerReadyDetector.stop(session);
336
}));
337
338
const trackers = new Set<string>();
339
340
context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider('*', {
341
resolveDebugConfigurationWithSubstitutedVariables(_folder: vscode.WorkspaceFolder | undefined, debugConfiguration: vscode.DebugConfiguration) {
342
if (debugConfiguration.type && debugConfiguration.serverReadyAction) {
343
if (!trackers.has(debugConfiguration.type)) {
344
trackers.add(debugConfiguration.type);
345
startTrackerForType(context, debugConfiguration.type);
346
}
347
}
348
return debugConfiguration;
349
}
350
}));
351
}
352
353
function startTrackerForType(context: vscode.ExtensionContext, type: string) {
354
355
// scan debug console output for a PORT message
356
context.subscriptions.push(vscode.debug.registerDebugAdapterTrackerFactory(type, {
357
createDebugAdapterTracker(session: vscode.DebugSession) {
358
const detector = ServerReadyDetector.start(session);
359
if (detector) {
360
let runInTerminalRequestSeq: number | undefined;
361
return {
362
onDidSendMessage: m => {
363
if (m.type === 'event' && m.event === 'output' && m.body) {
364
switch (m.body.category) {
365
case 'console':
366
case 'stderr':
367
case 'stdout':
368
if (m.body.output) {
369
detector.detectPattern(m.body.output);
370
}
371
break;
372
default:
373
break;
374
}
375
}
376
if (m.type === 'request' && m.command === 'runInTerminal' && m.arguments) {
377
if (m.arguments.kind === 'integrated') {
378
runInTerminalRequestSeq = m.seq; // remember this to find matching response
379
}
380
}
381
},
382
onWillReceiveMessage: m => {
383
if (runInTerminalRequestSeq && m.type === 'response' && m.command === 'runInTerminal' && m.body && runInTerminalRequestSeq === m.request_seq) {
384
runInTerminalRequestSeq = undefined;
385
ServerReadyDetector.rememberShellPid(session, m.body.shellProcessId);
386
}
387
}
388
};
389
}
390
return undefined;
391
}
392
}));
393
}
394
395