UNPKG

@thi.ng/interceptors

Version:

Interceptor based event bus, side effect & immutable state handling

667 lines (666 loc) 19.7 kB
import { Atom } from "@thi.ng/atom/atom"; import { implementsFunction } from "@thi.ng/checks/implements-function"; import { isArray } from "@thi.ng/checks/is-array"; import { isFunction } from "@thi.ng/checks/is-function"; import { isPromise } from "@thi.ng/checks/is-promise"; import { illegalArgs } from "@thi.ng/errors/illegal-arguments"; import { setInUnsafe } from "@thi.ng/paths/set-in"; import { updateInUnsafe } from "@thi.ng/paths/update-in"; import { EV_REDO, EV_SET_VALUE, EV_TOGGLE_VALUE, EV_UNDO, EV_UPDATE_VALUE, FX_CANCEL, FX_DELAY, FX_DISPATCH, FX_DISPATCH_ASYNC, FX_DISPATCH_NOW, FX_FETCH, FX_STATE, LOGGER } from "./api.js"; class StatelessEventBus { state; eventQueue; currQueue; currCtx; handlers; effects; priorities; /** * 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, effects) { this.handlers = {}; this.effects = {}; this.eventQueue = []; this.priorities = []; this.addBuiltIns(); if (handlers) { this.addHandlers(handlers); } if (effects) { this.addEffects(effects); } } /** * 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() { this.addEffects({ [FX_DISPATCH]: [(e) => this.dispatch(e), -999], [FX_DISPATCH_ASYNC]: [ ([id, arg, success, err], bus, ctx) => { const fx = this.effects[id]; if (fx) { const p = fx(arg, bus, ctx); if (isPromise(p)) { p.then( (res) => this.dispatch([success, res]) ).catch((e) => this.dispatch([err, e])); } else { LOGGER.warn("async effect did not return Promise"); } } else { LOGGER.warn(`skipping invalid async effect: ${id}`); } }, -999 ], [FX_DELAY]: [ ([x, body]) => new Promise((res) => setTimeout(() => res(body), x)), 1e3 ], [FX_FETCH]: [ (req) => fetch(req).then((resp) => { if (!resp.ok) { throw new Error(resp.statusText); } return resp; }), 1e3 ] }); } addHandler(id, spec) { const iceps = this.interceptorsFromSpec(spec); if (iceps.length > 0) { if (this.handlers[id]) { this.removeHandler(id); LOGGER.warn(`overriding handler for ID: ${id}`); } this.handlers[id] = iceps; } else { illegalArgs(`no handlers in spec for ID: ${id}`); } } addHandlers(specs) { for (let id in specs) { this.addHandler(id, specs[id]); } } addEffect(id, fx, priority = 1) { if (this.effects[id]) { this.removeEffect(id); LOGGER.warn(`overriding effect for ID: ${id}`); } this.effects[id] = fx; const p = [id, priority]; const priors = this.priorities; for (let i = 0; i < priors.length; i++) { if (p[1] < priors[i][1]) { priors.splice(i, 0, p); return; } } priors.push(p); } addEffects(specs) { for (let id in specs) { const fx = specs[id]; if (isArray(fx)) { this.addEffect(id, fx[0], fx[1]); } else { this.addEffect(id, fx); } } } /** * 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, ids) { const iceps = inject.map(__asInterceptor); const handlers = this.handlers; for (let id of ids || Object.keys(handlers)) { const h = handlers[id]; if (h) { handlers[id] = iceps.concat(h); } } } removeHandler(id) { delete this.handlers[id]; } removeHandlers(ids) { for (let id of ids) { this.removeHandler(id); } } removeEffect(id) { delete this.effects[id]; const p = this.priorities; for (let i = p.length - 1; i >= 0; i--) { if (id === p[i][0]) { p.splice(i, 1); return; } } } removeEffects(ids) { for (let id of ids) { this.removeEffect(id); } } /** * If called during event processing, returns current side effect * accumulator / interceptor context. Otherwise returns nothing. */ context() { return this.currCtx; } /** * 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) { this.eventQueue.push(...e); } /** * 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) { (this.currQueue || this.eventQueue).push(...e); } /** * 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, delay = 17) { setTimeout(() => this.dispatch(e), delay); } /** * 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) { if (this.eventQueue.length > 0) { this.currQueue = [...this.eventQueue]; this.eventQueue.length = 0; ctx = this.currCtx = ctx || {}; for (let e of this.currQueue) { this.processEvent(ctx, e); } this.currQueue = this.currCtx = void 0; this.processEffects(ctx); return true; } return false; } /** * 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 - */ processEvent(ctx, e) { const iceps = this.handlers[e[0]]; if (!iceps) { LOGGER.warn(`missing handler for event type: ${e[0].toString()}`); return; } if (!this.processForward(ctx, iceps, e)) { return; } this.processReverse(ctx, iceps, e); } processForward(ctx, iceps, e) { let hasPost = false; for (let i = 0, n = iceps.length; i < n && !ctx[FX_CANCEL]; i++) { const icep = iceps[i]; if (icep.pre) { this.mergeEffects(ctx, icep.pre(ctx[FX_STATE], e, this, ctx)); } hasPost = hasPost || !!icep.post; } return hasPost; } processReverse(ctx, iceps, e) { for (let i = iceps.length; i-- > 0 && !ctx[FX_CANCEL]; ) { const icep = iceps[i]; if (icep.post) { this.mergeEffects(ctx, icep.post(ctx[FX_STATE], e, this, ctx)); } } } /** * Takes a collection of side effects generated during event * processing and applies them in order of configured priorities. * * @param ctx - */ processEffects(ctx) { const effects = this.effects; for (let p of this.priorities) { const id = p[0]; const val = ctx[id]; val !== void 0 && this.processEffect(ctx, effects, id, val); } } processEffect(ctx, effects, id, val) { const fn = effects[id]; if (id !== FX_STATE) { for (let v of val) { fn(v, this, ctx); } } else { fn(val, this, ctx); } } /** * 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 - */ mergeEffects(ctx, ret) { if (!ret) { return; } for (let k in ret) { const v = ret[k]; if (v == null) { continue; } if (k === FX_STATE || k === FX_CANCEL) { ctx[k] = v; } else if (k === FX_DISPATCH_NOW) { if (isArray(v[0])) { for (let e of v) { e && this.dispatchNow(e); } } else { this.dispatchNow(v); } } else { ctx[k] || (ctx[k] = []); if (isArray(v[0])) { for (let e of v) { e !== void 0 && ctx[k].push(e); } } else { ctx[k].push(v); } } } } interceptorsFromSpec(spec) { return isArray(spec) ? spec.map(__asInterceptor) : isFunction(spec) ? [{ pre: spec }] : [spec]; } } class EventBus extends StatelessEventBus { state; /** * 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, handlers, effects) { super(handlers, effects); this.state = state || new Atom({}); } /** * Returns value of internal state. Shorthand for: * `bus.state.deref()` */ deref() { return this.state.deref(); } /** * 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() { super.addBuiltIns(); this.addHandlers({ [EV_SET_VALUE]: (state, [_, [path, val]]) => ({ [FX_STATE]: setInUnsafe(state, path, val) }), [EV_UPDATE_VALUE]: (state, [_, [path, fn, ...args]]) => ({ [FX_STATE]: updateInUnsafe(state, path, fn, ...args) }), [EV_TOGGLE_VALUE]: (state, [_, path]) => ({ [FX_STATE]: updateInUnsafe(state, path, (x) => !x) }), [EV_UNDO]: __undoHandler("undo"), [EV_REDO]: __undoHandler("redo") }); this.addEffects({ [FX_STATE]: [(state) => this.state.reset(state), -1e3] }); } /** * 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) { if (this.eventQueue.length > 0) { const prev = this.state.deref(); this.currQueue = [...this.eventQueue]; this.eventQueue.length = 0; ctx = this.currCtx = { ...ctx, [FX_STATE]: prev }; for (let e of this.currQueue) { this.processEvent(ctx, e); } this.currQueue = this.currCtx = void 0; this.processEffects(ctx); return this.state.deref() !== prev; } return false; } } const __asInterceptor = (i) => isFunction(i) ? { pre: i } : i; const __undoHandler = (action) => (_, [__, ev], bus, ctx) => { const id = ev ? ev[0] : "history"; if (implementsFunction(ctx[id], action)) { const ok = ctx[id][action](); return { [FX_STATE]: bus.state.deref(), [FX_DISPATCH_NOW]: ev ? ok !== void 0 ? ev[1] : ev[2] : void 0 }; } else { LOGGER.warn("no history in context"); } }; export { EventBus, StatelessEventBus };