mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
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:
@@ -1,21 +1,27 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type { TLSSocket } from 'tls';
|
||||
import type { WebSocket } from 'ws';
|
||||
import type { SingleThreaded } from '../init/cluster/SingleThreaded';
|
||||
import { getLoggerFor } from '../logging/LogUtil';
|
||||
import type { HttpRequest } from '../server/HttpRequest';
|
||||
import { WebSocketHandler } from '../server/WebSocketHandler';
|
||||
import type { ActivityEmitter } from '../server/notifications/ActivityEmitter';
|
||||
import { WebSocketServerConfigurator } from '../server/WebSocketServerConfigurator';
|
||||
import { createErrorMessage } from '../util/errors/ErrorUtil';
|
||||
import type { GenericEventEmitter } from '../util/GenericEventEmitter';
|
||||
import { createGenericEventEmitterClass } from '../util/GenericEventEmitter';
|
||||
import { parseForwarded } from '../util/HeaderUtil';
|
||||
import { splitCommaSeparated } from '../util/StringUtil';
|
||||
import type { ResourceIdentifier } from './representation/ResourceIdentifier';
|
||||
|
||||
const VERSION = 'solid-0.1';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const WebSocketListenerEmitter = createGenericEventEmitterClass<GenericEventEmitter<'closed', () => void>>();
|
||||
|
||||
/**
|
||||
* Implementation of Solid WebSockets API Spec solid-0.1
|
||||
* at https://github.com/solid/solid-spec/blob/master/api-websockets.md
|
||||
*/
|
||||
class WebSocketListener extends EventEmitter {
|
||||
class WebSocketListener extends WebSocketListenerEmitter {
|
||||
private host = '';
|
||||
private protocol = '';
|
||||
private readonly socket: WebSocket;
|
||||
@@ -30,7 +36,7 @@ class WebSocketListener extends EventEmitter {
|
||||
socket.addListener('message', (message: string): void => this.onMessage(message));
|
||||
}
|
||||
|
||||
public start({ headers, socket }: HttpRequest): void {
|
||||
public start({ headers, socket }: IncomingMessage): void {
|
||||
// Greet the client
|
||||
this.sendMessage('protocol', VERSION);
|
||||
|
||||
@@ -105,7 +111,7 @@ class WebSocketListener extends EventEmitter {
|
||||
this.logger.debug(`WebSocket subscribed to changes on ${url}`);
|
||||
} catch (error: unknown) {
|
||||
// Report errors to the socket
|
||||
const errorText: string = (error as any).message;
|
||||
const errorText: string = createErrorMessage(error);
|
||||
this.sendMessage('error', errorText);
|
||||
this.logger.warn(`WebSocket could not subscribe to ${path}: ${errorText}`);
|
||||
}
|
||||
@@ -120,11 +126,11 @@ class WebSocketListener extends EventEmitter {
|
||||
* Provides live update functionality following
|
||||
* the Solid WebSockets API Spec solid-0.1
|
||||
*/
|
||||
export class UnsecureWebSocketsProtocol extends WebSocketHandler implements SingleThreaded {
|
||||
private readonly logger = getLoggerFor(this);
|
||||
export class UnsecureWebSocketsProtocol extends WebSocketServerConfigurator implements SingleThreaded {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
private readonly listeners = new Set<WebSocketListener>();
|
||||
|
||||
public constructor(source: EventEmitter) {
|
||||
public constructor(source: ActivityEmitter) {
|
||||
super();
|
||||
|
||||
this.logger.warn('The chosen configuration includes Solid WebSockets API 0.1, which is unauthenticated.');
|
||||
@@ -133,8 +139,8 @@ export class UnsecureWebSocketsProtocol extends WebSocketHandler implements Sing
|
||||
source.on('changed', (changed: ResourceIdentifier): void => this.onResourceChanged(changed));
|
||||
}
|
||||
|
||||
public async handle(input: { webSocket: WebSocket; upgradeRequest: HttpRequest }): Promise<void> {
|
||||
const listener = new WebSocketListener(input.webSocket);
|
||||
protected async handleConnection(webSocket: WebSocket, upgradeRequest: IncomingMessage): Promise<void> {
|
||||
const listener = new WebSocketListener(webSocket);
|
||||
this.listeners.add(listener);
|
||||
this.logger.info(`New WebSocket added, ${this.listeners.size} in total`);
|
||||
|
||||
@@ -142,7 +148,7 @@ export class UnsecureWebSocketsProtocol extends WebSocketHandler implements Sing
|
||||
this.listeners.delete(listener);
|
||||
this.logger.info(`WebSocket closed, ${this.listeners.size} remaining`);
|
||||
});
|
||||
listener.start(input.upgradeRequest);
|
||||
listener.start(upgradeRequest);
|
||||
}
|
||||
|
||||
private onResourceChanged(changed: ResourceIdentifier): void {
|
||||
|
||||
@@ -283,15 +283,16 @@ export * from './pods/PodManager';
|
||||
|
||||
// Server
|
||||
export * from './server/AuthorizingHttpHandler';
|
||||
export * from './server/BaseHttpServerFactory';
|
||||
export * from './server/BaseServerFactory';
|
||||
export * from './server/HandlerServerConfigurator';
|
||||
export * from './server/HttpHandler';
|
||||
export * from './server/HttpRequest';
|
||||
export * from './server/HttpResponse';
|
||||
export * from './server/HttpServerFactory';
|
||||
export * from './server/OperationHttpHandler';
|
||||
export * from './server/ParsingHttpHandler';
|
||||
export * from './server/WebSocketHandler';
|
||||
export * from './server/WebSocketServerFactory';
|
||||
export * from './server/ServerConfigurator';
|
||||
export * from './server/WebSocketServerConfigurator';
|
||||
|
||||
// Server/Middleware
|
||||
export * from './server/middleware/AcpHeaderHandler';
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Server } from 'http';
|
||||
import { URL } from 'url';
|
||||
import { promisify } from 'util';
|
||||
import { getLoggerFor } from '../logging/LogUtil';
|
||||
import { isHttpsServer } from '../server/HttpServerFactory';
|
||||
import type { HttpServerFactory } from '../server/HttpServerFactory';
|
||||
import type { Finalizable } from './final/Finalizable';
|
||||
import { Initializer } from './Initializer';
|
||||
@@ -8,6 +11,8 @@ import { Initializer } from './Initializer';
|
||||
* Creates and starts an HTTP server.
|
||||
*/
|
||||
export class ServerInitializer extends Initializer implements Finalizable {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly serverFactory: HttpServerFactory;
|
||||
private readonly port: number;
|
||||
|
||||
@@ -20,7 +25,11 @@ export class ServerInitializer extends Initializer implements Finalizable {
|
||||
}
|
||||
|
||||
public async handle(): Promise<void> {
|
||||
this.server = this.serverFactory.startServer(this.port);
|
||||
this.server = await this.serverFactory.createServer();
|
||||
|
||||
const url = new URL(`http${isHttpsServer(this.server) ? 's' : ''}://localhost:${this.port}/`).href;
|
||||
this.logger.info(`Listening to server at ${url}`);
|
||||
this.server.listen(this.port);
|
||||
}
|
||||
|
||||
public async finalize(): Promise<void> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
69
src/server/BaseServerFactory.ts
Normal file
69
src/server/BaseServerFactory.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
68
src/server/HandlerServerConfigurator.ts
Normal file
68
src/server/HandlerServerConfigurator.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
7
src/server/ServerConfigurator.ts
Normal file
7
src/server/ServerConfigurator.ts
Normal 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> {}
|
||||
@@ -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 }> {}
|
||||
30
src/server/WebSocketServerConfigurator.ts
Normal file
30
src/server/WebSocketServerConfigurator.ts
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user