aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'src/server')
-rw-r--r--src/server/ActionUtilities.ts24
-rw-r--r--src/server/ApiManagers/SessionManager.ts38
-rw-r--r--src/server/DashSession.ts139
-rw-r--r--src/server/DashSession/DashSessionAgent.ts221
-rw-r--r--src/server/DashSession/templates/crash_instructions.txt14
-rw-r--r--src/server/DashSession/templates/remote_debug_instructions.txt (renamed from src/server/remote_debug_instructions.txt)0
-rw-r--r--src/server/RouteManager.ts4
-rw-r--r--src/server/index.ts12
-rw-r--r--src/server/session/README.txt11
-rw-r--r--src/server/session/agents/applied_session_agent.ts58
-rw-r--r--src/server/session/agents/monitor.ts302
-rw-r--r--src/server/session/agents/process_message_router.ts46
-rw-r--r--src/server/session/agents/promisified_ipc_manager.ts97
-rw-r--r--src/server/session/agents/server_worker.ts160
-rw-r--r--src/server/session/utilities/repl.ts (renamed from src/server/repl.ts)2
-rw-r--r--src/server/session/utilities/session_config.ts (renamed from src/server/Session/session_config_schema.ts)62
-rw-r--r--src/server/session/utilities/utilities.ts31
17 files changed, 1051 insertions, 170 deletions
diff --git a/src/server/ActionUtilities.ts b/src/server/ActionUtilities.ts
index f0bfbc525..60f66c878 100644
--- a/src/server/ActionUtilities.ts
+++ b/src/server/ActionUtilities.ts
@@ -119,16 +119,24 @@ export namespace Email {
}
});
+ export interface DispatchOptions<T extends string | string[]> {
+ to: T;
+ subject: string;
+ content: string;
+ attachments?: Mail.Attachment | Mail.Attachment[];
+ }
+
export interface DispatchFailure {
recipient: string;
error: Error;
}
- export async function dispatchAll(recipients: string[], subject: string, content: string) {
+ export async function dispatchAll({ to, subject, content, attachments }: DispatchOptions<string[]>) {
const failures: DispatchFailure[] = [];
- await Promise.all(recipients.map(async (recipient: string) => {
+ await Promise.all(to.map(async recipient => {
let error: Error | null;
- if ((error = await Email.dispatch(recipient, subject, content)) !== null) {
+ const resolved = attachments ? "length" in attachments ? attachments : [attachments] : undefined;
+ if ((error = await Email.dispatch({ to: recipient, subject, content, attachments: resolved })) !== null) {
failures.push({
recipient,
error
@@ -138,17 +146,15 @@ export namespace Email {
return failures.length ? failures : undefined;
}
- export async function dispatch(recipient: string, subject: string, content: string, attachments?: Mail.Attachment[]): Promise<Error | null> {
+ export async function dispatch({ to, subject, content, attachments }: DispatchOptions<string>): Promise<Error | null> {
const mailOptions = {
- to: recipient,
+ to,
from: 'brownptcdash@gmail.com',
subject,
- text: `Hello ${recipient.split("@")[0]},\n\n${content}`,
+ text: `Hello ${to.split("@")[0]},\n\n${content}`,
attachments
} as MailOptions;
- return new Promise<Error | null>(resolve => {
- smtpTransport.sendMail(mailOptions, resolve);
- });
+ return new Promise<Error | null>(resolve => smtpTransport.sendMail(mailOptions, resolve));
}
} \ No newline at end of file
diff --git a/src/server/ApiManagers/SessionManager.ts b/src/server/ApiManagers/SessionManager.ts
index 0290b578c..d989d8d1b 100644
--- a/src/server/ApiManagers/SessionManager.ts
+++ b/src/server/ApiManagers/SessionManager.ts
@@ -2,24 +2,25 @@ import ApiManager, { Registration } from "./ApiManager";
import { Method, _permission_denied, AuthorizedCore, SecureHandler } from "../RouteManager";
import RouteSubscriber from "../RouteSubscriber";
import { sessionAgent } from "..";
+import { DashSessionAgent } from "../DashSession/DashSessionAgent";
const permissionError = "You are not authorized!";
export default class SessionManager extends ApiManager {
- private secureSubscriber = (root: string, ...params: string[]) => new RouteSubscriber(root).add("password", ...params);
+ private secureSubscriber = (root: string, ...params: string[]) => new RouteSubscriber(root).add("sessionKey", ...params);
private authorizedAction = (handler: SecureHandler) => {
return (core: AuthorizedCore) => {
const { req, res, isRelease } = core;
- const { password } = req.params;
+ const { sessionKey } = req.params;
if (!isRelease) {
return res.send("This can be run only on the release server.");
}
- if (password !== process.env.session_key) {
+ if (sessionKey !== process.env.session_key) {
return _permission_denied(res, permissionError);
}
- handler(core);
+ return handler(core);
};
}
@@ -27,14 +28,21 @@ export default class SessionManager extends ApiManager {
register({
method: Method.GET,
- subscription: this.secureSubscriber("debug", "mode", "recipient"),
- secureHandler: this.authorizedAction(({ req, res }) => {
+ subscription: this.secureSubscriber("debug", "mode?", "recipient?"),
+ secureHandler: this.authorizedAction(async ({ req, res }) => {
const { mode, recipient } = req.params;
- if (["passive", "active"].includes(mode)) {
- sessionAgent.serverWorker.sendMonitorAction("debug", { mode, recipient });
- res.send(`Your request was successful: the server is ${mode === "active" ? "creating and compressing a new" : "retrieving and compressing the most recent"} back up. It will be sent to ${recipient}.`);
- } else {
+ if (mode && !["passive", "active"].includes(mode)) {
res.send(`Your request failed. '${mode}' is not a valid mode: please choose either 'active' or 'passive'`);
+ } else {
+ const response = await sessionAgent.serverWorker.emitToMonitorPromise("debug", {
+ mode: mode || "active",
+ recipient: recipient || DashSessionAgent.notificationRecipient
+ });
+ if (response instanceof Error) {
+ res.send(response);
+ } else {
+ res.send(`Your request was successful: the server ${mode === "active" ? "created and compressed a new" : "retrieved and compressed the most recent"} back up. It was sent to ${recipient}.`);
+ }
}
})
});
@@ -42,9 +50,13 @@ export default class SessionManager extends ApiManager {
register({
method: Method.GET,
subscription: this.secureSubscriber("backup"),
- secureHandler: this.authorizedAction(({ res }) => {
- sessionAgent.serverWorker.sendMonitorAction("backup");
- res.send(`Your request was successful: the server is creating a new back up.`);
+ secureHandler: this.authorizedAction(async ({ res }) => {
+ const response = await sessionAgent.serverWorker.emitToMonitor("backup");
+ if (response instanceof Error) {
+ res.send(response);
+ } else {
+ res.send("Your request was successful: the server successfully created a new back up.");
+ }
})
});
diff --git a/src/server/DashSession.ts b/src/server/DashSession.ts
deleted file mode 100644
index 56610874e..000000000
--- a/src/server/DashSession.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import { Session } from "./Session/session";
-import { Email, pathFromRoot } from "./ActionUtilities";
-import { red, yellow, green, cyan } from "colors";
-import { get } from "request-promise";
-import { Utils } from "../Utils";
-import { WebSocket } from "./Websocket/Websocket";
-import { MessageStore } from "./Message";
-import { launchServer, onWindows } from ".";
-import { existsSync, mkdirSync, readdirSync, statSync, createWriteStream, readFileSync } from "fs";
-import * as Archiver from "archiver";
-import { resolve } from "path";
-
-/**
- * If we're the monitor (master) thread, we should launch the monitor logic for the session.
- * Otherwise, we must be on a worker thread that was spawned *by* the monitor (master) thread, and thus
- * our job should be to run the server.
- */
-export class DashSessionAgent extends Session.AppliedSessionAgent {
-
- private readonly notificationRecipients = ["samuel_wilkins@brown.edu"];
- private readonly signature = "-Dash Server Session Manager";
- private readonly releaseDesktop = pathFromRoot("../../Desktop");
-
- protected async launchMonitor() {
- const monitor = Session.Monitor.Create(this.notifiers);
- monitor.addReplCommand("pull", [], () => monitor.exec("git pull"));
- monitor.addReplCommand("solr", [/start|stop|index/], this.executeSolrCommand);
- monitor.addReplCommand("backup", [], this.backup);
- monitor.addReplCommand("debug", [/active|passive/, /\S+\@\S+/], async ([mode, recipient]) => this.dispatchZippedDebugBackup(mode, recipient));
- monitor.addServerMessageListener("backup", this.backup);
- monitor.addServerMessageListener("debug", ({ args: { mode, recipient } }) => this.dispatchZippedDebugBackup(mode, recipient));
- return monitor;
- }
-
- protected async launchServerWorker() {
- const worker = Session.ServerWorker.Create(launchServer); // server initialization delegated to worker
- worker.addExitHandler(this.notifyClient);
- return worker;
- }
-
- private readonly notifiers: Session.Monitor.NotifierHooks = {
- key: async key => {
- // this sends a pseudorandomly generated guid to the configuration's recipients, allowing them alone
- // to kill the server via the /kill/:key route
- const content = `The key for this session (started @ ${new Date().toUTCString()}) is ${key}.\n\n${this.signature}`;
- const failures = await Email.dispatchAll(this.notificationRecipients, "Dash Release Session Admin Authentication Key", content);
- if (failures) {
- failures.map(({ recipient, error: { message } }) => this.sessionMonitor.mainLog(red(`dispatch failure @ ${recipient} (${yellow(message)})`)));
- return false;
- }
- return true;
- },
- crash: async ({ name, message, stack }) => {
- const body = [
- "You, as a Dash Administrator, are being notified of a server crash event. Here's what we know:",
- `name:\n${name}`,
- `message:\n${message}`,
- `stack:\n${stack}`,
- "The server is already restarting itself, but if you're concerned, use the Remote Desktop Connection to monitor progress.",
- ].join("\n\n");
- const content = `${body}\n\n${this.signature}`;
- const failures = await Email.dispatchAll(this.notificationRecipients, "Dash Web Server Crash", content);
- if (failures) {
- failures.map(({ recipient, error: { message } }) => this.sessionMonitor.mainLog(red(`dispatch failure @ ${recipient} (${yellow(message)})`)));
- return false;
- }
- return true;
- }
- };
-
- private executeSolrCommand = async (args: string[]) => {
- const { exec, mainLog } = this.sessionMonitor;
- const action = args[0];
- if (action === "index") {
- exec("npx ts-node ./updateSearch.ts", { cwd: pathFromRoot("./src/server") });
- } else {
- const command = `${onWindows ? "solr.cmd" : "solr"} ${args[0] === "start" ? "start" : "stop -p 8983"}`;
- await exec(command, { cwd: "./solr-8.3.1/bin" });
- try {
- await get("http://localhost:8983");
- mainLog(green("successfully connected to 8983 after running solr initialization"));
- } catch {
- mainLog(red("unable to connect at 8983 after running solr initialization"));
- }
- }
- }
-
- private notifyClient: Session.ExitHandler = reason => {
- const { _socket } = WebSocket;
- if (_socket) {
- const message = typeof reason === "boolean" ? (reason ? "exit" : "temporary") : "crash";
- Utils.Emit(_socket, MessageStore.ConnectionTerminated, message);
- }
- }
-
- private backup = async () => this.sessionMonitor.exec("backup.bat", { cwd: this.releaseDesktop });
-
- private async dispatchZippedDebugBackup(mode: string, recipient: string) {
- const { mainLog } = this.sessionMonitor;
- try {
- if (mode === "active") {
- await this.backup();
- }
- mainLog("backup complete");
- const backupsDirectory = `${this.releaseDesktop}/backups`;
- const compressedDirectory = `${this.releaseDesktop}/compressed`;
- if (!existsSync(compressedDirectory)) {
- mkdirSync(compressedDirectory);
- }
- const target = readdirSync(backupsDirectory).map(filename => ({
- modifiedTime: statSync(`${backupsDirectory}/${filename}`).mtimeMs,
- filename
- })).sort((a, b) => b.modifiedTime - a.modifiedTime)[0].filename;
- mainLog(`targeting ${target}...`);
- const zipName = `${target}.zip`;
- const zipPath = `${compressedDirectory}/${zipName}`;
- const output = createWriteStream(zipPath);
- const zip = Archiver('zip');
- zip.pipe(output);
- zip.directory(`${backupsDirectory}/${target}/Dash`, false);
- await zip.finalize();
- mainLog(`zip finalized with size ${statSync(zipPath).size} bytes, saved to ${zipPath}`);
- let instructions = readFileSync(resolve(__dirname, "./remote_debug_instructions.txt"), { encoding: "utf8" });
- instructions = instructions.replace(/__zipname__/, zipName).replace(/__target__/, target).replace(/__signature__/, this.signature);
- const error = await Email.dispatch(recipient, `Compressed backup of ${target}...`, instructions, [
- {
- filename: zipName,
- path: zipPath
- }
- ]);
- mainLog(`${error === null ? green("successfully dispatched") : red("failed to dispatch")} ${zipName} to ${cyan(recipient)}`);
- error && mainLog(red(error.message));
- } catch (error) {
- mainLog(red("unable to dispatch zipped backup..."));
- mainLog(red(error.message));
- }
- }
-
-} \ No newline at end of file
diff --git a/src/server/DashSession/DashSessionAgent.ts b/src/server/DashSession/DashSessionAgent.ts
new file mode 100644
index 000000000..fe7cdae88
--- /dev/null
+++ b/src/server/DashSession/DashSessionAgent.ts
@@ -0,0 +1,221 @@
+import { Email, pathFromRoot } from "../ActionUtilities";
+import { red, yellow, green, cyan } from "colors";
+import { get } from "request-promise";
+import { Utils } from "../../Utils";
+import { WebSocket } from "../Websocket/Websocket";
+import { MessageStore } from "../Message";
+import { launchServer, onWindows } from "..";
+import { existsSync, mkdirSync, readdirSync, statSync, createWriteStream, readFileSync } from "fs";
+import * as Archiver from "archiver";
+import { resolve } from "path";
+import { AppliedSessionAgent, ExitHandler } from "../session/agents/applied_session_agent";
+import { Monitor } from "../session/agents/monitor";
+import { ServerWorker } from "../session/agents/server_worker";
+import { MessageHandler } from "../session/agents/promisified_ipc_manager";
+
+/**
+ * If we're the monitor (master) thread, we should launch the monitor logic for the session.
+ * Otherwise, we must be on a worker thread that was spawned *by* the monitor (master) thread, and thus
+ * our job should be to run the server.
+ */
+export class DashSessionAgent extends AppliedSessionAgent {
+
+ private readonly signature = "-Dash Server Session Manager";
+ private readonly releaseDesktop = pathFromRoot("../../Desktop");
+
+ /**
+ * The core method invoked when the single master thread is initialized.
+ * Installs event hooks, repl commands and additional IPC listeners.
+ */
+ protected async initializeMonitor(monitor: Monitor, sessionKey: string) {
+ await this.dispatchSessionPassword(sessionKey);
+ monitor.addReplCommand("pull", [], () => monitor.exec("git pull"));
+ monitor.addReplCommand("solr", [/start|stop|index/], this.executeSolrCommand);
+ monitor.addReplCommand("backup", [], this.backup);
+ monitor.addReplCommand("debug", [/active|passive/, /\S+\@\S+/], async ([mode, recipient]) => this.dispatchZippedDebugBackup(mode, recipient));
+ monitor.on("backup", this.backup);
+ monitor.on("debug", ({ mode, recipient }) => this.dispatchZippedDebugBackup(mode, recipient));
+ monitor.coreHooks.onCrashDetected(this.dispatchCrashReport);
+ }
+
+ /**
+ * The core method invoked when a server worker thread is initialized.
+ * Installs logic to be executed when the server worker dies.
+ */
+ protected async initializeServerWorker() {
+ const worker = ServerWorker.Create(launchServer); // server initialization delegated to worker
+ worker.addExitHandler(this.notifyClient);
+ return worker;
+ }
+
+ /**
+ * Prepares the body of the email with instructions on restoring the transmitted remote database backup locally.
+ */
+ private _remoteDebugInstructions: string | undefined;
+ private generateDebugInstructions = (zipName: string, target: string) => {
+ if (!this._remoteDebugInstructions) {
+ this._remoteDebugInstructions = readFileSync(resolve(__dirname, "./templates/remote_debug_instructions.txt"), { encoding: "utf8" });
+ }
+ return this._remoteDebugInstructions
+ .replace(/__zipname__/, zipName)
+ .replace(/__target__/, target)
+ .replace(/__signature__/, this.signature);
+ }
+
+ /**
+ * Prepares the body of the email with information regarding a crash event.
+ */
+ private _crashInstructions: string | undefined;
+ private generateCrashInstructions({ name, message, stack }: Error) {
+ if (!this._crashInstructions) {
+ this._crashInstructions = readFileSync(resolve(__dirname, "./templates/crash_instructions.txt"), { encoding: "utf8" });
+ }
+ return this._crashInstructions
+ .replace(/__name__/, name || "[no error name found]")
+ .replace(/__message__/, message || "[no error message found]")
+ .replace(/__stack__/, stack || "[no error stack found]")
+ .replace(/__signature__/, this.signature);
+ }
+
+ /**
+ * This sends a pseudorandomly generated guid to the configuration's recipients, allowing them alone
+ * to kill the server via the /kill/:key route.
+ */
+ private dispatchSessionPassword = async (sessionKey: string) => {
+ const { mainLog } = this.sessionMonitor;
+ const { notificationRecipient } = DashSessionAgent;
+ mainLog(green("dispatching session key..."));
+ const error = await Email.dispatch({
+ to: notificationRecipient,
+ subject: "Dash Release Session Admin Authentication Key",
+ content: `Here's the key for this session (started @ ${new Date().toUTCString()}):\n\n${sessionKey}.\n\n${this.signature}`
+ });
+ if (error) {
+ this.sessionMonitor.mainLog(red(`dispatch failure @ ${notificationRecipient} (${yellow(error.message)})`));
+ mainLog(red("distribution of session key experienced errors"));
+ } else {
+ mainLog(green("successfully distributed session key to recipients"));
+ }
+ }
+
+ /**
+ * This sends an email with the generated crash report.
+ */
+ private dispatchCrashReport: MessageHandler<{ error: Error }> = async ({ error: crashCause }) => {
+ const { mainLog } = this.sessionMonitor;
+ const { notificationRecipient } = DashSessionAgent;
+ const error = await Email.dispatch({
+ to: notificationRecipient,
+ subject: "Dash Web Server Crash",
+ content: this.generateCrashInstructions(crashCause)
+ });
+ if (error) {
+ this.sessionMonitor.mainLog(red(`dispatch failure @ ${notificationRecipient} ${yellow(`(${error.message})`)}`));
+ mainLog(red("distribution of crash notification experienced errors"));
+ } else {
+ mainLog(green("successfully distributed crash notification to recipients"));
+ }
+ }
+
+ /**
+ * Logic for interfacing with Solr. Either starts it,
+ * stops it, or rebuilds its indicies.
+ */
+ private executeSolrCommand = async (args: string[]) => {
+ const { exec, mainLog } = this.sessionMonitor;
+ const action = args[0];
+ if (action === "index") {
+ exec("npx ts-node ./updateSearch.ts", { cwd: pathFromRoot("./src/server") });
+ } else {
+ const command = `${onWindows ? "solr.cmd" : "solr"} ${args[0] === "start" ? "start" : "stop -p 8983"}`;
+ await exec(command, { cwd: "./solr-8.3.1/bin" });
+ try {
+ await get("http://localhost:8983");
+ mainLog(green("successfully connected to 8983 after running solr initialization"));
+ } catch {
+ mainLog(red("unable to connect at 8983 after running solr initialization"));
+ }
+ }
+ }
+
+ /**
+ * Broadcast to all clients that their connection
+ * is no longer valid, and explain why / what to expect.
+ */
+ private notifyClient: ExitHandler = reason => {
+ const { _socket } = WebSocket;
+ if (_socket) {
+ const message = typeof reason === "boolean" ? (reason ? "exit" : "temporary") : "crash";
+ Utils.Emit(_socket, MessageStore.ConnectionTerminated, message);
+ }
+ }
+
+ /**
+ * Performs a backup of the database, saved to the desktop subdirectory.
+ * This should work as is only on our specific release server.
+ */
+ private backup = async () => this.sessionMonitor.exec("backup.bat", { cwd: this.releaseDesktop });
+
+ /**
+ * Compress either a brand new backup or the most recent backup and send it
+ * as an attachment to an email, dispatched to the requested recipient.
+ * @param mode specifies whether or not to make a new backup before exporting
+ * @param to the recipient of the email
+ */
+ private async dispatchZippedDebugBackup(mode: string, to: string) {
+ const { mainLog } = this.sessionMonitor;
+ try {
+ // if desired, complete an immediate backup to send
+ if (mode === "active") {
+ await this.backup();
+ mainLog("backup complete");
+ }
+
+ // ensure the directory for compressed backups exists
+ const backupsDirectory = `${this.releaseDesktop}/backups`;
+ const compressedDirectory = `${this.releaseDesktop}/compressed`;
+ if (!existsSync(compressedDirectory)) {
+ mkdirSync(compressedDirectory);
+ }
+
+ // sort all backups by their modified time, and choose the most recent one
+ const target = readdirSync(backupsDirectory).map(filename => ({
+ modifiedTime: statSync(`${backupsDirectory}/${filename}`).mtimeMs,
+ filename
+ })).sort((a, b) => b.modifiedTime - a.modifiedTime)[0].filename;
+ mainLog(`targeting ${target}...`);
+
+ // create a zip file and to it, write the contents of the backup directory
+ const zipName = `${target}.zip`;
+ const zipPath = `${compressedDirectory}/${zipName}`;
+ const output = createWriteStream(zipPath);
+ const zip = Archiver('zip');
+ zip.pipe(output);
+ zip.directory(`${backupsDirectory}/${target}/Dash`, false);
+ await zip.finalize();
+ mainLog(`zip finalized with size ${statSync(zipPath).size} bytes, saved to ${zipPath}`);
+
+ // dispatch the email to the recipient, containing the finalized zip file
+ const error = await Email.dispatch({
+ to,
+ subject: `Remote debug: compressed backup of ${target}...`,
+ content: this.generateDebugInstructions(zipName, target),
+ attachments: [{ filename: zipName, path: zipPath }]
+ });
+
+ // indicate success or failure
+ mainLog(`${error === null ? green("successfully dispatched") : red("failed to dispatch")} ${zipName} to ${cyan(to)}`);
+ error && mainLog(red(error.message));
+ } catch (error) {
+ mainLog(red("unable to dispatch zipped backup..."));
+ mainLog(red(error.message));
+ }
+ }
+
+}
+
+export namespace DashSessionAgent {
+
+ export const notificationRecipient = "brownptcdash@gmail.com";
+
+} \ No newline at end of file
diff --git a/src/server/DashSession/templates/crash_instructions.txt b/src/server/DashSession/templates/crash_instructions.txt
new file mode 100644
index 000000000..65417919d
--- /dev/null
+++ b/src/server/DashSession/templates/crash_instructions.txt
@@ -0,0 +1,14 @@
+You, as a Dash Administrator, are being notified of a server crash event. Here's what we know:
+
+name:
+__name__
+
+message:
+__message__
+
+stack:
+__stack__
+
+The server is already restarting itself, but if you're concerned, use the Remote Desktop Connection to monitor progress.
+
+__signature__ \ No newline at end of file
diff --git a/src/server/remote_debug_instructions.txt b/src/server/DashSession/templates/remote_debug_instructions.txt
index c279c460a..c279c460a 100644
--- a/src/server/remote_debug_instructions.txt
+++ b/src/server/DashSession/templates/remote_debug_instructions.txt
diff --git a/src/server/RouteManager.ts b/src/server/RouteManager.ts
index 35d5131a4..a7ee405a7 100644
--- a/src/server/RouteManager.ts
+++ b/src/server/RouteManager.ts
@@ -68,7 +68,7 @@ export default class RouteManager {
console.log('please remove all duplicate routes before continuing');
}
if (malformedCount) {
- console.log(`please ensure all routes adhere to ^\/$|^\/[A-Za-z]+(\/\:[A-Za-z]+)*$`);
+ console.log(`please ensure all routes adhere to ^\/$|^\/[A-Za-z]+(\/\:[A-Za-z?]+)*$`);
}
process.exit(1);
} else {
@@ -131,7 +131,7 @@ export default class RouteManager {
} else {
route = subscriber.build;
}
- if (!/^\/$|^\/[A-Za-z]+(\/\:[A-Za-z]+)*$/g.test(route)) {
+ if (!/^\/$|^\/[A-Za-z]+(\/\:[A-Za-z?]+)*$/g.test(route)) {
this.failedRegistrations.push({
reason: RegistrationError.Malformed,
route
diff --git a/src/server/index.ts b/src/server/index.ts
index 2c8f32130..ef8ed9700 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -9,7 +9,7 @@ import initializeServer from './server_Initialization';
import RouteManager, { Method, _success, _permission_denied, _error, _invalid, PublicHandler } from './RouteManager';
import * as qs from 'query-string';
import UtilManager from './ApiManagers/UtilManager';
-import { SearchManager, SolrManager } from './ApiManagers/SearchManager';
+import { SearchManager } from './ApiManagers/SearchManager';
import UserManager from './ApiManagers/UserManager';
import { WebSocket } from './Websocket/Websocket';
import DownloadManager from './ApiManagers/DownloadManager';
@@ -17,17 +17,17 @@ import { GoogleCredentialsLoader } from './credentials/CredentialsLoader';
import DeleteManager from "./ApiManagers/DeleteManager";
import PDFManager from "./ApiManagers/PDFManager";
import UploadManager from "./ApiManagers/UploadManager";
-import { log_execution, Email } from "./ActionUtilities";
+import { log_execution } from "./ActionUtilities";
import GeneralGoogleManager from "./ApiManagers/GeneralGoogleManager";
import GooglePhotosManager from "./ApiManagers/GooglePhotosManager";
import { Logger } from "./ProcessFactory";
-import { yellow, red } from "colors";
-import { Session } from "./Session/session";
-import { DashSessionAgent } from "./DashSession";
+import { yellow } from "colors";
+import { DashSessionAgent } from "./DashSession/DashSessionAgent";
import SessionManager from "./ApiManagers/SessionManager";
+import { AppliedSessionAgent } from "./session/agents/applied_session_agent";
export const onWindows = process.platform === "win32";
-export let sessionAgent: Session.AppliedSessionAgent;
+export let sessionAgent: AppliedSessionAgent;
export const publicDirectory = path.resolve(__dirname, "public");
export const filesDirectory = path.resolve(publicDirectory, "files");
diff --git a/src/server/session/README.txt b/src/server/session/README.txt
new file mode 100644
index 000000000..ac7d3d4e7
--- /dev/null
+++ b/src/server/session/README.txt
@@ -0,0 +1,11 @@
+/**
+ * These abstractions rely on NodeJS's cluster module, which allows a parent (master) process to share
+ * code with its children (workers). A simple `isMaster` flag indicates who is trying to access
+ * the code, and thus determines the functionality that actually gets invoked (checked by the caller, not internally).
+ *
+ * Think of the master thread as a factory, and the workers as the helpers that actually run the server.
+ *
+ * So, when we run `npm start`, given the appropriate check, initializeMaster() is called in the parent process
+ * This will spawn off its own child process (by default, mirrors the execution path of its parent),
+ * in which initializeWorker() is invoked.
+ */ \ No newline at end of file
diff --git a/src/server/session/agents/applied_session_agent.ts b/src/server/session/agents/applied_session_agent.ts
new file mode 100644
index 000000000..48226dab6
--- /dev/null
+++ b/src/server/session/agents/applied_session_agent.ts
@@ -0,0 +1,58 @@
+import { isMaster } from "cluster";
+import { Monitor } from "./monitor";
+import { ServerWorker } from "./server_worker";
+import { Utils } from "../../../Utils";
+
+export type ExitHandler = (reason: Error | boolean) => void | Promise<void>;
+
+export abstract class AppliedSessionAgent {
+
+ // the following two methods allow the developer to create a custom
+ // session and use the built in customization options for each thread
+ protected abstract async initializeMonitor(monitor: Monitor, key: string): Promise<void>;
+ protected abstract async initializeServerWorker(): Promise<ServerWorker>;
+
+ private launched = false;
+
+ public killSession = (reason: string, graceful = true, errorCode = 0) => {
+ const target = isMaster ? this.sessionMonitor : this.serverWorker;
+ target.killSession(reason, graceful, errorCode);
+ }
+
+ private sessionMonitorRef: Monitor | undefined;
+ public get sessionMonitor(): Monitor {
+ if (!isMaster) {
+ this.serverWorker.emitToMonitor("kill", {
+ graceful: false,
+ reason: "Cannot access the session monitor directly from the server worker thread.",
+ errorCode: 1
+ });
+ throw new Error();
+ }
+ return this.sessionMonitorRef!;
+ }
+
+ private serverWorkerRef: ServerWorker | undefined;
+ public get serverWorker(): ServerWorker {
+ if (isMaster) {
+ throw new Error("Cannot access the server worker directly from the session monitor thread");
+ }
+ return this.serverWorkerRef!;
+ }
+
+ public async launch(): Promise<void> {
+ if (!this.launched) {
+ this.launched = true;
+ if (isMaster) {
+ const sessionKey = Utils.GenerateGuid();
+ await this.initializeMonitor(this.sessionMonitorRef = Monitor.Create(sessionKey), sessionKey);
+ this.sessionMonitorRef.finalize();
+ } else {
+ this.serverWorkerRef = await this.initializeServerWorker();
+ }
+ } else {
+ throw new Error("Cannot launch a session thread more than once per process.");
+ }
+ }
+
+} \ No newline at end of file
diff --git a/src/server/session/agents/monitor.ts b/src/server/session/agents/monitor.ts
new file mode 100644
index 000000000..5ea950b2b
--- /dev/null
+++ b/src/server/session/agents/monitor.ts
@@ -0,0 +1,302 @@
+import { ExitHandler } from "./applied_session_agent";
+import { Configuration, configurationSchema, defaultConfig, Identifiers, colorMapping } from "../utilities/session_config";
+import Repl, { ReplAction } from "../utilities/repl";
+import { isWorker, setupMaster, on, Worker, fork } from "cluster";
+import { IPC_Promisify, MessageHandler } from "./promisified_ipc_manager";
+import { red, cyan, white, yellow, blue } from "colors";
+import { exec, ExecOptions } from "child_process";
+import { validate, ValidationError } from "jsonschema";
+import { Utilities } from "../utilities/utilities";
+import { readFileSync } from "fs";
+import ProcessMessageRouter from "./process_message_router";
+import { ServerWorker } from "./server_worker";
+
+/**
+ * Validates and reads the configuration file, accordingly builds a child process factory
+ * and spawns off an initial process that will respawn as predecessors die.
+ */
+export class Monitor extends ProcessMessageRouter {
+ private static count = 0;
+ private finalized = false;
+ private exitHandlers: ExitHandler[] = [];
+ private readonly config: Configuration;
+ private activeWorker: Worker | undefined;
+ private key: string | undefined;
+ private repl: Repl;
+
+ public static Create(sessionKey: string) {
+ if (isWorker) {
+ ServerWorker.IPCManager.emit("kill", {
+ reason: "cannot create a monitor on the worker process.",
+ graceful: false,
+ errorCode: 1
+ });
+ process.exit(1);
+ } else if (++Monitor.count > 1) {
+ console.error(red("cannot create more than one monitor."));
+ process.exit(1);
+ } else {
+ return new Monitor(sessionKey);
+ }
+ }
+
+ private constructor(sessionKey: string) {
+ super();
+ this.config = this.loadAndValidateConfiguration();
+ this.initialize(sessionKey);
+ this.repl = this.initializeRepl();
+ }
+
+ private initialize = (sessionKey: string) => {
+ console.log(this.timestamp(), cyan("initializing session..."));
+ this.key = sessionKey;
+
+ // determines whether or not we see the compilation / initialization / runtime output of each child server process
+ const output = this.config.showServerOutput ? "inherit" : "ignore";
+ setupMaster({ stdio: ["ignore", output, output, "ipc"] });
+
+ // handle exceptions in the master thread - there shouldn't be many of these
+ // the IPC (inter process communication) channel closed exception can't seem
+ // to be caught in a try catch, and is inconsequential, so it is ignored
+ process.on("uncaughtException", ({ message, stack }): void => {
+ if (message !== "Channel closed") {
+ this.mainLog(red(message));
+ if (stack) {
+ this.mainLog(`uncaught exception\n${red(stack)}`);
+ }
+ }
+ });
+
+ // a helpful cluster event called on the master thread each time a child process exits
+ on("exit", ({ process: { pid } }, code, signal) => {
+ const prompt = `server worker with process id ${pid} has exited with code ${code}${signal === null ? "" : `, having encountered signal ${signal}`}.`;
+ this.mainLog(cyan(prompt));
+ // to make this a robust, continuous session, every time a child process dies, we immediately spawn a new one
+ this.spawn();
+ });
+ }
+
+ public finalize = (): void => {
+ if (this.finalized) {
+ throw new Error("Session monitor is already finalized");
+ }
+ this.finalized = true;
+ this.spawn();
+ }
+
+ public readonly coreHooks = Object.freeze({
+ onCrashDetected: (listener: MessageHandler<{ error: Error }>) => this.on(Monitor.IntrinsicEvents.CrashDetected, listener),
+ onServerRunning: (listener: MessageHandler<{ isFirstTime: boolean }>) => this.on(Monitor.IntrinsicEvents.ServerRunning, listener)
+ });
+
+ /**
+ * Kill this session and its active child
+ * server process, either gracefully (may wait
+ * indefinitely, but at least allows active networking
+ * requests to complete) or immediately.
+ */
+ public killSession = async (reason: string, graceful = true, errorCode = 0) => {
+ this.mainLog(cyan(`exiting session ${graceful ? "clean" : "immediate"}ly`));
+ this.mainLog(`session exit reason: ${(red(reason))}`);
+ await this.executeExitHandlers(true);
+ this.killActiveWorker(graceful, true);
+ process.exit(errorCode);
+ }
+
+ /**
+ * Execute the list of functions registered to be called
+ * whenever the process exits.
+ */
+ public addExitHandler = (handler: ExitHandler) => this.exitHandlers.push(handler);
+
+ /**
+ * Extend the default repl by adding in custom commands
+ * that can invoke application logic external to this module
+ */
+ public addReplCommand = (basename: string, argPatterns: (RegExp | string)[], action: ReplAction) => {
+ this.repl.registerCommand(basename, argPatterns, action);
+ }
+
+ public exec = (command: string, options?: ExecOptions) => {
+ return new Promise<void>(resolve => {
+ exec(command, { ...options, encoding: "utf8" }, (error, stdout, stderr) => {
+ if (error) {
+ this.execLog(red(`unable to execute ${white(command)}`));
+ error.message.split("\n").forEach(line => line.length && this.execLog(red(`(error) ${line}`)));
+ } else {
+ let outLines: string[], errorLines: string[];
+ if ((outLines = stdout.split("\n").filter(line => line.length)).length) {
+ outLines.forEach(line => line.length && this.execLog(cyan(`(stdout) ${line}`)));
+ }
+ if ((errorLines = stderr.split("\n").filter(line => line.length)).length) {
+ errorLines.forEach(line => line.length && this.execLog(yellow(`(stderr) ${line}`)));
+ }
+ }
+ resolve();
+ });
+ });
+ }
+
+ /**
+ * Generates a blue UTC string associated with the time
+ * of invocation.
+ */
+ private timestamp = () => blue(`[${new Date().toUTCString()}]`);
+
+ /**
+ * A formatted, identified and timestamped log in color
+ */
+ public mainLog = (...optionalParams: any[]) => {
+ console.log(this.timestamp(), this.config.identifiers.master.text, ...optionalParams);
+ }
+
+ /**
+ * A formatted, identified and timestamped log in color for non-
+ */
+ private execLog = (...optionalParams: any[]) => {
+ console.log(this.timestamp(), this.config.identifiers.exec.text, ...optionalParams);
+ }
+
+ /**
+ * Reads in configuration .json file only once, in the master thread
+ * and pass down any variables the pertinent to the child processes as environment variables.
+ */
+ private loadAndValidateConfiguration = (): Configuration => {
+ let config: Configuration;
+ try {
+ console.log(this.timestamp(), cyan("validating configuration..."));
+ config = JSON.parse(readFileSync('./session.config.json', 'utf8'));
+ const options = {
+ throwError: true,
+ allowUnknownAttributes: false
+ };
+ // ensure all necessary and no excess information is specified by the configuration file
+ validate(config, configurationSchema, options);
+ config = Utilities.preciseAssign({}, defaultConfig, config);
+ } catch (error) {
+ if (error instanceof ValidationError) {
+ console.log(red("\nSession configuration failed."));
+ console.log("The given session.config.json configuration file is invalid.");
+ console.log(`${error.instance}: ${error.stack}`);
+ process.exit(0);
+ } else if (error.code === "ENOENT" && error.path === "./session.config.json") {
+ console.log(cyan("Loading default session parameters..."));
+ console.log("Consider including a session.config.json configuration file in your project root for customization.");
+ config = Utilities.preciseAssign({}, defaultConfig);
+ } else {
+ console.log(red("\nSession configuration failed."));
+ console.log("The following unknown error occurred during configuration.");
+ console.log(error.stack);
+ process.exit(0);
+ }
+ } finally {
+ const { identifiers } = config!;
+ Object.keys(identifiers).forEach(key => {
+ const resolved = key as keyof Identifiers;
+ const { text, color } = identifiers[resolved];
+ identifiers[resolved].text = (colorMapping.get(color) || white)(`${text}:`);
+ });
+ return config!;
+ }
+ }
+
+ /**
+ * Builds the repl that allows the following commands to be typed into stdin of the master thread.
+ */
+ private initializeRepl = (): Repl => {
+ const repl = new Repl({ identifier: () => `${this.timestamp()} ${this.config.identifiers.master.text}` });
+ const boolean = /true|false/;
+ const number = /\d+/;
+ const letters = /[a-zA-Z]+/;
+ repl.registerCommand("exit", [/clean|force/], args => this.killSession("manual exit requested by repl", args[0] === "clean", 0));
+ repl.registerCommand("restart", [/clean|force/], args => this.killActiveWorker(args[0] === "clean"));
+ repl.registerCommand("set", [letters, "port", number, boolean], args => this.setPort(args[0], Number(args[2]), args[3] === "true"));
+ repl.registerCommand("set", [/polling/, number, boolean], args => {
+ const newPollingIntervalSeconds = Math.floor(Number(args[1]));
+ if (newPollingIntervalSeconds < 0) {
+ this.mainLog(red("the polling interval must be a non-negative integer"));
+ } else {
+ if (newPollingIntervalSeconds !== this.config.polling.intervalSeconds) {
+ this.config.polling.intervalSeconds = newPollingIntervalSeconds;
+ if (args[2] === "true") {
+ Monitor.IPCManager.emit("updatePollingInterval", { newPollingIntervalSeconds });
+ }
+ }
+ }
+ });
+ return repl;
+ }
+
+ private executeExitHandlers = async (reason: Error | boolean) => Promise.all(this.exitHandlers.map(handler => handler(reason)));
+
+ /**
+ * Attempts to kill the active worker gracefully, unless otherwise specified.
+ */
+ private killActiveWorker = (graceful = true, isSessionEnd = false): void => {
+ if (this.activeWorker && !this.activeWorker.isDead()) {
+ if (graceful) {
+ Monitor.IPCManager.emit("manualExit", { isSessionEnd });
+ } else {
+ this.activeWorker.process.kill();
+ }
+ }
+ }
+
+ /**
+ * Allows the caller to set the port at which the target (be it the server,
+ * the websocket, some other custom port) is listening. If an immediate restart
+ * is specified, this monitor will kill the active child and re-launch the server
+ * at the port. Otherwise, the updated port won't be used until / unless the child
+ * dies on its own and triggers a restart.
+ */
+ private setPort = (port: "server" | "socket" | string, value: number, immediateRestart: boolean): void => {
+ if (value > 1023 && value < 65536) {
+ this.config.ports[port] = value;
+ if (immediateRestart) {
+ this.killActiveWorker();
+ }
+ } else {
+ this.mainLog(red(`${port} is an invalid port number`));
+ }
+ }
+
+ /**
+ * Kills the current active worker and proceeds to spawn a new worker,
+ * feeding in configuration information as environment variables.
+ */
+ private spawn = (): void => {
+ const {
+ polling: {
+ route,
+ failureTolerance,
+ intervalSeconds
+ },
+ ports
+ } = this.config;
+ this.killActiveWorker();
+ this.activeWorker = fork({
+ pollingRoute: route,
+ pollingFailureTolerance: failureTolerance,
+ serverPort: ports.server,
+ socketPort: ports.socket,
+ pollingIntervalSeconds: intervalSeconds,
+ session_key: this.key
+ });
+ Monitor.IPCManager = IPC_Promisify(this.activeWorker, this.route);
+ this.mainLog(cyan(`spawned new server worker with process id ${this.activeWorker?.process.pid}`));
+
+ this.on("kill", ({ reason, graceful, errorCode }) => this.killSession(reason, graceful, errorCode), true);
+ this.on("lifecycle", ({ event }) => console.log(this.timestamp(), `${this.config.identifiers.worker.text} lifecycle phase (${event})`), true);
+ }
+
+}
+
+export namespace Monitor {
+
+ export enum IntrinsicEvents {
+ KeyGenerated = "key_generated",
+ CrashDetected = "crash_detected",
+ ServerRunning = "server_running"
+ }
+
+} \ No newline at end of file
diff --git a/src/server/session/agents/process_message_router.ts b/src/server/session/agents/process_message_router.ts
new file mode 100644
index 000000000..d359e97c3
--- /dev/null
+++ b/src/server/session/agents/process_message_router.ts
@@ -0,0 +1,46 @@
+import { MessageHandler, PromisifiedIPCManager } from "./promisified_ipc_manager";
+
+export default abstract class ProcessMessageRouter {
+
+ protected static IPCManager: PromisifiedIPCManager;
+ private onMessage: { [name: string]: MessageHandler[] | undefined } = {};
+
+ /**
+ * Add a listener at this message. When the monitor process
+ * receives a message, it will invoke all registered functions.
+ */
+ public on = (name: string, handler: MessageHandler, exclusive = false) => {
+ const handlers = this.onMessage[name];
+ if (exclusive || !handlers) {
+ this.onMessage[name] = [handler];
+ } else {
+ handlers.push(handler);
+ }
+ }
+
+ /**
+ * Unregister a given listener at this message.
+ */
+ public off = (name: string, handler: MessageHandler) => {
+ const handlers = this.onMessage[name];
+ if (handlers) {
+ const index = handlers.indexOf(handler);
+ if (index > -1) {
+ handlers.splice(index, 1);
+ }
+ }
+ }
+
+ /**
+ * Unregister all listeners at this message.
+ */
+ public clearMessageListeners = (...names: string[]) => names.map(name => this.onMessage[name] = undefined);
+
+ protected route: MessageHandler = async ({ name, args }) => {
+ const handlers = this.onMessage[name];
+ if (handlers) {
+ await Promise.all(handlers.map(handler => handler(args)));
+ }
+ }
+
+} \ No newline at end of file
diff --git a/src/server/session/agents/promisified_ipc_manager.ts b/src/server/session/agents/promisified_ipc_manager.ts
new file mode 100644
index 000000000..216e9be44
--- /dev/null
+++ b/src/server/session/agents/promisified_ipc_manager.ts
@@ -0,0 +1,97 @@
+import { Utils } from "../../../Utils";
+import { isMaster } from "cluster";
+
+/**
+ * Convenience constructor
+ * @param target the process / worker to which to attach the specialized listeners
+ */
+export function IPC_Promisify(target: IPCTarget, router: Router) {
+ return new PromisifiedIPCManager(target, router);
+}
+
+/**
+ * Essentially, a node process or node cluster worker
+ */
+export type IPCTarget = NodeJS.EventEmitter & { send?: Function };
+
+/**
+ * Some external code that maps the name of incoming messages to registered handlers, if any
+ * when this returns, the message is assumed to have been handled in its entirety by the process, so
+ * await any asynchronous code inside this router.
+ */
+export type Router = (message: Message) => void | Promise<void>;
+
+/**
+ * Specifies a general message format for this API
+ */
+export type Message<T = any> = { name: string; args: T; };
+export type MessageHandler<T = any> = (args: T) => any | Promise<any>;
+
+/**
+ * When a message is emitted, it
+ */
+type InternalMessage = Message & { metadata: any };
+type InternalMessageHandler = (message: InternalMessage) => any | Promise<any>;
+
+/**
+ * This is a wrapper utility class that allows the caller process
+ * to emit an event and return a promise that resolves when it and all
+ * other processes listening to its emission of this event have completed.
+ */
+export class PromisifiedIPCManager {
+ private readonly target: IPCTarget;
+
+ constructor(target: IPCTarget, router: Router) {
+ this.target = target;
+ this.target.addListener("message", this.internalHandler(router));
+ }
+
+ /**
+ * A convenience wrapper around the standard process emission.
+ * Does not wait for a response.
+ */
+ public emit = async (name: string, args?: any) => this.target.send?.({ name, args });
+
+ /**
+ * This routine uniquely identifies each message, then adds a general
+ * message listener that waits for a response with the same id before resolving
+ * the promise.
+ */
+ public emitPromise = async (name: string, args?: any) => {
+ return new Promise(resolve => {
+ const messageId = Utils.GenerateGuid();
+ const responseHandler: InternalMessageHandler = ({ metadata: { id, isResponse }, args, name }) => {
+ if (isResponse && id === messageId) {
+ this.target.removeListener("message", responseHandler);
+ resolve(args?.error as Error | undefined);
+ }
+ };
+ this.target.addListener("message", responseHandler);
+ const message = { name, args, metadata: { id: messageId } };
+ this.target.send?.(message);
+ });
+ }
+
+ /**
+ * This routine receives a uniquely identified message. If the message is itself a response,
+ * it is ignored to avoid infinite mutual responses. Otherwise, the routine awaits its completion using whatever
+ * router the caller has installed, and then sends a response containing the original message id,
+ * which will ultimately invoke the responseHandler of the original emission and resolve the
+ * sender's promise.
+ */
+ private internalHandler = (router: Router) => async ({ name, args, metadata }: InternalMessage) => {
+ if (name && (!metadata || !metadata.isResponse)) {
+ let error: Error | undefined;
+ try {
+ await router({ name, args });
+ } catch (e) {
+ error = e;
+ }
+ if (metadata && this.target.send) {
+ metadata.isResponse = true;
+ this.target.send({ name, args: { error }, metadata });
+ }
+ }
+ }
+
+} \ No newline at end of file
diff --git a/src/server/session/agents/server_worker.ts b/src/server/session/agents/server_worker.ts
new file mode 100644
index 000000000..705307030
--- /dev/null
+++ b/src/server/session/agents/server_worker.ts
@@ -0,0 +1,160 @@
+import { ExitHandler } from "./applied_session_agent";
+import { isMaster } from "cluster";
+import { PromisifiedIPCManager } from "./promisified_ipc_manager";
+import ProcessMessageRouter from "./process_message_router";
+import { red, green, white, yellow } from "colors";
+import { get } from "request-promise";
+import { Monitor } from "./monitor";
+
+/**
+ * Effectively, each worker repairs the connection to the server by reintroducing a consistent state
+ * if its predecessor has died. It itself also polls the server heartbeat, and exits with a notification
+ * email if the server encounters an uncaught exception or if the server cannot be reached.
+ */
+export class ServerWorker extends ProcessMessageRouter {
+ private static count = 0;
+ private shouldServerBeResponsive = false;
+ private exitHandlers: ExitHandler[] = [];
+ private pollingFailureCount = 0;
+ private pollingIntervalSeconds: number;
+ private pollingFailureTolerance: number;
+ private pollTarget: string;
+ private serverPort: number;
+ private isInitialized = false;
+
+ public static Create(work: Function) {
+ if (isMaster) {
+ console.error(red("cannot create a worker on the monitor process."));
+ process.exit(1);
+ } else if (++ServerWorker.count > 1) {
+ ServerWorker.IPCManager.emit("kill", {
+ reason: "cannot create more than one worker on a given worker process.",
+ graceful: false,
+ errorCode: 1
+ });
+ process.exit(1);
+ } else {
+ return new ServerWorker(work);
+ }
+ }
+
+ /**
+ * Allows developers to invoke application specific logic
+ * by hooking into the exiting of the server process.
+ */
+ public addExitHandler = (handler: ExitHandler) => this.exitHandlers.push(handler);
+
+ /**
+ * Kill the session monitor (parent process) from this
+ * server worker (child process). This will also kill
+ * this process (child process).
+ */
+ public killSession = (reason: string, graceful = true, errorCode = 0) => this.emitToMonitor("kill", { reason, graceful, errorCode });
+
+ /**
+ * A convenience wrapper to tell the session monitor (parent process)
+ * to carry out the action with the specified message and arguments.
+ */
+ public emitToMonitor = (name: string, args?: any) => ServerWorker.IPCManager.emit(name, args);
+
+ public emitToMonitorPromise = (name: string, args?: any) => ServerWorker.IPCManager.emitPromise(name, args);
+
+ private constructor(work: Function) {
+ super();
+ ServerWorker.IPCManager = new PromisifiedIPCManager(process, this.route);
+ this.lifecycleNotification(green(`initializing process... ${white(`[${process.execPath} ${process.execArgv.join(" ")}]`)}`));
+
+ const { pollingRoute, serverPort, pollingIntervalSeconds, pollingFailureTolerance } = process.env;
+ this.serverPort = Number(serverPort);
+ this.pollingIntervalSeconds = Number(pollingIntervalSeconds);
+ this.pollingFailureTolerance = Number(pollingFailureTolerance);
+ this.pollTarget = `http://localhost:${serverPort}${pollingRoute}`;
+
+ this.configureProcess();
+ work();
+ this.pollServer();
+ }
+
+ /**
+ * Set up message and uncaught exception handlers for this
+ * server process.
+ */
+ private configureProcess = () => {
+ // updates the local values of variables to the those sent from master
+ this.on("updatePollingInterval", ({ newPollingIntervalSeconds }) => this.pollingIntervalSeconds = newPollingIntervalSeconds);
+ this.on("manualExit", async ({ isSessionEnd }) => {
+ await this.executeExitHandlers(isSessionEnd);
+ process.exit(0);
+ });
+
+ // one reason to exit, as the process might be in an inconsistent state after such an exception
+ process.on('uncaughtException', this.proactiveUnplannedExit);
+ process.on('unhandledRejection', reason => {
+ const appropriateError = reason instanceof Error ? reason : new Error(`unhandled rejection: ${reason}`);
+ this.proactiveUnplannedExit(appropriateError);
+ });
+ }
+
+ /**
+ * Execute the list of functions registered to be called
+ * whenever the process exits.
+ */
+ private executeExitHandlers = async (reason: Error | boolean) => Promise.all(this.exitHandlers.map(handler => handler(reason)));
+
+ /**
+ * Notify master thread (which will log update in the console) of initialization via IPC.
+ */
+ public lifecycleNotification = (event: string) => ServerWorker.IPCManager.emit("lifecycle", { event });
+
+ /**
+ * Called whenever the process has a reason to terminate, either through an uncaught exception
+ * in the process (potentially inconsistent state) or the server cannot be reached.
+ */
+ private proactiveUnplannedExit = async (error: Error): Promise<void> => {
+ this.shouldServerBeResponsive = false;
+ // communicates via IPC to the master thread that it should dispatch a crash notification email
+ this.emitToMonitor(Monitor.IntrinsicEvents.CrashDetected, { error });
+ await this.executeExitHandlers(error);
+ // notify master thread (which will log update in the console) of crash event via IPC
+ this.lifecycleNotification(red(`crash event detected @ ${new Date().toUTCString()}`));
+ this.lifecycleNotification(red(error.message));
+ process.exit(1);
+ }
+
+ /**
+ * This monitors the health of the server by submitting a get request to whatever port / route specified
+ * by the configuration every n seconds, where n is also given by the configuration.
+ */
+ private pollServer = async (): Promise<void> => {
+ await new Promise<void>(resolve => {
+ setTimeout(async () => {
+ try {
+ await get(this.pollTarget);
+ if (!this.shouldServerBeResponsive) {
+ // notify monitor thread that the server is up and running
+ this.lifecycleNotification(green(`listening on ${this.serverPort}...`));
+ this.emitToMonitor(Monitor.IntrinsicEvents.ServerRunning, { isFirstTime: !this.isInitialized });
+ this.isInitialized = true;
+ }
+ this.shouldServerBeResponsive = true;
+ } catch (error) {
+ // if we expect the server to be unavailable, i.e. during compilation,
+ // the listening variable is false, activeExit will return early and the child
+ // process will continue
+ if (this.shouldServerBeResponsive) {
+ if (++this.pollingFailureCount > this.pollingFailureTolerance) {
+ this.proactiveUnplannedExit(error);
+ } else {
+ this.lifecycleNotification(yellow(`the server has encountered ${this.pollingFailureCount} of ${this.pollingFailureTolerance} tolerable failures`));
+ }
+ }
+ } finally {
+ resolve();
+ }
+ }, 1000 * this.pollingIntervalSeconds);
+ });
+ // controlled, asynchronous infinite recursion achieves a persistent poll that does not submit a new request until the previous has completed
+ this.pollServer();
+ }
+
+} \ No newline at end of file
diff --git a/src/server/repl.ts b/src/server/session/utilities/repl.ts
index ad55b6aaa..643141286 100644
--- a/src/server/repl.ts
+++ b/src/server/session/utilities/repl.ts
@@ -54,7 +54,7 @@ export default class Repl {
}
}
- private success = (command: string) => `${this.resolvedIdentifier()} completed execution of ${white(command)}`;
+ private success = (command: string) => `${this.resolvedIdentifier()} completed local execution of ${white(command)}`;
public registerCommand = (basename: string, argPatterns: (RegExp | string)[], action: ReplAction) => {
const existing = this.commandMap.get(basename);
diff --git a/src/server/Session/session_config_schema.ts b/src/server/session/utilities/session_config.ts
index e32cf8c6a..b0e65dde4 100644
--- a/src/server/Session/session_config_schema.ts
+++ b/src/server/session/utilities/session_config.ts
@@ -1,4 +1,5 @@
import { Schema } from "jsonschema";
+import { yellow, red, cyan, green, blue, magenta, Color, grey, gray, white, black } from "colors";
const colorPattern = /black|red|green|yellow|blue|magenta|cyan|white|gray|grey/;
@@ -64,4 +65,65 @@ export const configurationSchema: Schema = {
}
},
}
+};
+
+type ColorLabel = "yellow" | "red" | "cyan" | "green" | "blue" | "magenta" | "grey" | "gray" | "white" | "black";
+
+export const colorMapping: Map<ColorLabel, Color> = new Map([
+ ["yellow", yellow],
+ ["red", red],
+ ["cyan", cyan],
+ ["green", green],
+ ["blue", blue],
+ ["magenta", magenta],
+ ["grey", grey],
+ ["gray", gray],
+ ["white", white],
+ ["black", black]
+]);
+
+interface Identifier {
+ text: string;
+ color: ColorLabel;
+}
+
+export interface Identifiers {
+ master: Identifier;
+ worker: Identifier;
+ exec: Identifier;
+}
+
+export interface Configuration {
+ showServerOutput: boolean;
+ identifiers: Identifiers;
+ ports: { [description: string]: number };
+ polling: {
+ route: string;
+ intervalSeconds: number;
+ failureTolerance: number;
+ };
+}
+
+export const defaultConfig: Configuration = {
+ showServerOutput: false,
+ identifiers: {
+ master: {
+ text: "__monitor__",
+ color: "yellow"
+ },
+ worker: {
+ text: "__server__",
+ color: "magenta"
+ },
+ exec: {
+ text: "__exec__",
+ color: "green"
+ }
+ },
+ ports: { server: 3000 },
+ polling: {
+ route: "/",
+ intervalSeconds: 30,
+ failureTolerance: 0
+ }
}; \ No newline at end of file
diff --git a/src/server/session/utilities/utilities.ts b/src/server/session/utilities/utilities.ts
new file mode 100644
index 000000000..ac8a6590a
--- /dev/null
+++ b/src/server/session/utilities/utilities.ts
@@ -0,0 +1,31 @@
+export namespace Utilities {
+
+ /**
+ * At any arbitrary layer of nesting within the configuration objects, any single value that
+ * is not specified by the configuration is given the default counterpart. If, within an object,
+ * one peer is given by configuration and two are not, the one is preserved while the two are given
+ * the default value.
+ * @returns the composition of all of the assigned objects, much like Object.assign(), but with more
+ * granularity in the overwriting of nested objects
+ */
+ export function preciseAssign(target: any, ...sources: any[]): any {
+ for (const source of sources) {
+ preciseAssignHelper(target, source);
+ }
+ return target;
+ }
+
+ export function preciseAssignHelper(target: any, source: any) {
+ Array.from(new Set([...Object.keys(target), ...Object.keys(source)])).map(property => {
+ let targetValue: any, sourceValue: any;
+ if (sourceValue = source[property]) {
+ if (typeof sourceValue === "object" && typeof (targetValue = target[property]) === "object") {
+ preciseAssignHelper(targetValue, sourceValue);
+ } else {
+ target[property] = sourceValue;
+ }
+ }
+ });
+ }
+
+} \ No newline at end of file