2022-06-15 10:05:25 +00: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.
|
|
|
|
*/
|
|
|
|
|
|
|
|
import assert from 'assert';
|
2022-06-22 13:25:44 +00:00
|
|
|
import {readFile, readFileSync} from 'fs';
|
2022-06-15 10:05:25 +00:00
|
|
|
import {
|
|
|
|
createServer as createHttpServer,
|
|
|
|
IncomingMessage,
|
|
|
|
RequestListener,
|
|
|
|
Server as HttpServer,
|
|
|
|
ServerResponse,
|
|
|
|
} from 'http';
|
|
|
|
import {
|
|
|
|
createServer as createHttpsServer,
|
|
|
|
Server as HttpsServer,
|
|
|
|
ServerOptions as HttpsServerOptions,
|
|
|
|
} from 'https';
|
2022-06-22 13:25:44 +00:00
|
|
|
import {getType as getMimeType} from 'mime';
|
|
|
|
import {join} from 'path';
|
|
|
|
import {Duplex} from 'stream';
|
|
|
|
import {Server as WebSocketServer, WebSocket} from 'ws';
|
|
|
|
import {gzip} from 'zlib';
|
2022-06-15 10:05:25 +00:00
|
|
|
|
|
|
|
interface Subscriber {
|
|
|
|
resolve: (msg: IncomingMessage) => void;
|
|
|
|
reject: (err?: Error) => void;
|
|
|
|
promise: Promise<IncomingMessage>;
|
|
|
|
}
|
|
|
|
|
2022-06-22 13:25:44 +00:00
|
|
|
type TestIncomingMessage = IncomingMessage & {postBody?: Promise<string>};
|
2022-06-15 10:05:25 +00:00
|
|
|
|
|
|
|
export class TestServer {
|
2022-06-15 10:09:22 +00:00
|
|
|
PORT!: number;
|
|
|
|
PREFIX!: string;
|
|
|
|
CROSS_PROCESS_PREFIX!: string;
|
|
|
|
EMPTY_PAGE!: string;
|
2022-06-15 10:05:25 +00:00
|
|
|
|
|
|
|
#dirPath: string;
|
|
|
|
#server: HttpsServer | HttpServer;
|
|
|
|
#wsServer: WebSocketServer;
|
|
|
|
|
|
|
|
#startTime = new Date();
|
|
|
|
#cachedPathPrefix?: string;
|
|
|
|
|
|
|
|
#connections = new Set<Duplex>();
|
|
|
|
#routes = new Map<
|
|
|
|
string,
|
|
|
|
(msg: IncomingMessage, res: ServerResponse) => void
|
|
|
|
>();
|
2022-06-22 13:25:44 +00:00
|
|
|
#auths = new Map<string, {username: string; password: string}>();
|
2022-06-15 10:05:25 +00:00
|
|
|
#csp = new Map<string, string>();
|
|
|
|
#gzipRoutes = new Set<string>();
|
|
|
|
#requestSubscribers = new Map<string, Subscriber>();
|
|
|
|
|
|
|
|
static async create(dirPath: string, port: number): Promise<TestServer> {
|
|
|
|
const server = new TestServer(dirPath, port);
|
2022-06-22 13:25:44 +00:00
|
|
|
await new Promise(x => {
|
2022-06-15 10:42:21 +00:00
|
|
|
return server.#server.once('listening', x);
|
|
|
|
});
|
2022-06-15 10:05:25 +00:00
|
|
|
return server;
|
|
|
|
}
|
|
|
|
|
|
|
|
static async createHTTPS(dirPath: string, port: number): Promise<TestServer> {
|
|
|
|
const server = new TestServer(dirPath, port, {
|
|
|
|
key: readFileSync(join(__dirname, '..', 'key.pem')),
|
|
|
|
cert: readFileSync(join(__dirname, '..', 'cert.pem')),
|
|
|
|
passphrase: 'aaaa',
|
|
|
|
});
|
2022-06-22 13:25:44 +00:00
|
|
|
await new Promise(x => {
|
2022-06-15 10:42:21 +00:00
|
|
|
return server.#server.once('listening', x);
|
|
|
|
});
|
2022-06-15 10:05:25 +00:00
|
|
|
return server;
|
|
|
|
}
|
|
|
|
|
|
|
|
constructor(dirPath: string, port: number, sslOptions?: HttpsServerOptions) {
|
|
|
|
this.#dirPath = dirPath;
|
|
|
|
|
|
|
|
if (sslOptions) {
|
|
|
|
this.#server = createHttpsServer(sslOptions, this.#onRequest);
|
|
|
|
} else {
|
|
|
|
this.#server = createHttpServer(this.#onRequest);
|
|
|
|
}
|
|
|
|
this.#server.on('connection', this.#onServerConnection);
|
2022-06-22 13:25:44 +00:00
|
|
|
this.#wsServer = new WebSocketServer({server: this.#server});
|
2022-06-15 10:05:25 +00:00
|
|
|
this.#wsServer.on('connection', this.#onWebSocketConnection);
|
|
|
|
this.#server.listen(port);
|
|
|
|
}
|
|
|
|
|
|
|
|
#onServerConnection = (connection: Duplex): void => {
|
|
|
|
this.#connections.add(connection);
|
|
|
|
// ECONNRESET is a legit error given
|
|
|
|
// that tab closing simply kills process.
|
2022-06-22 13:25:44 +00:00
|
|
|
connection.on('error', error => {
|
2022-06-15 10:05:25 +00:00
|
|
|
if ((error as NodeJS.ErrnoException).code !== 'ECONNRESET') {
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
});
|
2022-06-15 10:42:21 +00:00
|
|
|
connection.once('close', () => {
|
|
|
|
return this.#connections.delete(connection);
|
|
|
|
});
|
2022-06-15 10:05:25 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
enableHTTPCache(pathPrefix: string): void {
|
|
|
|
this.#cachedPathPrefix = pathPrefix;
|
|
|
|
}
|
|
|
|
|
|
|
|
setAuth(path: string, username: string, password: string): void {
|
2022-06-22 13:25:44 +00:00
|
|
|
this.#auths.set(path, {username, password});
|
2022-06-15 10:05:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
enableGzip(path: string): void {
|
|
|
|
this.#gzipRoutes.add(path);
|
|
|
|
}
|
|
|
|
|
|
|
|
setCSP(path: string, csp: string): void {
|
|
|
|
this.#csp.set(path, csp);
|
|
|
|
}
|
|
|
|
|
|
|
|
async stop(): Promise<void> {
|
|
|
|
this.reset();
|
|
|
|
for (const socket of this.#connections) {
|
|
|
|
socket.destroy();
|
|
|
|
}
|
|
|
|
this.#connections.clear();
|
2022-06-22 13:25:44 +00:00
|
|
|
await new Promise(x => {
|
2022-06-15 10:42:21 +00:00
|
|
|
return this.#server.close(x);
|
|
|
|
});
|
2022-06-15 10:05:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
setRoute(
|
|
|
|
path: string,
|
|
|
|
handler: (req: IncomingMessage, res: ServerResponse) => void
|
|
|
|
): void {
|
|
|
|
this.#routes.set(path, handler);
|
|
|
|
}
|
|
|
|
|
|
|
|
setRedirect(from: string, to: string): void {
|
|
|
|
this.setRoute(from, (_, res) => {
|
2022-06-22 13:25:44 +00:00
|
|
|
res.writeHead(302, {location: to});
|
2022-06-15 10:05:25 +00:00
|
|
|
res.end();
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
waitForRequest(path: string): Promise<TestIncomingMessage> {
|
|
|
|
const subscriber = this.#requestSubscribers.get(path);
|
|
|
|
if (subscriber) {
|
|
|
|
return subscriber.promise;
|
|
|
|
}
|
|
|
|
let resolve!: (value: IncomingMessage) => void;
|
|
|
|
let reject!: (reason?: Error) => void;
|
|
|
|
const promise = new Promise<IncomingMessage>((res, rej) => {
|
|
|
|
resolve = res;
|
|
|
|
reject = rej;
|
|
|
|
});
|
2022-06-22 13:25:44 +00:00
|
|
|
this.#requestSubscribers.set(path, {resolve, reject, promise});
|
2022-06-15 10:05:25 +00:00
|
|
|
return promise;
|
|
|
|
}
|
|
|
|
|
|
|
|
reset(): void {
|
|
|
|
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.reject.call(undefined, error);
|
|
|
|
}
|
|
|
|
this.#requestSubscribers.clear();
|
|
|
|
}
|
|
|
|
|
|
|
|
#onRequest: RequestListener = (
|
|
|
|
request: TestIncomingMessage,
|
|
|
|
response
|
|
|
|
): void => {
|
2022-06-22 13:25:44 +00:00
|
|
|
request.on('error', (error: {code: string}) => {
|
2022-06-15 10:05:25 +00:00
|
|
|
if (error.code === 'ECONNRESET') {
|
|
|
|
response.end();
|
|
|
|
} else {
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
});
|
2022-06-22 13:25:44 +00:00
|
|
|
request.postBody = new Promise(resolve => {
|
2022-06-15 10:05:25 +00:00
|
|
|
let body = '';
|
2022-06-15 10:42:21 +00:00
|
|
|
request.on('data', (chunk: string) => {
|
|
|
|
return (body += chunk);
|
|
|
|
});
|
|
|
|
request.on('end', () => {
|
|
|
|
return resolve(body);
|
|
|
|
});
|
2022-06-15 10:05:25 +00:00
|
|
|
});
|
|
|
|
assert(request.url);
|
|
|
|
const url = new URL(request.url, `https://${request.headers.host}`);
|
|
|
|
const path = url.pathname + url.search;
|
|
|
|
const auth = this.#auths.get(path);
|
|
|
|
if (auth) {
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const subscriber = this.#requestSubscribers.get(path);
|
|
|
|
if (subscriber) {
|
|
|
|
subscriber.resolve.call(undefined, request);
|
|
|
|
this.#requestSubscribers.delete(path);
|
|
|
|
}
|
|
|
|
const handler = this.#routes.get(path);
|
|
|
|
if (handler) {
|
|
|
|
handler.call(undefined, request, response);
|
|
|
|
} else {
|
|
|
|
this.serveFile(request, response, path);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
serveFile(
|
|
|
|
request: IncomingMessage,
|
|
|
|
response: ServerResponse,
|
|
|
|
pathName: string
|
|
|
|
): void {
|
|
|
|
if (pathName === '/') {
|
|
|
|
pathName = '/index.html';
|
|
|
|
}
|
|
|
|
const filePath = join(this.#dirPath, pathName.substring(1));
|
|
|
|
|
|
|
|
if (this.#cachedPathPrefix && 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');
|
|
|
|
response.setHeader('Last-Modified', this.#startTime.toISOString());
|
|
|
|
} else {
|
|
|
|
response.setHeader('Cache-Control', 'no-cache, no-store');
|
|
|
|
}
|
|
|
|
const csp = this.#csp.get(pathName);
|
|
|
|
if (csp) {
|
|
|
|
response.setHeader('Content-Security-Policy', csp);
|
|
|
|
}
|
|
|
|
|
|
|
|
readFile(filePath, (err, data) => {
|
|
|
|
if (err) {
|
|
|
|
response.statusCode = 404;
|
|
|
|
response.end(`File not found: ${filePath}`);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const mimeType = getMimeType(filePath);
|
|
|
|
if (mimeType) {
|
|
|
|
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');
|
|
|
|
gzip(data, (_, result) => {
|
|
|
|
response.end(result);
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
response.end(data);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
#onWebSocketConnection = (socket: WebSocket): void => {
|
|
|
|
socket.send('opened');
|
|
|
|
};
|
|
|
|
}
|