chore: simplify remote connection protocol (#6164)

This changes the root object from RemoteBrowser to Playwright,
similar to local driver connection. This way, any remote connection
gets a Playwright object.

This also starts reusing PlaywrightServer class, and introduces
`cli run-server` hidden command that runs ws server on the
specified port.

Previous structure:
```
RemoteBrowser
  - browser (using ConnectedBrowser for remote-specific behavior)
  - selectors (special instance for this remote connection)
```

New structure:
```
Playwright
  - ...
  - selectors (special instance for this remote connection)
  - preLaunchedBrowser (using ConnectedBrowser for remote-specific behavior)
```
This commit is contained in:
Dmitry Gozman 2021-04-12 11:14:54 -07:00 committed by GitHub
parent c4c9809f85
commit fff1f3d45c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 154 additions and 198 deletions

View File

@ -16,11 +16,9 @@
import { LaunchServerOptions, Logger } from './client/types';
import { BrowserType } from './server/browserType';
import * as ws from 'ws';
import { Browser } from './server/browser';
import { ChildProcess } from 'child_process';
import { EventEmitter } from 'ws';
import { Dispatcher, DispatcherScope, DispatcherConnection } from './dispatchers/dispatcher';
import { DispatcherScope } from './dispatchers/dispatcher';
import { BrowserDispatcher } from './dispatchers/browserDispatcher';
import { BrowserContextDispatcher } from './dispatchers/browserContextDispatcher';
import * as channels from './protocol/channels';
@ -30,123 +28,69 @@ import { createGuid } from './utils/utils';
import { SelectorsDispatcher } from './dispatchers/selectorsDispatcher';
import { Selectors } from './server/selectors';
import { ProtocolLogger } from './server/types';
import { CallMetadata, internalCallMetadata, SdkObject } from './server/instrumentation';
import { CallMetadata, internalCallMetadata } from './server/instrumentation';
import { Playwright } from './server/playwright';
import { PlaywrightDispatcher } from './dispatchers/playwrightDispatcher';
import { PlaywrightServer, PlaywrightServerDelegate } from './remote/playwrightServer';
export class BrowserServerLauncherImpl implements BrowserServerLauncher {
private _playwright: Playwright;
private _browserType: BrowserType;
constructor(browserType: BrowserType) {
constructor(playwright: Playwright, browserType: BrowserType) {
this._playwright = playwright;
this._browserType = browserType;
}
async launchServer(options: LaunchServerOptions = {}): Promise<BrowserServerImpl> {
async launchServer(options: LaunchServerOptions = {}): Promise<BrowserServer> {
// 1. Pre-launch the browser
const browser = await this._browserType.launch(internalCallMetadata(), {
...options,
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined,
ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs),
env: options.env ? envObjectToArray(options.env) : undefined,
}, toProtocolLogger(options.logger));
return BrowserServerImpl.start(browser, options.port);
}
}
export class BrowserServerImpl extends EventEmitter implements BrowserServer {
private _server: ws.Server;
private _browser: Browser;
private _wsEndpoint: string;
private _process: ChildProcess;
private _ready: Promise<void>;
static async start(browser: Browser, port: number = 0): Promise<BrowserServerImpl> {
const server = new BrowserServerImpl(browser, port);
await server._ready;
return server;
}
constructor(browser: Browser, port: number) {
super();
this._browser = browser;
this._wsEndpoint = '';
this._process = browser.options.browserProcess.process!;
let readyCallback = () => {};
this._ready = new Promise<void>(f => readyCallback = f);
const token = createGuid();
this._server = new ws.Server({ port, path: '/' + token }, () => {
const address = this._server.address();
this._wsEndpoint = typeof address === 'string' ? `${address}/${token}` : `ws://127.0.0.1:${address.port}/${token}`;
readyCallback();
});
this._server.on('connection', (socket: ws, req) => {
this._clientAttached(socket);
});
browser.options.browserProcess.onclose = (exitCode, signal) => {
this._server.close();
this.emit('close', exitCode, signal);
// 2. Start the server
const delegate: PlaywrightServerDelegate = {
path: '/' + createGuid(),
allowMultipleClients: true,
onClose: () => {},
onConnect: this._onConnect.bind(this, browser),
};
}
const server = new PlaywrightServer(delegate);
const wsEndpoint = await server.listen(options.port);
process(): ChildProcess {
return this._process;
}
wsEndpoint(): string {
return this._wsEndpoint;
}
async close(): Promise<void> {
await this._browser.options.browserProcess.close();
}
async kill(): Promise<void> {
await this._browser.options.browserProcess.kill();
}
private _clientAttached(socket: ws) {
const connection = new DispatcherConnection();
connection.onmessage = message => {
if (socket.readyState !== ws.CLOSING)
socket.send(JSON.stringify(message));
// 3. Return the BrowserServer interface
const browserServer = new EventEmitter() as (BrowserServer & EventEmitter);
browserServer.process = () => browser.options.browserProcess.process!;
browserServer.wsEndpoint = () => wsEndpoint;
browserServer.close = () => browser.options.browserProcess.close();
browserServer.kill = () => browser.options.browserProcess.kill();
browser.options.browserProcess.onclose = async (exitCode, signal) => {
server.close();
browserServer.emit('close', exitCode, signal);
};
socket.on('message', (message: string) => {
connection.dispatch(JSON.parse(Buffer.from(message).toString()));
});
socket.on('error', () => {});
return browserServer;
}
private _onConnect(browser: Browser, scope: DispatcherScope) {
const selectors = new Selectors();
const scope = connection.rootDispatcher();
const remoteBrowser = new RemoteBrowserDispatcher(scope, this._browser, selectors);
socket.on('close', () => {
// Avoid sending any more messages over closed socket.
connection.onmessage = () => {};
const selectorsDispatcher = new SelectorsDispatcher(scope, selectors);
const browserDispatcher = new ConnectedBrowser(scope, browser, selectors);
new PlaywrightDispatcher(scope, this._playwright, selectorsDispatcher, browserDispatcher);
return () => {
// Cleanup contexts upon disconnect.
remoteBrowser.connectedBrowser.close().catch(e => {});
});
}
}
class RemoteBrowserDispatcher extends Dispatcher<SdkObject, channels.RemoteBrowserInitializer> implements channels.PlaywrightChannel {
readonly connectedBrowser: ConnectedBrowser;
constructor(scope: DispatcherScope, browser: Browser, selectors: Selectors) {
const connectedBrowser = new ConnectedBrowser(scope, browser, selectors);
super(scope, browser, 'RemoteBrowser', {
selectors: new SelectorsDispatcher(scope, selectors),
browser: connectedBrowser,
}, false, 'remoteBrowser');
this.connectedBrowser = connectedBrowser;
connectedBrowser._remoteBrowser = this;
browserDispatcher.close().catch(e => {});
};
}
}
// This class implements multiplexing multiple BrowserDispatchers over a single Browser instance.
class ConnectedBrowser extends BrowserDispatcher {
private _contexts: BrowserContextDispatcher[] = [];
private _selectors: Selectors;
_closed = false;
_remoteBrowser?: RemoteBrowserDispatcher;
private _closed = false;
constructor(scope: DispatcherScope, browser: Browser, selectors: Selectors) {
super(scope, browser);

View File

@ -22,7 +22,7 @@ import path from 'path';
import program from 'commander';
import os from 'os';
import fs from 'fs';
import { runServer, printApiJson, launchBrowserServer, installBrowsers } from './driver';
import { runDriver, runServer, printApiJson, launchBrowserServer, installBrowsers } from './driver';
import { showTraceViewer } from '../server/trace/viewer/traceViewer';
import * as playwright from '../..';
import { BrowserContext } from '../client/browserContext';
@ -176,7 +176,9 @@ if (process.env.PWTRACE) {
}
if (process.argv[2] === 'run-driver')
runServer();
runDriver();
else if (process.argv[2] === 'run-server')
runServer(process.argv[3] ? +process.argv[3] : undefined);
else if (process.argv[2] === 'print-api-json')
printApiJson();
else if (process.argv[2] === 'launch-server')

View File

@ -25,6 +25,7 @@ import { DispatcherConnection } from '../dispatchers/dispatcher';
import { PlaywrightDispatcher } from '../dispatchers/playwrightDispatcher';
import { installBrowsersWithProgressBar } from '../install/installer';
import { Transport } from '../protocol/transport';
import { PlaywrightServer } from '../remote/playwrightServer';
import { createPlaywright } from '../server/playwright';
import { gracefullyCloseAll } from '../server/processLauncher';
import { BrowserName } from '../utils/registry';
@ -37,7 +38,7 @@ export function printProtocol() {
console.log(fs.readFileSync(path.join(__dirname, '..', '..', 'protocol.yml'), 'utf8'));
}
export function runServer() {
export function runDriver() {
const dispatcherConnection = new DispatcherConnection();
const transport = new Transport(process.stdout, process.stdin);
transport.onmessage = message => dispatcherConnection.dispatch(JSON.parse(message));
@ -56,6 +57,11 @@ export function runServer() {
new PlaywrightDispatcher(dispatcherConnection.rootDispatcher(), playwright);
}
export async function runServer(port: number | undefined) {
const wsEndpoint = await PlaywrightServer.startDefault(port);
console.log('Listening on ' + wsEndpoint); // eslint-disable-line no-console
}
export async function launchBrowserServer(browserName: string, configFile?: string) {
let options: LaunchServerOptions = {};
if (configFile)

View File

@ -20,19 +20,16 @@ import { BrowserContext, prepareBrowserContextParams } from './browserContext';
import { ChannelOwner } from './channelOwner';
import { LaunchOptions, LaunchServerOptions, ConnectOptions, LaunchPersistentContextOptions } from './types';
import WebSocket from 'ws';
import path from 'path';
import fs from 'fs';
import { Connection } from './connection';
import { serializeError } from '../protocol/serializers';
import { Events } from './events';
import { TimeoutSettings } from '../utils/timeoutSettings';
import { ChildProcess } from 'child_process';
import { envObjectToArray } from './clientHelper';
import { assert, makeWaitForNextTask, mkdirIfNeeded } from '../utils/utils';
import { SelectorsOwner, sharedSelectors } from './selectors';
import { assert, makeWaitForNextTask } from '../utils/utils';
import { kBrowserClosedError } from '../utils/errors';
import { Stream } from './stream';
import * as api from '../../types/types';
import type { Playwright } from './playwright';
export interface BrowserServerLauncher {
launchServer(options?: LaunchServerOptions): Promise<api.BrowserServer>;
@ -152,13 +149,15 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
reject(new Error('Server disconnected: ' + event.reason));
};
ws.addEventListener('close', prematureCloseListener);
const remoteBrowser = await connection.waitForObjectWithKnownName('remoteBrowser') as RemoteBrowser;
const playwright = await connection.waitForObjectWithKnownName('Playwright') as Playwright;
// Inherit shared selectors for connected browser.
const selectorsOwner = SelectorsOwner.from(remoteBrowser._initializer.selectors);
sharedSelectors._addChannel(selectorsOwner);
if (!playwright._initializer.preLaunchedBrowser) {
reject(new Error('Malformed endpoint. Did you use launchServer method?'));
ws.close();
return;
}
const browser = Browser.from(remoteBrowser._initializer.browser);
const browser = Browser.from(playwright._initializer.preLaunchedBrowser!);
browser._logger = logger;
browser._isRemote = true;
const closeListener = () => {
@ -173,7 +172,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
ws.removeEventListener('close', prematureCloseListener);
ws.addEventListener('close', closeListener);
browser.on(Events.Browser.Disconnected, () => {
sharedSelectors._removeChannel(selectorsOwner);
playwright._cleanup();
ws.removeEventListener('close', closeListener);
ws.close();
});
@ -209,16 +208,3 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
}, logger);
}
}
export class RemoteBrowser extends ChannelOwner<channels.RemoteBrowserChannel, channels.RemoteBrowserInitializer> {
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.RemoteBrowserInitializer) {
super(parent, type, guid, initializer);
this._channel.on('video', ({ context, stream, relativePath }) => this._onVideo(BrowserContext.from(context), Stream.from(stream), relativePath));
}
private async _onVideo(context: BrowserContext, stream: Stream, relativePath: string) {
const videoFile = path.join(context._options.recordVideo!.dir, relativePath);
await mkdirIfNeeded(videoFile);
stream.stream().pipe(fs.createWriteStream(videoFile));
}
}

View File

@ -16,7 +16,7 @@
import { Browser } from './browser';
import { BrowserContext } from './browserContext';
import { BrowserType, RemoteBrowser } from './browserType';
import { BrowserType } from './browserType';
import { ChannelOwner } from './channelOwner';
import { ElementHandle } from './elementHandle';
import { Frame } from './frame';
@ -197,9 +197,6 @@ export class Connection {
case 'Playwright':
result = new Playwright(parent, type, guid, initializer);
break;
case 'RemoteBrowser':
result = new RemoteBrowser(parent, type, guid, initializer);
break;
case 'Request':
result = new Request(parent, type, guid, initializer);
break;

View File

@ -42,6 +42,7 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel, channel
readonly devices: Devices;
readonly selectors: Selectors;
readonly errors: { TimeoutError: typeof TimeoutError };
private _selectorsOwner: SelectorsOwner;
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) {
super(parent, type, guid, initializer);
@ -55,6 +56,12 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel, channel
this.devices[name] = descriptor;
this.selectors = sharedSelectors;
this.errors = { TimeoutError };
this.selectors._addChannel(SelectorsOwner.from(initializer.selectors));
this._selectorsOwner = SelectorsOwner.from(initializer.selectors);
this.selectors._addChannel(this._selectorsOwner);
}
_cleanup() {
this.selectors._removeChannel(this._selectorsOwner);
}
}

View File

@ -21,10 +21,11 @@ import { BrowserTypeDispatcher } from './browserTypeDispatcher';
import { Dispatcher, DispatcherScope } from './dispatcher';
import { ElectronDispatcher } from './electronDispatcher';
import { SelectorsDispatcher } from './selectorsDispatcher';
import type { BrowserDispatcher } from './browserDispatcher';
import * as types from '../server/types';
export class PlaywrightDispatcher extends Dispatcher<Playwright, channels.PlaywrightInitializer> implements channels.PlaywrightChannel {
constructor(scope: DispatcherScope, playwright: Playwright) {
constructor(scope: DispatcherScope, playwright: Playwright, customSelectors?: SelectorsDispatcher, preLaunchedBrowser?: BrowserDispatcher) {
const descriptors = require('../server/deviceDescriptors') as types.Devices;
const deviceDescriptors = Object.entries(descriptors)
.map(([name, descriptor]) => ({ name, descriptor }));
@ -35,7 +36,8 @@ export class PlaywrightDispatcher extends Dispatcher<Playwright, channels.Playwr
android: new AndroidDispatcher(scope, playwright.android),
electron: new ElectronDispatcher(scope, playwright.electron),
deviceDescriptors,
selectors: new SelectorsDispatcher(scope, playwright.selectors),
selectors: customSelectors || new SelectorsDispatcher(scope, playwright.selectors),
preLaunchedBrowser,
}, false, 'Playwright');
}
}

View File

@ -34,9 +34,9 @@ function setupInProcess(): PlaywrightAPI {
// Initialize Playwright channel.
new PlaywrightDispatcher(dispatcherConnection.rootDispatcher(), playwright);
const playwrightAPI = clientConnection.getObjectWithKnownName('Playwright') as PlaywrightAPI;
playwrightAPI.chromium._serverLauncher = new BrowserServerLauncherImpl(playwright.chromium);
playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl(playwright.firefox);
playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl(playwright.webkit);
playwrightAPI.chromium._serverLauncher = new BrowserServerLauncherImpl(playwright, playwright.chromium);
playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl(playwright, playwright.firefox);
playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl(playwright, playwright.webkit);
// Switch to async dispatch after we got Playwright object.
dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message));

View File

@ -177,18 +177,11 @@ export type PlaywrightInitializer = {
},
}[],
selectors: SelectorsChannel,
preLaunchedBrowser?: BrowserChannel,
};
export interface PlaywrightChannel extends Channel {
}
// ----------- RemoteBrowser -----------
export type RemoteBrowserInitializer = {
browser: BrowserChannel,
selectors: SelectorsChannel,
};
export interface RemoteBrowserChannel extends Channel {
}
// ----------- Selectors -----------
export type SelectorsInitializer = {};
export interface SelectorsChannel extends Channel {

View File

@ -371,14 +371,8 @@ Playwright:
- firefox
- webkit
selectors: Selectors
RemoteBrowser:
type: interface
initializer:
browser: Browser
selectors: Selectors
# Only present when connecting remotely via BrowserType.connect() method.
preLaunchedBrowser: Browser?
Selectors:

View File

@ -16,47 +16,99 @@
import debug from 'debug';
import * as http from 'http';
import WebSocket from 'ws';
import { DispatcherConnection } from '../dispatchers/dispatcher';
import * as ws from 'ws';
import { DispatcherConnection, DispatcherScope } from '../dispatchers/dispatcher';
import { PlaywrightDispatcher } from '../dispatchers/playwrightDispatcher';
import { createPlaywright } from '../server/playwright';
import { gracefullyCloseAll } from '../server/processLauncher';
const debugLog = debug('pw:server');
export interface PlaywrightServerDelegate {
path: string;
allowMultipleClients: boolean;
onConnect(rootScope: DispatcherScope): () => any;
onClose: () => any;
}
export class PlaywrightServer {
private _server: http.Server | undefined;
private _client: WebSocket | undefined;
private _clientsCount = 0;
private _delegate: PlaywrightServerDelegate;
listen(port: number) {
this._server = http.createServer((request, response) => {
static async startDefault(port: number = 0): Promise<string> {
const delegate: PlaywrightServerDelegate = {
path: '/ws',
allowMultipleClients: false,
onClose: gracefullyCloseAll,
onConnect: (rootScope: DispatcherScope) => {
new PlaywrightDispatcher(rootScope, createPlaywright());
return () => gracefullyCloseAll().catch(e => {});
},
};
const server = new PlaywrightServer(delegate);
return server.listen(port);
}
constructor(delegate: PlaywrightServerDelegate) {
this._delegate = delegate;
}
async listen(port: number = 0): Promise<string> {
const server = http.createServer((request, response) => {
response.end('Running');
});
this._server.on('error', error => debugLog(error));
this._server.listen(port);
debugLog('Listening on ' + port);
server.on('error', error => debugLog(error));
const wsServer = new WebSocket.Server({ server: this._server, path: '/ws' });
wsServer.on('connection', async ws => {
if (this._client) {
ws.close();
const path = this._delegate.path;
const wsEndpoint = await new Promise<string>(resolve => {
server.listen(port, () => {
const address = server.address();
const wsEndpoint = typeof address === 'string' ? `${address}${path}` : `ws://127.0.0.1:${address.port}${path}`;
resolve(wsEndpoint);
});
});
this._server = server;
debugLog('Listening at ' + wsEndpoint);
const wsServer = new ws.Server({ server: this._server, path });
wsServer.on('connection', async socket => {
if (this._clientsCount && !this._delegate.allowMultipleClients) {
socket.close();
return;
}
this._client = ws;
this._clientsCount++;
debugLog('Incoming connection');
const dispatcherConnection = new DispatcherConnection();
ws.on('message', message => dispatcherConnection.dispatch(JSON.parse(message.toString())));
ws.on('close', () => {
const connection = new DispatcherConnection();
connection.onmessage = message => {
if (socket.readyState !== ws.CLOSING)
socket.send(JSON.stringify(message));
};
socket.on('message', (message: string) => {
connection.dispatch(JSON.parse(Buffer.from(message).toString()));
});
const scope = connection.rootDispatcher();
const onDisconnect = this._delegate.onConnect(scope);
const disconnect = () => {
this._clientsCount--;
// Avoid sending any more messages over closed socket.
connection.onmessage = () => {};
onDisconnect();
};
socket.on('close', () => {
debugLog('Client closed');
this._onDisconnect().catch(debugLog);
disconnect();
});
ws.on('error', error => {
socket.on('error', error => {
debugLog('Client error ' + error);
this._onDisconnect().catch(debugLog);
disconnect();
});
dispatcherConnection.onmessage = message => ws.send(JSON.stringify(message));
new PlaywrightDispatcher(dispatcherConnection.rootDispatcher(), createPlaywright());
});
return wsEndpoint;
}
async close() {
@ -64,11 +116,6 @@ export class PlaywrightServer {
return;
debugLog('Closing server');
await new Promise(f => this._server!.close(f));
await gracefullyCloseAll();
}
private async _onDisconnect() {
await gracefullyCloseAll();
this._client = undefined;
await this._delegate.onClose();
}
}

View File

@ -1,21 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the 'License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PlaywrightServer } from './remote/playwrightServer';
const server = new PlaywrightServer();
server.listen(+process.argv[2]);
console.log('Listening on ' + process.argv[2]); // eslint-disable-line no-console

View File

@ -58,7 +58,7 @@ class ServiceMode {
async setup(workerInfo: WorkerInfo) {
const port = 10507 + workerInfo.workerIndex;
this._serviceProcess = childProcess.fork(path.join(__dirname, '..', '..', 'lib', 'service.js'), [String(port)], {
this._serviceProcess = childProcess.fork(path.join(__dirname, '..', '..', 'lib', 'cli', 'cli.js'), ['run-server', String(port)], {
stdio: 'pipe'
});
this._serviceProcess.stderr.pipe(process.stderr);

View File

@ -149,7 +149,6 @@ DEPS['src/web/traceViewer/'] = ['src/common/', 'src/web/'];
DEPS['src/web/traceViewer/ui/'] = ['src/common/', 'src/web/traceViewer/', 'src/web/', 'src/server/trace/viewer/', 'src/server/trace/', 'src/server/trace/common/', 'src/server/snapshot/snapshotTypes.ts'];
// The service is a cross-cutting feature, and so it depends on a bunch of things.
DEPS['src/remote/'] = ['src/client/', 'src/debug/', 'src/dispatchers/', 'src/server/', 'src/server/supplements/', 'src/server/electron/', 'src/server/trace/'];
DEPS['src/service.ts'] = ['src/remote/'];
// CLI should only use client-side features.
DEPS['src/cli/'] = ['src/cli/**', 'src/client/**', 'src/install/**', 'src/generated/', 'src/server/injected/', 'src/debug/injected/', 'src/server/trace/**', 'src/utils/**'];