mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-17 08:11:49 +03: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));
|
|
}
|
|
}
|