/** * 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 WebSocketServer = require('ws').Server; const fulfillSymbol = Symbol('fullfil callback'); const rejectSymbol = Symbol('reject callback'); class TestServer { /** * @param {string} dirPath * @param {number} port * @return {!Promise} */ 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 * @return {!Promise} */ 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, path: '/ws'}); this._wsServer.on('connection', this._onWebSocketConnection.bind(this)); this._server.listen(port); this._dirPath = dirPath; this.debugServer = require('debug')('pw:server'); this._startTime = new Date(); this._cachedPathPrefix = null; /** @type {!Set} */ this._sockets = new Set(); /** @type {!Map} */ this._routes = new Map(); /** @type {!Map} */ this._auths = new Map(); /** @type {!Map} */ this._csp = new Map(); /** @type {!Set} */ this._gzipRoutes = new Set(); /** @type {!Map} */ this._requestSubscribers = new Map(); const protocol = sslOptions ? 'https' : 'http'; this.PORT = port; this.PREFIX = `${protocol}://localhost:${port}`; this.CROSS_PROCESS_PREFIX = `${protocol}://127.0.0.1:${port}`; this.EMPTY_PAGE = `${protocol}://localhost:${port}/empty.html`; } _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.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} */ 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 { const pathName = url.parse(request.url).path; this.serveFile(request, response, pathName); } } /** * @param {!http.IncomingMessage} request * @param {!http.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, 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)); fs.readFile(filePath, (err, data) => { if (err) { response.statusCode = 404; response.end(`File not found: ${filePath}`); return; } const extension = filePath.substring(filePath.lastIndexOf('.') + 1); const mimeType = extensionToMime[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 zlib = require('zlib'); zlib.gzip(data, (_, result) => { response.end(result); }); } else { response.end(data); } }); } _onWebSocketConnection(ws) { ws.send('incoming'); } } const extensionToMime = { 'ai': 'application/postscript', 'apng': 'image/apng', 'appcache': 'text/cache-manifest', 'au': 'audio/basic', 'bmp': 'image/bmp', 'cer': 'application/pkix-cert', 'cgm': 'image/cgm', 'coffee': 'text/coffeescript', 'conf': 'text/plain', 'crl': 'application/pkix-crl', 'css': 'text/css', 'csv': 'text/csv', 'def': 'text/plain', 'doc': 'application/msword', 'dot': 'application/msword', 'drle': 'image/dicom-rle', 'dtd': 'application/xml-dtd', 'ear': 'application/java-archive', 'emf': 'image/emf', 'eps': 'application/postscript', 'exr': 'image/aces', 'fits': 'image/fits', 'g3': 'image/g3fax', 'gbr': 'application/rpki-ghostbusters', 'gif': 'image/gif', 'glb': 'model/gltf-binary', 'gltf': 'model/gltf+json', 'gz': 'application/gzip', 'h261': 'video/h261', 'h263': 'video/h263', 'h264': 'video/h264', 'heic': 'image/heic', 'heics': 'image/heic-sequence', 'heif': 'image/heif', 'heifs': 'image/heif-sequence', 'htm': 'text/html', 'html': 'text/html', 'ics': 'text/calendar', 'ief': 'image/ief', 'ifb': 'text/calendar', 'iges': 'model/iges', 'igs': 'model/iges', 'in': 'text/plain', 'ini': 'text/plain', 'jade': 'text/jade', 'jar': 'application/java-archive', 'jls': 'image/jls', 'jp2': 'image/jp2', 'jpe': 'image/jpeg', 'jpeg': 'image/jpeg', 'jpf': 'image/jpx', 'jpg': 'image/jpeg', 'jpg2': 'image/jp2', 'jpgm': 'video/jpm', 'jpgv': 'video/jpeg', 'jpm': 'image/jpm', 'jpx': 'image/jpx', 'js': 'application/javascript', 'json': 'application/json', 'json5': 'application/json5', 'jsx': 'text/jsx', 'jxr': 'image/jxr', 'kar': 'audio/midi', 'ktx': 'image/ktx', 'less': 'text/less', 'list': 'text/plain', 'litcoffee': 'text/coffeescript', 'log': 'text/plain', 'm1v': 'video/mpeg', 'm21': 'application/mp21', 'm2a': 'audio/mpeg', 'm2v': 'video/mpeg', 'm3a': 'audio/mpeg', 'm4a': 'audio/mp4', 'm4p': 'application/mp4', 'man': 'text/troff', 'manifest': 'text/cache-manifest', 'markdown': 'text/markdown', 'mathml': 'application/mathml+xml', 'md': 'text/markdown', 'mdx': 'text/mdx', 'me': 'text/troff', 'mesh': 'model/mesh', 'mft': 'application/rpki-manifest', 'mid': 'audio/midi', 'midi': 'audio/midi', 'mj2': 'video/mj2', 'mjp2': 'video/mj2', 'mjs': 'application/javascript', 'mml': 'text/mathml', 'mov': 'video/quicktime', 'mp2': 'audio/mpeg', 'mp21': 'application/mp21', 'mp2a': 'audio/mpeg', 'mp3': 'audio/mpeg', 'mp4': 'video/mp4', 'mp4a': 'audio/mp4', 'mp4s': 'application/mp4', 'mp4v': 'video/mp4', 'mpe': 'video/mpeg', 'mpeg': 'video/mpeg', 'mpg': 'video/mpeg', 'mpg4': 'video/mp4', 'mpga': 'audio/mpeg', 'mrc': 'application/marc', 'ms': 'text/troff', 'msh': 'model/mesh', 'n3': 'text/n3', 'oga': 'audio/ogg', 'ogg': 'audio/ogg', 'ogv': 'video/ogg', 'ogx': 'application/ogg', 'otf': 'font/otf', 'p10': 'application/pkcs10', 'p7c': 'application/pkcs7-mime', 'p7m': 'application/pkcs7-mime', 'p7s': 'application/pkcs7-signature', 'p8': 'application/pkcs8', 'pdf': 'application/pdf', 'pki': 'application/pkixcmp', 'pkipath': 'application/pkix-pkipath', 'png': 'image/png', 'ps': 'application/postscript', 'pskcxml': 'application/pskc+xml', 'qt': 'video/quicktime', 'rmi': 'audio/midi', 'rng': 'application/xml', 'roa': 'application/rpki-roa', 'roff': 'text/troff', 'rsd': 'application/rsd+xml', 'rss': 'application/rss+xml', 'rtf': 'application/rtf', 'rtx': 'text/richtext', 's3m': 'audio/s3m', 'sgi': 'image/sgi', 'sgm': 'text/sgml', 'sgml': 'text/sgml', 'shex': 'text/shex', 'shtml': 'text/html', 'sil': 'audio/silk', 'silo': 'model/mesh', 'slim': 'text/slim', 'slm': 'text/slim', 'snd': 'audio/basic', 'spx': 'audio/ogg', 'stl': 'model/stl', 'styl': 'text/stylus', 'stylus': 'text/stylus', 'svg': 'image/svg+xml', 'svgz': 'image/svg+xml', 't': 'text/troff', 't38': 'image/t38', 'text': 'text/plain', 'tfx': 'image/tiff-fx', 'tif': 'image/tiff', 'tiff': 'image/tiff', 'tr': 'text/troff', 'ts': 'video/mp2t', 'tsv': 'text/tab-separated-values', 'ttc': 'font/collection', 'ttf': 'font/ttf', 'ttl': 'text/turtle', 'txt': 'text/plain', 'uri': 'text/uri-list', 'uris': 'text/uri-list', 'urls': 'text/uri-list', 'vcard': 'text/vcard', 'vrml': 'model/vrml', 'vtt': 'text/vtt', 'war': 'application/java-archive', 'wasm': 'application/wasm', 'wav': 'audio/wav', 'weba': 'audio/webm', 'webm': 'video/webm', 'webmanifest': 'application/manifest+json', 'webp': 'image/webp', 'wmf': 'image/wmf', 'woff': 'font/woff', 'woff2': 'font/woff2', 'wrl': 'model/vrml', 'x3d': 'model/x3d+xml', 'x3db': 'model/x3d+fastinfoset', 'x3dbz': 'model/x3d+binary', 'x3dv': 'model/x3d-vrml', 'x3dvz': 'model/x3d+vrml', 'x3dz': 'model/x3d+xml', 'xaml': 'application/xaml+xml', 'xht': 'application/xhtml+xml', 'xhtml': 'application/xhtml+xml', 'xm': 'audio/xm', 'xml': 'text/xml', 'xsd': 'application/xml', 'xsl': 'application/xml', 'xslt': 'application/xslt+xml', 'yaml': 'text/yaml', 'yml': 'text/yaml', 'zip': 'application/zip' }; module.exports = {TestServer};