diff --git a/src/browserServerImpl.ts b/src/browserServerImpl.ts index b7b2e28214..2dddeaf9dd 100644 --- a/src/browserServerImpl.ts +++ b/src/browserServerImpl.ts @@ -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 { + async launchServer(options: LaunchServerOptions = {}): Promise { + // 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; - - static async start(browser: Browser, port: number = 0): Promise { - 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(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 { - await this._browser.options.browserProcess.close(); - } - - async kill(): Promise { - 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 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); diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 217567c080..21b6306b61 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -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') diff --git a/src/cli/driver.ts b/src/cli/driver.ts index 55b6d44cbe..5f7d622c17 100644 --- a/src/cli/driver.ts +++ b/src/cli/driver.ts @@ -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) diff --git a/src/client/browserType.ts b/src/client/browserType.ts index fbd01113fe..4242d22fdd 100644 --- a/src/client/browserType.ts +++ b/src/client/browserType.ts @@ -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; @@ -152,13 +149,15 @@ export class BrowserType extends ChannelOwner { @@ -173,7 +172,7 @@ export class BrowserType extends ChannelOwner { - sharedSelectors._removeChannel(selectorsOwner); + playwright._cleanup(); ws.removeEventListener('close', closeListener); ws.close(); }); @@ -209,16 +208,3 @@ export class BrowserType extends ChannelOwner { - 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)); - } -} diff --git a/src/client/connection.ts b/src/client/connection.ts index d6c5712e8b..5c30bc6125 100644 --- a/src/client/connection.ts +++ b/src/client/connection.ts @@ -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; diff --git a/src/client/playwright.ts b/src/client/playwright.ts index 2c4f2e3fa4..a2f0bcf9c1 100644 --- a/src/client/playwright.ts +++ b/src/client/playwright.ts @@ -42,6 +42,7 @@ export class Playwright extends ChannelOwner 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 setImmediate(() => clientConnection.dispatch(message)); diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 31cb4f4b38..213f126414 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -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 { diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 2cf7d227ea..3b47e44789 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -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: diff --git a/src/remote/playwrightServer.ts b/src/remote/playwrightServer.ts index 14880cdb24..b7ae6a6c6f 100644 --- a/src/remote/playwrightServer.ts +++ b/src/remote/playwrightServer.ts @@ -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 { + 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 { + 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(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(); } } diff --git a/src/service.ts b/src/service.ts deleted file mode 100644 index 421455a015..0000000000 --- a/src/service.ts +++ /dev/null @@ -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 diff --git a/tests/config/browserEnv.ts b/tests/config/browserEnv.ts index d38c6e141c..3f229d731b 100644 --- a/tests/config/browserEnv.ts +++ b/tests/config/browserEnv.ts @@ -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); diff --git a/utils/check_deps.js b/utils/check_deps.js index 5ce9aad0ff..ecbce2a57b 100644 --- a/utils/check_deps.js +++ b/utils/check_deps.js @@ -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/**'];