aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'src/server')
-rw-r--r--src/server/ActionUtilities.ts6
-rw-r--r--src/server/ApiManagers/SearchManager.ts26
-rw-r--r--src/server/ApiManagers/SessionManager.ts62
-rw-r--r--src/server/DashSession.ts174
-rw-r--r--src/server/RouteManager.ts3
-rw-r--r--src/server/Search.ts8
-rw-r--r--src/server/Session/session.ts867
-rw-r--r--src/server/Session/session_config_schema.ts72
-rw-r--r--src/server/index.ts20
-rw-r--r--src/server/remote_debug_instructions.txt16
-rw-r--r--src/server/repl.ts13
11 files changed, 854 insertions, 413 deletions
diff --git a/src/server/ActionUtilities.ts b/src/server/ActionUtilities.ts
index a93566fb1..f0bfbc525 100644
--- a/src/server/ActionUtilities.ts
+++ b/src/server/ActionUtilities.ts
@@ -6,6 +6,7 @@ import * as rimraf from "rimraf";
import { yellow, Color } from 'colors';
import * as nodemailer from "nodemailer";
import { MailOptions } from "nodemailer/lib/json-transport";
+import Mail = require('nodemailer/lib/mailer');
const projectRoot = path.resolve(__dirname, "../../");
export function pathFromRoot(relative?: string) {
@@ -137,12 +138,13 @@ export namespace Email {
return failures.length ? failures : undefined;
}
- export async function dispatch(recipient: string, subject: string, content: string): Promise<Error | null> {
+ export async function dispatch(recipient: string, subject: string, content: string, attachments?: Mail.Attachment[]): Promise<Error | null> {
const mailOptions = {
to: recipient,
from: 'brownptcdash@gmail.com',
subject,
- text: `Hello ${recipient.split("@")[0]},\n\n${content}`
+ text: `Hello ${recipient.split("@")[0]},\n\n${content}`,
+ attachments
} as MailOptions;
return new Promise<Error | null>(resolve => {
smtpTransport.sendMail(mailOptions, resolve);
diff --git a/src/server/ApiManagers/SearchManager.ts b/src/server/ApiManagers/SearchManager.ts
index c1c908088..4ce12f9f3 100644
--- a/src/server/ApiManagers/SearchManager.ts
+++ b/src/server/ApiManagers/SearchManager.ts
@@ -4,11 +4,11 @@ import { Search } from "../Search";
const findInFiles = require('find-in-files');
import * as path from 'path';
import { pathToDirectory, Directory } from "./UploadManager";
-import { command_line } from "../ActionUtilities";
-import request = require('request-promise');
-import { red } from "colors";
+import { red, cyan, yellow } from "colors";
import RouteSubscriber from "../RouteSubscriber";
-import { execSync } from "child_process";
+import { exec } from "child_process";
+import { onWindows } from "..";
+import { get } from "request-promise";
export class SearchManager extends ApiManager {
@@ -69,15 +69,23 @@ export class SearchManager extends ApiManager {
export namespace SolrManager {
+ const command = onWindows ? "solr.cmd" : "solr";
+
export async function SetRunning(status: boolean): Promise<boolean> {
const args = status ? "start" : "stop -p 8983";
+ console.log(`solr management: trying to ${args}`);
+ exec(`${command} ${args}`, { cwd: "./solr-8.3.1/bin" }, (error, stdout, stderr) => {
+ if (error) {
+ console.log(red(`solr management error: unable to ${args} server`));
+ console.log(red(error.message));
+ }
+ console.log(cyan(stdout));
+ console.log(yellow(stderr));
+ });
try {
- console.log(`Solr management: trying to ${args}`);
- console.log(execSync(`./solr.cmd ${args}`, { cwd: "./solr-8.3.1/bin" }));
+ await get("http://localhost:8983");
return true;
- } catch (e) {
- console.log(red(`Solr management error: unable to ${args}`));
- console.log(e);
+ } catch {
return false;
}
}
diff --git a/src/server/ApiManagers/SessionManager.ts b/src/server/ApiManagers/SessionManager.ts
new file mode 100644
index 000000000..0290b578c
--- /dev/null
+++ b/src/server/ApiManagers/SessionManager.ts
@@ -0,0 +1,62 @@
+import ApiManager, { Registration } from "./ApiManager";
+import { Method, _permission_denied, AuthorizedCore, SecureHandler } from "../RouteManager";
+import RouteSubscriber from "../RouteSubscriber";
+import { sessionAgent } from "..";
+
+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 authorizedAction = (handler: SecureHandler) => {
+ return (core: AuthorizedCore) => {
+ const { req, res, isRelease } = core;
+ const { password } = req.params;
+ if (!isRelease) {
+ return res.send("This can be run only on the release server.");
+ }
+ if (password !== process.env.session_key) {
+ return _permission_denied(res, permissionError);
+ }
+ handler(core);
+ };
+ }
+
+ protected initialize(register: Registration): void {
+
+ register({
+ method: Method.GET,
+ subscription: this.secureSubscriber("debug", "mode", "recipient"),
+ secureHandler: this.authorizedAction(({ 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 {
+ res.send(`Your request failed. '${mode}' is not a valid mode: please choose either 'active' or 'passive'`);
+ }
+ })
+ });
+
+ 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.`);
+ })
+ });
+
+ register({
+ method: Method.GET,
+ subscription: this.secureSubscriber("kill"),
+ secureHandler: this.authorizedAction(({ res }) => {
+ res.send("Your request was successful: the server and its session have been killed.");
+ sessionAgent.killSession("an authorized user has manually ended the server session via the /kill route");
+ })
+ });
+
+ }
+
+} \ No newline at end of file
diff --git a/src/server/DashSession.ts b/src/server/DashSession.ts
index 9c36fa17f..56610874e 100644
--- a/src/server/DashSession.ts
+++ b/src/server/DashSession.ts
@@ -1,60 +1,138 @@
import { Session } from "./Session/session";
-import { Email } from "./ActionUtilities";
-import { red, yellow } from "colors";
-import { SolrManager } from "./ApiManagers/SearchManager";
-import { execSync } from "child_process";
-import { isMaster } from "cluster";
+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 } from ".";
-
-const notificationRecipients = ["samuel_wilkins@brown.edu"];
-const signature = "-Dash Server Session Manager";
-
-const monitorHooks: Session.MonitorNotifierHooks = {
- key: async (key, masterLog) => {
- const content = `The key for this session (started @ ${new Date().toUTCString()}) is ${key}.\n\n${signature}`;
- const failures = await Email.dispatchAll(notificationRecipients, "Server Termination Key", content);
- if (failures) {
- failures.map(({ recipient, error: { message } }) => masterLog(red(`dispatch failure @ ${recipient} (${yellow(message)})`)));
- return false;
+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;
}
- return true;
- },
- crash: async ({ name, message, stack }, masterLog) => {
- 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${signature}`;
- const failures = await Email.dispatchAll(notificationRecipients, "Dash Web Server Crash", content);
- if (failures) {
- failures.map(({ recipient, error: { message } }) => masterLog(red(`dispatch failure @ ${recipient} (${yellow(message)})`)));
- return false;
+ };
+
+ 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"));
+ }
}
- return true;
}
-};
-export class DashSessionAgent extends Session.AppliedSessionAgent {
+ 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);
+ }
+ }
- /**
- * 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.
- */
- protected async launchImplementation() {
- if (isMaster) {
- this.sessionMonitor = await Session.initializeMonitorThread(monitorHooks);
- this.sessionMonitor.addReplCommand("pull", [], () => execSync("git pull", { stdio: ["ignore", "inherit", "inherit"] }));
- this.sessionMonitor.addReplCommand("solr", [/start|stop/g], args => SolrManager.SetRunning(args[0] === "start"));
- } else {
- this.serverWorker = await Session.initializeWorkerThread(launchServer); // server initialization delegated to worker
- this.serverWorker.addExitHandler(() => Utils.Emit(WebSocket._socket, MessageStore.ConnectionTerminated, "Manual"));
+ 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));
}
}
diff --git a/src/server/RouteManager.ts b/src/server/RouteManager.ts
index 75bf5f3b1..35d5131a4 100644
--- a/src/server/RouteManager.ts
+++ b/src/server/RouteManager.ts
@@ -14,7 +14,8 @@ export interface CoreArguments {
isRelease: boolean;
}
-export type SecureHandler = (core: CoreArguments & { user: DashUserModel }) => any | Promise<any>;
+export type AuthorizedCore = CoreArguments & { user: DashUserModel };
+export type SecureHandler = (core: AuthorizedCore) => any | Promise<any>;
export type PublicHandler = (core: CoreArguments) => any | Promise<any>;
export type ErrorHandler = (core: CoreArguments & { error: any }) => any | Promise<any>;
diff --git a/src/server/Search.ts b/src/server/Search.ts
index 2b59c14b1..21064e520 100644
--- a/src/server/Search.ts
+++ b/src/server/Search.ts
@@ -1,4 +1,5 @@
import * as rp from 'request-promise';
+import { red } from 'colors';
const pathTo = (relative: string) => `http://localhost:8983/solr/dash/${relative}`;
@@ -43,7 +44,7 @@ export namespace Search {
export async function clear() {
try {
- return rp.post(pathTo("update"), {
+ await rp.post(pathTo("update"), {
body: {
delete: {
query: "*:*"
@@ -51,7 +52,10 @@ export namespace Search {
},
json: true
});
- } catch { }
+ } catch (e) {
+ console.log(red("Unable to clear search..."));
+ console.log(red(e.message));
+ }
}
export async function deleteDocuments(docs: string[]) {
diff --git a/src/server/Session/session.ts b/src/server/Session/session.ts
index a3e6c4e16..ec3d46ac1 100644
--- a/src/server/Session/session.ts
+++ b/src/server/Session/session.ts
@@ -1,439 +1,686 @@
-import { red, cyan, green, yellow, magenta, blue } from "colors";
-import { on, fork, setupMaster, Worker, isMaster } from "cluster";
+import { red, cyan, green, yellow, magenta, blue, white, Color, grey, gray, black } from "colors";
+import { on, fork, setupMaster, Worker, isMaster, isWorker } from "cluster";
import { get } from "request-promise";
import { Utils } from "../../Utils";
import Repl, { ReplAction } from "../repl";
import { readFileSync } from "fs";
import { validate, ValidationError } from "jsonschema";
import { configurationSchema } from "./session_config_schema";
+import { exec, ExecOptions } from "child_process";
/**
- * This namespace relies 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.
- */
+ * This namespace relies 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.
+ */
export namespace Session {
+ type ColorLabel = "yellow" | "red" | "cyan" | "green" | "blue" | "magenta" | "grey" | "gray" | "white" | "black";
+ 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]
+ ]);
+
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 launchMonitor(): Promise<Session.Monitor>;
+ protected abstract async launchServerWorker(): Promise<Session.ServerWorker>;
+
private launched = false;
- protected sessionMonitorRef: Session.Monitor | undefined;
+ public killSession = (reason: string, graceful = true, errorCode = 0) => {
+ const target = isMaster ? this.sessionMonitor : this.serverWorker;
+ target.killSession(reason, graceful, errorCode);
+ }
+
+ private sessionMonitorRef: Session.Monitor | undefined;
public get sessionMonitor(): Session.Monitor {
if (!isMaster) {
- throw new Error("Cannot access the session monitor directly from the server worker thread");
+ this.serverWorker.sendMonitorAction("kill", {
+ graceful: false,
+ reason: "Cannot access the session monitor directly from the server worker thread.",
+ errorCode: 1
+ });
+ throw new Error();
}
return this.sessionMonitorRef!;
}
- public set sessionMonitor(monitor: Session.Monitor) {
- if (!isMaster) {
- throw new Error("Cannot set the session monitor directly from the server worker thread");
- }
- this.sessionMonitorRef = monitor;
- }
- protected serverWorkerRef: Session.ServerWorker | undefined;
+ private serverWorkerRef: Session.ServerWorker | undefined;
public get serverWorker(): Session.ServerWorker {
if (isMaster) {
throw new Error("Cannot access the server worker directly from the session monitor thread");
}
return this.serverWorkerRef!;
}
- public set serverWorker(worker: Session.ServerWorker) {
- if (isMaster) {
- throw new Error("Cannot set the server worker directly from the session monitor thread");
- }
- this.serverWorkerRef = worker;
- }
public async launch(): Promise<void> {
if (!this.launched) {
this.launched = true;
- await this.launchImplementation();
+ if (isMaster) {
+ this.sessionMonitorRef = await this.launchMonitor();
+ } else {
+ this.serverWorkerRef = await this.launchServerWorker();
+ }
} else {
throw new Error("Cannot launch a session thread more than once per process.");
}
}
- protected abstract async launchImplementation(): Promise<void>;
+ }
+
+ interface Identifier {
+ text: string;
+ color: ColorLabel;
+ }
+ interface Identifiers {
+ master: Identifier;
+ worker: Identifier;
+ exec: Identifier;
}
interface Configuration {
showServerOutput: boolean;
- masterIdentifier: string;
- workerIdentifier: string;
+ identifiers: Identifiers;
ports: { [description: string]: number };
- pollingRoute: string;
- pollingIntervalSeconds: number;
- pollingFailureTolerance: number;
- [key: string]: any;
+ polling: {
+ route: string;
+ intervalSeconds: number;
+ failureTolerance: number;
+ };
}
- const defaultConfiguration: Configuration = {
+ const defaultConfig: Configuration = {
showServerOutput: false,
- masterIdentifier: yellow("__monitor__:"),
- workerIdentifier: magenta("__server__:"),
+ identifiers: {
+ master: {
+ text: "__monitor__",
+ color: "yellow"
+ },
+ worker: {
+ text: "__server__",
+ color: "magenta"
+ },
+ exec: {
+ text: "__exec__",
+ color: "green"
+ }
+ },
ports: { server: 3000 },
- pollingRoute: "/",
- pollingIntervalSeconds: 30,
- pollingFailureTolerance: 1
+ polling: {
+ route: "/",
+ intervalSeconds: 30,
+ failureTolerance: 0
+ }
};
- export interface Monitor {
- log: (...optionalParams: any[]) => void;
- restartServer: () => void;
- setPort: (port: "server" | "socket" | string, value: number, immediateRestart: boolean) => void;
- killSession: (graceful?: boolean) => never;
- addReplCommand: (basename: string, argPatterns: (RegExp | string)[], action: ReplAction) => void;
- addChildMessageHandler: (message: string, handler: ActionHandler) => void;
- }
+ export type ExitHandler = (reason: Error | boolean) => void | Promise<void>;
- export interface ServerWorker {
- killSession: () => void;
- addExitHandler: (handler: ExitHandler) => void;
- }
+ export namespace Monitor {
- export interface MonitorNotifierHooks {
- key?: (key: string, masterLog: (...optionalParams: any[]) => void) => boolean | Promise<boolean>;
- crash?: (error: Error, masterLog: (...optionalParams: any[]) => void) => boolean | Promise<boolean>;
- }
+ export interface NotifierHooks {
+ key?: (key: string) => (boolean | Promise<boolean>);
+ crash?: (error: Error) => (boolean | Promise<boolean>);
+ }
- export interface SessionAction {
- message: string;
- args: any;
- }
+ export interface Action {
+ message: string;
+ args: any;
+ }
+
+ export type ServerMessageHandler = (action: Action) => void | Promise<void>;
- export type ExitHandler = (reason: Error | null) => void | Promise<void>;
- export type ActionHandler = (action: SessionAction) => void | Promise<void>;
- export interface EmailTemplate {
- subject: string;
- body: string;
}
- function loadAndValidateConfiguration(): Configuration {
- try {
- console.log(timestamp(), cyan("validating configuration..."));
- const configuration: Configuration = 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(configuration, configurationSchema, options);
- let formatMaster = true;
- let formatWorker = true;
- Object.keys(defaultConfiguration).forEach(property => {
- if (!configuration[property]) {
- if (property === "masterIdentifier") {
- formatMaster = false;
- } else if (property === "workerIdentifier") {
- formatWorker = false;
+ /**
+ * 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 {
+
+ private static count = 0;
+ private exitHandlers: ExitHandler[] = [];
+ private readonly notifiers: Monitor.NotifierHooks | undefined;
+ private readonly config: Configuration;
+ private onMessage: { [message: string]: Monitor.ServerMessageHandler[] | undefined } = {};
+ private activeWorker: Worker | undefined;
+ private key: string | undefined;
+ private repl: Repl;
+
+ public static Create(notifiers?: Monitor.NotifierHooks) {
+ if (isWorker) {
+ process.send?.({
+ action: {
+ message: "kill",
+ args: {
+ reason: "cannot create a monitor on the worker process.",
+ graceful: false,
+ errorCode: 1
+ }
}
- configuration[property] = defaultConfiguration[property];
- }
+ });
+ process.exit(1);
+ } else if (++Monitor.count > 1) {
+ console.error(red("cannot create more than one monitor."));
+ process.exit(1);
+ } else {
+ return new Monitor(notifiers);
+ }
+ }
+
+ /**
+ * 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();
+ });
});
- if (formatMaster) {
- configuration.masterIdentifier = yellow(configuration.masterIdentifier + ":");
+ }
+
+ /**
+ * Add a listener at this message. When the monitor process
+ * receives a message, it will invoke all registered functions.
+ */
+ public addServerMessageListener = (message: string, handler: Monitor.ServerMessageHandler) => {
+ const handlers = this.onMessage[message];
+ if (handlers) {
+ handlers.push(handler);
+ } else {
+ this.onMessage[message] = [handler];
}
- if (formatWorker) {
- configuration.workerIdentifier = magenta(configuration.workerIdentifier + ":");
+ }
+
+ /**
+ * Unregister a given listener at this message.
+ */
+ public removeServerMessageListener = (message: string, handler: Monitor.ServerMessageHandler) => {
+ const handlers = this.onMessage[message];
+ if (handlers) {
+ const index = handlers.indexOf(handler);
+ if (index > -1) {
+ handlers.splice(index, 1);
+ }
}
- return configuration;
- } 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.");
- return defaultConfiguration;
- } else {
- console.log(red("\nSession configuration failed."));
- console.log("The following unknown error occurred during configuration.");
- console.log(error.stack);
- process.exit(0);
+ }
+
+ /**
+ * Unregister all listeners at this message.
+ */
+ public clearServerMessageListeners = (message: string) => this.onMessage[message] = undefined;
+
+ private constructor(notifiers?: Monitor.NotifierHooks) {
+ this.notifiers = notifiers;
+
+ console.log(this.timestamp(), cyan("initializing session..."));
+
+ this.config = this.loadAndValidateConfiguration();
+
+ this.initializeSessionKey();
+ // 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();
+ });
+
+ this.repl = this.initializeRepl();
+ this.spawn();
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * If the caller has indicated an interest
+ * in being notified of this feature, creates
+ * a GUID for this session that can, for example,
+ * be used as authentication for killing the server
+ * (checked externally).
+ */
+ private initializeSessionKey = async (): Promise<void> => {
+ if (this.notifiers?.key) {
+ this.key = Utils.GenerateGuid();
+ const success = await this.notifiers.key(this.key);
+ const statement = success ? green("distributed session key to recipients") : red("distribution of session key failed");
+ this.mainLog(statement);
}
}
- }
- function timestamp() {
- return blue(`[${new Date().toUTCString()}]`);
- }
+ /**
+ * 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
+ */
+ private preciseAssign = (target: any, ...sources: any[]): any => {
+ for (const source of sources) {
+ this.preciseAssignHelper(target, source);
+ }
+ return target;
+ }
- /**
- * 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 async function initializeMonitorThread(notifiers?: MonitorNotifierHooks): Promise<Monitor> {
- console.log(timestamp(), cyan("initializing session..."));
- let activeWorker: Worker;
- const childMessageHandlers: { [message: string]: ActionHandler } = {};
-
- // read in configuration .json file only once, in the master thread
- // pass down any variables the pertinent to the child processes as environment variables
- const configuration = loadAndValidateConfiguration();
- const {
- masterIdentifier,
- workerIdentifier,
- ports,
- pollingRoute,
- showServerOutput,
- pollingFailureTolerance
- } = configuration;
- let { pollingIntervalSeconds } = configuration;
-
- const log = (...optionalParams: any[]) => console.log(timestamp(), masterIdentifier, ...optionalParams);
-
- // this sends a pseudorandomly generated guid to the configuration's recipients, allowing them alone
- // to kill the server via the /kill/:key route
- let key: string | undefined;
- if (notifiers && notifiers.key) {
- key = Utils.GenerateGuid();
- const success = await notifiers.key(key, log);
- const statement = success ? green("distributed session key to recipients") : red("distribution of session key failed");
- log(statement);
+ private 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") {
+ this.preciseAssignHelper(targetValue, sourceValue);
+ } else {
+ target[property] = sourceValue;
+ }
+ }
+ });
}
- // 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") {
- log(red(message));
- if (stack) {
- log(`uncaught exception\n${red(stack)}`);
+ /**
+ * 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 = this.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 = this.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!;
}
- });
+ }
- // determines whether or not we see the compilation / initialization / runtime output of each child server process
- setupMaster({ silent: !showServerOutput });
+ /**
+ * 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[2]));
+ 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[3] === "true") {
+ this.activeWorker?.send({ newPollingIntervalSeconds });
+ }
+ }
+ }
+ });
+ return repl;
+ }
+
+ private executeExitHandlers = async (reason: Error | boolean) => Promise.all(this.exitHandlers.map(handler => handler(reason)));
- // attempts to kills the active worker ungracefully, unless otherwise specified
- const tryKillActiveWorker = (graceful = false): boolean => {
- if (activeWorker && !activeWorker.isDead()) {
+ /**
+ * 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) {
- activeWorker.kill();
+ this.activeWorker.send({ manualExit: { isSessionEnd } });
} else {
- activeWorker.process.kill();
+ this.activeWorker.process.kill();
}
- return true;
}
- return false;
- };
-
- const restartServer = (): void => {
- // indicate to the worker that we are 'expecting' this restart
- activeWorker.send({ setResponsiveness: false });
- tryKillActiveWorker(true);
- };
-
- const killSession = (graceful = true): never => {
- log(cyan(`exiting session ${graceful ? "clean" : "immediate"}ly`));
- tryKillActiveWorker(graceful);
- process.exit(0);
- };
+ }
- const setPort = (port: "server" | "socket" | string, value: number, immediateRestart: boolean): void => {
+ /**
+ * 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) {
- ports[port] = value;
+ this.config.ports[port] = value;
if (immediateRestart) {
- restartServer();
+ this.killActiveWorker();
}
} else {
- log(red(`${port} is an invalid port number`));
+ 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
- const spawn = (): void => {
- tryKillActiveWorker();
- activeWorker = fork({
- pollingRoute,
- pollingFailureTolerance,
+ /**
+ * 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,
- session_key: key,
+ pollingIntervalSeconds: intervalSeconds,
+ session_key: this.key,
DB: process.env.DB
});
- log(cyan(`spawned new server worker with process id ${activeWorker.process.pid}`));
+ this.mainLog(cyan(`spawned new server worker with process id ${this.activeWorker.process.pid}`));
// an IPC message handler that executes actions on the master thread when prompted by the active worker
- activeWorker.on("message", async ({ lifecycle, action }) => {
+ this.activeWorker.on("message", async ({ lifecycle, action }) => {
if (action) {
- const { message, args } = action as SessionAction;
- console.log(timestamp(), `${workerIdentifier} action requested (${cyan(message)})`);
+ const { message, args } = action as Monitor.Action;
+ console.log(this.timestamp(), `${this.config.identifiers.worker.text} action requested (${cyan(message)})`);
switch (message) {
case "kill":
- log(red("an authorized user has manually ended the server session"));
- killSession();
+ const { reason, graceful, errorCode } = args;
+ this.killSession(reason, graceful, errorCode);
+ break;
case "notify_crash":
- if (notifiers && notifiers.crash) {
+ if (this.notifiers?.crash) {
const { error } = args;
- const success = await notifiers.crash(error, log);
+ const success = await this.notifiers.crash(error);
const statement = success ? green("distributed crash notification to recipients") : red("distribution of crash notification failed");
- log(statement);
+ this.mainLog(statement);
}
+ break;
case "set_port":
const { port, value, immediateRestart } = args;
- setPort(port, value, immediateRestart);
- default:
- const handler = childMessageHandlers[message];
- if (handler) {
- handler({ message, args });
- }
+ this.setPort(port, value, immediateRestart);
+ break;
+ }
+ const handlers = this.onMessage[message];
+ if (handlers) {
+ handlers.forEach(handler => handler({ message, args }));
}
- } else if (lifecycle) {
- console.log(timestamp(), `${workerIdentifier} lifecycle phase (${lifecycle})`);
+ }
+ if (lifecycle) {
+ console.log(this.timestamp(), `${this.config.identifiers.worker.text} lifecycle phase (${lifecycle})`);
}
});
- };
+ }
- // 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}`}.`;
- log(cyan(prompt));
- // to make this a robust, continuous session, every time a child process dies, we immediately spawn a new one
- spawn();
- });
-
- // builds the repl that allows the following commands to be typed into stdin of the master thread
- const repl = new Repl({ identifier: () => `${timestamp()} ${masterIdentifier}` });
- const boolean = /true|false/;
- const number = /\d+/;
- const letters = /[a-zA-Z]+/;
- repl.registerCommand("exit", [/clean|force/], args => killSession(args[0] === "clean"));
- repl.registerCommand("restart", [], restartServer);
- repl.registerCommand("set", [letters, "port", number, boolean], args => setPort(args[0], Number(args[2]), args[3] === "true"));
- repl.registerCommand("set", [/polling/, number, boolean], args => {
- const newPollingIntervalSeconds = Math.floor(Number(args[2]));
- if (newPollingIntervalSeconds < 0) {
- log(red("the polling interval must be a non-negative integer"));
- } else {
- if (newPollingIntervalSeconds !== pollingIntervalSeconds) {
- pollingIntervalSeconds = newPollingIntervalSeconds;
- if (args[3] === "true") {
- activeWorker.send({ newPollingIntervalSeconds });
- }
- }
- }
- });
- // finally, set things in motion by spawning off the first child (server) process
- spawn();
-
- // returned to allow the caller to add custom commands
- return {
- addReplCommand: repl.registerCommand,
- addChildMessageHandler: (message: string, handler: ActionHandler) => { childMessageHandlers[message] = handler; },
- restartServer,
- killSession,
- setPort,
- log
- };
}
/**
* 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.
- * @param work the function specifying the work to be done by each worker thread
*/
- export async function initializeWorkerThread(work: Function): Promise<ServerWorker> {
- let shouldServerBeResponsive = false;
- const exitHandlers: ExitHandler[] = [];
- let pollingFailureCount = 0;
-
- const lifecycleNotification = (lifecycle: string) => process.send?.({ lifecycle });
-
- // notify master thread (which will log update in the console) of initialization via IPC
- lifecycleNotification(green("compiling and initializing..."));
-
- // updates the local value of listening to the value sent from master
- process.on("message", ({ setResponsiveness, newPollingIntervalSeconds }) => {
- if (setResponsiveness) {
- shouldServerBeResponsive = setResponsiveness;
- }
- if (newPollingIntervalSeconds) {
- pollingIntervalSeconds = newPollingIntervalSeconds;
+ export class ServerWorker {
+
+ 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;
+
+ 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) {
+ process.send?.({
+ action: {
+ message: "kill", args: {
+ reason: "cannot create more than one worker on a given worker process.",
+ graceful: false,
+ errorCode: 1
+ }
+ }
+ });
+ process.exit(1);
+ } else {
+ return new ServerWorker(work);
}
- });
+ }
- const executeExitHandlers = async (reason: Error | null) => Promise.all(exitHandlers.map(handler => handler(reason)));
+ /**
+ * 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.sendMonitorAction("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 sendMonitorAction = (message: string, args?: any) => process.send!({ action: { message, args } });
+
+ private constructor(work: Function) {
+ 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();
+ }
- // 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
- const activeExit = async (error: Error): Promise<void> => {
- shouldServerBeResponsive = false;
- // communicates via IPC to the master thread that it should dispatch a crash notification email
- process.send?.({
- action: {
- message: "notify_crash",
- args: { error }
+ /**
+ * 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
+ process.on("message", async ({ newPollingIntervalSeconds, manualExit }) => {
+ if (newPollingIntervalSeconds !== undefined) {
+ this.pollingIntervalSeconds = newPollingIntervalSeconds;
}
+ if (manualExit !== undefined) {
+ const { isSessionEnd } = manualExit;
+ 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);
});
- await executeExitHandlers(error);
+ }
+
+ /**
+ * 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) => process.send?.({ 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.sendMonitorAction("notify_crash", { error });
+ await this.executeExitHandlers(error);
// notify master thread (which will log update in the console) of crash event via IPC
- lifecycleNotification(red(`crash event detected @ ${new Date().toUTCString()}`));
- lifecycleNotification(red(error.message));
+ this.lifecycleNotification(red(`crash event detected @ ${new Date().toUTCString()}`));
+ this.lifecycleNotification(red(error.message));
process.exit(1);
- };
+ }
- // one reason to exit, as the process might be in an inconsistent state after such an exception
- process.on('uncaughtException', activeExit);
-
- const { env } = process;
- const { pollingRoute, serverPort } = env;
- let pollingIntervalSeconds = Number(env.pollingIntervalSeconds);
- const pollingFailureTolerance = Number(env.pollingFailureTolerance);
- // 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.
- const pollTarget = `http://localhost:${serverPort}${pollingRoute}`;
- const pollServer = async (): Promise<void> => {
+ /**
+ * 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(pollTarget);
- if (!shouldServerBeResponsive) {
- // notify master thread (which will log update in the console) via IPC that the server is up and running
- process.send?.({ lifecycle: green(`listening on ${serverPort}...`) });
+ await get(this.pollTarget);
+ if (!this.shouldServerBeResponsive) {
+ // notify monitor thread that the server is up and running
+ this.lifecycleNotification(green(`listening on ${this.serverPort}...`));
}
- shouldServerBeResponsive = true;
- resolve();
+ 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 (shouldServerBeResponsive) {
- if (++pollingFailureCount > pollingFailureTolerance) {
- activeExit(error);
+ if (this.shouldServerBeResponsive) {
+ if (++this.pollingFailureCount > this.pollingFailureTolerance) {
+ this.proactiveUnplannedExit(error);
} else {
- lifecycleNotification(yellow(`the server has encountered ${pollingFailureCount} of ${pollingFailureTolerance} tolerable failures`));
+ this.lifecycleNotification(yellow(`the server has encountered ${this.pollingFailureCount} of ${this.pollingFailureTolerance} tolerable failures`));
}
}
+ } finally {
+ resolve();
}
- }, 1000 * pollingIntervalSeconds);
+ }, 1000 * this.pollingIntervalSeconds);
});
// controlled, asynchronous infinite recursion achieves a persistent poll that does not submit a new request until the previous has completed
- pollServer();
- };
-
- work();
- pollServer(); // begin polling
+ this.pollServer();
+ }
- return {
- addExitHandler: (handler: ExitHandler) => exitHandlers.push(handler),
- killSession: () => process.send!({ action: { message: "kill" } })
- };
}
}
diff --git a/src/server/Session/session_config_schema.ts b/src/server/Session/session_config_schema.ts
index 5a85a45e3..e32cf8c6a 100644
--- a/src/server/Session/session_config_schema.ts
+++ b/src/server/Session/session_config_schema.ts
@@ -1,39 +1,67 @@
import { Schema } from "jsonschema";
+const colorPattern = /black|red|green|yellow|blue|magenta|cyan|white|gray|grey/;
+
+const identifierProperties: Schema = {
+ type: "object",
+ properties: {
+ text: {
+ type: "string",
+ minLength: 1
+ },
+ color: {
+ type: "string",
+ pattern: colorPattern
+ }
+ }
+};
+
+const portProperties: Schema = {
+ type: "number",
+ minimum: 1024,
+ maximum: 65535
+};
+
export const configurationSchema: Schema = {
id: "/configuration",
type: "object",
properties: {
+ showServerOutput: { type: "boolean" },
ports: {
type: "object",
properties: {
- server: { type: "number", minimum: 1024, maximum: 65535 },
- socket: { type: "number", minimum: 1024, maximum: 65535 }
+ server: portProperties,
+ socket: portProperties
},
required: ["server"],
additionalProperties: true
},
- pollingRoute: {
- type: "string",
- pattern: /\/[a-zA-Z]*/g
- },
- masterIdentifier: {
- type: "string",
- minLength: 1
- },
- workerIdentifier: {
- type: "string",
- minLength: 1
+ identifiers: {
+ type: "object",
+ properties: {
+ master: identifierProperties,
+ worker: identifierProperties,
+ exec: identifierProperties
+ }
},
- showServerOutput: { type: "boolean" },
- pollingIntervalSeconds: {
- type: "number",
- minimum: 1,
- maximum: 86400
+ polling: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ intervalSeconds: {
+ type: "number",
+ minimum: 1,
+ maximum: 86400
+ },
+ route: {
+ type: "string",
+ pattern: /\/[a-zA-Z]*/g
+ },
+ failureTolerance: {
+ type: "number",
+ minimum: 0,
+ }
+ }
},
- pollingFailureTolerance: {
- type: "number",
- minimum: 0,
- }
}
}; \ No newline at end of file
diff --git a/src/server/index.ts b/src/server/index.ts
index 85242bef7..2c8f32130 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -24,7 +24,10 @@ import { Logger } from "./ProcessFactory";
import { yellow, red } from "colors";
import { Session } from "./Session/session";
import { DashSessionAgent } from "./DashSession";
+import SessionManager from "./ApiManagers/SessionManager";
+export const onWindows = process.platform === "win32";
+export let sessionAgent: Session.AppliedSessionAgent;
export const publicDirectory = path.resolve(__dirname, "public");
export const filesDirectory = path.resolve(publicDirectory, "files");
@@ -58,6 +61,7 @@ async function preliminaryFunctions() {
*/
function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }: RouteManager) {
const managers = [
+ new SessionManager(),
new UserManager(),
new UploadManager(),
new DownloadManager(),
@@ -88,19 +92,6 @@ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }:
secureHandler: ({ res }) => res.send(true)
});
- addSupervisedRoute({
- method: Method.GET,
- subscription: new RouteSubscriber("kill").add("key"),
- secureHandler: ({ req, res }) => {
- if (req.params.key === process.env.session_key) {
- res.send("<img src='https://media.giphy.com/media/NGIfqtcS81qi4/giphy.gif' style='width:100%;height:100%;'/>");
- sessionAgent.serverWorker.killSession();
- } else {
- res.redirect("/home");
- }
- }
- });
-
const serve: PublicHandler = ({ req, res }) => {
const detector = new mobileDetect(req.headers['user-agent'] || "");
const filename = detector.mobile() !== null ? 'mobile/image.html' : 'index.html';
@@ -143,7 +134,6 @@ export async function launchServer() {
await initializeServer(routeSetter);
}
-export const sessionAgent = new DashSessionAgent();
/**
* If you're in development mode, you won't need to run a session.
* The session spawns off new server processes each time an error is encountered, and doesn't
@@ -151,7 +141,7 @@ export const sessionAgent = new DashSessionAgent();
* So, the 'else' clause is exactly what we've always run when executing npm start.
*/
if (process.env.RELEASE) {
- sessionAgent.launch();
+ (sessionAgent = new DashSessionAgent()).launch();
} else {
launchServer();
}
diff --git a/src/server/remote_debug_instructions.txt b/src/server/remote_debug_instructions.txt
new file mode 100644
index 000000000..c279c460a
--- /dev/null
+++ b/src/server/remote_debug_instructions.txt
@@ -0,0 +1,16 @@
+Instructions:
+
+Download this attachment, open your downloads folder and find this file (__zipname__).
+Right click on the zip file and select 'Extract to __target__\'.
+Open up the command line, and remember that you can get the path to any file or directory by literally dragging it from the file system and dropping it onto the terminal.
+Unless it's in your path, you'll want to navigate to the MongoDB bin directory, given for Windows:
+
+cd '/c/Program Files/MongoDB/Server/[your version, i.e. 4.0, goes here]/bin'
+
+Then run the following command (if you're in the bin folder, make that ./mongorestore ...):
+
+mongorestore --gzip [/path/to/directory/you/just/unzipped] --db Dash
+
+Assuming everything runs well, this will mirror your local database with that of the server. Now, just start the server locally and debug.
+
+__signature__ \ No newline at end of file
diff --git a/src/server/repl.ts b/src/server/repl.ts
index faf1eab15..ad55b6aaa 100644
--- a/src/server/repl.ts
+++ b/src/server/repl.ts
@@ -97,20 +97,25 @@ export default class Repl {
const candidates = registered.filter(({ argPatterns: { length: count } }) => count === length);
for (const { argPatterns, action } of candidates) {
const parsed: string[] = [];
- let matched = false;
+ let matched = true;
if (length) {
for (let i = 0; i < length; i++) {
let matches: RegExpExecArray | null;
if ((matches = argPatterns[i].exec(args[i])) === null) {
+ matched = false;
break;
}
parsed.push(matches[0]);
}
- matched = true;
}
if (!length || matched) {
- await action(parsed);
- this.valid(`${command} ${parsed.join(" ")}`);
+ const result = action(parsed);
+ const resolve = () => this.valid(`${command} ${parsed.join(" ")}`);
+ if (result instanceof Promise) {
+ result.then(resolve);
+ } else {
+ resolve();
+ }
return;
}
}