sub-events
Version:
Lightweight, strongly-typed events, with monitored subscriptions.
441 lines (440 loc) • 16.3 kB
JavaScript
;
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;
}());