Bootstrap Playwright service (#295261)

* Bootstrap Playwright service

* feedback
This commit is contained in:
Kyle Cutler
2026-02-16 15:19:57 -08:00
committed by GitHub
parent 33dc3c2956
commit 06c5122892
13 changed files with 413 additions and 1 deletions

26
package-lock.json generated
View File

@@ -50,6 +50,7 @@
"native-keymap": "^3.3.5",
"node-pty": "^1.2.0-beta.10",
"open": "^10.1.2",
"playwright-core": "^1.58.2",
"tas-client": "0.3.1",
"undici": "^7.18.2",
"v8-inspect-profiler": "^0.1.1",
@@ -2044,6 +2045,19 @@
"node": ">=18"
}
},
"node_modules/@playwright/browser-chromium/node_modules/playwright-core": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@playwright/test": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
@@ -13958,6 +13972,18 @@
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/playwright-core": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",

View File

@@ -115,6 +115,7 @@
"native-keymap": "^3.3.5",
"node-pty": "^1.2.0-beta.10",
"open": "^10.1.2",
"playwright-core": "^1.58.2",
"tas-client": "0.3.1",
"undici": "^7.18.2",
"v8-inspect-profiler": "^0.1.1",

View File

@@ -38,6 +38,7 @@ import { EncryptionMainService } from '../../platform/encryption/electron-main/e
import { NativeBrowserElementsMainService, INativeBrowserElementsMainService } from '../../platform/browserElements/electron-main/nativeBrowserElementsMainService.js';
import { ipcBrowserViewChannelName } from '../../platform/browserView/common/browserView.js';
import { BrowserViewMainService, IBrowserViewMainService } from '../../platform/browserView/electron-main/browserViewMainService.js';
import { BrowserViewCDPProxyServer, IBrowserViewCDPProxyServer } from '../../platform/browserView/electron-main/browserViewCDPProxyServer.js';
import { NativeParsedArgs } from '../../platform/environment/common/argv.js';
import { IEnvironmentMainService } from '../../platform/environment/electron-main/environmentMainService.js';
import { isLaunchedFromCli } from '../../platform/environment/node/argvHelper.js';
@@ -1040,6 +1041,7 @@ export class CodeApplication extends Disposable {
services.set(INativeBrowserElementsMainService, new SyncDescriptor(NativeBrowserElementsMainService, undefined, false /* proxied to other processes */));
// Browser View
services.set(IBrowserViewCDPProxyServer, new SyncDescriptor(BrowserViewCDPProxyServer, undefined, true));
services.set(IBrowserViewMainService, new SyncDescriptor(BrowserViewMainService, undefined, false /* proxied to other processes */));
// Keyboard Layout
@@ -1202,6 +1204,7 @@ export class CodeApplication extends Disposable {
// Browser View
const browserViewChannel = ProxyChannel.fromService(accessor.get(IBrowserViewMainService), disposables);
mainProcessElectronServer.registerChannel(ipcBrowserViewChannelName, browserViewChannel);
sharedProcessClient.then(client => client.registerChannel(ipcBrowserViewChannelName, browserViewChannel));
// Signing
const signChannel = ProxyChannel.fromService(accessor.get(ISignService), disposables);

View File

@@ -134,6 +134,8 @@ import { IMcpGalleryManifestService } from '../../../platform/mcp/common/mcpGall
import { McpGalleryManifestIPCService } from '../../../platform/mcp/common/mcpGalleryManifestServiceIpc.js';
import { IMeteredConnectionService } from '../../../platform/meteredConnection/common/meteredConnection.js';
import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js';
import { IPlaywrightService } from '../../../platform/browserView/common/playwrightService.js';
import { PlaywrightService } from '../../../platform/browserView/node/playwrightService.js';
class SharedProcessMain extends Disposable implements IClientConnectionFilter {
@@ -401,6 +403,9 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter {
// Web Content Extractor
services.set(ISharedWebContentExtractorService, new SyncDescriptor(SharedWebContentExtractorService));
// Playwright
services.set(IPlaywrightService, new SyncDescriptor(PlaywrightService));
return new InstantiationService(services);
}
@@ -467,6 +472,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter {
// Web Content Extractor
const webContentExtractorChannel = ProxyChannel.fromService(accessor.get(ISharedWebContentExtractorService), this._store);
this.server.registerChannel('sharedWebContentExtractor', webContentExtractorChannel);
// Playwright
const playwrightChannel = ProxyChannel.fromService(accessor.get(IPlaywrightService), this._store);
this.server.registerChannel('playwright', playwrightChannel);
}
private registerErrorHandler(logService: ILogService): void {

View File

@@ -275,4 +275,9 @@ export interface IBrowserViewService {
* @param id The browser view identifier
*/
clearStorage(id: string): Promise<void>;
/**
* Get a CDP WebSocket endpoint URL.
*/
getDebugWebSocketEndpoint(): Promise<string>;
}

View File

@@ -0,0 +1,18 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { createDecorator } from '../../instantiation/common/instantiation.js';
export const IPlaywrightService = createDecorator<IPlaywrightService>('playwrightService');
/**
* A service for using Playwright to connect to and automate the integrated browser.
*/
export interface IPlaywrightService {
readonly _serviceBrand: undefined;
// TODO@kycutler: define a more specific API.
initialize(): Promise<void>;
}

View File

@@ -0,0 +1,238 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
import { ILogService } from '../../log/common/log.js';
import type * as http from 'http';
import { AddressInfo, Socket } from 'net';
import { upgradeToISocket } from '../../../base/parts/ipc/node/ipc.net.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { VSBuffer } from '../../../base/common/buffer.js';
import { CDPBrowserProxy } from '../common/cdp/proxy.js';
import { CDPEvent, CDPRequest, CDPError, CDPErrorCode, ICDPBrowserTarget, ICDPConnection } from '../common/cdp/types.js';
import { disposableTimeout } from '../../../base/common/async.js';
import { ISocket } from '../../../base/parts/ipc/common/ipc.net.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
export const IBrowserViewCDPProxyServer = createDecorator<IBrowserViewCDPProxyServer>('browserViewCDPProxyServer');
export interface IBrowserViewCDPProxyServer {
readonly _serviceBrand: undefined;
/**
* Returns a debug endpoint with a short-lived, single-use token.
*/
getWebSocketEndpoint(): Promise<string>;
}
/**
* WebSocket server that provides CDP debugging for browser views.
*/
export class BrowserViewCDPProxyServer extends Disposable implements IBrowserViewCDPProxyServer {
declare readonly _serviceBrand: undefined;
private server: http.Server | undefined;
private port: number | undefined;
private readonly tokens: TokenManager;
constructor(
private readonly browserTarget: ICDPBrowserTarget,
@ILogService private readonly logService: ILogService
) {
super();
this.tokens = this._register(new TokenManager());
}
/**
* Returns a debug endpoint with a short-lived, single-use token in the
* WebSocket URL. The token is revoked once a WebSocket connection is made
* or after 30 seconds, whichever comes first.
*/
async getWebSocketEndpoint(): Promise<string> {
await this.ensureServerStarted();
const token = await this.tokens.issueToken();
return this.getWebSocketUrl(token);
}
private getWebSocketUrl(token: string): string {
return `ws://localhost:${this.port}/devtools/browser?token=${token}`;
}
private async ensureServerStarted(): Promise<void> {
if (this.server) {
return;
}
const http = await import('http');
this.server = http.createServer();
await new Promise<void>((resolve, reject) => {
// Only listen on localhost to prevent external access
this.server!.listen(0, '127.0.0.1', () => resolve());
this.server!.once('error', reject);
});
const address = this.server.address() as AddressInfo;
this.port = address.port;
this.server.on('request', (req, res) => this.handleHttpRequest(req, res));
this.server.on('upgrade', (req: http.IncomingMessage, socket: Socket) => this.handleWebSocketUpgrade(req, socket));
}
private async handleHttpRequest(_req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
this.logService.debug(`[BrowserViewDebugProxy] HTTP request at ${_req.url}`);
// No support for HTTP endpoints for now.
res.writeHead(404);
res.end();
}
private handleWebSocketUpgrade(req: http.IncomingMessage, socket: Socket): void {
const [pathname, params] = (req.url || '').split('?');
const token = new URLSearchParams(params).get('token');
if (!token || !this.tokens.consumeToken(token)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.end();
return;
}
const browserMatch = pathname.match(/^\/devtools\/browser(\/.*)?$/);
this.logService.debug(`[BrowserViewDebugProxy] WebSocket upgrade requested: ${pathname}`);
if (!browserMatch) {
this.logService.warn(`[BrowserViewDebugProxy] Rejecting WebSocket on unknown path: ${pathname}`);
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
socket.end();
return;
}
this.logService.debug(`[BrowserViewDebugProxy] WebSocket connected: ${pathname}`);
const upgraded = upgradeToISocket(req, socket, {
debugLabel: 'browser-view-cdp-' + generateUuid(),
enableMessageSplitting: false,
});
if (!upgraded) {
return;
}
const proxy = new CDPBrowserProxy(this.browserTarget);
const disposables = this.wireWebSocket(upgraded, proxy);
this._register(disposables);
this._register(upgraded);
}
/**
* Wire a WebSocket (ISocket) to an ICDPConnection bidirectionally.
* Returns a DisposableStore that cleans up all subscriptions.
*/
private wireWebSocket(upgraded: ISocket, connection: ICDPConnection): DisposableStore {
const disposables = new DisposableStore();
// Socket -> Connection: parse JSON, call sendMessage, write response/error
disposables.add(upgraded.onData((rawData: VSBuffer) => {
try {
const message = rawData.toString();
const { id, method, params, sessionId } = JSON.parse(message) as CDPRequest;
this.logService.debug(`[BrowserViewDebugProxy] <- ${message}`);
connection.sendMessage(method, params, sessionId)
.then((result: unknown) => {
const response = { id, result, sessionId };
const responseStr = JSON.stringify(response);
this.logService.debug(`[BrowserViewDebugProxy] -> ${responseStr}`);
upgraded.write(VSBuffer.fromString(responseStr));
})
.catch((error: Error) => {
const response = {
id,
error: {
code: error instanceof CDPError ? error.code : CDPErrorCode.ServerError,
message: error.message || 'Unknown error'
},
sessionId
};
const responseStr = JSON.stringify(response);
this.logService.debug(`[BrowserViewDebugProxy] -> ${responseStr}`);
upgraded.write(VSBuffer.fromString(responseStr));
});
} catch (error) {
this.logService.error('[BrowserViewDebugProxy] Error parsing message:', error);
upgraded.end();
}
}));
// Connection -> Socket: serialize events and write
disposables.add(connection.onEvent((event: CDPEvent) => {
const eventStr = JSON.stringify(event);
this.logService.debug(`[BrowserViewDebugProxy] -> ${eventStr}`);
upgraded.write(VSBuffer.fromString(eventStr));
}));
// Connection close -> close socket
disposables.add(connection.onClose(() => {
this.logService.debug(`[BrowserViewDebugProxy] WebSocket closing`);
upgraded.end();
}));
// Socket closed -> cleanup
disposables.add(upgraded.onClose(() => {
this.logService.debug(`[BrowserViewDebugProxy] WebSocket closed`);
connection.dispose();
disposables.dispose();
}));
return disposables;
}
override dispose(): void {
if (this.server) {
this.server.close();
this.server = undefined;
}
super.dispose();
}
}
class TokenManager extends Disposable {
/** Map of currently valid single-use tokens. Each expires after 30 seconds. */
private readonly tokens = new Map<string, { expiresAt: number }>();
/**
* Creates a short-lived, single-use token.
* The token is revoked once consumed or after 30 seconds.
*/
async issueToken(): Promise<string> {
const token = this.makeToken();
this.tokens.set(token, { expiresAt: Date.now() + 30_000 });
this._register(disposableTimeout(() => this.tokens.delete(token), 30_000));
return token;
}
consumeToken(token: string): boolean {
if (!token) {
return false;
}
const info = this.tokens.get(token);
if (!info) {
return false;
}
this.tokens.delete(token);
return Date.now() <= info.expiresAt;
}
private makeToken(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join('');
const base64 = btoa(binary);
const urlSafeToken = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
return urlSafeToken;
}
}

View File

@@ -15,12 +15,15 @@ import { generateUuid } from '../../../base/common/uuid.js';
import { BrowserViewUri } from '../common/browserViewUri.js';
import { IWindowsMainService } from '../../windows/electron-main/windows.js';
import { BrowserSession } from './browserSession.js';
import { IBrowserViewCDPProxyServer } from './browserViewCDPProxyServer.js';
import { IProductService } from '../../product/common/productService.js';
import { CDPBrowserProxy } from '../common/cdp/proxy.js';
export const IBrowserViewMainService = createDecorator<IBrowserViewMainService>('browserViewMainService');
export interface IBrowserViewMainService extends IBrowserViewService, ICDPBrowserTarget {
readonly _serviceBrand: undefined;
tryGetBrowserView(id: string): BrowserView | undefined;
}
@@ -48,7 +51,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa
@IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IWindowsMainService private readonly windowsMainService: IWindowsMainService,
@IProductService private readonly productService: IProductService
@IProductService private readonly productService: IProductService,
@IBrowserViewCDPProxyServer private readonly cdpProxyServer: IBrowserViewCDPProxyServer,
) {
super();
}
@@ -359,4 +363,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa
);
await browserSession.electronSession.clearData();
}
async getDebugWebSocketEndpoint(): Promise<string> {
return this.cdpProxyServer.getWebSocketEndpoint();
}
}

