diff --git a/navigator/src/lib/Error.ts b/navigator/src/lib/Error.ts new file mode 100644 index 0000000..8645f4d --- /dev/null +++ b/navigator/src/lib/Error.ts @@ -0,0 +1,12 @@ + +export class NavigatorError extends Error { + constructor(message: string, cause?: Error) { + super(message, { cause }); + this.cause = cause; + this.name = this.constructor.name; + } +} + +export class SourceError extends NavigatorError { } + +export class SourceAdapterError extends NavigatorError {} diff --git a/navigator/src/lib/Process.ts b/navigator/src/lib/Process.ts new file mode 100644 index 0000000..1f777e5 --- /dev/null +++ b/navigator/src/lib/Process.ts @@ -0,0 +1,168 @@ +import { NavigatorError } from './Error' +import cockpit from '@45drives/cockpit-typings'; + +export class ProcessError extends NavigatorError { } + +export interface IProcess { + /** + * Exit code of process. + */ + exitCode: number; + /** + * Standard output of process. + */ + stdout: Uint8Array; + /** + * Standard error of process. + */ + stderr: Uint8Array; + /** + * Argument vector of process + */ + readonly argv: string[]; +} + +const utf8Decoder = new TextDecoder('utf-8'); +const utf8Encoder = new TextEncoder(); + +export class Process implements PromiseLike { + private spawnProc: cockpit.Spawn.ProcessHandle; + private innerPromise: Promise; + /** + * Execute a server-side process. Awaiting on {@link Process} will return + * {@link IProcess}. Wrapper for + * [cockpit.spawn](https://cockpit-project.org/guide/latest/cockpit-spawn) + * + * @example + * try { + * const hostname = await new Process(['hostname']).output; + * } catch (error) { + * console.error(error); + * } + * // or with noThrow modifier: + * const proc = new Process(['hostname']).noThrow; + * const hostname = await proc.output; + * if (await proc.exitCode !== 0) + * console.error(Process.errorMessage(proc)); + * + * @param argv - Argument vector to execute + * @param spawnOpts - Options for process spawning + */ + constructor(argv: string[], spawnOpts: Omit); + constructor(other: Process); + constructor(argvOrOther: string[] | Process, spawnOpts: Omit = {}) { + if (argvOrOther instanceof Process) { + const other = argvOrOther; + this.spawnProc = other.spawnProc; + this.innerPromise = other.innerPromise; + } else { + const argv = argvOrOther; + spawnOpts.err ??= 'message'; + spawnOpts.superuser ??= 'try'; + const spawnProc = cockpit.spawn(argv, { ...spawnOpts, binary: true }); + this.spawnProc = spawnProc; + this.innerPromise = new Promise((resolve, reject) => { + spawnProc.then((stdout, stderr) => { + resolve({ + exitCode: 0, + stdout, + stderr: stderr ?? new Uint8Array([]), + argv: [...argv], + }); + }); + spawnProc.catch((exception, stdout) => { + reject({ + exitCode: exception.exit_status ?? 1, + stdout: stdout ?? new Uint8Array([]), + stderr: utf8Encoder.encode(exception.message) ?? new Uint8Array([]), + argv: [...argv], + }); + }); + }); + } + } + then(onfulfilled?: ((value: IProcess) => TResult1 | PromiseLike) | null | undefined, onrejected?: ((reason: IProcess) => TResult2 | PromiseLike) | null | undefined): PromiseLike { + return this.innerPromise + .catch((proc: IProcess) => { + throw new ProcessError(Process.errorMessage(proc)); + }) + .then(onfulfilled, onrejected); + } + /** + * Always resolve even when process exits with code other than `0`. + */ + get noThrow() { + const proc = new Process(this); + proc.innerPromise = proc.innerPromise.catch(proc => proc); + return proc; + } + /** + * Exit code of process. Resolves when the process exits. + */ + get exitCode() { + return this.then(({ exitCode }) => exitCode); + } + /** + * Standard output of process. Resolves when the process exits. + */ + get stdout() { + return this.then(({ stdout }) => stdout); + } + /** + * Standard error of process. Resolves when the process exits. + */ + get stderr() { + return this.then(({ stderr }) => stderr); + } + /** + * Standard output of process as a string, decoded as utf-8. Resolves when + * the process exits. + */ + get output() { + return this.stdout.then(bin => utf8Decoder.decode(bin)); + } + /** + * Standard error of process as a string, decoded as utf-8. Resolves when + * the process exits. + */ + get error() { + return this.stderr.then(bin => utf8Decoder.decode(bin)); + } + /** + * Send data to process. If string, it will be encoded as utf-8. + * @param data - Data to send + */ + sendInput(data: string | Uint8Array) { + if (typeof data === 'string') { + data = utf8Encoder.encode(data); + } + this.spawnProc.input(data, true); + } + /** + * Close communication with the process by closing STDIN and STDOUT. + */ + close() { + this.spawnProc.close(); + } + /** + * Set up a callback to run when the process writes to STDOUT. Doing this + * consumes the process's STDOUT and will result in stdout being empty when + * awaited for later. + * @param callback - Function to call on process output + */ + stdoutCallback(callback: (data: Uint8Array) => void) { + this.spawnProc.stream(callback); + } + /** + * Set up a callback to run when the process writes to STDOUT, with output + * decoded as a string. Doing this consumes the process's STDOUT and will + * result in stdout being empty when awaited for later. + * @param callback - Function to call on process output + */ + outputCallback(callback: (data: string) => void) { + this.spawnProc.stream((data: Uint8Array) => callback(utf8Decoder.decode(data))); + } + static errorMessage(proc: IProcess) { + return `${proc.argv[0]} -> ${proc.exitCode}: ${utf8Decoder.decode(proc.stderr)}`; + } +}