"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebSocketServer = exports.debugInfo = void 0;
/**
* 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.
*/
const http_1 = __importDefault(require("http"));
const debug_1 = __importDefault(require("debug"));
const websocket = __importStar(require("websocket"));
const Deferred_js_1 = require("../utils/Deferred.js");
const uuid_js_1 = require("../utils/uuid.js");
const BrowserInstance_js_1 = require("./BrowserInstance.js");
exports.debugInfo = (0, debug_1.default)('bidi:server:info');
const debugInternal = (0, debug_1.default)('bidi:server:internal');
const debugSend = (0, debug_1.default)('bidi:server:SEND ▸');
const debugRecv = (0, debug_1.default)('bidi:server:RECV ◂');
class WebSocketServer {
#sessions = new Map();
#port;
#verbose;
#server;
#wsServer;
constructor(port, verbose) {
this.#port = port;
this.#verbose = verbose;
this.#server = http_1.default.createServer((request, response) => {
return this.#onRequest(request, response).catch((e) => {
(0, exports.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() {
(0, exports.debugInfo)('BiDi server is listening on port', this.#port);
(0, exports.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);
});
(0, exports.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 = (0, uuid_js_1.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) => {
(0, exports.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) {
(0, exports.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 = (0, uuid_js_1.uuidv4)();
session = {
sessionId,
browserInstancePromise: Promise.resolve(browserInstance),
sessionOptions,
};
this.#sessions.set(sessionId, session);
}
catch (e) {
(0, exports.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) {
(0, exports.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) {
(0, exports.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) {
(0, exports.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) {
(0, exports.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) {
(0, exports.debugInfo)('Scheduling browser launch...');
const browserInstance = await BrowserInstance_js_1.BrowserInstance.run(sessionOptions.chromeOptions, sessionOptions.verbose);
const body = JSON.parse(sessionOptions.sessionNewBody);
const id = body.id;
const sessionCreated = new Deferred_js_1.Deferred();
const sessionResponseListener = (message) => {
const jsonMessage = JSON.parse(message);
if (jsonMessage['id'] === id) {
(0, exports.debugInfo)('Receiving session.new response from mapper', message);
sessionCreated.resolve();
if (passSessionNewThrough) {
this.#sendClientMessageString(message, connection);
}
}
};
browserInstance.bidiSession().on('message', sessionResponseListener);
(0, exports.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);
});
(0, exports.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);
});
});
}
}
exports.WebSocketServer = WebSocketServer;
//# sourceMappingURL=WebSocketServer.js.map |