View File

@@ -0,0 +1,85 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable } from '../../../base/common/lifecycle.js';
import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js';
import { ILogService } from '../../log/common/log.js';
import { IBrowserViewService, ipcBrowserViewChannelName } from '../common/browserView.js';
import { IPlaywrightService } from '../common/playwrightService.js';
import { IMainProcessService } from '../../ipc/common/mainProcessService.js';
// eslint-disable-next-line local/code-import-patterns
import type { Browser } from 'playwright-core';
/**
* Shared-process implementation of {@link IPlaywrightService}.
*/
export class PlaywrightService extends Disposable implements IPlaywrightService {
declare readonly _serviceBrand: undefined;
private readonly browserViewService: IBrowserViewService;
private _browser: Browser | undefined;
private _initPromise: Promise<void> | undefined;
constructor(
@IMainProcessService mainProcessService: IMainProcessService,
@ILogService private readonly logService: ILogService,
) {
super();
const channel = mainProcessService.getChannel(ipcBrowserViewChannelName);
this.browserViewService = ProxyChannel.toService<IBrowserViewService>(channel);
}
async initialize(): Promise<void> {
if (this._browser?.isConnected()) {
return;
}
if (this._initPromise) {
return this._initPromise;
}
this._initPromise = (async () => {
try {
this.logService.debug('[PlaywrightService] Connecting to browser via CDP');
const playwright = await import('playwright-core');
const endpoint = await this.browserViewService.getDebugWebSocketEndpoint();
const browser = await playwright.chromium.connectOverCDP(endpoint);
this.logService.debug('[PlaywrightService] Connected to browser');
browser.on('disconnected', () => {
this.logService.debug('[PlaywrightService] Browser disconnected');
if (this._browser === browser) {
this._browser = undefined;
}
});
// This can happen if the service was disposed while we were waiting for the connection. In that case, clean up immediately.
if (this._initPromise === undefined) {
browser.close().catch(() => { /* ignore */ });
throw new Error('PlaywrightService was disposed during initialization');
}
this._browser = browser;
} finally {
this._initPromise = undefined;
}
})();
return this._initPromise;
}
override dispose(): void {
if (this._browser) {
this._browser.close().catch(() => { /* ignore */ });
this._browser = undefined;
}
this._initPromise = undefined;
super.dispose();
}
}

