UNPKG

@thi.ng/interceptors

Version:

Interceptor based event bus, side effect & immutable state handling

443 lines 17.3 kB
import type { IDeref, IObjectOf, Maybe } from "@thi.ng/api"; import type { IAtom } from "@thi.ng/atom"; import { type EffectDef, type EffectPriority, type Event, type EventDef, type IDispatch, type Interceptor, type InterceptorContext, type InterceptorFn, type SideEffect } from "./api.js"; /** * Batched event processor for using composable interceptors for event handling * and side effects to execute the result of handled events. * * @remarks * Events processed by this class are simple 2-element tuples/arrays of this * form: `["event-id", payload?]`, where the `payload` is optional and can be of * any type. * * Events are processed by registered handlers which transform each event into a * number of side effect descriptions to be executed later. This separation * ensures event handlers themselves are pure functions and leads to more * efficient reuse of side effecting operations. The pure data nature until the * last stage of processing (the application side effects) too means that event * flow can be much easier inspected and debugged. * * In this model a single event handler itself is an array of objects with `pre` * and/or `post` keys and functions attached to each key. These functions are * called interceptors, since each intercepts the processing of an event and can * contribute their own side effects. Each event's interceptor chain is * processed bi-directionally (`pre` in forward, `post` in reverse order) and * the effects returned from each interceptor are merged/collected. The outcome * of this setup is a more aspect-oriented, composable approach to event * handling and allows to inject common, re-usable behaviors for multiple event * types (logging, validation, undo/redo triggers etc.). * * Side effects are only processed after all event handlers have run. * Furthermore, their order of execution can be configured with optional * priorities. * * See for further details: * * - {@link StatelessEventBus.processQueue} * - {@link StatelessEventBus.processEvent} * - {@link StatelessEventBus.processEffects} * - {@link StatelessEventBus.mergeEffects} * * The overall approach of this type of event processing is heavily based on the * pattern initially pioneered by @Day8/re-frame, with the following * differences: * * - stateless (see {@link EventBus} for the more common stateful alternative) * - standalone implementation (no assumptions about surrounding * context/framework) * - manual control over event queue processing * - supports event cancellation (via FX_CANCEL side effect) * - side effect collection (multiple side effects for same effect type per * frame) * - side effect priorities (to control execution order) * - dynamic addition/removal of handlers & effects */ export declare class StatelessEventBus implements IDispatch { state: any; protected eventQueue: Event[]; protected currQueue: Maybe<Event[]>; protected currCtx: Maybe<InterceptorContext>; protected handlers: IObjectOf<Interceptor[]>; protected effects: IObjectOf<SideEffect>; protected priorities: EffectPriority[]; /** * Creates a new event bus instance with given handler and effect * definitions (all optional). * * @remarks * In addition to the user provided handlers & effects, a number of * built-ins are added automatically. See * {@link StatelessEventBus.addBuiltIns}. User handlers can override * built-ins. * * @param handlers - * @param effects - */ constructor(handlers?: IObjectOf<EventDef>, effects?: IObjectOf<EffectDef>); /** * Adds built-in event & side effect handlers. * * @remarks * Also see additional built-ins defined by the stateful {@link EventBus} * extension of this class, as well as comments for these class methods: * * - {@link StatelessEventBus.mergeEffects} * - {@link StatelessEventBus.processEvent} * * ### Handlers * * currently none... * * ### Side effects * * #### `FX_CANCEL` * * If assigned `true`, cancels processing of current event, though still * applies any side effects already accumulated. * * #### `FX_DISPATCH` * * Dispatches assigned events to be processed in next frame. * * #### `FX_DISPATCH_ASYNC` * * Async wrapper for promise based side effects. * * #### `FX_DISPATCH_NOW` * * Dispatches assigned events as part of currently processed event queue (no * delay). * * #### `FX_DELAY` * * Async side effect. Only to be used in conjunction with * `FX_DISPATCH_ASYNC`. Triggers given event after `x` milliseconds. * * ```js * import { FX_DELAY, FX_DISPATCH_ASYNC } from "@thi.ng/interceptors"; * * // this triggers `[EV_SUCCESS, "ok"]` event after 1000 ms * { [FX_DISPATCH_ASYNC]: [FX_DELAY, [1000, "ok"], EV_SUCCESS, EV_ERROR] } * ``` * * #### `FX_FETCH` * * Async side effect. Only to be used in conjunction with * `FX_DISPATCH_ASYNC`. Performs `fetch()` HTTP request and triggers success * with received response, or if there was an error with response's * `statusText`. The error event is only triggered if the fetched response's * `ok` field is non-truthy. * * - https://developer.mozilla.org/en-US/docs/Web/API/Response/ok * - https://developer.mozilla.org/en-US/docs/Web/API/Response/statusText * * ```js * import { FX_FETCH, FX_DISPATCH_ASYNC } from "@thi.ng/interceptors"; * * // fetches "foo.json" and then dispatches EV_SUCCESS or EV_ERROR event * { [FX_DISPATCH_ASYNC]: [FX_FETCH, "foo.json", EV_SUCCESS, EV_ERROR] } * ``` */ addBuiltIns(): any; addHandler(id: string, spec: EventDef): void; addHandlers(specs: IObjectOf<EventDef>): void; addEffect(id: string, fx: SideEffect, priority?: number): void; addEffects(specs: IObjectOf<EffectDef>): void; /** * Prepends given interceptors (or interceptor functions) to * selected handlers. If no handler IDs are given, applies * instrumentation to all currently registered handlers. * * @param inject - * @param ids - */ instrumentWith(inject: (Interceptor | InterceptorFn)[], ids?: string[]): void; removeHandler(id: string): void; removeHandlers(ids: string[]): void; removeEffect(id: string): void; removeEffects(ids: string[]): void; /** * If called during event processing, returns current side effect * accumulator / interceptor context. Otherwise returns nothing. */ context(): Maybe<InterceptorContext>; /** * Adds given events to event queue to be processed by * {@link StatelessEventBus.processQueue} later on. * * @remarks * It's the user's responsibility to call that latter function * repeatedly in a timely manner, preferably via * `requestAnimationFrame()` or similar. * * @param e - */ dispatch(...e: Event[]): void; /** * Adds given events to whatever is the current event queue. If * triggered via the `FX_DISPATCH_NOW` side effect from an event * handler / interceptor, the event will still be executed in the * currently active batch / frame. If called from elsewhere, the * result is the same as calling {@link dispatch}. * * @param e - */ dispatchNow(...e: Event[]): void; /** * Dispatches given event after `delay` milliseconds (by default * 17). * * @remarks * Since events are only processed by calling * {@link StatelessEventBus.processQueue}, it's the user's * responsibility to call that latter function repeatedly in a * timely manner, preferably via `requestAnimationFrame()` or * similar. * * @param e - * @param delay - */ dispatchLater(e: Event, delay?: number): void; /** * Triggers processing of current event queue and returns `true` if * any events have been processed. * * @remarks * If an event handler triggers the `FX_DISPATCH_NOW` side effect, * the new event will be added to the currently processed batch and * therefore executed in the same frame. Also see {@link dispatchNow}. * * An optional `ctx` (context) object can be provided, which is used * to collect any side effect definitions during processing. This * can be useful for debugging, inspection or post-processing * purposes. * * @param ctx - */ processQueue(ctx?: InterceptorContext): boolean; /** * Processes a single event using its configured handler/interceptor * chain. Logs warning message and skips processing if no handler is * available for the event type. * * @remarks * The array of interceptors is processed in bi-directional order. * First any `pre` interceptors are processed in forward order. Then * `post` interceptors are processed in reverse. * * Each interceptor can return a result object of side effects, * which are being merged and collected for * {@link StatelessEventBus.processEffects}. * * Any interceptor can trigger zero or more known side effects, each * (side effect) will be collected in an array to support multiple * invocations of the same effect type per frame. If no side effects * are requested, an interceptor can return `undefined`. * * Processing of the current event stops immediately, if an * interceptor sets the `FX_CANCEL` side effect key to `true`. * However, the results of any previous interceptors (incl. the one * which cancelled) are kept and processed further as usual. * * @param ctx - * @param e - */ protected processEvent(ctx: InterceptorContext, e: Event): void; protected processForward(ctx: InterceptorContext, iceps: Interceptor[], e: Event): boolean; protected processReverse(ctx: InterceptorContext, iceps: Interceptor[], e: Event): void; /** * Takes a collection of side effects generated during event * processing and applies them in order of configured priorities. * * @param ctx - */ protected processEffects(ctx: InterceptorContext): void; protected processEffect(ctx: InterceptorContext, effects: IObjectOf<SideEffect>, id: string, val: any): void; /** * Merges the new side effects returned from an interceptor into the * internal effect accumulator. * * @remarks * Any events assigned to the `FX_DISPATCH_NOW` effect key are * immediately added to the currently active event batch. * * If an interceptor wishes to cause multiple invocations of a * single side effect type (e.g. dispatch multiple other events), it * MUST return an array of these values. The only exceptions to this * are the following effects, which for obvious reasons can only * accept a single value. * * **Note:** the `FX_STATE` effect is not actually defined by this * class here, but is supported to avoid code duplication in * {@link EventBus}. * * - `FX_CANCEL` * - `FX_STATE` * * Because of this support (multiple values), the value of a single * side effect MUST NOT be a nested array itself, or rather its * first item can't be an array. * * For example: * * ```js * import { FX_DISPATCH } from "@thi.ng/interceptors"; * * // interceptor result map to dispatch a single event * { [FX_DISPATCH]: ["foo", "bar"]} * * // result map format to dispatch multiple events * { [FX_DISPATCH]: [ ["foo", "bar"], ["baz", "beep"] ]} * ``` * * Any `null` / `undefined` values directly assigned to a side * effect are ignored and will not trigger the effect. * * @param ctx - * @param ret - */ protected mergeEffects(ctx: InterceptorContext, ret: any): void; protected interceptorsFromSpec(spec: EventDef): any; } /** * Stateful version of {@link StatelessEventBus}. * * @remarks * Wraps an [`IAtom`](https://docs.thi.ng/umbrella/atom/interfaces/IAtom.html) * state container (i.e. `Atom`/`Cursor`/`History`) and provides additional * pre-defined event handlers and side effects to manipulate wrapped state. * Prefer this as the default implementation for most use cases. */ export declare class EventBus extends StatelessEventBus implements IDeref<any>, IDispatch { readonly state: IAtom<any>; /** * Creates a new event bus instance with given parent state, handler and * effect definitions (all optional). * * @remarks * If no state is given, automatically creates an * [`Atom`](https://docs.thi.ng/umbrella/atom/classes/Atom.html) with empty * state object. * * In addition to the user provided handlers & effects, a number of * built-ins are added automatically. See {@link EventBus.addBuiltIns}. User * handlers can override built-ins. * * @param state - * @param handlers - * @param effects - */ constructor(state?: IAtom<any> | null, handlers?: IObjectOf<EventDef>, effects?: IObjectOf<EffectDef>); /** * Returns value of internal state. Shorthand for: * `bus.state.deref()` */ deref(): any; /** * Adds same built-in event & side effect handlers as in * `StatelessEventBus.addBuiltIns()` and the following additions: * * ### Handlers * * #### `EV_SET_VALUE` * * Resets state path to provided value. See * [`setIn`](https://docs.thi.ng/umbrella/paths/functions/setIn.html). * * Example event definition: * ```js * import { EV_SET_VALUE } from "@thi.ng/interceptors"; * * [EV_SET_VALUE, ["path.to.value", val]] * ``` * * #### `EV_UPDATE_VALUE` * * Updates a state path's value with provided function and optional extra * arguments. See * [`updateIn`](https://docs.thi.ng/umbrella/paths/functions/updateIn.html). * * Example event definition: * ```js * import { EV_UPDATE_VALUE } from "@thi.ng/interceptors"; * * [EV_UPDATE_VALUE, ["path.to.value", (x, y) => x + y, 1]] * ``` * * #### `EV_TOGGLE_VALUE` * * Negates a boolean state value at given path. * * Example event definition: * ```js * import { EV_TOGGLE_VALUE } from "@thi.ng/interceptors"; * * [EV_TOGGLE_VALUE, "path.to.value"] * ``` * * #### `EV_UNDO` * * Calls `ctx[id].undo()` and uses return value as new state. Assumes * `ctx[id]` is a * [`History`](https://docs.thi.ng/umbrella/atom/classes/History.html) * instance, provided via e.g. `processQueue({ history })`. The event can be * triggered with or without ID. By default `"history"` is used as default * key to lookup the `History` instance. Furthermore, an additional event * can be triggered based on if a previous state has been restored or not * (basically, if the undo was successful). This is useful for * resetting/re-initializing stateful resources after a successful undo * action or to notify the user that no more undo's are possible. The new * event will be processed in the same frame and has access to the * (possibly) restored state. The event structure for these options is shown * below: * * ```js * import { EV_UNDO } from "@thi.ng/interceptors"; * * // using default ID * bus.dispatch([EV_UNDO]); * * // using custom history ID * bus.dispatch([EV_UNDO, ["custom"]]); * * // using custom ID and dispatch another event after undo * bus.dispatch([EV_UNDO, ["custom", ["ev-undo-success"], ["ev-undo-fail"]]]); * ``` * * #### `EV_REDO` * * Similar to `EV_UNDO`, but for redo actions. * * ### Side effects * * #### `FX_STATE` * * Resets state atom to provided value (only a single update per processing * frame). */ addBuiltIns(): any; /** * Triggers processing of current event queue and returns `true` if the any * of the processed events caused a state change. * * If an event handler triggers the `FX_DISPATCH_NOW` side effect, the new * event will be added to the currently processed batch and therefore * executed in the same frame. Also see {@link dispatchNow}. * * If the optional `ctx` arg is provided it will be merged into the * {@link InterceptorContext} object passed to each interceptor. Since the * merged object is also used to collect triggered side effects, care must * be taken that there're no key name clashes. * * In order to use the built-in `EV_UNDO`, `EV_REDO` events, users MUST * provide a * [`History`](https://docs.thi.ng/umbrella/atom/classes/History.html) (or * compatible undo history instance) via the `ctx` arg, e.g. * * ``` * bus.processQueue({ history }); * ``` */ processQueue(ctx?: InterceptorContext): boolean; } //# sourceMappingURL=event-bus.d.ts.map