playwright/utils/testserver/index.js

320 lines
10 KiB
JavaScript

/**
* Copyright 2017 Google Inc. All rights reserved.
*
* 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 = require('http');
const https = require('https');
const url = require('url');
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const util = require('util');
const mime = require('mime');
const WebSocketServer = require('ws').Server;
const fulfillSymbol = Symbol('fullfil callback');
const rejectSymbol = Symbol('reject callback');
const gzipAsync = util.promisify(zlib.gzip.bind(zlib));
class TestServer {
/**
* @param {string} dirPath
* @param {number} port
* @param {string=} loopback
* @return {!Promise<TestServer>}
*/
static async create(dirPath, port, loopback) {
const server = new TestServer(dirPath, port, loopback);
await new Promise(x => server._server.once('listening', x));
return server;
}
/**
* @param {string} dirPath
* @param {number} port
* @param {string=} loopback
* @return {!Promise<TestServer>}
*/
static async createHTTPS(dirPath, port, loopback) {
const server = new TestServer(dirPath, port, loopback, {
key: await fs.promises.readFile(path.join(__dirname, 'key.pem')),
cert: await fs.promises.readFile(path.join(__dirname, 'cert.pem')),
passphrase: 'aaaa',
});
await new Promise(x => server._server.once('listening', x));
return server;
}
/**
* @param {string} dirPath
* @param {number} port
* @param {string=} loopback
* @param {!Object=} sslOptions
*/
constructor(dirPath, port, loopback, sslOptions) {
if (sslOptions)
this._server = https.createServer(sslOptions, this._onRequest.bind(this));
else
this._server = http.createServer(this._onRequest.bind(this));
this._server.on('connection', socket => this._onSocket(socket));
this._wsServer = new WebSocketServer({server: this._server });
this._wsServer.shouldHandle = (request) => {
const pathname = url.parse(request.url).pathname;
return ['/ws', '/ws-emit-and-close'].includes(pathname);
};
this._wsServer.on('connection', (ws, request) => {
const pathname = url.parse(request.url).pathname;
if (this._onWebSocketConnectionData !== undefined)
ws.send(this._onWebSocketConnectionData);
if (pathname === '/ws-emit-and-close')
ws.close(1003, 'closed by Playwright test-server');
});
this._server.listen(port);
this._dirPath = dirPath;
this.debugServer = require('debug')('pw:server');
this._startTime = new Date();
this._cachedPathPrefix = null;
/** @type {!Set<!NodeJS.Socket>} */
this._sockets = new Set();
/** @type {!Map<string, function(!http.IncomingMessage,http.ServerResponse)>} */
this._routes = new Map();
/** @type {!Map<string, !{username:string, password:string}>} */
this._auths = new Map();
/** @type {!Map<string, string>} */
this._csp = new Map();
/** @type {!Set<string>} */
this._gzipRoutes = new Set();
/** @type {!Map<string, !Promise>} */
this._requestSubscribers = new Map();
/** @type {string|undefined} */
this._onWebSocketConnectionData = undefined;
const cross_origin = loopback || '127.0.0.1';
const same_origin = loopback || 'localhost';
const protocol = sslOptions ? 'https' : 'http';
this.PORT = port;
this.PREFIX = `${protocol}://${same_origin}:${port}`;
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}`;
this.EMPTY_PAGE = `${protocol}://${same_origin}:${port}/empty.html`;
}
_onSocket(socket) {
this._sockets.add(socket);
// ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given
// that tab closing aborts outgoing connections to the server.
socket.on('error', error => {
if (error.code !== 'ECONNRESET' && error.code !== 'HPE_INVALID_EOF_STATE')
throw error;
});
socket.once('close', () => this._sockets.delete(socket));
}
/**
* @param {string} pathPrefix
*/
enableHTTPCache(pathPrefix) {
this._cachedPathPrefix = pathPrefix;
}
/**
* @param {string} path
* @param {string} username
* @param {string} password
*/
setAuth(path, username, password) {
this.debugServer(`set auth for ${path} to ${username}:${password}`);
this._auths.set(path, {username, password});
}
enableGzip(path) {
this._gzipRoutes.add(path);
}
/**
* @param {string} path
* @param {string} csp
*/
setCSP(path, csp) {
this._csp.set(path, csp);
}
async stop() {
this.reset();
for (const socket of this._sockets)
socket.destroy();
this._sockets.clear();
await new Promise(x => this._server.close(x));
}
/**
* @param {string} path
* @param {function(!http.IncomingMessage,http.ServerResponse)} handler
*/
setRoute(path, handler) {
this._routes.set(path, handler);
}
/**
* @param {string} from
* @param {string} to
*/
setRedirect(from, to) {
this.setRoute(from, (req, res) => {
res.writeHead(302, { location: to });
res.end();
});
}
/**
* @param {string} path
* @return {!Promise<!http.IncomingMessage>}
*/
waitForRequest(path) {
let promise = this._requestSubscribers.get(path);
if (promise)
return promise;
let fulfill, reject;
promise = new Promise((f, r) => {
fulfill = f;
reject = r;
});
promise[fulfillSymbol] = fulfill;
promise[rejectSymbol] = reject;
this._requestSubscribers.set(path, promise);
return promise;
}
reset() {
this._routes.clear();
this._auths.clear();
this._csp.clear();
this._gzipRoutes.clear();
const error = new Error('Static Server has been reset');
for (const subscriber of this._requestSubscribers.values())
subscriber[rejectSymbol].call(null, error);
this._requestSubscribers.clear();
}
/**
* @param {http.IncomingMessage} request
* @param {http.ServerResponse} response
*/
_onRequest(request, response) {
request.on('error', error => {
if (error.code === 'ECONNRESET')
response.end();
else
throw error;
});
request.postBody = new Promise(resolve => {
let body = Buffer.from([]);
request.on('data', chunk => body = Buffer.concat([body, chunk]));
request.on('end', () => resolve(body));
});
const pathName = url.parse(request.url).path;
this.debugServer(`request ${request.method} ${pathName}`);
if (this._auths.has(pathName)) {
const auth = this._auths.get(pathName);
const credentials = Buffer.from((request.headers.authorization || '').split(' ')[1] || '', 'base64').toString();
this.debugServer(`request credentials ${credentials}`);
this.debugServer(`actual credentials ${auth.username}:${auth.password}`);
if (credentials !== `${auth.username}:${auth.password}`) {
this.debugServer(`request write www-auth`);
response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Secure Area"' });
response.end('HTTP Error 401 Unauthorized: Access is denied');
return;
}
}
// Notify request subscriber.
if (this._requestSubscribers.has(pathName)) {
this._requestSubscribers.get(pathName)[fulfillSymbol].call(null, request);
this._requestSubscribers.delete(pathName);
}
const handler = this._routes.get(pathName);
if (handler) {
handler.call(null, request, response);
} else {
this.serveFile(request, response);
}
}
/**
* @param {!http.IncomingMessage} request
* @param {!http.ServerResponse} response
* @param {string|undefined} filePath
*/
async serveFile(request, response, filePath) {
let pathName = url.parse(request.url).path;
if (!filePath) {
if (pathName === '/')
pathName = '/index.html';
filePath = path.join(this._dirPath, pathName.substring(1));
}
if (this._cachedPathPrefix !== null && filePath.startsWith(this._cachedPathPrefix)) {
if (request.headers['if-modified-since']) {
response.statusCode = 304; // not modified
response.end();
return;
}
response.setHeader('Cache-Control', 'public, max-age=31536000, no-cache');
response.setHeader('Last-Modified', this._startTime.toISOString());
} else {
response.setHeader('Cache-Control', 'no-cache, no-store');
}
if (this._csp.has(pathName))
response.setHeader('Content-Security-Policy', this._csp.get(pathName));
const {err, data} = await fs.promises.readFile(filePath).then(data => ({data})).catch(err => ({err}));
// The HTTP transaction might be already terminated after async hop here - do nothing in this case.
if (response.writableEnded)
return;
if (err) {
response.statusCode = 404;
response.end(`File not found: ${filePath}`);
return;
}
const extension = filePath.substring(filePath.lastIndexOf('.') + 1);
const mimeType = mime.getType(extension) || 'application/octet-stream';
const isTextEncoding = /^text\/|^application\/(javascript|json)/.test(mimeType);
const contentType = isTextEncoding ? `${mimeType}; charset=utf-8` : mimeType;
response.setHeader('Content-Type', contentType);
if (this._gzipRoutes.has(pathName)) {
response.setHeader('Content-Encoding', 'gzip');
const result = await gzipAsync(data);
// The HTTP transaction might be already terminated after async hop here.
if (!response.writableEnded)
response.end(result);
} else {
response.end(data);
}
}
waitForWebSocketConnectionRequest() {
return new Promise(fullfil => {
this._wsServer.once('connection', (ws, req) => fullfil(req));
});
}
sendOnWebSocketConnection(data) {
this._onWebSocketConnectionData = data;
}
}
module.exports = {TestServer};