mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
162 lines
6.3 KiB
TypeScript
162 lines
6.3 KiB
TypeScript
|
import { ActionRouter } from 'app/common/ActionRouter';
|
||
|
import { LocalPlugin } from 'app/common/plugin';
|
||
|
import { BaseComponent, createRpcLogger, warnIfNotReady } from 'app/common/PluginInstance';
|
||
|
import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI';
|
||
|
import * as log from 'app/server/lib/log';
|
||
|
import { getAppPathTo } from 'app/server/lib/places';
|
||
|
import { makeLinePrefixer } from 'app/server/lib/sandboxUtil';
|
||
|
import { exitPromise, timeoutReached } from 'app/server/lib/serverUtils';
|
||
|
import { ChildProcess, fork, ForkOptions } from 'child_process';
|
||
|
import * as fse from 'fs-extra';
|
||
|
import { IMessage, IMsgCustom, IMsgRpcCall, Rpc } from 'grain-rpc';
|
||
|
import * as path from 'path';
|
||
|
|
||
|
// Error for not yet implemented api.
|
||
|
class NotImplemented extends Error {
|
||
|
constructor(name: string) {
|
||
|
super(`calling ${name} from UnsafeNode is not yet implemented`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* The unsafeNode component used by a PluginInstance.
|
||
|
*
|
||
|
*/
|
||
|
export class UnsafeNodeComponent extends BaseComponent {
|
||
|
private _child?: ChildProcess; /* plugin node code will run as separate process */
|
||
|
private _exited: Promise<void>; /* fulfulled when process has completed */
|
||
|
private _rpc: Rpc;
|
||
|
private _pluginPath: string;
|
||
|
private _pluginId: string;
|
||
|
private _actionRouter: ActionRouter;
|
||
|
|
||
|
private _gristAPI: GristAPI = {
|
||
|
render() { throw new NotImplemented('render'); },
|
||
|
dispose() { throw new NotImplemented('dispose'); },
|
||
|
subscribe: (tableId: string) => this._actionRouter.subscribeTable(tableId),
|
||
|
unsubscribe: (tableId: string) => this._actionRouter.unsubscribeTable(tableId),
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
*
|
||
|
* @arg parent: the plugin instance this component is part of
|
||
|
* @arg _mainPath: main script file to run
|
||
|
* @arg appRoot: root path for application (important for setting a good NODE_PATH)
|
||
|
* @arg _gristDocPath: path to the current Grist doc (to which this plugin applies).
|
||
|
*
|
||
|
*/
|
||
|
constructor(plugin: LocalPlugin, pluginRpc: Rpc, private _mainPath: string, public appRoot: string,
|
||
|
private _gristDocPath: string,
|
||
|
rpcLogger = createRpcLogger(log, `PLUGIN ${plugin.id}/${_mainPath} UnsafeNode:`)) {
|
||
|
super(plugin.manifest, rpcLogger);
|
||
|
this._pluginPath = plugin.path;
|
||
|
this._pluginId = plugin.id;
|
||
|
this._rpc = new Rpc({
|
||
|
sendMessage: (msg) => this.sendMessage(msg),
|
||
|
logger: rpcLogger,
|
||
|
});
|
||
|
this._rpc.registerForwarder('*', pluginRpc);
|
||
|
this._rpc.registerImpl<GristAPI>(RPC_GRISTAPI_INTERFACE, this._gristAPI);
|
||
|
this._actionRouter = new ActionRouter(this._rpc);
|
||
|
}
|
||
|
|
||
|
public async sendMessage(data: IMessage): Promise<void> {
|
||
|
if (!this._child) {
|
||
|
await this.activateImplementation();
|
||
|
}
|
||
|
this._child!.send(data);
|
||
|
return Promise.resolve();
|
||
|
}
|
||
|
|
||
|
public receiveAction(action: any[]) {
|
||
|
this._actionRouter.process(action)
|
||
|
.catch((err: any) => log.warn('unsafeNode[%s] receiveAction failed with %s',
|
||
|
this._child ? this._child.pid : "NULL", err));
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
*
|
||
|
* Create the child node process needed for this component.
|
||
|
*
|
||
|
*/
|
||
|
protected async activateImplementation(): Promise<void> {
|
||
|
log.info(`unsafeNode operating in ${this._pluginPath}`);
|
||
|
const base = this._pluginPath;
|
||
|
const script = path.resolve(base, this._mainPath);
|
||
|
await fse.access(script, fse.constants.R_OK);
|
||
|
// Time to set up the node search path the client will see.
|
||
|
// We take our own, via Module.globalPaths, a poorly documented
|
||
|
// method listing the search path for the active node program
|
||
|
// https://github.com/nodejs/node/blob/master/test/parallel/test-module-globalpaths-nodepath.js
|
||
|
const paths = require('module').globalPaths.slice().concat([
|
||
|
// add the path to the plugin itself
|
||
|
path.resolve(base),
|
||
|
// add the path to grist's public api
|
||
|
getAppPathTo(this.appRoot, 'public-api'),
|
||
|
// add the path to the node_modules packaged with grist, in electron form
|
||
|
getAppPathTo(this.appRoot, 'node_modules')
|
||
|
]);
|
||
|
const env = Object.assign({}, process.env, {
|
||
|
NODE_PATH: paths.join(path.delimiter),
|
||
|
GRIST_PLUGIN_PATH: `${this._pluginId}/${this._mainPath}`,
|
||
|
GRIST_DOC_PATH: this._gristDocPath,
|
||
|
});
|
||
|
const electronVersion: string = (process.versions as any).electron;
|
||
|
if (electronVersion) {
|
||
|
// Pass along the fact that we are running under an electron-ified node, for the purposes of
|
||
|
// finding binaries (sqlite3 in particular).
|
||
|
env.ELECTRON_VERSION = electronVersion;
|
||
|
}
|
||
|
const child = this._child = fork(script, [], {
|
||
|
env,
|
||
|
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||
|
} as ForkOptions); // Explicit cast only because node-6 typings mistakenly omit stdio property
|
||
|
|
||
|
log.info("unsafeNode[%s] started %s", child.pid, script);
|
||
|
|
||
|
// Important to use exitPromise() before events from child may be received, so don't call
|
||
|
// yield or await between fork and here.
|
||
|
this._exited = exitPromise(child)
|
||
|
.then(code => log.info("unsafeNode[%s] exited with %s", child.pid, code))
|
||
|
.catch(err => log.warn("unsafeNode[%s] failed with %s", child.pid, err))
|
||
|
.then(() => { this._child = undefined; });
|
||
|
|
||
|
child.stdout.on('data', makeLinePrefixer('PLUGIN stdout: '));
|
||
|
child.stderr.on('data', makeLinePrefixer('PLUGIN stderr: '));
|
||
|
|
||
|
warnIfNotReady(this._rpc, 3000, "Plugin isn't ready; be sure to call grist.ready() from plugin");
|
||
|
child.on('message', this._rpc.receiveMessage.bind(this._rpc));
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
*
|
||
|
* Remove the child node process needed for this component.
|
||
|
*
|
||
|
*/
|
||
|
protected async deactivateImplementation(): Promise<void> {
|
||
|
if (!this._child) {
|
||
|
log.info('unsafeNode deactivating: no child process');
|
||
|
} else {
|
||
|
log.info('unsafeNode[%s] deactivate: disconnecting child', this._child.pid);
|
||
|
this._child.disconnect();
|
||
|
if (await timeoutReached(2000, this._exited)) {
|
||
|
log.info("unsafeNode[%s] deactivate: sending SIGTERM", this._child.pid);
|
||
|
this._child.kill('SIGTERM');
|
||
|
}
|
||
|
if (await timeoutReached(5000, this._exited)) {
|
||
|
log.warn("unsafeNode[%s] deactivate: child still has not exited", this._child.pid);
|
||
|
} else {
|
||
|
log.info("unsafeNode deactivate: child exited");
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
protected doForwardCall(c: IMsgRpcCall): Promise<any> {
|
||
|
return this._rpc.forwardCall({...c, mdest: ''});
|
||
|
}
|
||
|
|
||
|
protected async doForwardMessage(c: IMsgCustom): Promise<any> {
|
||
|
return this._rpc.forwardMessage({...c, mdest: ''});
|
||
|
}
|
||
|
}
|