feat: Split up server creation and request handling

This allows us to decouple the WebSocket listening from the HTTP configs,
making these features completely orthogonal.
This commit is contained in:
Joachim Van Herwegen
2022-09-29 16:34:38 +02:00
parent 764ce3cc28
commit 4223dcf8a4
64 changed files with 949 additions and 694 deletions

View File

@@ -1,108 +0,0 @@
import { readFileSync } from 'fs';
import type { Server, IncomingMessage, ServerResponse } from 'http';
import { createServer as createHttpServer } from 'http';
import { createServer as createHttpsServer } from 'https';
import { URL } from 'url';
import { getLoggerFor } from '../logging/LogUtil';
import { isError } from '../util/errors/ErrorUtil';
import { guardStream } from '../util/GuardedStream';
import type { HttpHandler } from './HttpHandler';
import type { HttpServerFactory } from './HttpServerFactory';
/**
* Options to be used when creating the server.
* Due to Components.js not supporting external types, this has been simplified (for now?).
* The common https keys here (key/cert/pfx) will be interpreted as file paths that need to be read
* before passing the options to the `createServer` function.
*/
export interface BaseHttpServerOptions {
/**
* If the server should start as an http or https server.
*/
https?: boolean;
/**
* If the error stack traces should be shown in case the HttpHandler throws one.
*/
showStackTrace?: boolean;
key?: string;
cert?: string;
pfx?: string;
passphrase?: string;
}
/**
* HttpServerFactory based on the native Node.js http module
*/
export class BaseHttpServerFactory implements HttpServerFactory {
protected readonly logger = getLoggerFor(this);
/** The main HttpHandler */
private readonly handler: HttpHandler;
private readonly options: BaseHttpServerOptions;
public constructor(handler: HttpHandler, options: BaseHttpServerOptions = { https: false }) {
this.handler = handler;
this.options = { ...options };
}
/**
* Creates and starts an HTTP(S) server
* @param port - Port on which the server listens
*/
public startServer(port: number): Server {
const protocol = this.options.https ? 'https' : 'http';
const url = new URL(`${protocol}://localhost:${port}/`).href;
this.logger.info(`Listening to server at ${url}`);
const createServer = this.options.https ? createHttpsServer : createHttpServer;
const options = this.createServerOptions();
const server = createServer(options,
async(request: IncomingMessage, response: ServerResponse): Promise<void> => {
try {
this.logger.info(`Received ${request.method} request for ${request.url}`);
const guardedRequest = guardStream(request);
guardedRequest.on('error', (error): void => {
this.logger.error(`Request error: ${error.message}`);
});
await this.handler.handleSafe({ request: guardedRequest, response });
} catch (error: unknown) {
let errMsg: string;
if (!isError(error)) {
errMsg = `Unknown error: ${error}.\n`;
} else if (this.options.showStackTrace && error.stack) {
errMsg = `${error.stack}\n`;
} else {
errMsg = `${error.name}: ${error.message}\n`;
}
this.logger.error(errMsg);
if (response.headersSent) {
response.end();
} else {
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
response.writeHead(500).end(errMsg);
}
} finally {
if (!response.headersSent) {
response.writeHead(404).end();
}
}
});
return server.listen(port);
}
private createServerOptions(): BaseHttpServerOptions {
const options = { ...this.options };
for (const id of [ 'key', 'cert', 'pfx' ] as const) {
const val = options[id];
if (val) {
options[id] = readFileSync(val, 'utf8');
}
}
return options;
}
}

View File

