tgrid
Version:
Grid Computing Framework for TypeScript
366 lines (328 loc) • 11.6 kB
text/typescript
import { Singleton, is_node, sleep_until } from "tstl";
import { Invoke } from "../../components/Invoke";
import { ConnectorBase } from "../internal/ConnectorBase";
import { IHeaderWrapper } from "../internal/IHeaderWrapper";
import { once } from "../internal/once";
import { IWorkerCompiler } from "./internal/IWorkerCompiler";
import { IWorkerSystem } from "./internal/IWorkerSystem";
import { NodeWorkerCompiler } from "./internal/NodeWorkerCompiler";
import { WebWorkerCompiler } from "./internal/WebWorkerCompiler";
/**
* Worker Connector.
*
* The `WorkerConnector` is a communicator class, which creates an `Worker` instance
* and interacts with it through RPC (Remote Procedure Call). In other words,
* `WorkerConnector` considers the `Worker` instance as a remote server accepting
* only one client; {@link WorkerServer}.
*
* You can create an `Worker` instance with {@link connect} or {@link compile} method.
* The {@link connect} method just opens an existing JS (or TS) file, and
* {@link compile} method writes a temporary JS (TS) file, and connects to it.
* Anyway, the `Worker` instanced program must open the {@link WorkerServer}.
*
* By the way, don't forget {@link close closing} the worker to clean up the resources.
* If the closing be performed by {@link WorkerServer}, you can wait
* the worker server closing through the {@link join} method.
*
* Also, when declaring this `WorkerConnector` type, you've to define three
* generic arguments; `Header`, `Provider` and `Remote`. Those generic arguments must
* be same with the ones defined in the target {@link WorkerServer} class
* (`Provider` and `Remote` must be reversed).
*
* For reference, the first `Header` type represents an initial data from the
* remote client after the connection. I recommend utilize it as an activation tool
* for security enhancement. The second generic argument `Provider` represents a
* provider from client to server, and the other `Remote` means a provider from the
* remote server to client.
*
* @template Header Type of the header containing initial data.
* @template Provider Type of features provided for the remote server.
* @template Remote Type of features supported by remote server.
* @author Jeongho Nam - https://github.com/samchon
*/
export class WorkerConnector<
Header,
Provider extends object | null,
Remote extends object | null,
>
extends ConnectorBase<Header, Provider, Remote>
implements IWorkerSystem
{
/**
* @hidden
*/
private readonly compiler_: Singleton<Promise<IWorkerCompiler>>;
/**
* @hidden
*/
private worker_?: Worker;
/**
* Initializer Constructor.
*
* For reference, you're planning to run a bundled JavaScript file,
* and you're using the NodeJS environment, you can't use the `"thread"`
* mode. You've to use the `"process"` mode instead.
*
* @param header An object containing initialization data like activation.
* @param provider An object providing features for remote system.
* @param type You can specify the worker mode when NodeJS. Default is "process".
*/
public constructor(
header: Header,
provider: Provider,
type?: "thread" | "process",
) {
super(header, provider);
this.compiler_ = new Singleton(() =>
is_node() ? NodeWorkerCompiler(type ?? "process") : WebWorkerCompiler(),
);
}
/* ----------------------------------------------------------------
CONNECTIONS
---------------------------------------------------------------- */
/**
* Compile server and connect to there.
*
* The {@link compile} method tries compile JS source code, creates `Worker` instance
* with that code connects to the `Worker`. To complete the compilation and connection,
* the `Worker` program must open that server using the {@link WorkerServer.open}()
* method.
*
* Note that, after your business has been completed, you've to close the `Worker` using
* {@link close}() or {@link WorkerServer.close}(). If you don't close that, vulnerable
* memory usage and communication channel would not be destroyed and it may cause the
* memory leak.
*
* @param content JS Source code to compile.
* @param options Detailed options like timeout.
*/
public async compile(
content: string,
options: Partial<WorkerConnector.IConnectOptions> = {},
): Promise<void> {
//----
// PRELIMINARIES
//----
// TEST CONDITION
this._Test_connection("compile");
// COMPILATION
const compiler: IWorkerCompiler = await this.compiler_.get();
const path: string = await compiler.compile(content);
let error: Error | null = null; // FOR LAZY-THROWING
//----
// CONNECT
//----
// TRY CONNECTION
try {
await this._Connect("compile", path, options);
} catch (exp) {
error = exp as Error;
}
// REMOVE THE TEMPORARY FILE
await compiler.remove(path);
// LAZY THROWING
if (error !== null) throw error;
}
/**
* Connect to server.
*
* The {@link connect}() method tries to create an `Worker` instance and connect to the
* `Worker`. To complete the connection, the `Worker` program must open that server using
* the {@link WorkerServer.open}() method.
*
* Note that, after your business has been completed, you've to close the `Worker` using
* {@link close}() or {@link WorkerServer.close}(). If you don't close that, vulnerable
* memory usage and communication channel would not be destroyed and it may cause the
* memory leak.
*
* @param jsFile JS File to be {@link WorkerServer}.
* @param options Detailed options like timeout.
*/
public async connect(
jsFile: string,
options: Partial<WorkerConnector.IConnectOptions> = {},
): Promise<void> {
// TEST CONDITION
this._Test_connection("connect");
// DO CONNECT
await this._Connect("connect", jsFile, options);
}
/**
* @hidden
*/
private _Test_connection(method: string): void {
if (this.worker_ && this.state !== WorkerConnector.State.CLOSED) {
if (this.state_ === WorkerConnector.State.CONNECTING)
throw new Error(`Error on WorkerConnector.${method}(): on connecting.`);
else if (this.state_ === WorkerConnector.State.OPEN)
throw new Error(
`Error on WorkerConnector.${method}(): already connected.`,
);
else throw new Error(`Error on WorkerConnector.${method}(): closing.`);
}
}
/**
* @hidden
*/
private async _Connect(
method: string,
jsFile: string,
options: Partial<WorkerConnector.IConnectOptions>,
): Promise<void> {
// TIME LIMIT
const at: Date | undefined =
options.timeout !== undefined
? new Date(Date.now() + options.timeout)
: undefined;
// SET CURRENT STATE
this.state_ = WorkerConnector.State.CONNECTING;
try {
// EXECUTE THE WORKER
const compiler: IWorkerCompiler = await this.compiler_.get();
this.worker_ = await compiler.execute(
jsFile,
is_node() === true ? options : undefined,
);
// WAIT THE WORKER TO BE READY
if (
(await this._Handshake(method, options.timeout, at)) !==
WorkerConnector.State.CONNECTING
)
throw new Error(
`Error on WorkerConnector.${method}(): target worker may not be opened by TGrid. It's not following the TGrid's own handshake rule when connecting.`,
);
// SEND HEADERS
this.worker_!.postMessage(
JSON.stringify(IHeaderWrapper.wrap(this.header)),
);
// WAIT COMPLETION
if (
(await this._Handshake(method, options.timeout, at)) !==
WorkerConnector.State.OPEN
)
throw new Error(
`Error on WorkerConnector.${method}(): target worker may not be opened by TGrid. It's not following the TGrid's own handshake rule when connected.`,
);
// SUCCESS
this.state_ = WorkerConnector.State.OPEN;
this.worker_!.onmessage = this._Handle_message.bind(this);
} catch (exp) {
try {
if (this.worker_) this.worker_.terminate();
} catch {}
this.state_ = WorkerConnector.State.NONE;
throw exp;
}
}
/**
* @hidden
*/
private _Handshake(
method: string,
timeout?: number,
until?: Date,
): Promise<number> {
return new Promise((resolve, reject) => {
/* eslint-disable */
let completed: boolean = false;
/* eslint-disable */
let expired: boolean = false;
if (until !== undefined)
sleep_until(until).then(() => {
if (completed === false) {
reject(
new Error(
`Error on WorkerConnector.${method}(): target worker is not sending handshake data over ${timeout} milliseconds.`,
),
);
expired = true;
}
});
this.worker_!.onmessage = once((evt) => {
if (expired === false) {
completed = true;
resolve(evt.data);
}
});
});
}
/**
* @inheritDoc
*/
public async close(): Promise<void> {
// TEST CONDITION
const error: Error | null = this.inspectReady("close");
if (error) throw error;
//----
// CLOSE WITH JOIN
//----
// PROMISE RETURN
const ret: Promise<void> = this.join();
// REQUEST CLOSE TO SERVER
this.state_ = WorkerConnector.State.CLOSING;
this.worker_!.postMessage(WorkerConnector.State.CLOSING);
// LAZY RETURN
await ret;
}
/* ----------------------------------------------------------------
COMMUNICATOR
---------------------------------------------------------------- */
/**
* @hidden
*/
protected async sendData(invoke: Invoke): Promise<void> {
this.worker_!.postMessage(JSON.stringify(invoke));
}
/**
* @hidden
*/
private _Handle_message(evt: MessageEvent): void {
if (evt.data === WorkerConnector.State.CLOSING)
this._Handle_close().catch(() => {});
else this.replyData(JSON.parse(evt.data));
}
/**
* @hidden
*/
private async _Handle_close(): Promise<void> {
// STATE & PROMISE RETURN
await this.destructor();
this.state_ = WorkerConnector.State.CLOSED;
}
}
/**
*
*/
export namespace WorkerConnector {
/**
* Current state of the {@link WorkerConnector}.
*/
export import State = ConnectorBase.State;
/**
* Connection options for the {@link WorkerConnector.connect}.
*/
export interface IConnectOptions {
/**
* Milliseconds to wait the worker server to accept or reject it. If omitted, the waiting would be forever.
*/
timeout: number;
/**
* Arguments only for the NodeJS environments.
*/
execArgv: string[];
/**
* Whether to redirect the standard input to the worker server.
*
* Available only in the NodeJS + Process environments.
*/
stdio: "overlapped" | "pipe" | "ignore" | "inherit";
/**
* Current working directory of the worker server.
*/
cwd: string;
/**
* Environment variables to be passed to the worker server.
*/
env: Record<string, string>;
}
}