mirror of
				https://github.com/microsoft/playwright.git
				synced 2025-06-26 21:40:17 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			482 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			482 lines
		
	
	
		
			14 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 WebSocketServer = require('ws').Server;
 | |
| 
 | |
| const fulfillSymbol = Symbol('fullfil callback');
 | |
| const rejectSymbol = Symbol('reject callback');
 | |
| 
 | |
| class TestServer {
 | |
|   /**
 | |
|    * @param {string} dirPath
 | |
|    * @param {number} port
 | |
|    * @return {!Promise<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
 | |
|    * @return {!Promise<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, 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<!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();
 | |
|   }
 | |
| 
 | |
|   _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<!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 = '';
 | |
|       request.on('data', chunk => body += chunk);
 | |
|       request.on('end', () => resolve(body));
 | |
|     });
 | |
|     const pathName = url.parse(request.url).path;
 | |
|     this.debugServer(`request ${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};
 | 
