@towercg2/server
Version:
The server runtime for the TowerCG2 video graphics system.
226 lines (187 loc) • 7.48 kB
text/typescript
import Bunyan from "bunyan";
import * as Express from "express";
import * as lodash from "lodash";
import * as path from "path";
import * as fsx from "fs-extra";
import { Store, DeepPartial, Reducer, AnyAction } from "redux";
import {
Message,
RequestMessage,
Response,
responseEventName,
Client,
ProtocolNames
} from "@towercg2/client";
import { Server } from "../Server/index";
import { Behaviors } from "./Behaviors";
import { buildStore } from "./store";
export interface ServerPluginClassBase {
new(config: Partial<ServerPluginConfig>, server: Server): ServerPluginBase;
readonly pluginName: string;
};
export interface ServerPluginClass<TConfig extends ServerPluginConfig, TPlugin extends ServerPluginBase> extends ServerPluginClassBase {
new(config: Partial<TConfig>, server: Server): TPlugin;
}
export interface ServerPluginConfig {
}
export abstract class ServerPluginBase {
private static readonly DEFAULT_BEHAVIORS: Behaviors = {
enableFileServing: true,
storeJsonReviver: null,
storeJsonReplacer: null
};
readonly name: string;
protected readonly logger: Bunyan;
/**
* As opposed to configuration, which determines the runtime behavior of this plugin,
* _behaviors_ determine immutable aspects of the plugin, such as what portions of its data
* store are skipped when serializing to disk.
*/
readonly behaviors: Behaviors;
private _client: Client | null = null;
protected get client(): Client {
if (!this._client) {
throw new Error("Attempted to get client before instantiation.");
}
return this._client;
}
constructor(
protected readonly server: Server
) {
this.name = (this.constructor as ServerPluginClassBase).pluginName;
this.logger = server.baseLogger.child({ plugin: this.constructor.name, pluginName: this.name });
this.behaviors = this.behaviors = lodash.merge({}, ServerPluginBase.DEFAULT_BEHAVIORS, this.buildPluginBehaviors());
}
abstract get state(): object;
protected buildPluginBehaviors(): Partial<Behaviors> { return {}; }
get dataDirectory(): string { return path.resolve(this.server.config.dataDirectory, this.name); }
protected abstract get config(): ServerPluginConfig;
protected abstract buildDefaultConfig(): ServerPluginConfig;
protected bindHttpEndpoints(app: Express.Application): void {}
abstract doBindHttpEndpoints(app: Express.Application): void;
protected bindSocketIOHandlers(socket: SocketIO.Socket): void {}
abstract doBindSocketIOHandlers(socket: SocketIO.Socket): void;
doSetClientConnection(localUri: string, clientId: string, clientSecret: string): void {
if (this._client) {
throw new Error("Cannot reset client.");
}
this._client = new Client(localUri, { clientId, clientSecret }, this.logger.child({ component: "TowerCG2Client" }));
}
abstract async doInitialize(http: Express.Application): Promise<void>;
abstract async initialize(http: Express.Application): Promise<void>;
abstract async cleanup(): Promise<void>;
protected eventName(eventName: string): string {
return `${this.name}:${eventName}`;
}
}
export abstract class ServerPlugin<TConfig extends ServerPluginConfig, TState extends {}> extends ServerPluginBase {
protected readonly config: TConfig;
protected readonly store: Store<TState>;
constructor(
config: Partial<TConfig>,
server: Server
) {
super(server);
fsx.mkdirpSync(this.dataDirectory);
this.config = lodash.merge({}, this.buildDefaultConfig(), config);
this.store = buildStore<TState>(
this.logger,
this.buildReducer(),
this.buildDefaultState(),
this.behaviors.storeJsonReviver,
this.behaviors.storeJsonReplacer,
path.resolve(this.dataDirectory, "store.json")
);
this.store.subscribe(() => {
this.logger.debug("Store updated; pushing out.");
this.server.messageToAuthenticated(`${this.name}:stateUpdated`, this.store.getState());
});
}
get state(): TState {
return lodash.cloneDeep(this.store.getState());
}
protected abstract buildDefaultConfig(): TConfig;
protected abstract buildDefaultState(): DeepPartial<TState>;
protected abstract buildReducer(): Reducer<TState, AnyAction>;
async doInitialize(http: Express.Application) {
await this.initialize(http);
this.store.subscribe(() => {
this.server.messageToAuthenticated(`${this.name}:stateUpdated`, this.store.getState());
});
this.client.connect();
}
doBindHttpEndpoints(app: Express.Application): void {
if (this.behaviors.enableFileServing) {
const fileDirectory = path.resolve(this.dataDirectory, "files");
this.logger.debug({ fileDirectory }, "Enabling express-static for plugin.");
app.use("/files", Express.static(fileDirectory));
}
app.use("/state", (req, res) => {
const json = JSON.stringify(this.store.getState(), this.behaviors.storeJsonReplacer || undefined, 2);
res.send(json);
});
this.bindHttpEndpoints(app);
}
doBindSocketIOHandlers(socket: SocketIO.Socket): void {
this.bindSocketIOHandlers(socket);
}
// PROBLEM: this method does accept poorly defined events despite its signature!
// Ideally this method would infer TEventType and TPayload when passed a generic
// event interface, i.e. `this.emit<TickEvent>(event)`. But it can't. So we need
// higher-kinded types. (And people say category theory isn't useful.)
//
// The current workaround is to create the event and strictly define its type,
// then pass it in, because you can't create a misnamed event without TypeScript
// throwing an error.
//
// This is issue #1213 in TypeScript.
protected broadcastEvent<
TEventType extends string,
TPayload extends object,
TEvent extends Message<TEventType, TPayload>
>(event: TEvent) {
this.logger.trace({ event }, "Emitting event.");
this.server.messageToAuthenticated(event.type, event);
}
protected handleMessage<
TMessageType extends string,
TPayload extends object
>(
messageType: TMessageType,
client: SocketIO.Socket,
fn: (payload: TPayload, sender: SocketIO.Socket) => Promise<any>
): void {
client.on(messageType, async (message: Message<TMessageType, TPayload>) => {
const { payload } = message;
try {
this.logger.trace({ messageType }, "Message received.");
await fn(payload, client);
} catch (error) {
// as events are fire-and-forget, we do not return an error.
this.logger.error({ messageType, payload, error }, "Message when handling event.");
}
});
}
protected handleRequest<
TRequestType extends string,
TPayload extends object,
TData extends object
>(
requestType: TRequestType,
client: SocketIO.Socket,
fn: (payload: TPayload, sender: SocketIO.Socket) => Promise<TData>
): void {
client.on(requestType, async (request: RequestMessage<TRequestType, TPayload, TData>): Promise<void> => {
const { requestId, payload } = request;
const responseName = responseEventName(requestId);
try {
this.logger.trace({ requestType, requestId }, "Request received.");
const data = await fn(payload, client);
client.emit(responseName, data);
} catch (error) {
const errorResponse: Response<TData> = { error: error.message || "No message in error." };
client.emit(responseName, errorResponse);
}
})
}
}