Skip to content

Commit

Permalink
feat: 🎸 async hooks & basic hook
Browse files Browse the repository at this point in the history
  • Loading branch information
vzt7 committed Dec 15, 2022
1 parent 137353a commit 5b02a95
Show file tree
Hide file tree
Showing 15 changed files with 404 additions and 18 deletions.
40 changes: 40 additions & 0 deletions src/AsyncParallelBailHook.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
20 changes: 20 additions & 0 deletions src/AsyncParallelBailHook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Hook from './Hook';

export class AsyncParallelBailHook<Args extends unknown[]> extends Hook<Args, void> {
async dispatch(...args: Args) {
return Promise.race(
this._taps.map(
({ fn }) =>
new Promise<void>((resolve) => {
return Promise.resolve(fn(...args)).then((result: unknown) => {
if (result !== undefined) {
return resolve();
}
});
})
)
);
}
}

export default AsyncParallelBailHook;
22 changes: 22 additions & 0 deletions src/AsyncParallelHook.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
9 changes: 9 additions & 0 deletions src/AsyncParallelHook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Hook from './Hook';

export class AsyncParallelHook<Args extends unknown[], Returns> extends Hook<Args, Returns> {
async dispatch(...args: Args) {
return Promise.all<Returns>(this.taps.map(({ fn }) => fn(...args)));
}
}

export default AsyncParallelHook;
40 changes: 40 additions & 0 deletions src/AsyncSeriesBailHook.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 additions & 0 deletions src/AsyncSeriesBailHook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Hook from './Hook';

export const AsyncSeriesBailHook = class<Args extends unknown[]> extends Hook<Args, void> {
async dispatch(...args: Args) {
for (const tap of this.taps) {
const result = await tap.fn(...args);
if (result !== undefined) {
break;
}
}
}
};

export default AsyncSeriesBailHook;
22 changes: 22 additions & 0 deletions src/AsyncSeriesHook.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
13 changes: 13 additions & 0 deletions src/AsyncSeriesHook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Hook from './Hook';

export class AsyncSeriesHook<Args extends unknown[], Returns> extends Hook<Args, Returns> {
async dispatch(...args: Args) {
const returns: Returns[] = [];
for (const { fn } of this.taps) {
returns.push(await fn(...args));
}
return returns;
}
}

export default AsyncSeriesHook;
28 changes: 28 additions & 0 deletions src/AsyncSeriesWaterfallHook.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions src/AsyncSeriesWaterfallHook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { pipe } from './compose';
import Hook from './Hook';

export class AsyncSeriesWaterfallHook<Args extends unknown[], Returns> extends Hook<Args, Returns> {
async dispatch(...args: Args) {
return pipe<Returns>(this.taps.map(({ fn }) => fn))(...args);
}
}

export default AsyncSeriesWaterfallHook;
64 changes: 64 additions & 0 deletions src/Hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Hook } from './Hook';

class TestHook<T extends any[], R> extends Hook<T, R> {
dispatch(...args: T) {
return Promise.all<R>(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);
});
});
91 changes: 91 additions & 0 deletions src/Hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
interface InternalTap {
name: string;
/** @internal */
fn: CallableFunction;
stage: number;
before?: InternalTap['name'] | InternalTap['name'][];
}

interface Tap extends Partial<InternalTap> {
name: string;
stage?: number;
}

export abstract class Hook<Args extends unknown[] = [], Returns = void> {
protected _interceptor: Parameters<typeof this['intercept']>['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;
Loading

0 comments on commit 5b02a95

Please sign in to comment.