diff --git a/src/AsyncParallelBailHook.test.ts b/src/AsyncParallelBailHook.test.ts new file mode 100644 index 0000000..abba471 --- /dev/null +++ b/src/AsyncParallelBailHook.test.ts @@ -0,0 +1,40 @@ +import { AsyncParallelBailHook } from './AsyncParallelBailHook'; + +describe('AsyncParallelBailHook', () => { + const hook = new AsyncParallelBailHook<[number, string]>(); + + const fn0 = vi.fn((a, b) => { + return; + }); + hook.tap('hook0', fn0); + + const fn1 = vi.fn((a, b) => { + return; + }); + hook.tap('hook1', fn1); + + it('dispatch', async () => { + const results = await hook.dispatch(7274, 'foo'); + expect(fn0).toHaveBeenCalledWith(7274, 'foo'); + expect(fn1).toHaveBeenCalledWith(7274, 'foo'); + expect(results).eq(undefined); + }); + + const fn2 = vi.fn((a, b) => { + return 2; + }); + hook.tap('hook2', fn2); + + const fn3 = vi.fn((a, b) => { + return; + }); + hook.tap('hook3', fn3); + + it('dispatch - break with error', async () => { + await hook.dispatch(7274, 'bar'); + expect(fn0).toHaveBeenCalledWith(7274, 'foo'); + expect(fn1).toHaveBeenCalledWith(7274, 'foo'); + expect(fn2).toHaveBeenCalledWith(7274, 'foo'); + expect(fn3).toHaveBeenCalledWith(7274, 'foo'); + }); +}); diff --git a/src/AsyncParallelBailHook.ts b/src/AsyncParallelBailHook.ts new file mode 100644 index 0000000..8e3206a --- /dev/null +++ b/src/AsyncParallelBailHook.ts @@ -0,0 +1,20 @@ +import Hook from './Hook'; + +export class AsyncParallelBailHook extends Hook { + async dispatch(...args: Args) { + return Promise.race( + this._taps.map( + ({ fn }) => + new Promise((resolve) => { + return Promise.resolve(fn(...args)).then((result: unknown) => { + if (result !== undefined) { + return resolve(); + } + }); + }) + ) + ); + } +} + +export default AsyncParallelBailHook; diff --git a/src/AsyncParallelHook.test.ts b/src/AsyncParallelHook.test.ts new file mode 100644 index 0000000..dad3c2a --- /dev/null +++ b/src/AsyncParallelHook.test.ts @@ -0,0 +1,22 @@ +import { AsyncParallelHook } from './AsyncParallelHook'; + +describe('AsyncParallelHook', () => { + const hook = new AsyncParallelHook<[number, string], number>(); + + const fn0 = vi.fn((a, b) => { + return 0; + }); + hook.tap('hook0', fn0); + + const fn1 = vi.fn((a, b) => { + return 1; + }); + hook.tap('hook1', fn1); + + it('dispatch', async () => { + const results = await hook.dispatch(7274, 'foo'); + expect(fn0).toHaveBeenCalledWith(7274, 'foo'); + expect(fn1).toHaveBeenCalledWith(7274, 'foo'); + expect(results).toStrictEqual([0, 1]); + }); +}); diff --git a/src/AsyncParallelHook.ts b/src/AsyncParallelHook.ts new file mode 100644 index 0000000..8785e77 --- /dev/null +++ b/src/AsyncParallelHook.ts @@ -0,0 +1,9 @@ +import Hook from './Hook'; + +export class AsyncParallelHook extends Hook { + async dispatch(...args: Args) { + return Promise.all(this.taps.map(({ fn }) => fn(...args))); + } +} + +export default AsyncParallelHook; diff --git a/src/AsyncSeriesBailHook.test.ts b/src/AsyncSeriesBailHook.test.ts new file mode 100644 index 0000000..5921da7 --- /dev/null +++ b/src/AsyncSeriesBailHook.test.ts @@ -0,0 +1,40 @@ +import { AsyncSeriesBailHook } from './AsyncSeriesBailHook'; + +describe('AsyncSeriesBailHook', () => { + const hook = new AsyncSeriesBailHook<[number, string]>(); + + const fn0 = vi.fn((a, b) => { + return; + }); + hook.tap('hook0', fn0); + + const fn1 = vi.fn((a, b) => { + return; + }); + hook.tap('hook1', fn1); + + it('dispatch', async () => { + const results = await hook.dispatch(7274, 'foo'); + expect(fn0).toHaveBeenCalledWith(7274, 'foo'); + expect(fn1).toHaveBeenCalledWith(7274, 'foo'); + expect(results).eq(undefined); + }); + + const fn2 = vi.fn((a, b) => { + return 2; + }); + hook.tap('hook2', fn2); + + const fn3 = vi.fn((a, b) => { + return; + }); + hook.tap('hook3', fn3); + + it('dispatch - break with error', async () => { + await hook.dispatch(7274, 'bar'); + expect(fn0).toHaveBeenCalledWith(7274, 'foo'); + expect(fn1).toHaveBeenCalledWith(7274, 'foo'); + expect(fn2).toHaveBeenCalledWith(7274, 'foo'); + expect(fn3).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/AsyncSeriesBailHook.ts b/src/AsyncSeriesBailHook.ts new file mode 100644 index 0000000..98594f9 --- /dev/null +++ b/src/AsyncSeriesBailHook.ts @@ -0,0 +1,14 @@ +import Hook from './Hook'; + +export const AsyncSeriesBailHook = class extends Hook { + async dispatch(...args: Args) { + for (const tap of this.taps) { + const result = await tap.fn(...args); + if (result !== undefined) { + break; + } + } + } +}; + +export default AsyncSeriesBailHook; diff --git a/src/AsyncSeriesHook.test.ts b/src/AsyncSeriesHook.test.ts new file mode 100644 index 0000000..a5ecb98 --- /dev/null +++ b/src/AsyncSeriesHook.test.ts @@ -0,0 +1,22 @@ +import { AsyncSeriesHook } from './AsyncSeriesHook'; + +describe('AsyncSeriesHook', () => { + const hook = new AsyncSeriesHook<[number, string], number>(); + + const fn0 = vi.fn((a, b) => { + return 0; + }); + hook.tap('hook0', fn0); + + const fn1 = vi.fn((a, b) => { + return 1; + }); + hook.tap('hook1', fn1); + + it('dispatch', async () => { + const results = await hook.dispatch(7274, 'foo'); + expect(fn0).toHaveBeenCalledWith(7274, 'foo'); + expect(fn1).toHaveBeenCalledWith(7274, 'foo'); + expect(results).toStrictEqual([0, 1]); + }); +}); diff --git a/src/AsyncSeriesHook.ts b/src/AsyncSeriesHook.ts new file mode 100644 index 0000000..d2f26dd --- /dev/null +++ b/src/AsyncSeriesHook.ts @@ -0,0 +1,13 @@ +import Hook from './Hook'; + +export class AsyncSeriesHook extends Hook { + async dispatch(...args: Args) { + const returns: Returns[] = []; + for (const { fn } of this.taps) { + returns.push(await fn(...args)); + } + return returns; + } +} + +export default AsyncSeriesHook; diff --git a/src/AsyncSeriesWaterfallHook.test.ts b/src/AsyncSeriesWaterfallHook.test.ts new file mode 100644 index 0000000..ae18cf0 --- /dev/null +++ b/src/AsyncSeriesWaterfallHook.test.ts @@ -0,0 +1,28 @@ +import { AsyncSeriesWaterfallHook } from './AsyncSeriesWaterfallHook'; + +describe('AsyncSeriesWaterfallHook', () => { + const hook = new AsyncSeriesWaterfallHook<[number, string], number>(); + + const fn0 = vi.fn((a, b) => { + return 0; + }); + hook.tap('hook0', fn0); + + const fn1 = vi.fn((a, b) => { + return 1; + }); + hook.tap('hook1', fn1); + + const fn2 = vi.fn((a, b) => { + return 2; + }); + hook.tap('hook2', fn2); + + it('dispatch', async () => { + const results = await hook.dispatch(7274, 'foo'); + expect(fn0).toHaveBeenCalledWith(7274, 'foo'); + expect(fn1).toHaveBeenCalledWith(0); + expect(fn2).toHaveBeenCalledWith(1); + expect(results).toBe(2); + }); +}); diff --git a/src/AsyncSeriesWaterfallHook.ts b/src/AsyncSeriesWaterfallHook.ts new file mode 100644 index 0000000..2b47c2d --- /dev/null +++ b/src/AsyncSeriesWaterfallHook.ts @@ -0,0 +1,10 @@ +import { pipe } from './compose'; +import Hook from './Hook'; + +export class AsyncSeriesWaterfallHook extends Hook { + async dispatch(...args: Args) { + return pipe(this.taps.map(({ fn }) => fn))(...args); + } +} + +export default AsyncSeriesWaterfallHook; diff --git a/src/Hook.test.ts b/src/Hook.test.ts new file mode 100644 index 0000000..5dcfe1b --- /dev/null +++ b/src/Hook.test.ts @@ -0,0 +1,64 @@ +import { Hook } from './Hook'; + +class TestHook extends Hook { + dispatch(...args: T) { + return Promise.all(this._taps.map(({ fn }) => fn(...args))); + } +} + +describe('Hook', () => { + const hook = new TestHook<[number, string], number>(); + + const fn0 = vi.fn((a, b) => { + return 0; + }); + hook.tap('hook0', fn0); + + const fn1 = vi.fn((a, b) => { + return 1; + }); + hook.tap('hook1', fn1); + + const fn2 = vi.fn((a, b) => { + return 2; + }); + hook.tap({ name: 'hook2', stage: -10 }, fn2); + + const fn3 = vi.fn((a, b) => { + return 3; + }); + hook.tap({ name: 'hook3', before: 'hook1' }, fn3); + + it('dispatch', async () => { + await hook.dispatch(7274, 'foo'); + expect(fn0).toHaveBeenCalledWith(7274, 'foo'); + expect(fn1).toHaveBeenCalledWith(7274, 'foo'); + expect(fn2).toHaveBeenCalledWith(7274, 'foo'); + expect(fn3).toHaveBeenCalledWith(7274, 'foo'); + }); + it('dispatch - stage & before', async () => { + const results = await hook.dispatch(7274, 'bar'); + expect(results).toStrictEqual([2, 0, 3, 1]); + }); + + it('intercept', async () => { + const hook = new TestHook<[number, string], number>(); + const fnRegisterTap = vi.fn((tap) => tap); + const fnCallTap = vi.fn((...args) => 0); + const fnTapTap = vi.fn((tap) => {}); + const fn0 = vi.fn(() => 0); + hook.intercept({ + register: fnRegisterTap, + call: fnCallTap, + tap: fnTapTap, + }); + hook.tap('hook0', fn0); + expect(fnRegisterTap).toHaveBeenCalledTimes(1); + expect(fnRegisterTap).toHaveBeenCalledTimes(1); + expect(fnTapTap).toHaveBeenCalledTimes(1); + + await hook.dispatch(7274, 'foo'); + expect(fnCallTap).toHaveBeenCalledWith(7274, 'foo'); + expect(fnCallTap).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/Hook.ts b/src/Hook.ts new file mode 100644 index 0000000..b2d4b77 --- /dev/null +++ b/src/Hook.ts @@ -0,0 +1,91 @@ +interface InternalTap { + name: string; + /** @internal */ + fn: CallableFunction; + stage: number; + before?: InternalTap['name'] | InternalTap['name'][]; +} + +interface Tap extends Partial { + name: string; + stage?: number; +} + +export abstract class Hook { + protected _interceptor: Parameters['0'][] = []; + protected readonly _taps: InternalTap[] = []; + + protected _wrap(data: InternalTap) { + const { fn } = data; + const interceptorHook = (...args: Args) => this._interceptor?.forEach((item) => item?.call?.call(null, ...args)); + return { + ...data, + fn: function (...args: Args) { + interceptorHook?.(...args); + return fn(...args); + }.bind(fn), + }; + } + + private _tap(data: InternalTap) { + data = this._wrap(data); + this._interceptor?.forEach((item) => item?.register?.call(null, data)); + this._interceptor?.forEach((item) => item?.tap?.call(null, data)); + + const isTargetTap = (item: InternalTap) => { + if (Array.isArray(data.before) ? data.before.includes(item.name) : data.before === item.name) { + return true; + } + if (data.stage === 0) { + return false; + } + return data.stage < item.stage; + }; + const index = this._taps.findIndex(isTargetTap); + + switch (index) { + case -1: + this._taps.push(data); + break; + case 0: + this._taps.unshift(data); + break; + default: + this._taps.splice(Math.max(0, index), 0, data); + } + } + + get taps() { + return this._taps; + } + + tap(name: string | Tap, fn: (...args: Args) => Returns) { + if (typeof name === 'string') { + this._tap({ + stage: 0, + name, + fn, + }); + return; + } + this._tap({ + stage: 0, + ...name, + fn, + }); + } + + intercept( + options: { + register?: (tap: Tap) => Tap; + tap?: (tap: Tap) => void; + call?: (...args: Args) => Returns; + } = {} + ) { + this._interceptor.push({ ...options }); + } + + abstract dispatch(...args: Args): unknown; +} + +export default Hook; diff --git a/src/compose.ts b/src/compose.ts new file mode 100644 index 0000000..2e43a26 --- /dev/null +++ b/src/compose.ts @@ -0,0 +1,26 @@ +// @ts-nocheck +export function pipe(middleware): (...args) => T; +export function pipe(middleware) { + if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!'); + for (const fn of middleware) { + if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!'); + } + + return function (...args) { + // last called middleware # + let index = -1; + return dispatch(0, ...args); + function dispatch(i, ...args) { + if (i <= index) return Promise.reject(new Error('next() called multiple times')); + index = i; + const fn = middleware[i]; + // if (i === middleware.length) fn = next!; + if (!fn) return Promise.resolve(...args); + try { + return Promise.resolve(fn(...args)).then((result) => dispatch(i + 1, result)); + } catch (err) { + return Promise.reject(err); + } + } + }; +} diff --git a/src/index.ts b/src/index.ts index 713f729..f2521d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ -export function testFunction (name: string) { - return `Hello ${name}` -} +export { default as AsyncHook } from './Hook'; +export { default as AsyncParallelHook } from './AsyncParallelHook'; +export { default as AsyncSeriesBailHook } from './AsyncSeriesBailHook'; +export { default as AsyncSeriesHook } from './AsyncSeriesHook'; +export { default as AsyncSeriesWaterfallHook } from './AsyncSeriesWaterfallHook'; diff --git a/test/index.test.ts b/test/index.test.ts deleted file mode 100644 index 6798033..0000000 --- a/test/index.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { expect, it, describe } from 'vitest' -import { testFunction } from '../src' - -describe('packageName', () => { - const tests = [ - { input: 'foo', output: 'Hello foo' }, - { input: 'bar', output: 'Hello bar' } - ] - - for (const test of tests) { - it(test.input, () => { - expect(testFunction(test.input)).eq(test.output) - }) - } -})