@thi.ng/interceptors
Version:
Interceptor based event bus, side effect & immutable state handling
443 lines • 17.3 kB
TypeScript
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