alm
Version:
The best IDE for TypeScript
406 lines (349 loc) • 14.3 kB
text/typescript
// This code is designed to be used by both the parent and the child
import cp = require('child_process');
import path = require('path');
export var resolve: typeof Promise.resolve = Promise.resolve.bind(Promise);
/**
* The main function you should call from master
*/
export function startWorker<TWorker>(config:{
workerPath: string,
workerContract: TWorker,
masterImplementation: any,
/**
* We automatially restart the worker if it crashes
* After that is done, you can do reinitialization in this if you want
*/
onCrashRestart?: ()=>any,
}): { parent: Parent; worker: TWorker } {
var parent = new Parent();
parent.startWorker(config.workerPath, showError, [], config.onCrashRestart || (()=>null));
function showError(error: Error) {
if (error) {
console.error('Failed to start a worker:', error);
}
}
var worker = parent.sendAllToIpc(config.workerContract);
parent.registerAllFunctionsExportedFromAsResponders(config.masterImplementation);
return { parent, worker };
}
/**
* The main function you should call from worker
*/
export function runWorker<TMaster>(config:{workerImplementation: any, masterContract: TMaster}): { child: Child; master: TMaster } {
var child = new Child();
child.registerAllFunctionsExportedFromAsResponders(config.workerImplementation);
let master = child.sendAllToIpc(config.masterContract);
return { child, master };
}
// Parent makes queries<T>
// Child responds<T>
export interface Message<T> {
message: string;
id: string;
data?: T;
error?: {
method: string;
message: string;
stack: string;
details: any;
};
/** Is this message a request or a response */
request: boolean;
}
/** Query Response function */
export interface QRFunction<Query, Response> {
(query: Query): Promise<Response>;
}
/** Creates a Guid (UUID v4) */
function createId(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/** Used by parent and child for keepalive */
var orphanExitCode = 100;
class RequesterResponder {
/** Must be implemented in children */
protected sendTarget: {
(): { send?: <T>(message: Message<T>) => any }
};
///////////////////////////////// REQUESTOR /////////////////////////
private currentListeners: { [message: string]: { [id: string]: PromiseDeferred<any> } } = {};
/** Only relevant when we only want the last of this type */
private currentLastOfType: {
[message: string]: { data: any; defer: PromiseDeferred<any>; }
} = {};
private pendingRequests: string[] = [];
public pendingRequestsChanged = (pending: string[]) => null;
/** process a message from the child */
protected processResponse(m: any) {
var parsed: Message<any> = m;
this.pendingRequests.shift();
this.pendingRequestsChanged(this.pendingRequests.slice());
if (!parsed.message || !parsed.id) {
console.log('PARENT ERR: Invalid JSON data from child:', m);
}
else if (!this.currentListeners[parsed.message] || !this.currentListeners[parsed.message][parsed.id]) {
console.log('PARENT ERR: No one was listening:', parsed.message, parsed.data);
}
else { // Alright nothing *weird* happened
if (parsed.error) {
this.currentListeners[parsed.message][parsed.id].reject(parsed.error);
console.log(parsed.error);
console.log(`======================= STACK (${parsed.error.method}) ==========================`);
console.log(parsed.error.stack);
}
else {
this.currentListeners[parsed.message][parsed.id].resolve(parsed.data);
}
delete this.currentListeners[parsed.message][parsed.id];
// If there is current last one queued then that needs to be resurrected
if (this.currentLastOfType[parsed.message]) {
let last = this.currentLastOfType[parsed.message];
delete this.currentLastOfType[parsed.message];
let lastPromise = this.sendToIpcHeart(last.data, parsed.message);
lastPromise.then((res) => last.defer.resolve(res), (rej) => last.defer.reject(rej));
}
}
}
/**
* This is used by both the request and the reponse
*/
private sendToIpcHeart = (data, message) => {
// If we don't have a child exit
if (!this.sendTarget()) {
console.log('PARENT ERR: no child when you tried to send :', message);
return <any>Promise.reject(new Error("No worker active to recieve message: " + message));
}
// Initialize if this is the first call of this type
if (!this.currentListeners[message]) this.currentListeners[message] = {};
// Create an id unique to this call and store the defered against it
var id = createId();
const promise = new Promise((resolve,reject)=>{
this.currentListeners[message][id] = { resolve, reject, promise };
});
// Send data to worker
this.pendingRequests.push(message);
this.pendingRequestsChanged(this.pendingRequests);
this.sendTarget().send({ message: message, id: id, data: data, request: true });
return promise;
}
/**
* Send all the member functions to IPC
*/
sendAllToIpc<TWorker>(contract: TWorker): TWorker {
var toret = {} as TWorker;
Object.keys(contract).forEach(key => {
toret[key] = this.sendToIpc(contract[key], key);
});
return toret;
}
/**
* Takes a sync named function
* and returns a function that will execute this function by name using IPC
* (will only work if the process on the other side has this function as a registered responder)
*/
sendToIpc<Query, Response>(func: QRFunction<Query, Response>, name?: string): QRFunction<Query, Response> {
name = func.name || name;
if (!name) {
console.error('NO NAME for function', func.toString());
throw new Error('Name not specified for function: \n' + func.toString());
}
return (data) => this.sendToIpcHeart(data, name);
}
/**
* If there are more than one pending then we only want the last one as they come in.
* All others will get the default value
*/
sendToIpcOnlyLast<Query, Response>(func: QRFunction<Query, Response>, defaultResponse: Response): QRFunction<Query, Response> {
return (data) => {
var message = func.name;
// If we don't have a child exit
if (!this.sendTarget()) {
console.log('PARENT ERR: no child when you tried to send :', message);
return <any>Promise.reject(new Error("No worker active to recieve message: " + message));
}
// Allow if this is the only call of this type
if (!Object.keys(this.currentListeners[message] || {}).length) {
return this.sendToIpcHeart(data, message);
}
else {
// Note:
// The last needs to continue once the current one finishes
// That is done in our response handler
// If there is already something queued as last.
// Then it is no longer last and needs to be fed a default value
if (this.currentLastOfType[message]) {
this.currentLastOfType[message].defer.resolve(defaultResponse);
}
// this needs to be the new last
const promise = new Promise<Response>((resolve,reject)=>{
this.currentLastOfType[message] = {
data: data,
defer: {promise,resolve,reject}
}
});
return promise;
}
};
}
////////////////////////////////// RESPONDER ////////////////////////
private responders: { [message: string]: (query: any) => Promise<any> } = {};
protected processRequest = (m: any) => {
var parsed: Message<any> = m;
if (!parsed.message || !this.responders[parsed.message]) {
// TODO: handle this error scenario. Either the message is invalid or we do not have a registered responder
return;
}
var message = parsed.message;
var responsePromise: Promise<any>;
try {
responsePromise = this.responders[message](parsed.data);
} catch (err) {
responsePromise = Promise.reject({ method: message, message: err.message, stack: err.stack, details: err.details || {} });
}
responsePromise
.then((response) => {
// console.log('I have the response for:',parsed.message)
this.sendTarget().send({
message: message,
/** Note: to process a request we just pass the id as we recieve it */
id: parsed.id,
data: response,
error: null,
request: false
});
// console.log('I sent the response', parsed.message);
})
.catch((error) => {
this.sendTarget().send({
message: message,
/** Note: to process a request we just pass the id as we recieve it */
id: parsed.id,
data: null,
error: error,
request: false
});
});
}
private addToResponders<Query, Response>(func: (query: Query) => Promise<Response>, name?: string) {
name = func.name || name;
if (!name) {
console.error('NO NAME for function', func.toString());
throw new Error('Name not specified for function: \n' + func.toString());
}
this.responders[name] = func;
}
registerAllFunctionsExportedFromAsResponders(aModule: any) {
Object.keys(aModule)
.filter((funcName) => typeof aModule[funcName] == 'function')
.forEach((funcName) => this.addToResponders(aModule[funcName], funcName));
}
}
/** The parent */
export class Parent extends RequesterResponder {
private child: cp.ChildProcess;
private node = process.execPath;
/** If we get this error then the situation if fairly hopeless */
private gotENOENTonSpawnNode = false;
protected sendTarget = () => this.child;
private stopped = false;
/** start worker */
startWorker(
childJsPath: string,
terminalError: (e: Error) => any,
customArguments: string[] = [],
onCrashRestart: () => any
) {
try {
const fileName = path.basename(childJsPath);
this.child = cp.fork(
childJsPath,
customArguments,
{ cwd: path.dirname(childJsPath), env: process.env }
);
this.child.on('error', (error) => {
const err = error as any;
if (err.code === "ENOENT" && err.path === this.node) {
this.gotENOENTonSpawnNode = true;
}
console.log('CHILD ERR ONERROR:', err.message, err.stack, err);
this.child = null;
});
this.child.on('message', (message: Message<any>) => {
// console.log('PARENT: A child asked me', message.message)
if (message.request) {
this.processRequest(message);
}
else {
this.processResponse(message);
}
});
this.child.on('close', (code) => {
if (this.stopped) {
return;
}
// Handle process dropping
// If orphaned then Definitely restart
if (code === orphanExitCode) {
this.startWorker(childJsPath, terminalError, customArguments, onCrashRestart);
onCrashRestart();
}
// If we got ENOENT. Restarting will not help.
else if (this.gotENOENTonSpawnNode) {
terminalError(new Error('gotENOENTonSpawnNode'));
}
// We haven't found a reson to not start worker yet
else {
console.log(`${fileName} worker restarting. Don't know why it stopped with code:`, code);
this.startWorker(childJsPath, terminalError, customArguments, onCrashRestart);
onCrashRestart();
}
});
} catch (err) {
terminalError(err);
}
}
/** stop worker */
stopWorker() {
this.stopped = true;
if (!this.child) return;
try {
this.child.kill('SIGTERM');
}
catch (ex) {
console.error('failed to kill worker child');
}
this.child = null;
}
}
export class Child extends RequesterResponder {
protected sendTarget = () => process;
private connected = true;
constructor() {
super();
// Keep alive
this.keepAlive();
process.on('exit', () => this.connected = false);
// Start listening
process.on('message', (message: Message<any>) => {
// console.error('--------CHILD: parent told me :-/', message.message)
if (message.request) {
this.processRequest(message);
}
else {
this.processResponse(message);
}
});
}
/** keep the child process alive while its connected and die otherwise */
private keepAlive() {
setInterval(() => {
// We have been orphaned
if (!this.connected) {
process.exit(orphanExitCode);
}
}, 1000);
}
}