diff options
Diffstat (limited to 'remote/test/puppeteer/utils/testserver/index.js')
-rw-r--r-- | remote/test/puppeteer/utils/testserver/index.js | 284 |
1 files changed, 284 insertions, 0 deletions
diff --git a/remote/test/puppeteer/utils/testserver/index.js b/remote/test/puppeteer/utils/testserver/index.js new file mode 100644 index 0000000000..7d21009c94 --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/index.js @@ -0,0 +1,284 @@ +// @ts-nocheck + +/** + * 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 mime = require('mime'); +const WebSocketServer = require('ws').Server; + +const fulfillSymbol = Symbol('fullfil callback'); +const rejectSymbol = Symbol('reject callback'); + +class TestServer { + PORT = undefined; + PREFIX = undefined; + CROSS_PROCESS_PREFIX = undefined; + EMPTY_PAGE = undefined; + + /** + * @param {string} dirPath + * @param {number} port + * @returns {!TestServer} + */ + static async create(dirPath, port) { + const server = new TestServer(dirPath, port); + await new Promise((x) => server._server.once('listening', x)); + return server; + } + + /** + * @param {string} dirPath + * @param {number} port + * @returns {!TestServer} + */ + static async createHTTPS(dirPath, port) { + const server = new TestServer(dirPath, port, { + key: fs.readFileSync(path.join(__dirname, 'key.pem')), + cert: fs.readFileSync(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 {!Object=} sslOptions + */ + constructor(dirPath, port, 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.on('connection', this._onWebSocketConnection.bind(this)); + this._server.listen(port); + this._dirPath = dirPath; + + this._startTime = new Date(); + this._cachedPathPrefix = null; + + /** @type {!Set<!net.Socket>} */ + this._sockets = new Set(); + + /** @type {!Map<string, function(!IncomingMessage, !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(); + } + + _onSocket(socket) { + this._sockets.add(socket); + // ECONNRESET is a legit error given + // that tab closing simply kills process. + socket.on('error', (error) => { + if (error.code !== 'ECONNRESET') 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._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(!IncomingMessage, !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 + * @returns {!Promise<!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(); + } + + _onRequest(request, response) { + request.on('error', (error) => { + if (error.code === 'ECONNRESET') response.end(); + else throw error; + }); + request.postBody = new Promise((resolve) => { + let body = ''; + request.on('data', (chunk) => (body += chunk)); + request.on('end', () => resolve(body)); + }); + const pathName = url.parse(request.url).path; + if (this._auths.has(pathName)) { + const auth = this._auths.get(pathName); + const credentials = Buffer.from( + (request.headers.authorization || '').split(' ')[1] || '', + 'base64' + ).toString(); + if (credentials !== `${auth.username}:${auth.password}`) { + 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 { + const pathName = url.parse(request.url).path; + this.serveFile(request, response, pathName); + } + } + + /** + * @param {!IncomingMessage} request + * @param {!ServerResponse} response + * @param {string} pathName + */ + serveFile(request, response, pathName) { + if (pathName === '/') pathName = '/index.html'; + const 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'); + 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)); + + fs.readFile(filePath, (err, data) => { + if (err) { + response.statusCode = 404; + response.end(`File not found: ${filePath}`); + return; + } + const mimeType = mime.getType(filePath); + 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 zlib = require('zlib'); + zlib.gzip(data, (_, result) => { + response.end(result); + }); + } else { + response.end(data); + } + }); + } + + _onWebSocketConnection(connection) { + connection.send('opened'); + } +} + +module.exports = { TestServer }; |