Commit e8cc135c authored by nanahira's avatar nanahira

middleware dispatcher

parent d0490706
......@@ -3,3 +3,5 @@ export * from './src/dual-object';
export * from './src/workflow-dispatcher';
export * from './src/round-robin';
export * from './src/abortable';
export * from './src/types';
export * from './src/middleware-dispatcher';
import { Awaitable } from './types';
type AnyFunc = (...args: any[]) => any;
type MiddlewareValue<F extends AnyFunc> = Awaited<ReturnType<F>>;
type MiddlewareResult<F extends AnyFunc> = Promise<MiddlewareValue<F>>;
type MiddlewareNext<F extends AnyFunc> = () => MiddlewareResult<F>;
type MiddlewareArgs<F extends AnyFunc> = [
...args: Parameters<F>,
next: MiddlewareNext<F>,
];
type MiddlewareReturn<F extends AnyFunc> = Awaitable<MiddlewareValue<F>>;
export type Middleware<F extends AnyFunc> = (
...args: MiddlewareArgs<F>
) => MiddlewareReturn<F>;
type MiddlewareAcceptResult<F extends AnyFunc> = (
s: MiddlewareValue<F>,
) => Awaitable<boolean>;
type MiddlewareErrorHandler<F extends AnyFunc> = (
e: any,
args: Parameters<F>,
next: MiddlewareNext<F>,
) => Awaitable<MiddlewareValue<F>>;
export interface MiddlewareDispatcherOptions<F extends AnyFunc> {
acceptResult?: MiddlewareAcceptResult<F>;
errorHandler?: MiddlewareErrorHandler<F>;
}
export class MiddlewareDispatcher<F extends AnyFunc> {
constructor(private options: MiddlewareDispatcherOptions<F> = {}) {}
middlewares: Middleware<F>[] = [];
middleware(mw: Middleware<F>, prior = false) {
if (prior) {
this.middlewares.unshift(mw);
} else {
this.middlewares.push(mw);
}
return this;
}
removeMiddleware(mw: Middleware<F>) {
const index = this.middlewares.indexOf(mw);
if (index >= 0) {
this.middlewares.splice(index, 1);
}
return this;
}
dispatch(...args: Parameters<F>): MiddlewareResult<F> {
const mws = this.middlewares;
const acceptResult: MiddlewareAcceptResult<F> =
this.options.acceptResult || ((res) => res != null);
const errorHandler: MiddlewareErrorHandler<F> =
this.options.errorHandler || ((e, args, next) => next());
const dispatch = async (i: number): MiddlewareResult<F> => {
if (i >= mws.length) return undefined;
const mw = mws[i];
let nextCalled = false;
const next = async (): MiddlewareResult<F> => {
if (nextCalled) {
return undefined;
}
nextCalled = true;
return dispatch(i + 1);
};
const runMw = async (cb: () => MiddlewareReturn<F>) => {
const res = await cb();
if (!nextCalled && !(await acceptResult(res))) {
return dispatch(i + 1);
}
return res;
};
try {
return await runMw(() => mw(...args, next));
} catch (e) {
return await runMw(() => errorHandler(e, args, next));
}
};
return dispatch(0);
}
}
export type Awaitable<T> = T | Promise<T>;
import { Middleware, MiddlewareDispatcher } from '../src/middleware-dispatcher';
type Handler = (x: number) => number;
describe('MiddlewareDispatcher', () => {
it('runs middlewares in order and supports next()', async () => {
const d = new MiddlewareDispatcher<Handler>();
const order: string[] = [];
d.middleware(async (x, next) => {
order.push('mw1:before');
const r = await next();
order.push('mw1:after');
return (r ?? 0) + 1; // add 1 after next
});
d.middleware(async (x, next) => {
order.push('mw2:before');
const r = await next();
order.push('mw2:after');
return (r ?? 0) + 2; // add 2 after next
});
d.middleware(async (x) => {
order.push('mw3:leaf');
return x * 10; // base result
});
const res = await d.dispatch(3);
expect(res).toBe(3 * 10 + 2 + 1);
expect(order).toEqual([
'mw1:before',
'mw2:before',
'mw3:leaf',
'mw2:after',
'mw1:after',
]);
});
it('default acceptResult stops when middleware returns a non-null/undefined result', async () => {
const d = new MiddlewareDispatcher<Handler>(); // acceptResult: res != null
const calls: string[] = [];
d.middleware(async (x) => {
calls.push('mw1');
return 42; // non-null => stop here
});
d.middleware(async () => {
calls.push('mw2');
return 999;
});
const res = await d.dispatch(1);
expect(res).toBe(42);
expect(calls).toEqual(['mw1']); // mw2 never runs
});
it('when a middleware returns undefined and does not call next, it falls through to the next middleware', async () => {
const d = new MiddlewareDispatcher<Handler>();
const calls: string[] = [];
d.middleware(async () => {
calls.push('mw1');
return undefined; // not accepted by default acceptResult => continue
});
d.middleware(async () => {
calls.push('mw2');
return 7;
});
const res = await d.dispatch(5);
expect(res).toBe(7);
expect(calls).toEqual(['mw1', 'mw2']);
});
it('custom acceptResult can enforce a specific stopping condition', async () => {
const d = new MiddlewareDispatcher<Handler>({
acceptResult: async (res) => res === 100, // only stop if result is 100
});
const calls: string[] = [];
d.middleware(async () => {
calls.push('mw1');
return 50; // not accepted => continue
});
d.middleware(async () => {
calls.push('mw2');
return 100; // accepted => stop
});
d.middleware(async () => {
calls.push('mw3');
return 200;
});
const res = await d.dispatch(0);
expect(res).toBe(100);
expect(calls).toEqual(['mw1', 'mw2']);
});
it('default errorHandler swallows errors and continues with next middleware', async () => {
const d = new MiddlewareDispatcher<Handler>(); // default errorHandler => next()
const calls: string[] = [];
d.middleware(async () => {
calls.push('mw1:throw');
throw new Error('boom');
});
d.middleware(async () => {
calls.push('mw2');
return 5;
});
const res = await d.dispatch(1);
expect(res).toBe(5);
expect(calls).toEqual(['mw1:throw', 'mw2']);
});
it('custom errorHandler can recover and return a value (and still respects acceptResult)', async () => {
const d = new MiddlewareDispatcher<Handler>({
acceptResult: (res) => res != null, // default semantics
errorHandler: async (e, args, next) => {
// recover with a concrete value to stop the chain
return 777;
},
});
const calls: string[] = [];
d.middleware(async () => {
calls.push('mw1:throw');
throw new Error('oops');
});
d.middleware(async () => {
calls.push('mw2');
return 1; // should not run because errorHandler returns accepted value
});
const res = await d.dispatch(0);
expect(res).toBe(777);
expect(calls).toEqual(['mw1:throw']);
});
it('calling next() more than once should be a no-op after the first call', async () => {
const d = new MiddlewareDispatcher<Handler>();
let leafCount = 0;
d.middleware(async (x, next) => {
const a = await next(); // first call
const b = await next(); // second call (should return undefined and NOT advance)
return (a ?? 0) + (b ?? 0);
});
d.middleware(async (x) => {
leafCount += 1;
return x + 1;
});
const res = await d.dispatch(10);
expect(res).toBe(11);
expect(leafCount).toBe(1); // proved not advanced twice
});
it('supports prior insertion (unshift) and removal of middlewares', async () => {
const d = new MiddlewareDispatcher<Handler>();
const seen: string[] = [];
const mwA: Middleware<Handler> = async (x, next) => {
seen.push('A');
return next();
};
const mwB: Middleware<Handler> = async (x, next) => {
seen.push('B');
return next();
};
const mwC: Middleware<Handler> = async (x) => {
seen.push('C');
return x * 2;
};
d.middleware(mwB); // [B]
d.middleware(mwC); // [B, C]
d.middleware(mwA, true); // prior => [A, B, C]
d.removeMiddleware(mwB); // => [A, C]
const res = await d.dispatch(3);
expect(res).toBe(6);
expect(seen).toEqual(['A', 'C']);
});
it('returns undefined if the chain is empty', async () => {
const d = new MiddlewareDispatcher<Handler>();
const res = await d.dispatch(123);
expect(res).toBeUndefined();
});
it('returns the current middleware value when next() was awaited (Koa-like around behavior)', async () => {
const d = new MiddlewareDispatcher<Handler>();
d.middleware(async (x, next) => {
const inner = await next(); // advance
return (inner ?? 0) + 10; // wrap/transform inner
});
d.middleware(async (x) => x * 3);
const res = await d.dispatch(4);
expect(res).toBe(4 * 3 + 10); // 22
});
it('if a middleware does not call next() and returns a result accepted by acceptResult, chain stops', async () => {
const d = new MiddlewareDispatcher<Handler>({
acceptResult: (res) => typeof res === 'number' && res >= 100,
});
const calls: string[] = [];
d.middleware(async () => {
calls.push('mw1');
return 99; // not accepted => continue
});
d.middleware(async () => {
calls.push('mw2');
return 100; // accepted => stop
});
d.middleware(async () => {
calls.push('mw3');
return 1000;
});
const res = await d.dispatch(0);
expect(res).toBe(100);
expect(calls).toEqual(['mw1', 'mw2']);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment