mirror of
				https://github.com/microsoft/playwright.git
				synced 2025-06-26 21:40:17 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			298 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			298 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
/**
 | 
						|
 * Copyright 2017 Google Inc. All rights reserved.
 | 
						|
 * Modifications copyright (c) Microsoft Corporation.
 | 
						|
 *
 | 
						|
 * 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.
 | 
						|
 */
 | 
						|
 | 
						|
import fs from 'fs';
 | 
						|
import http from 'http';
 | 
						|
import https from 'https';
 | 
						|
import mime from 'mime';
 | 
						|
import type net from 'net';
 | 
						|
import path from 'path';
 | 
						|
import url from 'url';
 | 
						|
import util from 'util';
 | 
						|
import ws from 'ws';
 | 
						|
import zlib, { gzip } from 'zlib';
 | 
						|
 | 
						|
const fulfillSymbol = Symbol('fullfil callback');
 | 
						|
const rejectSymbol = Symbol('reject callback');
 | 
						|
 | 
						|
const gzipAsync = util.promisify(gzip.bind(zlib));
 | 
						|
 | 
						|
export class TestServer {
 | 
						|
  private _server: http.Server;
 | 
						|
  private _wsServer: ws.WebSocketServer;
 | 
						|
  private _dirPath: string;
 | 
						|
  readonly debugServer: any;
 | 
						|
  private _startTime: Date;
 | 
						|
  private _cachedPathPrefix: string | null;
 | 
						|
  private _sockets = new Set<net.Socket>();
 | 
						|
  private _routes = new Map<string, (arg0: http.IncomingMessage, arg1: http.ServerResponse) => any>();
 | 
						|
  private _auths = new Map<string, { username: string; password: string; }>();
 | 
						|
  private _csp = new Map<string, string>();
 | 
						|
  private _extraHeaders = new Map<string, object>();
 | 
						|
  private _gzipRoutes = new Set<string>();
 | 
						|
  private _requestSubscribers = new Map<string, Promise<any>>();
 | 
						|
  readonly PORT: number;
 | 
						|
  readonly PREFIX: string;
 | 
						|
  readonly CROSS_PROCESS_PREFIX: string;
 | 
						|
  readonly EMPTY_PAGE: string;
 | 
						|
 | 
						|
  static async create(dirPath: string, port: number, loopback?: string): Promise<TestServer> {
 | 
						|
    const server = new TestServer(dirPath, port, loopback);
 | 
						|
    await new Promise(x => server._server.once('listening', x));
 | 
						|
    return server;
 | 
						|
  }
 | 
						|
 | 
						|
  static async createHTTPS(dirPath: string, port: number, loopback?: string): Promise<TestServer> {
 | 
						|
    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;
 | 
						|
  }
 | 
						|
 | 
						|
  constructor(dirPath: string, port: number, loopback?: string, sslOptions?: object) {
 | 
						|
    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 ws.WebSocketServer({ noServer: true });
 | 
						|
    this._server.on('upgrade', async (request, socket, head) => {
 | 
						|
      const pathname = url.parse(request.url!).path;
 | 
						|
      if (pathname === '/ws-401') {
 | 
						|
        socket.write('HTTP/1.1 401 Unauthorized\r\n\r\nUnauthorized body');
 | 
						|
        socket.destroy();
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      if (pathname === '/ws-slow')
 | 
						|
        await new Promise(f => setTimeout(f, 2000));
 | 
						|
      if (!['/ws', '/ws-slow'].includes(pathname)) {
 | 
						|
        socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
 | 
						|
        socket.destroy();
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      this._wsServer.handleUpgrade(request, socket, head, ws => {
 | 
						|
        // Next emit is only for our internal 'connection' listeners.
 | 
						|
        this._wsServer.emit('connection', ws, request);
 | 
						|
      });
 | 
						|
    });
 | 
						|
    this._server.listen(port);
 | 
						|
    this._dirPath = dirPath;
 | 
						|
    this.debugServer = require('debug')('pw:testserver');
 | 
						|
 | 
						|
    this._startTime = new Date();
 | 
						|
    this._cachedPathPrefix = null;
 | 
						|
 | 
						|
    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: net.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 as any).code !== 'ECONNRESET' && (error as any).code !== 'HPE_INVALID_EOF_STATE')
 | 
						|
        throw error;
 | 
						|
    });
 | 
						|
    socket.once('close', () => this._sockets.delete(socket));
 | 
						|
  }
 | 
						|
 | 
						|
  enableHTTPCache(pathPrefix: string) {
 | 
						|
    this._cachedPathPrefix = pathPrefix;
 | 
						|
  }
 | 
						|
 | 
						|
  setAuth(path: string, username: string, password: string) {
 | 
						|
    this.debugServer(`set auth for ${path} to ${username}:${password}`);
 | 
						|
    this._auths.set(path, { username, password });
 | 
						|
  }
 | 
						|
 | 
						|
  enableGzip(path: string) {
 | 
						|
    this._gzipRoutes.add(path);
 | 
						|
  }
 | 
						|
 | 
						|
  setCSP(path: string, csp: string) {
 | 
						|
    this._csp.set(path, csp);
 | 
						|
  }
 | 
						|
 | 
						|
  setExtraHeaders(path: string, object: Record<string, string>) {
 | 
						|
    this._extraHeaders.set(path, object);
 | 
						|
  }
 | 
						|
 | 
						|
  async stop() {
 | 
						|
    this.reset();
 | 
						|
    for (const socket of this._sockets)
 | 
						|
      socket.destroy();
 | 
						|
    this._sockets.clear();
 | 
						|
    await new Promise(x => this._server.close(x));
 | 
						|
  }
 | 
						|
 | 
						|
  setRoute(path: string, handler: (arg0: http.IncomingMessage & { postBody: Promise<Buffer> }, arg1: http.ServerResponse) => any) {
 | 
						|
    this._routes.set(path, handler);
 | 
						|
  }
 | 
						|
 | 
						|
  setRedirect(from: string, to: string) {
 | 
						|
    this.setRoute(from, (req, res) => {
 | 
						|
      const headers = this._extraHeaders.get(req.url!) || {};
 | 
						|
      res.writeHead(302, { ...headers, location: to });
 | 
						|
      res.end();
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  waitForRequest(path: string): Promise<http.IncomingMessage & { postBody: Promise<Buffer> }> {
 | 
						|
    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._extraHeaders.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: http.IncomingMessage, response: http.ServerResponse) {
 | 
						|
    request.on('error', error => {
 | 
						|
      if ((error as any).code === 'ECONNRESET')
 | 
						|
        response.end();
 | 
						|
      else
 | 
						|
        throw error;
 | 
						|
    });
 | 
						|
    (request as any).postBody = new Promise(resolve => {
 | 
						|
      const chunks: Buffer[] = [];
 | 
						|
      request.on('data', chunk => {
 | 
						|
        chunks.push(chunk);
 | 
						|
      });
 | 
						|
      request.on('end', () => resolve(Buffer.concat(chunks)));
 | 
						|
    });
 | 
						|
    const path = url.parse(request.url!).path;
 | 
						|
    this.debugServer(`request ${request.method} ${path}`);
 | 
						|
    if (this._auths.has(path)) {
 | 
						|
      const auth = this._auths.get(path)!;
 | 
						|
      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(path)) {
 | 
						|
      this._requestSubscribers.get(path)![fulfillSymbol].call(null, request);
 | 
						|
      this._requestSubscribers.delete(path);
 | 
						|
    }
 | 
						|
    const handler = this._routes.get(path);
 | 
						|
    if (handler)
 | 
						|
      handler.call(null, request, response);
 | 
						|
    else
 | 
						|
      this.serveFile(request, response);
 | 
						|
  }
 | 
						|
 | 
						|
  async serveFile(request: http.IncomingMessage, response: http.ServerResponse, filePath?: string) {
 | 
						|
    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)!);
 | 
						|
 | 
						|
    if (this._extraHeaders.has(pathName)) {
 | 
						|
      const object = this._extraHeaders.get(pathName);
 | 
						|
      for (const key in object)
 | 
						|
        response.setHeader(key, object[key]);
 | 
						|
    }
 | 
						|
 | 
						|
    const { err, data } = await fs.promises.readFile(filePath).then(data => ({ data, err: undefined })).catch(err => ({ data: undefined, 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.setHeader('Content-Type', 'text/plain');
 | 
						|
      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);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  onceWebSocketConnection(handler: (socket: ws.WebSocket, request: http.IncomingMessage) => void) {
 | 
						|
    this._wsServer.once('connection', handler);
 | 
						|
  }
 | 
						|
 | 
						|
  waitForWebSocketConnectionRequest() {
 | 
						|
    return new Promise<http.IncomingMessage & { headers: http.IncomingHttpHeaders }>(fullfil => {
 | 
						|
      this._wsServer.once('connection', (ws, req) => fullfil(req));
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  sendOnWebSocketConnection(data) {
 | 
						|
    this.onceWebSocketConnection(ws => ws.send(data));
 | 
						|
  }
 | 
						|
}
 |