chore: migrate http server to ts (#17677)

This commit is contained in:
Pavel Feldman 2022-09-28 16:01:13 -08:00 committed by GitHub
parent 6fc7d20e35
commit cadd4d1dd0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 136 additions and 187 deletions

19
package-lock.json generated
View file

@ -26,6 +26,7 @@
"@types/react": "^18.0.12", "@types/react": "^18.0.12",
"@types/react-dom": "^18.0.5", "@types/react-dom": "^18.0.5",
"@types/resize-observer-browser": "^0.1.7", "@types/resize-observer-browser": "^0.1.7",
"@types/ws": "^8.5.3",
"@types/xml2js": "^0.4.9", "@types/xml2js": "^0.4.9",
"@typescript-eslint/eslint-plugin": "^5.10.2", "@typescript-eslint/eslint-plugin": "^5.10.2",
"@typescript-eslint/parser": "^5.10.2", "@typescript-eslint/parser": "^5.10.2",
@ -1294,6 +1295,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
"integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/xml2js": { "node_modules/@types/xml2js": {
"version": "0.4.9", "version": "0.4.9",
"dev": true, "dev": true,
@ -9205,6 +9215,15 @@
"version": "0.16.1", "version": "0.16.1",
"dev": true "dev": true
}, },
"@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
"integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/xml2js": { "@types/xml2js": {
"version": "0.4.9", "version": "0.4.9",
"dev": true, "dev": true,

View file

@ -62,6 +62,7 @@
"@types/react": "^18.0.12", "@types/react": "^18.0.12",
"@types/react-dom": "^18.0.5", "@types/react-dom": "^18.0.5",
"@types/resize-observer-browser": "^0.1.7", "@types/resize-observer-browser": "^0.1.7",
"@types/ws": "^8.5.3",
"@types/xml2js": "^0.4.9", "@types/xml2js": "^0.4.9",
"@typescript-eslint/eslint-plugin": "^5.10.2", "@typescript-eslint/eslint-plugin": "^5.10.2",
"@typescript-eslint/parser": "^5.10.2", "@typescript-eslint/parser": "^5.10.2",

View file

@ -15,7 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import type { ServerResponse } from '../../utils/testserver'; import type { ServerResponse } from 'http';
import { test as it, expect } from './pageTest'; import { test as it, expect } from './pageTest';
it('Page.Events.Request @smoke', async ({ page, server }) => { it('Page.Events.Request @smoke', async ({ page, server }) => {

View file

@ -1,15 +1,26 @@
const { TestServer } = require('../../../utils/testserver/'); const http = require('http');
TestServer.create(__dirname, process.argv[2] || 3000).then(server => {
console.log('listening on port', server.PORT); const port = process.argv[2] || 3000;
let ready = false;
setTimeout(() => ready = true, 750); let ready = false;
server.setRoute('/ready', (message, response) => { setTimeout(() => ready = true, 750);
const requestListener = function (req, res) {
if (req.url === '/ready') {
if (ready) { if (ready) {
response.statusCode = 200; res.writeHead(200);
response.end('hello'); res.end('hello');
} else { } else {
response.statusCode = 404; res.writeHead(404);
response.end('not-ready'); res.end('not-ready');
} }
}); } else {
res.writeHead(404);
res.end();
}
};
const server = http.createServer(requestListener);
server.listen(port, () => {
console.log('listening on port', port);
}); });

View file

@ -1,21 +1,36 @@
const { TestServer } = require('../../../utils/testserver/'); const http = require('http');
console.error('error from server'); console.error('error from server');
const port = process.argv[2] || 3000;
const requestListener = function (req, res) {
if (req.url === '/hello') {
res.end('hello');
return;
}
if (req.url === '/env-FOO') {
res.end(process.env.FOO);
return;
}
if (req.url === '/port') {
res.end('' + port);
return;
}
if (req.url === '/redirect') {
res.writeHead(301, 'Moved');
res.end();
return;
}
res.writeHead(404);
res.end();
};
const server = http.createServer(requestListener);
// delay creating the server to test waiting for it // delay creating the server to test waiting for it
setTimeout(() => { setTimeout(() => {
TestServer.create(__dirname, process.argv[2] || 3000).then(server => { server.listen(port, () => {
console.log('listening on port', server.PORT); console.log('listening on port', port);
server.setRoute('/hello', (message, response) => {
response.end('hello');
});
server.setRoute('/env-FOO', (message, response) => {
response.end(process.env.FOO);
});
server.setRoute('/port', (_, response) => {
response.end('' + server.PORT);
});
server.setRoute('/redirect', (_, response) => {
response.writeHead(301, 'Moved');
response.end();
});
}); });
}, process.argv[3] ? +process.argv[3] : 0); }, process.argv[3] ? +process.argv[3] : 0);

View file

@ -1,44 +0,0 @@
/**
* 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.
*/
type ServerResponse = import('http').ServerResponse;
type IncomingMessage = import('http').IncomingMessage;
import WebSocket from 'ws';
export class TestServer {
static create(dirPath: string, port: number, loopback?: string): Promise<TestServer>;
static createHTTPS(dirPath: string, port: number, loopback?: string): Promise<TestServer>;
enableHTTPCache(pathPrefix: string);
setAuth(path: string, username: string, password: string);
enableGzip(path: string);
setCSP(path: string, csp: string);
setExtraHeaders(path: string, headers: { [key: string]: string });
stop(): Promise<void>;
setRoute(path: string, handler: (message: IncomingMessage & { postBody: Promise<Buffer> }, response: ServerResponse) => void);
setRedirect(from: string, to: string);
waitForRequest(path: string): Promise<IncomingMessage & { postBody: Promise<Buffer> }>;
waitForWebSocketConnectionRequest(): Promise<IncomingMessage>;
onceWebSocketConnection(handler: (ws: WebSocket, request: IncomingMessage) => void);
sendOnWebSocketConnection(data: string);
reset();
serveFile(request: IncomingMessage, response: ServerResponse);
serveFile(request: IncomingMessage, response: ServerResponse, filePath: string);
PORT: number;
PREFIX: string;
CROSS_PROCESS_PREFIX: string;
EMPTY_PAGE: string;
}

View file

@ -1,5 +1,6 @@
/** /**
* Copyright 2017 Google Inc. All rights reserved. * Copyright 2017 Google Inc. All rights reserved.
* Modifications copyright (c) Microsoft Corporation.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,41 +15,48 @@
* limitations under the License. * limitations under the License.
*/ */
const http = require('http'); import fs from 'fs';
const https = require('https'); import http from 'http';
const url = require('url'); import https from 'https';
const fs = require('fs'); import mime from 'mime';
const path = require('path'); import type net from 'net';
const zlib = require('zlib'); import path from 'path';
const util = require('util'); import url from 'url';
const mime = require('mime'); import util from 'util';
const WebSocketServer = require('ws').Server; import ws from 'ws';
import zlib, { gzip } from 'zlib';
const fulfillSymbol = Symbol('fullfil callback'); const fulfillSymbol = Symbol('fullfil callback');
const rejectSymbol = Symbol('reject callback'); const rejectSymbol = Symbol('reject callback');
const gzipAsync = util.promisify(zlib.gzip.bind(zlib)); const gzipAsync = util.promisify(gzip.bind(zlib));
class TestServer { export class TestServer {
/** private _server: http.Server;
* @param {string} dirPath private _wsServer: ws.WebSocketServer;
* @param {number} port private _dirPath: string;
* @param {string=} loopback readonly debugServer: any;
* @return {!Promise<TestServer>} private _startTime: Date;
*/ private _cachedPathPrefix: string | null;
static async create(dirPath, port, loopback) { 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); const server = new TestServer(dirPath, port, loopback);
await new Promise(x => server._server.once('listening', x)); await new Promise(x => server._server.once('listening', x));
return server; return server;
} }
/** static async createHTTPS(dirPath: string, port: number, loopback?: string): Promise<TestServer> {
* @param {string} dirPath
* @param {number} port
* @param {string=} loopback
* @return {!Promise<TestServer>}
*/
static async createHTTPS(dirPath, port, loopback) {
const server = new TestServer(dirPath, port, loopback, { const server = new TestServer(dirPath, port, loopback, {
key: await fs.promises.readFile(path.join(__dirname, 'key.pem')), key: await fs.promises.readFile(path.join(__dirname, 'key.pem')),
cert: await fs.promises.readFile(path.join(__dirname, 'cert.pem')), cert: await fs.promises.readFile(path.join(__dirname, 'cert.pem')),
@ -58,21 +66,15 @@ class TestServer {
return server; return server;
} }
/** constructor(dirPath: string, port: number, loopback?: string, sslOptions?: object) {
* @param {string} dirPath
* @param {number} port
* @param {string=} loopback
* @param {!Object=} sslOptions
*/
constructor(dirPath, port, loopback, sslOptions) {
if (sslOptions) if (sslOptions)
this._server = https.createServer(sslOptions, this._onRequest.bind(this)); this._server = https.createServer(sslOptions, this._onRequest.bind(this));
else else
this._server = http.createServer(this._onRequest.bind(this)); this._server = http.createServer(this._onRequest.bind(this));
this._server.on('connection', socket => this._onSocket(socket)); this._server.on('connection', socket => this._onSocket(socket));
this._wsServer = new WebSocketServer({ noServer: true }); this._wsServer = new ws.WebSocketServer({ noServer: true });
this._server.on('upgrade', async (request, socket, head) => { this._server.on('upgrade', async (request, socket, head) => {
const pathname = url.parse(request.url).pathname; const pathname = url.parse(request.url!).path;
if (pathname === '/ws-slow') if (pathname === '/ws-slow')
await new Promise(f => setTimeout(f, 2000)); await new Promise(f => setTimeout(f, 2000));
if (!['/ws', '/ws-slow'].includes(pathname)) { if (!['/ws', '/ws-slow'].includes(pathname)) {
@ -92,21 +94,6 @@ class TestServer {
this._startTime = new Date(); this._startTime = new Date();
this._cachedPathPrefix = null; 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 {!Map<string, Object>} */
this._extraHeaders = new Map();
/** @type {!Set<string>} */
this._gzipRoutes = new Set();
/** @type {!Map<string, !Promise>} */
this._requestSubscribers = new Map();
const cross_origin = loopback || '127.0.0.1'; const cross_origin = loopback || '127.0.0.1';
const same_origin = loopback || 'localhost'; const same_origin = loopback || 'localhost';
const protocol = sslOptions ? 'https' : 'http'; const protocol = sslOptions ? 'https' : 'http';
@ -116,51 +103,35 @@ class TestServer {
this.EMPTY_PAGE = `${protocol}://${same_origin}:${port}/empty.html`; this.EMPTY_PAGE = `${protocol}://${same_origin}:${port}/empty.html`;
} }
_onSocket(socket) { _onSocket(socket: net.Socket) {
this._sockets.add(socket); this._sockets.add(socket);
// ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given // ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given
// that tab closing aborts outgoing connections to the server. // that tab closing aborts outgoing connections to the server.
socket.on('error', error => { socket.on('error', error => {
if (error.code !== 'ECONNRESET' && error.code !== 'HPE_INVALID_EOF_STATE') if ((error as any).code !== 'ECONNRESET' && (error as any).code !== 'HPE_INVALID_EOF_STATE')
throw error; throw error;
}); });
socket.once('close', () => this._sockets.delete(socket)); socket.once('close', () => this._sockets.delete(socket));
} }
/** enableHTTPCache(pathPrefix: string) {
* @param {string} pathPrefix
*/
enableHTTPCache(pathPrefix) {
this._cachedPathPrefix = pathPrefix; this._cachedPathPrefix = pathPrefix;
} }
/** setAuth(path: string, username: string, password: string) {
* @param {string} path
* @param {string} username
* @param {string} password
*/
setAuth(path, username, password) {
this.debugServer(`set auth for ${path} to ${username}:${password}`); this.debugServer(`set auth for ${path} to ${username}:${password}`);
this._auths.set(path, {username, password}); this._auths.set(path, { username, password });
} }
enableGzip(path) { enableGzip(path: string) {
this._gzipRoutes.add(path); this._gzipRoutes.add(path);
} }
/** setCSP(path: string, csp: string) {
* @param {string} path
* @param {string} csp
*/
setCSP(path, csp) {
this._csp.set(path, csp); this._csp.set(path, csp);
} }
/** setExtraHeaders(path: string, object: Record<string, string>) {
* @param {string} path
* @param {Object<string, string>} object
*/
setExtraHeaders(path, object) {
this._extraHeaders.set(path, object); this._extraHeaders.set(path, object);
} }
@ -172,31 +143,19 @@ class TestServer {
await new Promise(x => this._server.close(x)); await new Promise(x => this._server.close(x));
} }
/** setRoute(path: string, handler: (arg0: http.IncomingMessage & { postBody: Promise<Buffer> }, arg1: http.ServerResponse) => any) {
* @param {string} path
* @param {function(!http.IncomingMessage,http.ServerResponse)} handler
*/
setRoute(path, handler) {
this._routes.set(path, handler); this._routes.set(path, handler);
} }
/** setRedirect(from: string, to: string) {
* @param {string} from
* @param {string} to
*/
setRedirect(from, to) {
this.setRoute(from, (req, res) => { this.setRoute(from, (req, res) => {
let headers = this._extraHeaders.get(req.url) || {}; const headers = this._extraHeaders.get(req.url!) || {};
res.writeHead(302, { ...headers, location: to }); res.writeHead(302, { ...headers, location: to });
res.end(); res.end();
}); });
} }
/** waitForRequest(path: string): Promise<http.IncomingMessage & { postBody: Promise<Buffer> }> {
* @param {string} path
* @return {!Promise<!http.IncomingMessage>}
*/
waitForRequest(path) {
let promise = this._requestSubscribers.get(path); let promise = this._requestSubscribers.get(path);
if (promise) if (promise)
return promise; return promise;
@ -223,28 +182,24 @@ class TestServer {
this._requestSubscribers.clear(); this._requestSubscribers.clear();
} }
/** _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
* @param {http.IncomingMessage} request
* @param {http.ServerResponse} response
*/
_onRequest(request, response) {
request.on('error', error => { request.on('error', error => {
if (error.code === 'ECONNRESET') if ((error as any).code === 'ECONNRESET')
response.end(); response.end();
else else
throw error; throw error;
}); });
request.postBody = new Promise(resolve => { (request as any).postBody = new Promise(resolve => {
const chunks = []; const chunks: Buffer[] = [];
request.on('data', chunk => { request.on('data', chunk => {
chunks.push(chunk); chunks.push(chunk);
}); });
request.on('end', () => resolve(Buffer.concat(chunks))); request.on('end', () => resolve(Buffer.concat(chunks)));
}); });
const path = url.parse(request.url).path; const path = url.parse(request.url!).path;
this.debugServer(`request ${request.method} ${path}`); this.debugServer(`request ${request.method} ${path}`);
if (this._auths.has(path)) { if (this._auths.has(path)) {
const auth = this._auths.get(path); const auth = this._auths.get(path)!;
const credentials = Buffer.from((request.headers.authorization || '').split(' ')[1] || '', 'base64').toString(); const credentials = Buffer.from((request.headers.authorization || '').split(' ')[1] || '', 'base64').toString();
this.debugServer(`request credentials ${credentials}`); this.debugServer(`request credentials ${credentials}`);
this.debugServer(`actual credentials ${auth.username}:${auth.password}`); this.debugServer(`actual credentials ${auth.username}:${auth.password}`);
@ -257,24 +212,18 @@ class TestServer {
} }
// Notify request subscriber. // Notify request subscriber.
if (this._requestSubscribers.has(path)) { if (this._requestSubscribers.has(path)) {
this._requestSubscribers.get(path)[fulfillSymbol].call(null, request); this._requestSubscribers.get(path)![fulfillSymbol].call(null, request);
this._requestSubscribers.delete(path); this._requestSubscribers.delete(path);
} }
const handler = this._routes.get(path); const handler = this._routes.get(path);
if (handler) { if (handler)
handler.call(null, request, response); handler.call(null, request, response);
} else { else
this.serveFile(request, response); this.serveFile(request, response);
} }
}
/** async serveFile(request: http.IncomingMessage, response: http.ServerResponse, filePath?: string) {
* @param {!http.IncomingMessage} request let pathName = url.parse(request.url!).path;
* @param {!http.ServerResponse} response
* @param {string|undefined} filePath
*/
async serveFile(request, response, filePath) {
let pathName = url.parse(request.url).path;
if (!filePath) { if (!filePath) {
if (pathName === '/') if (pathName === '/')
pathName = '/index.html'; pathName = '/index.html';
@ -293,7 +242,7 @@ class TestServer {
response.setHeader('Cache-Control', 'no-cache, no-store'); response.setHeader('Cache-Control', 'no-cache, no-store');
} }
if (this._csp.has(pathName)) if (this._csp.has(pathName))
response.setHeader('Content-Security-Policy', this._csp.get(pathName)); response.setHeader('Content-Security-Policy', this._csp.get(pathName)!);
if (this._extraHeaders.has(pathName)) { if (this._extraHeaders.has(pathName)) {
const object = this._extraHeaders.get(pathName); const object = this._extraHeaders.get(pathName);
@ -301,7 +250,7 @@ class TestServer {
response.setHeader(key, object[key]); response.setHeader(key, object[key]);
} }
const {err, data} = await fs.promises.readFile(filePath).then(data => ({data})).catch(err => ({err})); 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. // The HTTP transaction might be already terminated after async hop here - do nothing in this case.
if (response.writableEnded) if (response.writableEnded)
return; return;
@ -332,7 +281,7 @@ class TestServer {
} }
waitForWebSocketConnectionRequest() { waitForWebSocketConnectionRequest() {
return new Promise(fullfil => { return new Promise<http.IncomingMessage & { headers: http.IncomingHttpHeaders }>(fullfil => {
this._wsServer.once('connection', (ws, req) => fullfil(req)); this._wsServer.once('connection', (ws, req) => fullfil(req));
}); });
} }
@ -341,5 +290,3 @@ class TestServer {
this.onceWebSocketConnection(ws => ws.send(data)); this.onceWebSocketConnection(ws => ws.send(data));
} }
} }
module.exports = {TestServer};