@@ -0,0 +1,69 @@
import { readFileSync } from 'fs';
import type { Server } from 'http';
import { createServer as createHttpServer } from 'http';
import { createServer as createHttpsServer } from 'https';
import { getLoggerFor } from '../logging/LogUtil';
import type { HttpServerFactory } from './HttpServerFactory';
import type { ServerConfigurator } from './ServerConfigurator';
/**
* Options to be used when creating the server.
* Due to Components.js not supporting external types, this has been simplified (for now?).
* The common https keys here (key/cert/pfx) will be interpreted as file paths that need to be read
* before passing the options to the `createServer` function.
*/
export interface BaseServerFactoryOptions {
/**
* If the server should start as an HTTP or HTTPS server.
*/
https?: boolean;
key?: string;
cert?: string;
pfx?: string;
passphrase?: string;
}
/**
* Creates an HTTP(S) server native Node.js `http`/`https` modules.
*
* Will apply a {@link ServerConfigurator} to the server,
* which should be used to attach listeners.
*/
export class BaseServerFactory implements HttpServerFactory {
protected readonly logger = getLoggerFor(this);
private readonly configurator: ServerConfigurator;
private readonly options: BaseServerFactoryOptions;
public constructor(configurator: ServerConfigurator, options: BaseServerFactoryOptions = { https: false }) {
this.configurator = configurator;
this.options = { ...options };
}
/**
* Creates an HTTP(S) server.
*/
public async createServer(): Promise<Server> {
const createServer = this.options.https ? createHttpsServer : createHttpServer;
const options = this.createServerOptions();
const server = createServer(options);
await this.configurator.handleSafe(server);
return server;
}
private createServerOptions(): BaseServerFactoryOptions {
const options = { ...this.options };
for (const id of [ 'key', 'cert', 'pfx' ] as const) {
const val = options[id];
if (val) {
options[id] = readFileSync(val, 'utf8');
}
}
return options;
}
}

View File

@@ -0,0 +1,68 @@
import type { Server, IncomingMessage, ServerResponse } from 'http';
import { getLoggerFor } from '../logging/LogUtil';
import { isError } from '../util/errors/ErrorUtil';
import { guardStream } from '../util/GuardedStream';
import type { HttpHandler } from './HttpHandler';
import { ServerConfigurator } from './ServerConfigurator';
/**
* A {@link ServerConfigurator} that attaches an {@link HttpHandler} to the `request` event of a {@link Server}.
* All incoming requests will be sent to the provided handler.
* Failsafes are added to make sure a valid response is sent in case something goes wrong.
*
* The `showStackTrace` parameter can be used to add stack traces to error outputs.
*/
export class HandlerServerConfigurator extends ServerConfigurator {
protected readonly logger = getLoggerFor(this);
protected readonly errorLogger = (error: Error): void => {
this.logger.error(`Request error: ${error.message}`);
};
/** The main HttpHandler */
private readonly handler: HttpHandler;
private readonly showStackTrace: boolean;
public constructor(handler: HttpHandler, showStackTrace = false) {
super();
this.handler = handler;
this.showStackTrace = showStackTrace;
}
public async handle(server: Server): Promise<void> {
server.on('request',
async(request: IncomingMessage, response: ServerResponse): Promise<void> => {
try {
this.logger.info(`Received ${request.method} request for ${request.url}`);
const guardedRequest = guardStream(request);
guardedRequest.on('error', this.errorLogger);
await this.handler.handleSafe({ request: guardedRequest, response });
} catch (error: unknown) {
const errMsg = this.createErrorMessage(error);
this.logger.error(errMsg);
if (response.headersSent) {
response.end();
} else {
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
response.writeHead(500).end(errMsg);
}
} finally {
if (!response.headersSent) {
response.writeHead(404).end();
}
}
});
}
/**
* Creates a readable error message based on the error and the `showStackTrace` parameter.
*/
private createErrorMessage(error: unknown): string {
if (!isError(error)) {
return `Unknown error: ${error}.\n`;
}
if (this.showStackTrace && error.stack) {
return `${error.stack}\n`;
}
return `${error.name}: ${error.message}\n`;
}
}

View File

