UNPKG

sub-events

Version:

Lightweight, strongly-typed events, with monitored subscriptions.

441 lines (440 loc) 16.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SubEvent = exports.EmitSchedule = void 0; var tslib_1 = require("tslib"); var sub_1 = require("./sub"); var consumer_1 = require("./consumer"); /** * Schedule for emitting / broadcasting data to subscribers, to be used by method {@link SubEvent.emit}. * It represents a concurrency strategy for delivering event to subscribers. */ var EmitSchedule; (function (EmitSchedule) { /** * Data is sent to all subscribers synchronously / immediately. * * This is the default schedule. */ EmitSchedule["sync"] = "sync"; /** * Data broadcast is fully asynchronous: each subscriber will be receiving the event * within its own processor tick (under Node.js), or timer tick (in browsers). * * Subscribers are enumerated after the initial delay. */ EmitSchedule["async"] = "async"; /** * Wait for the next processor tick (under Node.js), or timer tick (in browsers), * and then broadcast data to all subscribers synchronously. * * Subscribers are enumerated after the delay. */ EmitSchedule["next"] = "next"; })(EmitSchedule || (exports.EmitSchedule = EmitSchedule = {})); /** * Core class, implementing event subscription + emitting the event. * * @see {@link subscribe}, {@link emit} */ var SubEvent = /** @class */ (function () { /** * Event constructor. * * @param options * Configuration Options. */ function SubEvent(options) { /** * Internal list of subscribers. * @hidden */ this._subs = []; if (typeof (options !== null && options !== void 0 ? options : {}) !== 'object') { throw new TypeError(Stat.errInvalidOptions); } this.options = options !== null && options !== void 0 ? options : {}; } Object.defineProperty(SubEvent.prototype, "lastEvent", { /** * Last emitted event, if there was any, or `undefined` otherwise. * * It is set after all subscribers have received the event, but just before * optional {@link IEmitOptions.onFinished} callback is invoked. */ get: function () { return this._lastEvent; }, enumerable: false, configurable: true }); /** * Returns a new {@link EventConsumer} for the event, which physically hides methods {@link SubEvent.emit} and {@link SubEvent.cancelAll}. * * This method simplifies creation of a receive-only event object representation. * * ```ts * const e = new SubEvent<number>(); // full-access, emit-receive event * * const c = e.toConsumer(); // the same "e" event, but with receive-only access * * // It is equivalent to the full syntax of: * // const c = new EventConsumer<number>(e); * ``` */ SubEvent.prototype.toConsumer = function () { return new consumer_1.EventConsumer(this); }; /** * Subscribes to the event. * * When subscription is no longer needed, method {@link Subscription.cancel} should be called on the * returned object, to avoid performance degradation caused by abandoned subscribers. * * Method {@link SubEvent.getStat} can help with diagnosing leaked subscriptions. * * @param cb * Event notification callback function. * * @param options * Subscription Options. * * @returns * Object for cancelling the subscription safely. * * @see {@link once} */ SubEvent.prototype.subscribe = function (cb, options) { if (typeof (options !== null && options !== void 0 ? options : {}) !== 'object') { throw new TypeError(Stat.errInvalidOptions); } cb = options && 'thisArg' in options ? cb.bind(options.thisArg) : cb; var cancel = function () { if (typeof (options === null || options === void 0 ? void 0 : options.onCancel) === 'function') { options.onCancel(); } }; var name = options === null || options === void 0 ? void 0 : options.name; var sub = { event: this, cb: cb, name: name, cancel: cancel }; if (typeof this.options.onSubscribe === 'function') { var ctx = { event: sub.event, name: sub.name, data: sub.data }; this.options.onSubscribe(ctx); sub.data = ctx.data; } this._subs.push(sub); return new sub_1.Subscription({ cancel: this._createCancel(sub), sub: sub }); }; /** * Subscribes to receive just one event, and cancel the subscription immediately. * * You may still want to call {@link Subscription.cancel} on the returned object, * if you suddenly need to prevent the first event, or to avoid dead once-off * subscriptions that never received their event, and thus were not cancelled. * * @param cb * Event notification function, invoked after self-cancelling the subscription. * * @param options * Subscription Options. * * @returns * Object for cancelling the subscription safely. * * @see {@link toPromise} */ SubEvent.prototype.once = function (cb, options) { var sub = this.subscribe(function (data) { sub.cancel(); return cb.call(options === null || options === void 0 ? void 0 : options.thisArg, data); }, options); return sub; }; /** * Broadcasts data to all subscribers, according to the `emit` schedule, * which is synchronous by default. * * @param data * Event data to be sent, according to the template type. * * @param options * Event-emitting options. * * @returns * The event object itself. */ SubEvent.prototype.emit = function (data, options) { var _this = this; var _a; if (typeof (options !== null && options !== void 0 ? options : {}) !== 'object') { throw new TypeError(Stat.errInvalidOptions); } var schedule = (_a = options === null || options === void 0 ? void 0 : options.schedule) !== null && _a !== void 0 ? _a : EmitSchedule.sync; var onFinished = typeof (options === null || options === void 0 ? void 0 : options.onFinished) === 'function' && options.onFinished; var onError = typeof (options === null || options === void 0 ? void 0 : options.onError) === 'function' && options.onError; var start = schedule === EmitSchedule.sync ? Stat.callNow : Stat.callNext; var middle = schedule === EmitSchedule.async ? Stat.callNext : Stat.callNow; var onLast = function (count) { _this._lastEvent = data; // save the last event if (onFinished) { onFinished(count); // notify } }; start(function () { var r = _this._getRecipients(); r.forEach(function (sub, index) { return middle(function () { if (onError) { try { var res = sub.cb && sub.cb(data); if (res && typeof res.catch === 'function') { res.catch(function (err) { return onError(err, sub.name); }); } } catch (e) { onError(e, sub.name); } } else { sub.cb && sub.cb(data); } if (index === r.length - 1) { // the end of emission reached; onLast(r.length); } }); }); if (!r.length) { onLast(0); } }); return this; }; Object.defineProperty(SubEvent.prototype, "count", { /** * Current number of live subscriptions. */ get: function () { return this._subs.length; }, enumerable: false, configurable: true }); Object.defineProperty(SubEvent.prototype, "maxSubs", { /** * Maximum number of subscribers that can receive events. * Default is 0, meaning `no limit applies`. * * Newer subscriptions outside the maximum quota will start * receiving events when the older subscriptions get cancelled. * * It can only be set with the constructor. */ get: function () { var _a; return (_a = this.options.maxSubs) !== null && _a !== void 0 ? _a : 0; }, enumerable: false, configurable: true }); /** * Retrieves subscriptions statistics, to help with diagnosing subscription leaks. * * For this method to be useful, you need to set option `name` when calling {@link SubEvent.subscribe}. * * See also: {@link https://github.com/vitaly-t/sub-events/wiki/Diagnostics Diagnostics} * * @param options * Statistics Options: * * - `minUse: number` - Minimum subscription usage/count to be included into the list of named * subscriptions. If subscription is used fewer times, it will be excluded from the `named` list. * * @see {@link ISubStat} */ SubEvent.prototype.getStat = function (options) { var _a; var stat = { named: {}, unnamed: 0 }; this._subs.forEach(function (s) { if (s.name) { if (s.name in stat.named) { stat.named[s.name]++; } else { stat.named[s.name] = 1; } } else { stat.unnamed++; } }); var minUse = (_a = (options && options.minUse)) !== null && _a !== void 0 ? _a : 0; if (minUse > 1) { for (var a in stat.named) { if (stat.named[a] < minUse) { delete stat.named[a]; } } } return stat; }; /** * Cancels all existing subscriptions for the event. * * This is a convenience method for some special cases, when you want to cancel all subscriptions * for the event at once. Usually, subscribers just call {@link Subscription cancel} when they want to cancel their * own subscription. * * This method will always offer much better performance than cancelling each subscription individually, * which may become increasingly important when working with a large number of subscribers. * * @returns * Number of subscriptions cancelled. * * @see {@link Subscription.cancel} */ SubEvent.prototype.cancelAll = function () { var onCancel = typeof this.options.onCancel === 'function' && this.options.onCancel; var copy = onCancel ? tslib_1.__spreadArray([], this._subs, true) : []; var n = this._subs.length; this._subs.forEach(function (sub) { sub.cancel(); sub.cb = undefined; // prevent further emits }); this._subs.length = 0; if (onCancel) { copy.forEach(function (c) { onCancel({ event: c.event, name: c.name, data: c.data }); }); } return n; }; /** * Creates a new subscription as a promise, to resolve with the next received event value, * and cancel the subscription. * * Examples of where it can be useful include: * - verify that a fast-pace subscription keeps receiving data; * - peek at fast-pace subscription data for throttled updates; * - for simpler receive-once / signal async processing logic. * * ```ts * try { * const nextValue = await myEvent.toPromise({timeout: 1000}); * } catch(e) { * // Either subscription didn't produce any event after 1 second, * // or myEvent.cancelAll() was called somewhere. * } * ``` * * The returned promise can reject in two cases: * - when the timeout has been reached (if set via option `timeout`), it rejects with `Event timed out` error; * - when {@link cancelAll} is called on the event object, it rejects with `Event cancelled` error. * * Note that if you use this method consecutively, you can miss events in between, * because the subscription is auto-cancelled after receiving the first event. * * @param options * Subscription Options: * * - `name` - for the internal subscription name. See `name` in {@link ISubOptions}. * In this context, it is also included within any rejection error. * * - `timeout` - sets timeout in ms (when `timeout` >= 0), to auto-reject with * `Event timed out` error. * * @see {@link once} */ SubEvent.prototype.toPromise = function (options) { var _this = this; if (typeof (options !== null && options !== void 0 ? options : {}) !== 'object') { throw new TypeError(Stat.errInvalidOptions); } var _a = options || {}, name = _a.name, _b = _a.timeout, timeout = _b === void 0 ? -1 : _b; var timer, selfCancel = false; return new Promise(function (resolve, reject) { var onCancel = function () { if (!selfCancel) { if (timer) { clearTimeout(timer); } reject(new Error(name ? "Event \"".concat(name, "\" cancelled.") : "Event cancelled.")); } }; var sub = _this.subscribe(function (data) { if (timer) { clearTimeout(timer); } selfCancel = true; sub.cancel(); resolve(data); }, { name: name, onCancel: onCancel }); if (Number.isInteger(timeout) && timeout >= 0) { timer = setTimeout(function () { selfCancel = true; sub.cancel(); reject(new Error(name ? "Event \"".concat(name, "\" timed out.") : "Event timed out.")); }, timeout); } }); }; /** * Gets all recipients that must receive data. * * It returns a copy of subscribers' array for safe iteration, while applying the * maximum limit when it is set with the {@link IEventOptions.maxSubs} option. * * @hidden */ SubEvent.prototype._getRecipients = function () { var end = this.maxSubs > 0 ? this.maxSubs : this._subs.length; return this._subs.slice(0, end); }; /** * Creates unsubscribe callback function for the {@link Subscription} class. * @hidden * * @param sub * Subscriber details. * * @returns * Function that implements the `unsubscribe` request. */ SubEvent.prototype._createCancel = function (sub) { var _this = this; return function () { _this._cancelSub(sub); }; }; /** * Cancels an existing subscription. * @hidden * * @param sub * Subscriber to be removed, which must be on the list. */ SubEvent.prototype._cancelSub = function (sub) { this._subs.splice(this._subs.indexOf(sub), 1); sub.cancel(); sub.cb = undefined; // prevent further emits if (typeof this.options.onCancel === 'function') { var ctx = { event: sub.event, name: sub.name, data: sub.data }; this.options.onCancel(ctx); } }; return SubEvent; }()); exports.SubEvent = SubEvent; /** * Static isolated methods and properties. * * @hidden */ var Stat = /** @class */ (function () { function Stat() { } Stat.errInvalidOptions = "Invalid \"options\" parameter."; // istanbul ignore next: we are not auto-testing in the browser /** * For compatibility with web browsers. */ Stat.callNext = typeof process === 'undefined' ? setTimeout : process.nextTick; Stat.callNow = function (callback) { return callback(); }; return Stat; }());