From 30d188b0f95bc6ec3518a0086fac190e7ef69ffa Mon Sep 17 00:00:00 2001 From: ficristo Date: Sun, 8 Sep 2024 21:15:19 +0200 Subject: [PATCH] Add a pref shell.type to use node process instead of websocket (#357) LSP with the shell.type=process does not work at the moment. Fix https://github.com/quadre-code/quadre/issues/54 --- app/appshell/shell.ts | 7 + app/main.ts | 29 ++- app/node-process/BaseDomain.ts | 25 ++ app/node-process/base.ts | 21 ++ app/node-process/domain-manager.ts | 401 +++++++++++++++++++++++++++++ app/node-process/logging.ts | 15 ++ app/preload.ts | 7 + src/types/index.ts | 1 + src/utils/NodeConnection.ts | 378 ++++++++++++++++++++++++++- webpack.dev.ts | 6 +- 10 files changed, 869 insertions(+), 21 deletions(-) create mode 100644 app/node-process/BaseDomain.ts create mode 100644 app/node-process/base.ts create mode 100644 app/node-process/domain-manager.ts create mode 100644 app/node-process/logging.ts diff --git a/app/appshell/shell.ts b/app/appshell/shell.ts index 9e3f000e70f..49121e7b27e 100644 --- a/app/appshell/shell.ts +++ b/app/appshell/shell.ts @@ -1,4 +1,9 @@ import { BrowserWindow } from "electron"; +import _ = require("lodash"); +import { readBracketsPreferences } from "../brackets-config"; + +const bracketsPreferences = readBracketsPreferences(); +const shellType = _.get(bracketsPreferences, "shell.type"); export function getMainWindow(): BrowserWindow { const wins = BrowserWindow.getAllWindows(); @@ -11,3 +16,5 @@ export function getMainWindow(): BrowserWindow { export function getProcessArgv(): Array { return process.argv; } + +export const type = shellType; diff --git a/app/main.ts b/app/main.ts index 0cd71c5a05a..60330d3a842 100644 --- a/app/main.ts +++ b/app/main.ts @@ -51,19 +51,6 @@ const saveWindowPosition = _.debounce(_.partial(_saveWindowPosition, false), 100 // Quit when all windows are closed. let windowAllClosed = false; -// Start the socket server used by Brackets' -const socketServerLog = getLogger("socket-server"); -SocketServer.start(function (err: Error, port: number) { - if (err) { - shellState.set("socketServer.state", "ERR_NODE_FAILED"); - socketServerLog.error("failed to start: " + errToString(err)); - } else { - shellState.set("socketServer.state", "NO_ERROR"); - shellState.set("socketServer.port", port); - socketServerLog.info("started on port " + port); - } -}); - app.on("window-all-closed", function () { windowAllClosed = true; setTimeout(app.quit, 500); @@ -250,6 +237,22 @@ export function openMainBracketsWindow(query: {} | string = {}): BrowserWindow { app.commandLine.appendSwitch("disable-smooth-scrolling"); } + // Start the socket server used by Brackets is shell.type is not `process`. + const shellType = _.get(bracketsPreferences, "shell.type"); + if (shellType !== "process") { + const socketServerLog = getLogger("socket-server"); + SocketServer.start(function (err: Error, port: number) { + if (err) { + shellState.set("socketServer.state", "ERR_NODE_FAILED"); + socketServerLog.error("failed to start: " + errToString(err)); + } else { + shellState.set("socketServer.state", "NO_ERROR"); + shellState.set("socketServer.port", port); + socketServerLog.info("started on port " + port); + } + }); + } + // create the browser window const win = new BrowserWindow(winOptions); if (argv.devtools) { diff --git a/app/node-process/BaseDomain.ts b/app/node-process/BaseDomain.ts new file mode 100644 index 00000000000..d99db4a2f5c --- /dev/null +++ b/app/node-process/BaseDomain.ts @@ -0,0 +1,25 @@ +import DomainManager from "./domain-manager"; + +function init(domainManager: typeof DomainManager): void { + domainManager.registerDomain("base", {major: 0, minor: 1}); + + domainManager.registerCommand( + "base", + "loadDomainModulesFromPaths", + (paths: Array): boolean => { + return domainManager.loadDomainModulesFromPaths(paths); + }, + false, + "Attempt to load command modules from the given paths. The paths should be absolute.", + [{name: "paths", type: "array"}], + [{name: "success", type: "boolean"}] + ); + + domainManager.registerEvent( + "base", + "newDomains", + [] + ); +} + +exports.init = init; diff --git a/app/node-process/base.ts b/app/node-process/base.ts new file mode 100644 index 00000000000..88d500538cb --- /dev/null +++ b/app/node-process/base.ts @@ -0,0 +1,21 @@ +import { log } from "./logging"; +import DomainManager from "./domain-manager"; + +// load the base domain +DomainManager.loadDomainModulesFromPaths(["./BaseDomain"], false); + +process.on("message", async function (obj: any) { + const _type: string = obj.type; + switch (_type) { + case "message": + DomainManager._receive(obj.message); + break; + default: + log.warn(`no handler for ${_type}`); + } +}); + +process.on("uncaughtException", (err: Error) => { + log.error(`uncaughtException: ${err.stack}`); + process.exit(1); +}); diff --git a/app/node-process/domain-manager.ts b/app/node-process/domain-manager.ts new file mode 100644 index 00000000000..3de461a664a --- /dev/null +++ b/app/node-process/domain-manager.ts @@ -0,0 +1,401 @@ +export interface DomainDescription { + domain: string; + version: { major: number, minor: number }; + commands: { [commandName: string]: DomainCommand }; + events: { [eventName: string]: DomainEvent }; +} + +export interface DomainModule { + init: (domainManager: typeof DomainManager) => void; +} + +export interface DomainCommand { + commandFunction: (...args: Array) => any; + isAsync: boolean; + description: string; + parameters: Array; + returns: Array; +} + +export interface DomainEvent { + parameters: Array; +} + +export interface DomainCommandArgument { + name: string; + type: string; + description?: string; +} + +interface DomainMap { + [domainName: string]: DomainDescription; +} + +export interface ConnectionMessage { + id: number; + domain: string; + command?: string; + event?: string; + parameters?: Array; +} + +export interface ConnectionErrorMessage { + message: string; +} + +export interface CommandResponse { + id: number; + response: any; +} + +export interface CommandError { + id: number; + message: string; + stack: string; +} + +export function errToMessage(err: Error): string { + let message = err.message; + if (message && err.name) { + message = err.name + ": " + message; + } + return message ? message : err.toString(); +} + +export function errToString(err: Error): string { + if (err.stack) { + return err.stack; + } + if (err.name && err.message) { + return err.name + ": " + err.message; + } + return err.toString(); +} + +/** + * @private + * @type {object} + * Map of all the registered domains + */ +const _domains: DomainMap = {}; + +/** + * @private + * @type {Array.} + * Array of all modules we have loaded. Used for avoiding duplicate loading. + */ +const _initializedDomainModules: Array = []; + +/** + * @private + * @type {number} + * Used for generating unique IDs for events. + */ +let _eventCount = 1; + +/** + * @constructor + * DomainManager is a module/class that handles the loading, registration, + * and execution of all commands and events. It is a singleton, and is passed + * to a domain in its init() method. + */ +export class DomainManager { + + /** + * Returns whether a domain with the specified name exists or not. + * @param {string} domainName The domain name. + * @return {boolean} Whether the domain exists + */ + public hasDomain(domainName: string): boolean { + return !!_domains[domainName]; + } + + /** + * Returns a new empty domain. Throws error if the domain already exists. + * @param {string} domainName The domain name. + * @param {{major: number, minor: number}} version The domain version. + * The version has a format like {major: 1, minor: 2}. It is reported + * in the API spec, but serves no other purpose on the server. The client + * can make use of this. + */ + public registerDomain(domainName: string, version: { major: number, minor: number }): void { + if (!this.hasDomain(domainName)) { + _domains[domainName] = { + domain: domainName, + version, + commands: {}, + events: {} + }; + process.send && process.send({ + type: "refreshInterface", + spec: this.getDomainDescriptions() + }); + } else { + console.error("[DomainManager] Domain " + domainName + " already registered"); + } + } + + /** + * Registers a new command with the specified domain. If the domain does + * not yet exist, it registers the domain with a null version. + * @param {string} domainName The domain name. + * @param {string} commandName The command name. + * @param {Function} commandFunction The callback handler for the function. + * The function is called with the arguments specified by the client in the + * command message. Additionally, if the command is asynchronous (isAsync + * parameter is true), the function is called with an automatically- + * constructed callback function of the form cb(err, result). The function + * can then use this to send a response to the client asynchronously. + * @param {boolean} isAsync See explanation for commandFunction param + * @param {?string} description Used in the API documentation + * @param {?Array.<{name: string, type: string, description:string}>} parameters + * Used in the API documentation. + * @param {?Array.<{name: string, type: string, description:string}>} returns + * Used in the API documentation. + */ + public registerCommand( + domainName: string, + commandName: string, + commandFunction: (...args: Array) => any, + isAsync: boolean, + description: string, + parameters: Array, + returns: Array + ): void { + if (!this.hasDomain(domainName)) { + throw new Error(`Domain ${domainName} doesn't exist. Call .registerDomain first!`); + } + if (!_domains[domainName].commands[commandName]) { + _domains[domainName].commands[commandName] = { + commandFunction, + isAsync, + description, + parameters, + returns + }; + process.send && process.send({ + type: "refreshInterface", + spec: this.getDomainDescriptions() + }); + } else { + throw new Error("Command " + domainName + "." + commandName + " already registered"); + } + } + + /** + * Executes a command by domain name and command name. Called by a connection's + * message parser. Sends response or error (possibly asynchronously) to the + * connection. + * @param {Connection} connection The requesting connection object. + * @param {number} id The unique command ID. + * @param {string} domainName The domain name. + * @param {string} commandName The command name. + * @param {Array} parameters The parameters to pass to the command function. If + * the command is asynchronous, will be augmented with a callback function + * and progressCallback function + * (see description in registerCommand documentation) + */ + public executeCommand( + id: number, + domainName: string, + commandName: string, + parameters: Array = [] + ): void { + if (_domains[domainName] && _domains[domainName].commands[commandName]) { + const command = _domains[domainName].commands[commandName]; + if (command.isAsync) { + const callback = (err: Error, result: any): void => { + if (err) { + this.sendCommandError(id, errToMessage(err), errToString(err)); + } else { + this.sendCommandResponse(id, result); + } + }; + const progressCallback = (msg: any): void => { + this.sendCommandProgress(id, msg); + }; + parameters.push(callback, progressCallback); + command.commandFunction(...parameters); + } else { // synchronous command + try { + this.sendCommandResponse(id, command.commandFunction(...parameters)); + } catch (err) { + this.sendCommandError(id, errToMessage(err), errToString(err)); + } + } + } else { + this.sendCommandError(id, "no such command: " + domainName + "." + commandName); + } + } + + /** + * Registers an event domain and name. + * @param {string} domainName The domain name. + * @param {string} eventName The event name. + * @param {?Array.<{name: string, type: string, description:string}>} parameters + * Used in the API documentation. + */ + public registerEvent(domainName: string, eventName: string, parameters: Array): void { + if (!this.hasDomain(domainName)) { + throw new Error(`Domain ${domainName} doesn't exist. Call .registerDomain first!`); + } + if (!_domains[domainName].events[eventName]) { + _domains[domainName].events[eventName] = { + parameters + }; + process.send && process.send({ + type: "refreshInterface", + spec: this.getDomainDescriptions() + }); + } else { + throw new Error("[DomainManager] Event " + domainName + "." + eventName + " already registered"); + } + } + + /** + * Emits an event with the specified name and parameters to all connections. + * + * TODO: Future: Potentially allow individual connections to register + * for which events they want to receive. Right now, we have so few events + * that it's fine to just send all events to everyone and decide on the + * client side if the client wants to handle them. + * + * @param {string} domainName The domain name. + * @param {string} eventName The event name. + * @param {?Array} parameters The parameters. Must be JSON.stringify-able + */ + public emitEvent(domainName: string, eventName: string, parameters?: Array): void { + if (_domains[domainName] && _domains[domainName].events[eventName]) { + this.sendEventMessage( + _eventCount++, + domainName, + eventName, + parameters + ); + } else { + console.error("[DomainManager] No such event: " + domainName + "." + eventName); + } + } + + /** + * Loads and initializes domain modules using the specified paths. Checks to + * make sure that a module is not loaded/initialized more than once. + * + * @param {Array.} paths The paths to load. The paths can be relative + * to the DomainManager or absolute. However, modules that aren't in core + * won't know where the DomainManager module is, so in general, all paths + * should be absolute. + * @return {boolean} Whether loading succeded. (Failure will throw an exception). + */ + public loadDomainModulesFromPaths(paths: Array, notify: boolean = true): boolean { + paths.forEach((path) => { + const m = require(/* webpackIgnore: true */path); + if (m && m.init) { + if (_initializedDomainModules.indexOf(m) < 0) { + m.init(this); + _initializedDomainModules.push(m); // don't init more than once + } + } else { + throw new Error(`domain at ${path} didn't return an object with 'init' property`); + } + }); + if (notify) { + this.emitEvent("base", "newDomains", paths); + } + return true; // if we fail, an exception will be thrown + } + + public getDomainDescriptions(): DomainMap { + return _domains; + } + + public close(): void { + process.exit(0); + } + + public sendError(message: string): void { + this._send("error", { message }); + } + + public sendCommandResponse(id: number, response: Object | Buffer): void { + if (Buffer.isBuffer(response)) { + // Assume the id is an unsigned 32-bit integer, which is encoded as a four-byte header + const header = new Buffer(4); + header.writeUInt32LE(id, 0); + // Prepend the header to the message + const message = Buffer.concat([header, response], response.length + 4); + this._sendBinary(message); + } else { + this._send("commandResponse", { id, response }); + } + } + + public sendCommandProgress(id: number, message: any): void { + this._send("commandProgress", {id, message }); + } + + public sendCommandError(id: number, message: string, stack?: string): void { + this._send("commandError", { id, message, stack }); + } + + public sendEventMessage(id: number, domain: string, event: string, parameters?: Array): void { + this._send("event", { id, domain, event, parameters }); + } + + public _receive(message: string): void { + let m: ConnectionMessage; + try { + m = JSON.parse(message); + } catch (err) { + console.error(`[DomainManager] Error parsing message json -> ${err.name}: ${err.message}`); + this.sendError(`Unable to parse message: ${message}`); + return; + } + + const validId = m.id !== null && m.id !== undefined; + const hasDomain = !!m.domain; + const hasCommand = typeof m.command === "string"; + + if (validId && hasDomain && hasCommand) { + // okay if m.parameters is null/undefined + try { + this.executeCommand( + m.id, + m.domain, + m.command as string, + m.parameters + ); + } catch (executionError) { + this.sendCommandError(m.id, errToMessage(executionError), errToString(executionError)); + } + } else { + this.sendError(`Malformed message (${validId}, ${hasDomain}, ${hasCommand}): ${message}`); + } + } + + public _send( + type: string, + message: ConnectionMessage | ConnectionErrorMessage | CommandResponse | CommandError + ): void { + try { + process.send && process.send({ + type: "receive", + msg: JSON.stringify({ type, message }) + }); + } catch (e) { + console.error(`[DomainManager] Unable to stringify message: ${e.message}`); + } + } + + public _sendBinary(message: Buffer): void { + process.send && process.send({ + type: "receive", + msg: message, + options: { binary: true, mask: false } + }); + } + +} + +const dm = new DomainManager(); +export default dm; diff --git a/app/node-process/logging.ts b/app/node-process/logging.ts new file mode 100644 index 00000000000..21b538e99a4 --- /dev/null +++ b/app/node-process/logging.ts @@ -0,0 +1,15 @@ +export const log = { + info: (msg: string): void => { + process.send && process.send({ type: "log", level: "info", msg }); + }, + warn: (msg: string): void => { + process.send && process.send({ type: "log", level: "warn", msg }); + }, + error: (msg: string): void => { + process.send && process.send({ type: "log", level: "error", msg }); + } +}; +console.log = (...args: Array): void => log.info(args.join(" ")); +console.info = (...args: Array): void => log.info(args.join(" ")); +console.warn = (...args: Array): void => log.warn(args.join(" ")); +console.error = (...args: Array): void => log.error(args.join(" ")); diff --git a/app/preload.ts b/app/preload.ts index 8e9b027f97d..677a55410e1 100644 --- a/app/preload.ts +++ b/app/preload.ts @@ -14,6 +14,7 @@ interface BracketsWindowGlobal { node: { process: NodeJS.Process; require: NodeRequire; + requireResolve: RequireResolve, module: NodeModule; __filename: string; __dirname: string; @@ -23,6 +24,10 @@ interface BracketsWindowGlobal { function nodeRequire(name: string): NodeRequire { return require(/* webpackIgnore: true */ name); } +function nodeRequireResolve(name: string): string { + // @ts-expect-error + return __non_webpack_require__.resolve(name); +} process.once("loaded", function () { try { @@ -31,6 +36,7 @@ process.once("loaded", function () { electronRemote, process, require: nodeRequire, + requireResolve: nodeRequireResolve, module, __filename, __dirname, @@ -50,6 +56,7 @@ process.once("loaded", function () { g.node = { process: t.process, require: t.require, + requireResolve: t.requireResolve, module: t.module, __filename: t.__filename, __dirname: t.__dirname diff --git a/src/types/index.ts b/src/types/index.ts index 923ad9f1a00..a31586364f2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -178,6 +178,7 @@ declare global { const node: { process: NodeJS.Process; require: NodeRequire; + requireResolve: RequireResolve; module: NodeModule; __filename: string; __dirname: string; diff --git a/src/utils/NodeConnection.ts b/src/utils/NodeConnection.ts index 10f4a1625a3..f9f2c0d2fc8 100644 --- a/src/utils/NodeConnection.ts +++ b/src/utils/NodeConnection.ts @@ -26,6 +26,15 @@ import * as EventDispatcher from "utils/EventDispatcher"; +import * as cp from "child_process"; +import { NodeConnectionRequestMessage, NodeConnectionResponseMessage } from "../types/NodeConnectionMessages"; +import { NodeConnectionInterfaceSpec, NodeConnectionDomainSpec } from "../types/NodeConnectionInterfaceSpec"; + +const fork = node.require("child_process").fork; +const getLogger = node.require("./utils").getLogger; +const log = getLogger("NodeConnection"); + +const SHELL_TYPE: "websocket" | "process" = appshell.shell.type; /** * Connection attempts to make before failing @@ -61,7 +70,7 @@ const MAX_COUNTER_VALUE = 4294967295; // 2^32 - 1 * If the deferred is resolved/rejected manually, then the timeout is * automatically cleared. */ -function setDeferredTimeout(deferred: JQueryDeferred, delay: number): void { +function setDeferredTimeout(deferred: JQueryDeferred, delay = CONNECTION_TIMEOUT): void { const timer = setTimeout(function () { deferred.reject("timeout"); }, delay); @@ -109,6 +118,17 @@ function attemptSingleConnect(): JQueryPromise { return deferred.promise(); } +function waitFor(condition: Function, delay = CONNECTION_TIMEOUT): JQueryPromise { + const deferred = $.Deferred(); + setDeferredTimeout(deferred, delay); + // periodically check condition + function doCheck(): JQueryDeferred | NodeJS.Timeout { + return condition() ? deferred.resolve() : setTimeout(doCheck, 10); + } + doCheck(); + return deferred.promise(); +} + interface NodeDomain { base?: any; } @@ -186,6 +206,9 @@ class NodeConnection extends EventDispatcher.EventDispatcherBase { */ private _pendingInterfaceRefreshDeferreds: Array; + private _name: string; + private _nodeProcess: cp.ChildProcess | null; + /** * @private * @type {Array.} @@ -194,13 +217,22 @@ class NodeConnection extends EventDispatcher.EventDispatcherBase { */ private _pendingCommandDeferreds: Array>; + private _registeredDomains: { [domainPath: string]: { + loaded: boolean, + autoReload: boolean + } }; + constructor() { super(); this.domains = {}; + this.domainEvents = {}; + this._registeredDomains = {}; + this._nodeProcess = null; this._registeredModules = []; this._pendingInterfaceRefreshDeferreds = []; this._pendingCommandDeferreds = []; + this._name = ""; } /** @@ -225,6 +257,14 @@ class NodeConnection extends EventDispatcher.EventDispatcherBase { * Helper function to do cleanup work when a connection fails */ private _cleanup(): void { + if (SHELL_TYPE !== "process") { + this._cleanupSocket(); + } else { + this._cleanupProcess(); + } + } + + private _cleanupSocket(): void { // clear out the domains, since we may get different ones // on the next connection this.domains = {}; @@ -249,6 +289,32 @@ class NodeConnection extends EventDispatcher.EventDispatcherBase { this._port = null; } + private _cleanupProcess(): void { + // shut down the old process if there is one + if (this._nodeProcess) { + try { + this._nodeProcess.kill(); + } finally { + this._nodeProcess = null; + } + } + + // clear out the domains, since we may get different ones + // on the next connection + this.domains = {}; + + // reject all the commands that are to be resolved + this._pendingCommandDeferreds.forEach((d) => d.reject("cleanup")); + this._pendingCommandDeferreds = []; + + // need to call _refreshName because this.domains has been modified + this._refreshName(); + } + + public getName(): string { + return this._name; + } + /** * Connect to the node server. After connecting, the NodeConnection * object will trigger a "close" event when the underlying socket @@ -263,7 +329,15 @@ class NodeConnection extends EventDispatcher.EventDispatcherBase { * @return {jQuery.Promise} Promise that resolves/rejects when the * connection succeeds/fails */ - public connect(autoReconnect: boolean): JQueryPromise { + public connect(autoReconnect: boolean = false): JQueryPromise { + if (SHELL_TYPE !== "process") { + return this._connectSocket(autoReconnect); + } + + return this._connectProcess(autoReconnect); + } + + private _connectSocket(autoReconnect: boolean): JQueryPromise { const self = this; self._autoReconnect = autoReconnect; const deferred = $.Deferred(); @@ -343,12 +417,92 @@ class NodeConnection extends EventDispatcher.EventDispatcherBase { return deferred.promise(); } + public _connectProcess(autoReconnect: boolean): JQueryPromise { + this._autoReconnect = autoReconnect; + const deferred = $.Deferred(); + + // Start the connection process + this._cleanup(); + + // Fork the process base as a child + const nodeProcessPath = node.requireResolve("./node-process/base.js"); + this._nodeProcess = fork(nodeProcessPath); + if (this._nodeProcess === null || this._nodeProcess === undefined) { + throw new Error(`Unable to fork ${nodeProcessPath}`); + } + this._nodeProcess.on("error", (err: Error) => { + log.error(`[node-process-${this.getName()}] error: ${err.stack}`); + }); + this._nodeProcess.on("exit", (code: number, signal: string) => { + log.error(`[node-process-${this.getName()}] exit code: ${code}, signal: ${signal}`); + }); + this._nodeProcess.on("message", (obj: any) => { + + const _type: string = obj.type; + switch (_type) { + case "log": + log[obj.level](`[node-process-${this.getName()}]`, obj.msg); + break; + case "receive": + this._receive(obj.msg); + break; + case "refreshInterface": + this._refreshInterfaceCallback(obj.spec); + break; + default: + log.warn(`unhandled message: ${JSON.stringify(obj)}`); + } + + }); + + // Called if we succeed at the final setup + const success = (): void => { + if (this._nodeProcess === null || this._nodeProcess === undefined) { + throw new Error(`Unable to fork ${nodeProcessPath}`); + } + this._nodeProcess.on("disconnect", () => { + this._cleanup(); + if (this._autoReconnect) { + (this as any).trigger("close", this.connect(true)); + } else { + (this as any).trigger("close", ); // eslint-disable-line + } + }); + deferred.resolve(); + }; + + // Called if we fail at the final setup + const fail = (err: Error): void => { + this._cleanup(); + deferred.reject(err); + }; + + // refresh the current domains, then re-register any "autoregister" modules + waitFor(() => + this.connected() && + this.domains.base && + typeof this.domains.base.loadDomainModulesFromPaths === "function" + ).then(() => { + const toReload = Object.keys(this._registeredDomains) + .filter((_path) => this._registeredDomains[_path].autoReload === true); + return toReload.length > 0 + ? this._loadDomains(toReload).then(success, fail) + : success(); + }); + + return deferred.promise(); + } + /** * Determines whether the NodeConnection is currently connected * @return {boolean} Whether the NodeConnection is connected. */ public connected(): boolean { - return !!(this._ws && this._ws.readyState === WebSocket.OPEN); + if (SHELL_TYPE !== "process") { + return !!(this._ws && this._ws.readyState === WebSocket.OPEN); + } + + return !!(this._nodeProcess && this._nodeProcess.connected); } /** @@ -372,7 +526,15 @@ class NodeConnection extends EventDispatcher.EventDispatcherBase { * succeeded and the new API is availale at NodeConnection.domains, * or that rejects on failure. */ - public loadDomains(paths: Array, autoReload: boolean): JQueryPromise { + public loadDomains(paths: string | Array, autoReload: boolean): JQueryPromise { + if (SHELL_TYPE !== "process") { + return this._loadDomainsSocket(paths as Array, autoReload); + } + + return this._loadDomainsProcess(paths, autoReload); + } + + private _loadDomainsSocket(paths: Array, autoReload: boolean): JQueryPromise { const deferred = $.Deferred(); setDeferredTimeout(deferred, CONNECTION_TIMEOUT); let pathArray = paths; @@ -408,13 +570,86 @@ class NodeConnection extends EventDispatcher.EventDispatcherBase { return deferred.promise(); } + private _loadDomainsProcess(paths: string | Array, autoReload: boolean): JQueryPromise { + const pathArray: Array = Array.isArray(paths) ? paths : [paths]; + + pathArray.forEach((_path) => { + if (this._registeredDomains[_path]) { + throw new Error(`Domain path already registered: ${_path}`); + } + this._registeredDomains[_path] = { + loaded: false, + autoReload + }; + }); + + return this._loadDomains(pathArray); + } + + private _refreshName(): void { + const domainNames = Object.keys(this.domains); + if (domainNames.length > 1) { + // remove "base" + const io = domainNames.indexOf("base"); + if (io !== -1) { domainNames.splice(io, 1); } + this._name = domainNames.join(","); + return; + } + if (this._nodeProcess) { + this._name = this._nodeProcess.pid!.toString(); + return; + } + this._name = this._name || ""; + } + + private _loadDomains(pathArray: Array): JQueryPromise { + const deferred = $.Deferred(); + setDeferredTimeout(deferred, CONNECTION_TIMEOUT); + + if (this.domains.base && this.domains.base.loadDomainModulesFromPaths) { + this.domains.base.loadDomainModulesFromPaths(pathArray).then( + function (success: boolean) { // command call succeeded + if (!success) { + // response from commmand call was "false" so we know + // the actual load failed. + deferred.reject("loadDomainModulesFromPaths failed"); + } + // if the load succeeded, we wait for the API refresh to + // resolve the deferred. + }, + function (reason: string) { // command call failed + deferred.reject("Unable to load one of the modules: " + pathArray + (reason ? ", reason: " + reason : "")); + } + ); + waitFor(() => { + const loadedCount = pathArray + .map((_path) => this._registeredDomains[_path].loaded) + .filter((x) => x === true) + .length; + return loadedCount === pathArray.length; + }).then(deferred.resolve); + } else { + deferred.reject("this.domains.base is undefined"); + } + + return deferred.promise(); + } + /** * @private * Sends a message over the WebSocket. Automatically JSON.stringifys * the message if necessary. * @param {Object|string} m Object to send. Must be JSON.stringify-able. */ - private _send(m): void { + private _send(m: NodeConnectionRequestMessage): void { + if (SHELL_TYPE !== "process") { + this._sendSocket(m); + } else { + this._sendProcess(m); + } + } + + private _sendSocket(m): void { if (this.connected()) { // Convert the message to a string @@ -442,13 +677,49 @@ class NodeConnection extends EventDispatcher.EventDispatcherBase { } } + private _sendProcess(m: NodeConnectionRequestMessage): void { + if (this.connected()) { + + // Convert the message to a string + let messageString: string | null = null; + if (typeof m === "string") { + messageString = m; + } else { + try { + messageString = JSON.stringify(m); + } catch (stringifyError) { + log.error("Unable to stringify message in order to send: " + stringifyError.message); + } + } + + // If we succeded in making a string, try to send it + if (messageString) { + try { + this._nodeProcess!.send({ type: "message", message: messageString }); + } catch (sendError) { + log.error(`Error sending message: ${sendError.message}`); + } + } + } else { + log.error("Not connected to node, unable to send"); + } + } + /** * @private * Handler for receiving events on the WebSocket. Parses the message * and dispatches it appropriately. * @param {WebSocket.Message} message Message object from WebSocket */ - private _receive(message: MessageEvent): void { + private _receive(message: MessageEvent | string): void { + if (SHELL_TYPE !== "process") { + this._receiveSocket(message as MessageEvent); + } else { + this._receiveProcess(message as string); + } + } + + private _receiveSocket(message: MessageEvent): void { let responseDeferred: JQueryDeferred | null = null; const data = message.data; let m; @@ -525,6 +796,63 @@ class NodeConnection extends EventDispatcher.EventDispatcherBase { } } + private _receiveProcess(messageString: string): void { + let responseDeferred: JQueryDeferred | null = null; + let ipcMessage: any; + + try { + ipcMessage = JSON.parse(messageString); + } catch (err) { + log.error(`Received malformed message: ${messageString}`, err.message); + return; + } + + const message: NodeConnectionResponseMessage = ipcMessage.message; + + switch (ipcMessage.type) { + case "event": + if (message.domain === "base" && message.event === "newDomains") { + const newDomainPaths: Array = message.parameters; + newDomainPaths.forEach((newDomainPath: string) => { + this._registeredDomains[newDomainPath].loaded = true; + }); + } + // Event type "domain:event" + EventDispatcher.triggerWithArray( + this, message.domain + ":" + message.event, message.parameters + ); + break; + case "commandResponse": + responseDeferred = this._pendingCommandDeferreds[message.id]; + if (responseDeferred) { + responseDeferred.resolveWith(this, [message.response]); + delete this._pendingCommandDeferreds[message.id]; + } + break; + case "commandProgress": + responseDeferred = this._pendingCommandDeferreds[message.id]; + if (responseDeferred) { + responseDeferred.notifyWith(this, [message.message]); + } + break; + case "commandError": + responseDeferred = this._pendingCommandDeferreds[message.id]; + if (responseDeferred) { + responseDeferred.rejectWith( + this, + [message.message, message.stack] + ); + delete this._pendingCommandDeferreds[message.id]; + } + break; + case "error": + log.error(`Received error: ${message.message}`); + break; + default: + log.error(`Unknown event type: ${ipcMessage.type}`); + } + } + /** * @private * Helper function for refreshing the interface in the "domain" property. @@ -593,9 +921,45 @@ class NodeConnection extends EventDispatcher.EventDispatcherBase { return deferred.promise(); } + private _refreshInterfaceCallback(spec: NodeConnectionInterfaceSpec): void { + const self = this; + function makeCommandFunction(domain: string, command: string) { + return function (): JQueryDeferred { + const deferred = $.Deferred(); + const parameters = Array.prototype.slice.call(arguments, 0); + const id = self._getNextCommandID(); + self._pendingCommandDeferreds[id] = deferred; + self._send({ + id, + domain, + command, + parameters + }); + return deferred; + }; + } + this.domains = {}; + this.domainEvents = {}; + Object.keys(spec).forEach(function (domainKey) { + const domainSpec: NodeConnectionDomainSpec = spec[domainKey]; + self.domains[domainKey] = {}; + Object.keys(domainSpec.commands).forEach(function (commandKey) { + self.domains[domainKey][commandKey] = makeCommandFunction(domainKey, commandKey); + }); + self.domainEvents[domainKey] = {}; + Object.keys(domainSpec.events).forEach(function (eventKey) { + const eventSpec = domainSpec.events[eventKey]; + const parameters = eventSpec.parameters; + self.domainEvents[domainKey][eventKey] = parameters; + }); + }); + // need to call _refreshName because this.domains has been modified + this._refreshName(); + } + /** * @private - * Get the default timeout value + * Get the default timeout value. * @return {number} Timeout value in milliseconds */ public static _getConnectionTimeout(): number { diff --git a/webpack.dev.ts b/webpack.dev.ts index 493a88047f2..0339690aae0 100644 --- a/webpack.dev.ts +++ b/webpack.dev.ts @@ -24,8 +24,12 @@ const configs: Array = [ // Used by `app/appshell/index.ts` through `electronRemote.require`. "./app/appshell/app-menu.ts", "./app/appshell/shell.ts", - // Used by `app\socket-server\index.ts` through DomainManager.loadDomainModulesFromPaths. + // Used by `app/socket-server/index.ts` through DomainManager.loadDomainModulesFromPaths. "./app/socket-server/BaseDomain.ts", + // Used by `src/utils/NodeConnection.ts` + "./app/node-process/base.ts", + // Used by `app/node-process/base.ts` through DomainManager.loadDomainModulesFromPaths. + "./app/node-process/BaseDomain.ts", ], output: { path: path.resolve(__dirname, "dist"),