From 7950a6c142ed90b6da455b1042f7008e26af1480 Mon Sep 17 00:00:00 2001 From: Anton Golub Date: Fri, 15 Mar 2024 17:42:10 +0300 Subject: [PATCH] refactor(spawn): remove `onStdout` and `onStderr` handlers in favor or `on('stdout', () => {...})` --- README.md | 2 ++ package.json | 3 +- src/main/ts/foo.ts | 1 - src/main/ts/spawn.ts | 73 ++++++++++++++++++++++++++------------- src/main/ts/x.ts | 3 +- src/main/ts/zurk.ts | 26 ++++++++++---- src/test/ts/spawn.test.ts | 9 +++-- src/test/ts/x.test.ts | 7 +++- 8 files changed, 87 insertions(+), 37 deletions(-) delete mode 100644 src/main/ts/foo.ts diff --git a/README.md b/README.md index e87082e..121eabc 100644 --- a/README.md +++ b/README.md @@ -71,5 +71,7 @@ const { error } = await p error.message // 'The operation was aborted' ``` +- [ ] Stdout limit + ## License [MIT](./LICENSE) diff --git a/package.json b/package.json index aa6fc9b..3931cbe 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "build:stamp": "npx buildstamp", "test": "concurrently 'npm:test:*'", "test:lint": "eslint -c src/test/lint/.eslintrc.json src", - "test:unit": "c8 -r lcov -r text -o target/coverage -x src/scripts -x src/test -x target node --loader ts-node/esm --experimental-specifier-resolution=node src/scripts/test.mjs" + "test:unit": "c8 -r lcov -r text -o target/coverage -x src/scripts -x src/test -x target node --loader ts-node/esm --experimental-specifier-resolution=node src/scripts/test.mjs", + "publish:draft": "yarn build && npm publish --no-git-tag-version" }, "repository": { "type": "git", diff --git a/src/main/ts/foo.ts b/src/main/ts/foo.ts deleted file mode 100644 index 5624ab1..0000000 --- a/src/main/ts/foo.ts +++ /dev/null @@ -1 +0,0 @@ -export const foo = (): undefined => undefined diff --git a/src/main/ts/spawn.ts b/src/main/ts/spawn.ts index b0889ba..00f6dc7 100644 --- a/src/main/ts/spawn.ts +++ b/src/main/ts/spawn.ts @@ -2,6 +2,7 @@ import * as cp from 'node:child_process' import process from 'node:process' import { Readable, Writable, Stream, Transform } from 'node:stream' import { assign, noop } from './util.js' +import EventEmitter from 'node:events' export type TSpawnError = any @@ -25,14 +26,16 @@ export type TChild = ReturnType export type TInput = string | Buffer | Stream export interface TSpawnCtxNormalized { + id: string, cwd: string cmd: string sync: boolean args: ReadonlyArray input: TInput | null - env: Record stdio: ['pipe', 'pipe', 'pipe'] detached: boolean + env: Record + ee: EventEmitter ac: AbortController shell: string | true | undefined spawn: typeof cp.spawn @@ -48,16 +51,17 @@ export interface TSpawnCtxNormalized { fulfilled?: TSpawnResult error?: any run: (cb: () => void, ctx: TSpawnCtxNormalized) => void - // kill: (signal: number) => void } export const normalizeCtx = (...ctxs: TSpawnCtx[]): TSpawnCtxNormalized => assign({ + id: Math.random().toString(36).slice(2), cmd: '', cwd: process.cwd(), sync: false, args: [], input: null, env: process.env, + ee: new EventEmitter(), ac: new AbortController(), detached: true, shell: true, @@ -112,11 +116,14 @@ export const invoke = (c: TSpawnCtxNormalized): TSpawnCtxNormalized => { if (c.sync) { const opts = buildSpawnOpts(c) const result = c.spawnSync(c.cmd, c.args, opts) - - c.stdout.write(result.stdout) - c.stderr.write(result.stderr) - c.onStdout(result.stdout) - c.onStderr(result.stderr) + if (result.stdout.length) { + c.stdout.write(result.stdout) + c.ee.emit('stdout', result.stdout, c) + } + if (result.stderr.length) { + c.stderr.write(result.stderr) + c.ee.emit('stderr', result.stderr, c) + } c.callback(null, c.fulfilled = { ...result, stdout: result.stdout.toString(), @@ -126,6 +133,7 @@ export const invoke = (c: TSpawnCtxNormalized): TSpawnCtxNormalized => { duration: Date.now() - now, _ctx: c }) + c.ee.emit('end', c.fulfilled, c) } else { c.run(() => { @@ -138,7 +146,9 @@ export const invoke = (c: TSpawnCtxNormalized): TSpawnCtxNormalized => { const child = c.spawn(c.cmd, c.args, opts) c.child = child - opts.signal.addEventListener('abort', () => { + opts.signal.addEventListener('abort', event => { + c.ee.emit('abort', event, c) + if (opts.detached && child.pid) { try { // https://github.com/nodejs/node/issues/51766 @@ -150,23 +160,36 @@ export const invoke = (c: TSpawnCtxNormalized): TSpawnCtxNormalized => { }) processInput(child, c.input || c.stdin) - child.stdout.pipe(c.stdout).on('data', (d) => { stdout.push(d); stdall.push(d); c.onStdout(d) }) - child.stderr.pipe(c.stderr).on('data', (d) => { stderr.push(d); stdall.push(d); c.onStderr(d) }) - child.on('error', (e) => error = e) - // child.on('exit', (_status) => status = _status) - child.on('close', (status, signal) => { - c.callback(error, c.fulfilled = { - error, - status, - signal, - stdout: stdout.join(''), - stderr: stderr.join(''), - stdall: stdall.join(''), - stdio: [c.stdin, c.stdout, c.stderr], - duration: Date.now() - now, - _ctx: c - }) + child.stdout.pipe(c.stdout).on('data', d => { + stdout.push(d) + stdall.push(d) + c.ee.emit('stdout', d, c) + }) + child.stderr.pipe(c.stderr).on('data', d => { + stderr.push(d) + stdall.push(d) + c.ee.emit('stderr', d, c) }) + child + .on('error', (e: any) => { + error = e + c.ee.emit('err', error, c) + }) + // .on('exit', (_status) => status = _status) + .on('close', (status, signal) => { + c.callback(error, c.fulfilled = { + error, + status, + signal, + stdout: stdout.join(''), + stderr: stderr.join(''), + stdall: stdall.join(''), + stdio: [c.stdin, c.stdout, c.stderr], + duration: Date.now() - now, + _ctx: c + }) + c.ee.emit('end', c.fulfilled, c) + }) }, c) } } catch (error: unknown) { @@ -184,6 +207,8 @@ export const invoke = (c: TSpawnCtxNormalized): TSpawnCtxNormalized => { _ctx: c } ) + c.ee.emit('err', error, c) + c.ee.emit('end', c.fulfilled, c) } return c diff --git a/src/main/ts/x.ts b/src/main/ts/x.ts index 6173d19..293d9ed 100644 --- a/src/main/ts/x.ts +++ b/src/main/ts/x.ts @@ -6,7 +6,7 @@ import { TZurk, TZurkPromise, TZurkOptions, - TZurkCtx + TZurkCtx, TZurkListener } from './zurk.js' import { type Promisified, type TVoidCallback, isPromiseLike, isStringLiteral, assign, quote } from './util.js' import { pipeMixin } from './mixin/pipe.js' @@ -51,6 +51,7 @@ export type TShellOptions = Omit & { export interface TShellResponse extends Omit, 'stdio' | '_ctx'>, Promise>, TShellResponseExtra { stdio: [Readable | Writable, Writable, Writable] _ctx: TShellCtx + on: (event: string | symbol, listener: TZurkListener) => TShellResponse } export interface TShellResponseSync extends TZurk, TShellResponseExtra { diff --git a/src/main/ts/zurk.ts b/src/main/ts/zurk.ts index 942f500..bdbb1db 100644 --- a/src/main/ts/zurk.ts +++ b/src/main/ts/zurk.ts @@ -5,19 +5,26 @@ import { TSpawnCtxNormalized, TSpawnResult, } from './spawn.js' -import { isPromiseLike, makeDeferred, type Promisified } from './util.js' +import { isPromiseLike, makeDeferred, type Promisified, type TVoidCallback } from './util.js' export const ZURK = Symbol('Zurk') +export type TZurkListener = (value: any, ctx: TZurkCtx) => void + export interface TZurk extends TSpawnResult { _ctx: TZurkCtx + on(event: string | symbol, listener: TZurkListener): TZurk } export type TZurkCtx = TSpawnCtxNormalized & { nothrow?: boolean, nohandle?: boolean } export type TZurkOptions = Partial> -export type TZurkPromise = Promise & Promisified & { _ctx: TZurkCtx, stdio: TZurkCtx['stdio'] } +export type TZurkPromise = Promise & Promisified & { + _ctx: TZurkCtx + stdio: TZurkCtx['stdio'] + on(event: string | symbol, listener: TZurkListener): TZurkPromise +} export const zurk = (opts: T): R => (opts.sync ? zurkSync(opts) : zurkAsync(opts)) as R @@ -56,22 +63,28 @@ export const zurkSync = (opts: TZurkOptions): TZurk => { } // eslint-disable-next-line sonarjs/cognitive-complexity -export const zurkifyPromise = (target: Promise | TZurkPromise, ctx: TSpawnCtxNormalized) => isPromiseLike(target) && !util.types.isProxy(target) - ? new Proxy(target, { +export const zurkifyPromise = (target: Promise | TZurkPromise, ctx: TSpawnCtxNormalized) => { + if (!isPromiseLike(target) || util.types.isProxy(target)) { + return target as TZurkPromise + } + const proxy = new Proxy(target, { get(target: Promise, p: string | symbol, receiver: any): any { + if (p === ZURK) return ZURK if (p === 'then') return target.then.bind(target) if (p === 'catch') return target.catch.bind(target) if (p === 'finally') return target.finally.bind(target) if (p === 'stdio') return ctx.stdio if (p === '_ctx') return ctx - if (p === ZURK) return ZURK + if (p === 'on') return function (name: string, cb: VoidFunction){ ctx.ee.on(name, cb); return proxy } if (p in target) return Reflect.get(target, p, receiver) return target.then(v => Reflect.get(v, p, receiver)) } }) as TZurkPromise - : target as TZurkPromise + + return proxy +} export const getError = (data: TSpawnResult) => { if (data.error) return data.error @@ -93,6 +106,7 @@ class Zurk implements TZurk { constructor(ctx: TZurkCtx) { this._ctx = ctx } + on(name: string, cb: TVoidCallback): this { this._ctx.ee.on(name, cb); return this } get status() { return this._ctx.fulfilled?.status ?? null } get signal() { return this._ctx.fulfilled?.signal ?? null } get error() { return this._ctx.error } diff --git a/src/test/ts/spawn.test.ts b/src/test/ts/spawn.test.ts index 132733a..51f2772 100644 --- a/src/test/ts/spawn.test.ts +++ b/src/test/ts/spawn.test.ts @@ -1,11 +1,12 @@ import * as assert from 'node:assert' import { describe, it } from 'node:test' +import EventEmitter from 'node:events' import { invoke, normalizeCtx, TSpawnCtx, TSpawnResult } from '../../main/ts/spawn.js' import { makeDeferred } from '../../main/ts/util.js' describe('invoke()', () => { it('calls a given cmd', async () => { - const results = [] + const results: string[] = [] const callback: TSpawnCtx['callback'] = (_err, result) => results.push(result.stdout) const { promise, resolve, reject } = makeDeferred() @@ -13,7 +14,7 @@ describe('invoke()', () => { sync: true, cmd: 'echo', args: ['hello'], - callback + callback, })) invoke(normalizeCtx({ @@ -22,7 +23,7 @@ describe('invoke()', () => { args: ['world'], callback(err, result) { err ? reject(err) : resolve(result) - } + }, })) await promise.then((result) => callback(null, result)) @@ -61,5 +62,7 @@ describe('normalizeCtx()', () => { assert.equal(normalized.cwd, 'a') assert.equal(normalized.cwd, 'b') assert.equal(normalized.cwd, 'c') + assert.ok(normalized.ee instanceof EventEmitter) + assert.ok(normalized.ac instanceof AbortController) }) }) diff --git a/src/test/ts/x.test.ts b/src/test/ts/x.test.ts index 3845f7d..478a001 100644 --- a/src/test/ts/x.test.ts +++ b/src/test/ts/x.test.ts @@ -90,11 +90,16 @@ describe('mixins', () => { it('handles `abort`', async () => { const p = $({nothrow: true})`sleep 10` + const events: any[] = [] + setTimeout(() => p.abort(), 25) + p + .on('abort', () => events.push('abort')) + .on('end', () => events.push('end')) const { error } = await p - assert.equal(error.message, 'The operation was aborted') + assert.deepEqual(events, ['abort', 'end']) }) })