ngx-signal-hub
Version:
[](https://badge.fury.io/js/ngx-signal-hub) [](https://opensource.org/licenses/MIT)
276 lines (271 loc) • 10.5 kB
JavaScript
import * as i0 from '@angular/core';
import { signal, effect, untracked, computed, Injectable } from '@angular/core';
class SignalHubService {
injector;
eventRegistry = signal(new Map(), {
equal: (a, b) => a === b,
});
subscribers = new Map();
constructor(injector) {
this.injector = injector;
}
publish(key, data) {
if (!key)
throw new Error('Key cannot be empty');
const event = { key, data: data, timestamp: Date.now() };
this.eventRegistry.update((registry) => new Map(registry).set(key, event));
this.notifySubscribers(event);
}
on(options) {
const { key, callback, destroyRef, replayLatest, onError, until } = options;
if (!key)
throw new Error('Key cannot be empty');
if (typeof callback !== 'function')
throw new Error('Callback must be a function');
const unsubscribers = [];
let mainSubscription = null;
// Handle unsubscription conditions (from takeUntil)
const unsubscribeAll = () => {
unsubscribers.forEach((unsub) => unsub());
mainSubscription?.unsubscribe();
};
if (until) {
const conditions = Array.isArray(until) ? until : [until];
// Check signals immediately
const signals = conditions.filter((c) => typeof c !== 'string');
if (signals.some((signal) => signal())) {
return { unsubscribe: () => { } };
}
for (const condition of conditions) {
if (typeof condition === 'string') {
const sub = this.internalSubscribe(condition, unsubscribeAll, undefined, undefined, true);
unsubscribers.push(sub.unsubscribe);
}
else {
const signal = condition;
const effectRef = effect(() => {
if (signal())
unsubscribeAll();
}, { injector: this.injector });
unsubscribers.push(() => effectRef.destroy());
}
}
}
// Replay latest event if requested
if (replayLatest) {
untracked(() => {
const latestEvent = this.findLatestEventForKey(key);
if (latestEvent) {
try {
callback(latestEvent);
}
catch (error) {
this.handleError(error, latestEvent, onError, key);
}
}
});
}
mainSubscription = this.internalSubscribe(key, callback, destroyRef, onError);
return { unsubscribe: unsubscribeAll };
}
subscribe = this.on;
once({ key, callback, replayLatest, onError, until }) {
let subscription = null;
const onceCallback = (event) => {
subscription?.unsubscribe();
try {
callback(event);
}
catch (error) {
this.handleError(error, event, onError, key);
}
};
subscription = this.on({ key, callback: onceCallback, replayLatest, onError, until });
return subscription;
}
onCombineLatest(options) {
const { keys, callback, destroyRef, replayLatest, onError, sortBy } = options;
if (!keys?.length)
throw new Error('Keys array cannot be empty');
const sourceSignals = keys.map((key) => this.toSignal(key));
const combinedSignal = computed(() => {
const events = sourceSignals.map((s) => s()).filter((e) => e !== null);
if (events.length === keys.length) {
return sortBy === 'timestamp'
? events.sort((a, b) => b.timestamp - a.timestamp)
: sortBy === 'key'
? events.sort((a, b) => a.key.localeCompare(b.key))
: events;
}
return null;
});
const effectRef = effect(() => {
const combinedEvents = combinedSignal();
if (!combinedEvents)
return;
if (!replayLatest && !this.eventRegistry().size)
return;
try {
callback(combinedEvents);
}
catch (error) {
onError
? onError(error, combinedEvents)
: console.warn(`onCombineLatest error for keys [${keys.join(', ')}]:`, error);
}
}, { injector: this.injector });
const unsubscribe = () => effectRef.destroy();
if (destroyRef) {
destroyRef.onDestroy(unsubscribe);
}
return { unsubscribe };
}
toSignal(key) {
if (!key)
throw new Error('Key cannot be empty');
if (key.includes('*'))
throw new Error('toSignal does not support wildcards. Use toSignalMultiple instead.');
return computed(() => this.eventRegistry().get(key) ?? null);
}
toSignalMultiple(keys, options = {}) {
if (!keys?.length)
throw new Error('Keys array cannot be empty');
return computed(() => {
const registry = this.eventRegistry();
const matchingEvents = new Map();
for (const query of keys) {
if (!query)
continue;
const isWildcard = query.includes('*');
if (!isWildcard) {
const event = registry.get(query);
if (event) {
matchingEvents.set(event.key, event);
}
}
else {
const regex = this.buildWildcardRegex(query);
for (const [eventKey, event] of registry.entries()) {
if (regex.test(eventKey)) {
matchingEvents.set(eventKey, event);
}
}
}
}
const eventsArray = Array.from(matchingEvents.values());
if (options.sortBy === 'timestamp') {
return eventsArray.sort((a, b) => b.timestamp - a.timestamp);
}
if (options.sortBy === 'key') {
return eventsArray.sort((a, b) => a.key.localeCompare(b.key));
}
return eventsArray;
});
}
clearEvent(key) {
if (!key)
throw new Error('Key cannot be empty');
this.eventRegistry.update((registry) => {
const updated = new Map(registry);
updated.delete(key);
return updated;
});
}
reset(options = {}) {
this.eventRegistry.set(new Map());
if (options.clearSubscribers) {
this.subscribers.clear();
}
}
internalSubscribe(query, callback, destroyRef, onError, once = false) {
const subscriber = {
callback: callback,
onError: onError,
};
const subscriberSet = this.subscribers.get(query) ?? new Set();
subscriberSet.add(subscriber);
this.subscribers.set(query, subscriberSet);
const unsubscribe = () => {
const currentSubscribers = this.subscribers.get(query);
currentSubscribers?.delete(subscriber);
if (currentSubscribers?.size === 0) {
this.subscribers.delete(query);
}
};
if (once) {
const originalCallback = subscriber.callback;
subscriber.callback = (event) => {
unsubscribe();
return originalCallback(event);
};
}
if (destroyRef) {
destroyRef.onDestroy(unsubscribe);
}
return { unsubscribe };
}
notifySubscribers(event) {
this.subscribers.forEach((subscribers, query) => {
if (this.matchQuery(event.key, query)) {
subscribers.forEach(({ callback, onError }) => {
try {
Promise.resolve(callback(event)).catch((error) => {
this.handleError(error, event, onError, query);
});
}
catch (error) {
this.handleError(error, event, onError, query);
}
});
}
});
}
findLatestEventForKey(key) {
if (!key.includes('*')) {
return this.eventRegistry().get(key);
}
const regex = this.buildWildcardRegex(key);
let latestEvent;
for (const [eventKey, event] of this.eventRegistry().entries()) {
if (regex.test(eventKey)) {
if (!latestEvent || event.timestamp > latestEvent.timestamp) {
latestEvent = event;
}
}
}
return latestEvent;
}
handleError(error, event, handler, query) {
if (handler) {
handler(error, event);
}
else {
console.warn(`SignalHub error for query "${query}" on event "${event.key}":`, error);
}
}
matchQuery(key, query) {
if (query === '*' || key === query)
return true;
if (!query.includes('*'))
return false;
return this.buildWildcardRegex(query).test(key);
}
buildWildcardRegex(query) {
const pattern = query.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^:]+');
return new RegExp(`^${pattern}$`);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.6", ngImport: i0, type: SignalHubService, deps: [{ token: i0.Injector }], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.6", ngImport: i0, type: SignalHubService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.6", ngImport: i0, type: SignalHubService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [{ type: i0.Injector }] });
/*
* Public API Surface of ngx-signal-hub
*/
/**
* Generated bundle index. Do not edit.
*/
export { SignalHubService };
//# sourceMappingURL=ngx-signal-hub.mjs.map