export const enter: unique symbol = Symbol(); export const exit: unique symbol = Symbol(); type Result = { value: T } | { exception: E }; export class EnterException { exception: unknown; constructor(exception: unknown) { this.exception = exception; } } export class ExitException { exception: unknown; result: Result; constructor(exception: unknown, result: Result) { this.exception = exception; this.result = result; } } /** * An object implementing the `Context` interface has some notion of * "being entered" and always eventually "being exited". It should be used * together with the `within` function to enclose a function with a context. * * A "handle" is an object that can only assumed valid while within the * context. It should not be used after the context has exited and doing * so should be treated as undefined behavior. * * The `within` function will: * - call the `enter` method exactly once. * - call the `exit` exactly once and only after `enter` has been called. */ export interface Context { [enter]: () => MaybePromise; [exit]?: (handle: THandle) => MaybePromise; } type ContextResult< TEnterResult extends MaybePromise, TFnResult extends MaybePromise, TExitResult extends MaybePromise, > = TFnResult extends PromiseLike ? TFnResult : TEnterResult extends PromiseLike ? PromiseLike : TExitResult extends PromiseLike ? PromiseLike : TFnResult; type MaybePromise = T | PromiseLike; type Resolve> = T extends MaybePromise ? U : T; function isPromiseLike(obj: MaybePromise): obj is PromiseLike { return ( obj != null && (typeof obj === 'object' || typeof obj === 'function') && 'then' in obj && typeof obj.then === 'function' ); } function handleMaybeAsyncException< TArgs extends Array, TResolvedResult, TOnExceptionReturn, >( fn: (...args: TArgs) => MaybePromise, onException: (exception: unknown) => TOnExceptionReturn, ): (...args: TArgs) => MaybePromise { return (...args) => { try { const value = fn(...args); if (isPromiseLike(value)) { return Promise.resolve(value).catch(onException); } return value; } catch (exception) { return onException(exception); } }; } function asyncCompose< TInnerArgs extends Array, TResolvedResult, TOuterResult, >( innerFn: (...args: TInnerArgs) => MaybePromise, outerFn: (result: Resolve) => TOuterResult, ): (...args: TInnerArgs) => MaybePromise { return (...args) => { const value = innerFn(...args); if (isPromiseLike(value)) { return value.then(outerFn as (arg: TResolvedResult) => TOuterResult); } return outerFn(value as Resolve); }; } export function within< TEnterResult extends MaybePromise, TFnResult extends MaybePromise, TExitResult extends MaybePromise = void, >( context: { [enter]: () => TEnterResult; [exit]?: (handler: Resolve) => TExitResult; }, fn: (handler: Resolve) => TFnResult, ): ContextResult { return asyncCompose( handleMaybeAsyncException(context[enter], exception => { throw new EnterException(exception); }), handle => asyncCompose( handleMaybeAsyncException( asyncCompose(fn, value => ({ value })), exception => ({ exception }), ), result => asyncCompose( handleMaybeAsyncException( context[exit] ?? (() => {}), exception => { throw new ExitException(exception, result); }, ), () => { if ('value' in result) { return result.value; } throw result.exception; }, )(handle), )(handle), )() as ContextResult; }