View File

@@ -68,6 +68,11 @@ export interface IBrowserViewWorkbenchService {
* Clear all storage data for the current workspace browser session
*/
clearWorkspaceStorage(): Promise<void>;
/**
* Get the endpoint for connecting to a browser view's CDP proxy server
*/
getDebugWebSocketEndpoint(): Promise<string>;
}

View File

@@ -54,4 +54,8 @@ export class BrowserViewWorkbenchService implements IBrowserViewWorkbenchService
const workspaceId = this.workspaceContextService.getWorkspace().id;
return this._browserViewService.clearWorkspaceStorage(workspaceId);
}
async getDebugWebSocketEndpoint() {
return this._browserViewService.getDebugWebSocketEndpoint();
}
}

View File

@@ -0,0 +1,9 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js';
import { registerSharedProcessRemoteService } from '../../../../platform/ipc/electron-browser/services.js';
registerSharedProcessRemoteService(IPlaywrightService, 'playwright');

View File

@@ -91,6 +91,7 @@ import '../platform/userDataProfile/electron-browser/userDataProfileStorageServi
import './services/auxiliaryWindow/electron-browser/auxiliaryWindowService.js';
import '../platform/extensionManagement/electron-browser/extensionsProfileScannerService.js';
import '../platform/webContentExtractor/electron-browser/webContentExtractorService.js';
import './services/browserView/electron-browser/playwrightWorkbenchService.js';
import './services/process/electron-browser/processService.js';
import './services/power/electron-browser/powerService.js';