2019-11-19 05:18:28 +03:00
|
|
|
/**
|
|
|
|
* 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
|
2020-02-11 00:20:13 +03:00
|
|
|
* @return {!Promise<TestServer>}
|
2019-11-19 05:18:28 +03:00
|
|
|
*/
|
|
|
|
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
|
2020-02-11 00:20:13 +03:00
|
|
|
* @return {!Promise<TestServer>}
|
2019-11-19 05:18:28 +03:00
|
|
|
*/
|
|
|
|
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));
|
2020-01-21 22:48:48 +03:00
|
|
|
this._wsServer = new WebSocketServer({server: this._server, path: '/ws'});
|
2019-11-19 05:18:28 +03:00
|
|
|
this._wsServer.on('connection', this._onWebSocketConnection.bind(this));
|
|
|
|
this._server.listen(port);
|
|
|
|
this._dirPath = dirPath;
|
|
|
|
|
|
|
|
this._startTime = new Date();
|
|
|
|
this._cachedPathPrefix = null;
|
|
|
|
|
2020-02-11 00:20:13 +03:00
|
|
|
/** @type {!Set<!NodeJS.Socket>} */
|
2019-11-19 05:18:28 +03:00
|
|
|
this._sockets = new Set();
|
2020-02-11 05:35:47 +03:00
|
|
|
/** @type {!Map<string, function(!http.IncomingMessage,http.ServerResponse)>} */
|
2019-11-19 05:18:28 +03:00
|
|
|
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
|
2020-02-11 05:35:47 +03:00
|
|
|
* @param {function(!http.IncomingMessage,http.ServerResponse)} handler
|
2019-11-19 05:18:28 +03:00
|
|
|
*/
|
|
|
|
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
|
2020-02-11 05:35:47 +03:00
|
|
|
* @return {!Promise<!http.IncomingMessage>}
|
2019-11-19 05:18:28 +03:00
|
|
|
*/
|
|
|
|
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();
|
|
|
|
}
|
|
|
|
|
2020-02-11 05:35:47 +03:00
|
|
|
/**
|
|
|
|
* @param {http.IncomingMessage} request
|
|
|
|
* @param {http.ServerResponse} response
|
|
|
|
*/
|
2019-11-19 05:18:28 +03:00
|
|
|
_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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2020-02-11 05:35:47 +03:00
|
|
|
* @param {!http.IncomingMessage} request
|
|
|
|
* @param {!http.ServerResponse} response
|
2019-11-19 05:18:28 +03:00
|
|
|
* @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;
|
|
|
|
}
|
2020-01-04 02:34:51 +03:00
|
|
|
response.setHeader('Cache-Control', 'public, max-age=31536000, no-cache');
|
2019-11-19 05:18:28 +03:00
|
|
|
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;
|
|
|
|
}
|
2020-02-10 22:18:03 +03:00
|
|
|
const extension = filePath.substring(filePath.lastIndexOf('.') + 1);
|
|
|
|
const mimeType = extensionToMime[extension] || 'application/octet-stream';
|
2019-11-19 05:18:28 +03:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-01-21 22:48:48 +03:00
|
|
|
_onWebSocketConnection(ws) {
|
|
|
|
ws.send('incoming');
|
2019-11-19 05:18:28 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-02-10 22:18:03 +03:00
|
|
|
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'
|
|
|
|
};
|
|
|
|
|
2019-11-19 05:18:28 +03:00
|
|
|
module.exports = {TestServer};
|