@@ -1,8 +1,16 @@
import type { Server } from 'http';
import { Server as HttpsServer } from 'https';
/**
* A factory for HTTP servers
* Returns `true` if the server is an HTTPS server.
*/
export function isHttpsServer(server: Server): server is HttpsServer {
return server instanceof HttpsServer;
}
/**
* A factory for HTTP servers.
*/
export interface HttpServerFactory {
startServer: (port: number) => Server;
createServer: () => Promise<Server>;
}

View File

@@ -0,0 +1,7 @@
import type { Server } from 'http';
import { AsyncHandler } from '../util/handlers/AsyncHandler';
/**
* Configures a {@link Server} by attaching listeners for specific events.
*/
export abstract class ServerConfigurator extends AsyncHandler<Server> {}

View File

@@ -1,9 +0,0 @@
import type { WebSocket } from 'ws';
import { AsyncHandler } from '../util/handlers/AsyncHandler';
import type { HttpRequest } from './HttpRequest';
/**
* A WebSocketHandler handles the communication with multiple WebSockets
*/
export abstract class WebSocketHandler
extends AsyncHandler<{ webSocket: WebSocket; upgradeRequest: HttpRequest }> {}

View File

@@ -0,0 +1,30 @@
import type { IncomingMessage, Server } from 'http';
import type { Socket } from 'net';
import type { WebSocket } from 'ws';
import { WebSocketServer } from 'ws';
import { getLoggerFor } from '../logging/LogUtil';
import { createErrorMessage } from '../util/errors/ErrorUtil';
import { ServerConfigurator } from './ServerConfigurator';
/**
* {@link ServerConfigurator} that adds WebSocket functionality to an existing {@link Server}.
*
* Implementations need to implement the `handleConnection` function to receive the necessary information.
*/
export abstract class WebSocketServerConfigurator extends ServerConfigurator {
protected readonly logger = getLoggerFor(this);
public async handle(server: Server): Promise<void> {
// Create WebSocket server
const webSocketServer = new WebSocketServer({ noServer: true });
server.on('upgrade', (upgradeRequest: IncomingMessage, socket: Socket, head: Buffer): void => {
webSocketServer.handleUpgrade(upgradeRequest, socket, head, (webSocket: WebSocket): void => {
this.handleConnection(webSocket, upgradeRequest).catch((error: Error): void => {
this.logger.error(`Something went wrong handling a WebSocket connection: ${createErrorMessage(error)}`);
});
});
});
}
protected abstract handleConnection(webSocket: WebSocket, upgradeRequest: IncomingMessage): Promise<void>;
}

View File

@@ -1,37 +0,0 @@
import type { Server } from 'http';
import type { Socket } from 'net';
import type { WebSocket } from 'ws';
import { Server as WebSocketServer } from 'ws';
import type { HttpRequest } from './HttpRequest';
import type { HttpServerFactory } from './HttpServerFactory';
import type { WebSocketHandler } from './WebSocketHandler';
/**
* Factory that adds WebSocket functionality to an existing server
*/
export class WebSocketServerFactory implements HttpServerFactory {
private readonly baseServerFactory: HttpServerFactory;
private readonly webSocketHandler: WebSocketHandler;
public constructor(baseServerFactory: HttpServerFactory, webSocketHandler: WebSocketHandler) {
this.baseServerFactory = baseServerFactory;
this.webSocketHandler = webSocketHandler;
}
public startServer(port: number): Server {
// Create WebSocket server
const webSocketServer = new WebSocketServer({ noServer: true });
webSocketServer.on('connection', async(webSocket: WebSocket, upgradeRequest: HttpRequest): Promise<void> => {
await this.webSocketHandler.handleSafe({ webSocket, upgradeRequest });
});
// Create base HTTP server
const httpServer = this.baseServerFactory.startServer(port);
httpServer.on('upgrade', (upgradeRequest: HttpRequest, socket: Socket, head: Buffer): void => {
webSocketServer.handleUpgrade(upgradeRequest, socket, head, (webSocket: WebSocket): void => {
webSocketServer.emit('connection', webSocket, upgradeRequest);
});
});
return httpServer;
}
}