374 lines
16 KiB
JavaScript
374 lines
16 KiB
JavaScript
/**
|
|
* Copyright 2021 Google LLC.
|
|
* 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 http from 'http';
|
|
import debug from 'debug';
|
|
import * as websocket from 'websocket';
|
|
import { Deferred } from '../utils/Deferred.js';
|
|
import { uuidv4 } from '../utils/uuid.js';
|
|
import { BrowserInstance } from './BrowserInstance.js';
|
|
export const debugInfo = debug('bidi:server:info');
|
|
const debugInternal = debug('bidi:server:internal');
|
|
const debugSend = debug('bidi:server:SEND ▸');
|
|
const debugRecv = debug('bidi:server:RECV ◂');
|
|
export class WebSocketServer {
|
|
#sessions = new Map();
|
|
#port;
|
|
#verbose;
|
|
#server;
|
|
#wsServer;
|
|
constructor(port, verbose) {
|
|
this.#port = port;
|
|
this.#verbose = verbose;
|
|
this.#server = http.createServer((request, response) => {
|
|
return this.#onRequest(request, response).catch((e) => {
|
|
debugInfo('Error while processing request', e);
|
|
response.writeHead(500, String(e));
|
|
});
|
|
});
|
|
this.#wsServer = new websocket.server({
|
|
httpServer: this.#server,
|
|
autoAcceptConnections: false,
|
|
});
|
|
this.#wsServer.on('request', this.#onWsRequest.bind(this));
|
|
void this.#listen();
|
|
}
|
|
#logServerStarted() {
|
|
debugInfo('BiDi server is listening on port', this.#port);
|
|
debugInfo('BiDi server was started successfully.');
|
|
}
|
|
async #listen() {
|
|
try {
|
|
this.#server.listen(this.#port, () => {
|
|
this.#logServerStarted();
|
|
});
|
|
}
|
|
catch (error) {
|
|
if (error &&
|
|
typeof error === 'object' &&
|
|
'code' in error &&
|
|
error.code === 'EADDRINUSE') {
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 500);
|
|
});
|
|
debugInfo('Retrying to run BiDi server');
|
|
this.#server.listen(this.#port, () => {
|
|
this.#logServerStarted();
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
async #onRequest(request, response) {
|
|
debugInternal(`Received HTTP ${JSON.stringify(request.method)} request for ${JSON.stringify(request.url)}`);
|
|
if (!request.url) {
|
|
throw new Error('Request URL is empty.');
|
|
}
|
|
// https://w3c.github.io/webdriver-bidi/#transport, step 2.
|
|
if (request.url === '/session') {
|
|
const body = await new Promise((resolve, reject) => {
|
|
const bodyArray = [];
|
|
request.on('data', (chunk) => {
|
|
bodyArray.push(chunk);
|
|
});
|
|
request.on('error', reject);
|
|
request.on('end', () => {
|
|
resolve(Buffer.concat(bodyArray));
|
|
});
|
|
});
|
|
debugInternal(`Creating session by HTTP request ${body.toString()}`);
|
|
// https://w3c.github.io/webdriver-bidi/#transport, step 3.
|
|
const jsonBody = JSON.parse(body.toString());
|
|
response.writeHead(200, {
|
|
'Content-Type': 'application/json;charset=utf-8',
|
|
'Cache-Control': 'no-cache',
|
|
});
|
|
const sessionId = uuidv4();
|
|
const session = {
|
|
sessionId,
|
|
// TODO: launch browser instance and set it to the session after WPT
|
|
// tests clean up is switched to pure BiDi.
|
|
browserInstancePromise: undefined,
|
|
sessionOptions: {
|
|
chromeOptions: this.#getChromeOptions(jsonBody.capabilities),
|
|
verbose: this.#verbose,
|
|
sessionNewBody: `{"id":0,"method":"session.new","params":${body.toString()}}`,
|
|
},
|
|
};
|
|
this.#sessions.set(sessionId, session);
|
|
const webSocketUrl = `ws://localhost:${this.#port}/session/${sessionId}`;
|
|
debugInternal(`Session created. WebSocket URL: ${JSON.stringify(webSocketUrl)}.`);
|
|
response.write(JSON.stringify({
|
|
value: {
|
|
sessionId,
|
|
capabilities: {
|
|
webSocketUrl,
|
|
},
|
|
},
|
|
}));
|
|
return response.end();
|
|
}
|
|
else if (request.url.startsWith('/session')) {
|
|
debugInternal(`Unknown session command ${request.method ?? 'UNKNOWN METHOD'} request for ${request.url} with payload ${await this.#getHttpRequestPayload(request)}. 200 returned.`);
|
|
response.writeHead(200, {
|
|
'Content-Type': 'application/json;charset=utf-8',
|
|
'Cache-Control': 'no-cache',
|
|
});
|
|
response.write(JSON.stringify({
|
|
value: {},
|
|
}));
|
|
return response.end();
|
|
}
|
|
throw new Error(`Unknown "${request.method}" request for "${JSON.stringify(request.url)}" with payload "${await this.#getHttpRequestPayload(request)}".`);
|
|
}
|
|
#onWsRequest(request) {
|
|
// Session is set either by Classic or BiDi commands.
|
|
let session;
|
|
// Request to `/session` should be treated as a new session request.
|
|
let requestSessionId = '';
|
|
if ((request.resource ?? '').startsWith(`/session/`)) {
|
|
requestSessionId = (request.resource ?? '').split('/').pop() ?? '';
|
|
}
|
|
debugInternal(`new WS request received. Path: ${JSON.stringify(request.resourceURL.path)}, sessionId: ${JSON.stringify(requestSessionId)}`);
|
|
if (requestSessionId !== '' &&
|
|
requestSessionId !== undefined &&
|
|
!this.#sessions.has(requestSessionId)) {
|
|
debugInternal('Unknown session id:', requestSessionId);
|
|
request.reject();
|
|
return;
|
|
}
|
|
const connection = request.accept();
|
|
session = this.#sessions.get(requestSessionId ?? '');
|
|
if (session !== undefined) {
|
|
// BrowserInstance is created for each new WS connection, even for the
|
|
// same SessionId. This is because WPT uses a single session for all the
|
|
// tests, but cleans up tests using WebDriver Classic commands, which is
|
|
// not implemented in this Mapper runner.
|
|
// TODO: connect to an existing BrowserInstance instead.
|
|
const sessionOptions = session.sessionOptions;
|
|
session.browserInstancePromise = this.#closeBrowserInstanceIfLaunched(session)
|
|
.then(async () => await this.#launchBrowserInstance(connection, sessionOptions))
|
|
.catch((e) => {
|
|
debugInfo('Error while creating session', e);
|
|
connection.close(500, 'cannot create browser instance');
|
|
throw e;
|
|
});
|
|
}
|
|
connection.on('message', async (message) => {
|
|
// If type is not text, return error.
|
|
if (message.type !== 'utf8') {
|
|
this.#respondWithError(connection, {}, "invalid argument" /* ErrorCode.InvalidArgument */, `not supported type (${message.type})`);
|
|
return;
|
|
}
|
|
const plainCommandData = message.utf8Data;
|
|
if (debugRecv.enabled) {
|
|
try {
|
|
debugRecv(JSON.parse(plainCommandData));
|
|
}
|
|
catch {
|
|
debugRecv(plainCommandData);
|
|
}
|
|
}
|
|
// Try to parse the message to handle some of BiDi commands.
|
|
let parsedCommandData;
|
|
try {
|
|
parsedCommandData = JSON.parse(plainCommandData);
|
|
}
|
|
catch (error) {
|
|
this.#respondWithError(connection, {}, "invalid argument" /* ErrorCode.InvalidArgument */, `Cannot parse data as JSON, ${error}`);
|
|
return;
|
|
}
|
|
// Handle creating new session.
|
|
if (parsedCommandData.method === 'session.new') {
|
|
if (session !== undefined) {
|
|
debugInfo('WS connection already have an associated session.');
|
|
this.#respondWithError(connection, plainCommandData, "session not created" /* ErrorCode.SessionNotCreated */, 'WS connection already have an associated session.');
|
|
return;
|
|
}
|
|
try {
|
|
const sessionOptions = {
|
|
chromeOptions: this.#getChromeOptions(parsedCommandData.params?.capabilities),
|
|
verbose: this.#verbose,
|
|
sessionNewBody: plainCommandData,
|
|
};
|
|
const browserInstance = await this.#launchBrowserInstance(connection, sessionOptions, true);
|
|
const sessionId = uuidv4();
|
|
session = {
|
|
sessionId,
|
|
browserInstancePromise: Promise.resolve(browserInstance),
|
|
sessionOptions,
|
|
};
|
|
this.#sessions.set(sessionId, session);
|
|
}
|
|
catch (e) {
|
|
debugInfo('Error while creating session', e);
|
|
this.#respondWithError(connection, plainCommandData, "session not created" /* ErrorCode.SessionNotCreated */, e?.message ?? 'Unknown error');
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
// Handle ending session. Close browser if open, remove session.
|
|
if (parsedCommandData.method === 'session.end') {
|
|
if (session === undefined) {
|
|
debugInfo('WS connection does not have an associated session.');
|
|
this.#respondWithError(connection, plainCommandData, "session not created" /* ErrorCode.SessionNotCreated */, 'WS connection does not have an associated session.');
|
|
return;
|
|
}
|
|
try {
|
|
await this.#closeBrowserInstanceIfLaunched(session);
|
|
this.#sessions.delete(session.sessionId);
|
|
}
|
|
catch (e) {
|
|
debugInfo('Error while closing session', e);
|
|
this.#respondWithError(connection, plainCommandData, "unknown error" /* ErrorCode.UnknownError */, `Session cannot be closed. Error: ${e?.message}`);
|
|
return;
|
|
}
|
|
this.#sendClientMessage({
|
|
id: parsedCommandData.id,
|
|
type: 'success',
|
|
result: {},
|
|
}, connection);
|
|
return;
|
|
}
|
|
if (session === undefined) {
|
|
debugInfo('Session is not yet initialized.');
|
|
this.#respondWithError(connection, plainCommandData, "invalid session id" /* ErrorCode.InvalidSessionId */, 'Session is not yet initialized.');
|
|
return;
|
|
}
|
|
if (session.browserInstancePromise === undefined) {
|
|
debugInfo('Browser instance is not launched.');
|
|
this.#respondWithError(connection, plainCommandData, "invalid session id" /* ErrorCode.InvalidSessionId */, 'Browser instance is not launched.');
|
|
return;
|
|
}
|
|
const browserInstance = await session.browserInstancePromise;
|
|
// Handle `browser.close` command.
|
|
if (parsedCommandData.method === 'browser.close') {
|
|
await browserInstance.close();
|
|
this.#sendClientMessage({
|
|
id: parsedCommandData.id,
|
|
type: 'success',
|
|
result: {},
|
|
}, connection);
|
|
return;
|
|
}
|
|
// Forward all other commands to BiDi Mapper.
|
|
await browserInstance.bidiSession().sendCommand(plainCommandData);
|
|
});
|
|
connection.on('close', async () => {
|
|
debugInternal(`Peer ${connection.remoteAddress} disconnected.`);
|
|
// TODO: don't close Browser instance to allow re-connecting to the session.
|
|
await this.#closeBrowserInstanceIfLaunched(session);
|
|
});
|
|
}
|
|
async #closeBrowserInstanceIfLaunched(session) {
|
|
if (session === undefined || session.browserInstancePromise === undefined) {
|
|
return;
|
|
}
|
|
const browserInstance = await session.browserInstancePromise;
|
|
session.browserInstancePromise = undefined;
|
|
void browserInstance.close();
|
|
}
|
|
#getChromeOptions(capabilities) {
|
|
const chromeCapabilities = capabilities?.alwaysMatch?.['goog:chromeOptions'];
|
|
return {
|
|
chromeArgs: chromeCapabilities?.args ?? [],
|
|
chromeBinary: chromeCapabilities?.binary ?? undefined,
|
|
};
|
|
}
|
|
async #launchBrowserInstance(connection, sessionOptions, passSessionNewThrough = false) {
|
|
debugInfo('Scheduling browser launch...');
|
|
const browserInstance = await BrowserInstance.run(sessionOptions.chromeOptions, sessionOptions.verbose);
|
|
const body = JSON.parse(sessionOptions.sessionNewBody);
|
|
const id = body.id;
|
|
const sessionCreated = new Deferred();
|
|
const sessionResponseListener = (message) => {
|
|
const jsonMessage = JSON.parse(message);
|
|
if (jsonMessage['id'] === id) {
|
|
debugInfo('Receiving session.new response from mapper', message);
|
|
sessionCreated.resolve();
|
|
if (passSessionNewThrough) {
|
|
this.#sendClientMessageString(message, connection);
|
|
}
|
|
}
|
|
};
|
|
browserInstance.bidiSession().on('message', sessionResponseListener);
|
|
debugInfo('Sending session.new to mapper', sessionOptions.sessionNewBody);
|
|
await browserInstance
|
|
.bidiSession()
|
|
.sendCommand(sessionOptions.sessionNewBody);
|
|
await sessionCreated;
|
|
browserInstance.bidiSession().off('message', sessionResponseListener);
|
|
// Forward messages from BiDi Mapper to the client unconditionally.
|
|
browserInstance.bidiSession().on('message', (message) => {
|
|
this.#sendClientMessageString(message, connection);
|
|
});
|
|
debugInfo('Browser is launched!');
|
|
return browserInstance;
|
|
}
|
|
#sendClientMessageString(message, connection) {
|
|
if (debugSend.enabled) {
|
|
try {
|
|
debugSend(JSON.parse(message));
|
|
}
|
|
catch {
|
|
debugSend(message);
|
|
}
|
|
}
|
|
connection.sendUTF(message);
|
|
}
|
|
#sendClientMessage(object, connection) {
|
|
const json = JSON.stringify(object);
|
|
return this.#sendClientMessageString(json, connection);
|
|
}
|
|
#respondWithError(connection, plainCommandData, errorCode, errorMessage) {
|
|
const errorResponse = this.#getErrorResponse(plainCommandData, errorCode, errorMessage);
|
|
void this.#sendClientMessage(errorResponse, connection);
|
|
}
|
|
#getErrorResponse(plainCommandData, errorCode, errorMessage) {
|
|
// XXX: this is bizarre per spec. We reparse the payload and
|
|
// extract the ID, regardless of what kind of value it was.
|
|
let commandId;
|
|
try {
|
|
const commandData = JSON.parse(plainCommandData);
|
|
if ('id' in commandData) {
|
|
commandId = commandData.id;
|
|
}
|
|
}
|
|
catch { }
|
|
return {
|
|
type: 'error',
|
|
id: commandId,
|
|
error: errorCode,
|
|
message: errorMessage,
|
|
// XXX: optional stacktrace field.
|
|
};
|
|
}
|
|
#getHttpRequestPayload(request) {
|
|
return new Promise((resolve, reject) => {
|
|
let data = '';
|
|
request.on('data', (chunk) => {
|
|
data += chunk;
|
|
});
|
|
request.on('end', () => {
|
|
resolve(data);
|
|
});
|
|
request.on('error', (error) => {
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
//# sourceMappingURL=WebSocketServer.js.map
|