ngx-signal-hub
Version:
[](https://badge.fury.io/js/ngx-signal-hub) [](https://opensource.org/licenses/MIT) A lightweight, reactive signal hub service f
667 lines (662 loc) • 25.3 kB
JavaScript
import * as i0 from '@angular/core';
import { signal, effect, untracked, computed, Injectable } from '@angular/core';
/**
* A hybrid signal hub service for publishing and observing events using Signals and callbacks.
* Supports pub/sub with key-based routing, pattern matching, and Signal-based event observation.
*
* @example
* ```typescript
* @Component({...})
* export class MyComponent {
* constructor(private signalHub: SignalHubService, private destroyRef: DestroyRef) {
* // Synchronous subscription
* this.signalHub.on({
* key: 'user:*',
* callback: (event) => console.log(event.data),
* destroyRef: this.destroyRef,
* replayLatest: true,
* });
*
* // Asynchronous subscription
* this.signalHub.onAsync({
* key: 'user:login',
* callback: async (event) => {
* await fetch('/log', { method: 'POST', body: JSON.stringify(event.data) });
* console.log(event.data);
* },
* replayLatest: true,
* onError: (err) => console.error('Event error:', err),
* });
*
* // Publish an event
* this.signalHub.publish('user:login', { id: 123 });
*
* // Clear a specific event
* this.signalHub.clearEvent('user:login');
*
* // Reset everything
* this.signalHub.reset({ clearSubscribers: true });
* }
* }
* ```
*/
class SignalHubService {
eventQueue = signal([]);
eventRegistry = signal(new Map());
subscribers = new Map();
constructor(injector) {
effect(() => {
const queuedEvents = this.eventQueue();
if (!queuedEvents.length)
return;
untracked(() => {
const registry = this.eventRegistry();
const updatedRegistry = new Map(registry);
for (const event of queuedEvents) {
updatedRegistry.set(event.key, event);
this.notifySubscribers(event);
}
this.eventRegistry.set(updatedRegistry);
this.eventQueue.set([]);
});
}, { injector });
}
/**
* Publishes an event with the given key and data, triggering subscribers and updating the registry.
*
* @param key - The event key (e.g., 'user:login').
* @param data - The event data (must not be null or undefined).
* @throws Error if key is empty or data is null/undefined.
*
* @example
* ```typescript
* signalHub.publish('user:login', { id: 123 });
* // Triggers subscribers to 'user:login' or 'user:*'
* ```
*/
publish(key, data) {
if (!key)
throw new Error('Key cannot be empty');
if (data == null)
throw new Error('Data cannot be null or undefined');
const event = { key, data, timestamp: Date.now() };
this.eventQueue.update((queue) => [...queue, event]);
}
/**
* Publishes an event with the given key, triggering subscribers and updating the registry.
*
* @param key - The event key (e.g., 'user:login').
* @throws Error if key is empty.
*
* @example
* ```typescript
* signalHub.publish('user:login');
* // Triggers subscribers to 'user:login' or 'user:*'
* ```
*/
publishNoData(key) {
if (!key)
throw new Error('Key cannot be empty');
const event = { key, data: undefined, timestamp: Date.now() };
this.eventQueue.update((queue) => [...queue, event]);
}
/**
* Subscribes to events matching a query with a synchronous callback, optionally auto-cleaning with DestroyRef.
* Supports pattern matching (e.g., 'user:*') and optional replay of the latest event.
*
* @param options - Subscription options including key, callback, and optional settings.
* @returns A HubSubscription object with an unsubscribe method.
* @throws Error if key is empty or callback is not a function.
*
* @example
* ```typescript
* signalHub.subscribe({
* key: 'user:*',
* callback: (event) => console.log(event.data),
* replayLatest: true,
* destroyRef: inject(DestroyRef),
* onError: (err) => console.error('Error:', err),
* });
* ```
*/
subscribe({ key, callback, destroyRef, replayLatest, onError, }) {
if (!key)
throw new Error('Key cannot be empty');
if (typeof callback !== 'function')
throw new Error('Callback must be a function');
if (replayLatest) {
const latestEvent = this.eventRegistry().get(key);
if (latestEvent) {
try {
callback(latestEvent);
}
catch (error) {
if (onError) {
onError(error, latestEvent);
}
else {
console.warn(`Replay error for query "${key}":`, error);
}
}
}
}
return this.internalSubscribe(key, callback, destroyRef, onError);
}
/**
* Subscribes to events matching a query with an asynchronous callback, optionally auto-cleaning with DestroyRef.
* Supports pattern matching (e.g., 'user:*') and optional replay of the latest event.
*
* @param options - Subscription options including key, callback, and optional settings.
* @returns A Promise resolving to a HubSubscription object with an unsubscribe method.
* @throws Error if key is empty or callback is not a function.
*
* @example
* ```typescript
* await signalHub.subscribeAsync({
* key: 'user:*',
* callback: async (event) => console.log(event.data),
* replayLatest: true,
* destroyRef: inject(DestroyRef),
* onError: (err) => console.error('Error:', err),
* });
* ```
*/
async subscribeAsync({ key, callback, destroyRef, replayLatest, onError, }) {
if (!key)
throw new Error('Key cannot be empty');
if (typeof callback !== 'function')
throw new Error('Callback must be a function');
if (replayLatest) {
const latestEvent = this.eventRegistry().get(key);
if (latestEvent) {
try {
await callback(latestEvent);
}
catch (error) {
if (onError) {
onError(error, latestEvent);
}
else {
console.warn(`Replay error for query "${key}":`, error);
}
}
}
}
return this.internalSubscribe(key, callback, destroyRef, onError);
}
/**
* A shorthand for subscribing to events with a synchronous callback, with optional replay and auto-cleanup.
*
* @param options - Options including key, callback, and optional replayLatest, destroyRef, and onError.
* @returns A HubSubscription object with an unsubscribe method.
*
* @example
* ```typescript
* signalHub.on({
* key: 'user:login',
* callback: (event) => console.log(event.data),
* replayLatest: true,
* destroyRef: inject(DestroyRef),
* onError: (err) => console.error('Error:', err),
* });
* ```
*/
on({ key, callback, replayLatest, destroyRef, onError }) {
return this.subscribe({ key, callback, replayLatest, destroyRef, onError });
}
/**
* A shorthand for subscribing to events with an asynchronous callback, with optional replay and auto-cleanup.
*
* @param options - Options including key, callback, and optional replayLatest, destroyRef, and onError.
* @returns A Promise resolving to a HubSubscription object with an unsubscribe method.
*
* @example
* ```typescript
* await signalHub.onAsync({
* key: 'user:login',
* callback: async (event) => console.log(event.data),
* replayLatest: true,
* destroyRef: inject(DestroyRef),
* onError: (err) => console.error('Error:', err),
* });
* ```
*/
async onAsync({ key, callback, replayLatest, destroyRef, onError, }) {
return this.subscribeAsync({ key, callback, replayLatest, destroyRef, onError });
}
/**
* Subscribes to a single event matching the key with a synchronous callback, auto-unsubscribing after the first match.
*
* @param options - Options including key, callback, and optional replayLatest and onError.
* @returns A HubSubscription object with an unsubscribe method.
*
* @example
* ```typescript
* signalHub.once({
* key: 'user:login',
* callback: (event) => console.log('Logged in:', event.data),
* replayLatest: true,
* onError: (err) => console.error('Error:', err),
* });
* ```
*/
once({ key, callback, replayLatest, onError }) {
let subscription = null;
// Guarantees the callback runs exactly once, even in the same `notifySubscribers` cycle.
let hasRun = false;
subscription = this.subscribe({
key,
callback: (event) => {
if (hasRun)
return; // Skip if already run.
hasRun = true;
subscription?.unsubscribe(); // Unsubscribe first
callback(event);
},
replayLatest,
onError,
});
return subscription;
}
/**
* Subscribes to a single event matching the key with an asynchronous callback, auto-unsubscribing after the first match.
*
* @param options - Options including key, callback, and optional replayLatest and onError.
* @returns A Promise resolving to a HubSubscription object with an unsubscribe method.
*
* @example
* ```typescript
* await signalHub.onceAsync({
* key: 'user:login',
* callback: async (event) => console.log('Logged in:', event.data),
* replayLatest: true,
* onError: (err) => console.error('Error:', err),
* });
* ```
*/
async onceAsync({ key, callback, replayLatest, onError, }) {
let subscription = null;
subscription = await this.subscribeAsync({
key,
callback: async (event) => {
await callback(event);
subscription?.unsubscribe();
},
replayLatest,
onError,
});
return subscription;
}
/**
* Subscribes to the latest events from multiple keys, invoking the callback with an array of events
* whenever any key emits a new event, provided all keys have emitted at least once.
*
* @param options - Options including keys, callback, and optional settings.
* @returns A HubSubscription object with an unsubscribe method.
* @throws Error if keys array is empty or contains invalid keys.
*
* @example
* ```typescript
* signalHub.onCombineLatest({
* keys: ['user:login', 'user:logout'],
* callback: (events) => console.log(events.map(e => e.data)),
* replayLatest: true,
* destroyRef: inject(DestroyRef),
* onError: (err, events) => console.error('Error:', err, events),
* });
* ```
*/
onCombineLatest({ keys, callback, destroyRef, replayLatest, onError, }) {
if (!keys.length)
throw new Error('Keys array cannot be empty');
if (keys.some((key) => !key))
throw new Error('All keys must be non-empty');
const safeCallback = callback;
const safeOnError = onError;
// Check if all keys have events for replay
if (replayLatest) {
const registry = this.eventRegistry();
const latestEvents = keys.map((key) => registry.get(key)).filter(Boolean);
if (latestEvents.length === keys.length) {
try {
safeCallback(latestEvents);
}
catch (error) {
if (safeOnError) {
safeOnError(error, latestEvents);
}
else {
console.warn(`Replay error for combineLatest:`, error);
}
}
}
}
// Subscribe to each key
const subscriptions = keys.map((key) => this.on({
key,
callback: () => {
// Check if all keys have events
const registry = this.eventRegistry();
const events = keys.map((k) => registry.get(k)).filter(Boolean);
if (events.length === keys.length) {
try {
safeCallback(events);
}
catch (error) {
if (safeOnError) {
safeOnError(error, events);
}
else {
console.warn(`Callback error for combineLatest:`, error);
}
}
}
},
// Adapt onError for single event
onError: safeOnError
? (error, event) => safeOnError(error, [event]) // Wrap single event in array
: undefined,
}));
// Handle unsubscribe
let manualUnsubscribe = true;
if (destroyRef) {
manualUnsubscribe = false;
destroyRef.onDestroy(() => subscriptions.forEach((sub) => sub.unsubscribe()));
}
return {
unsubscribe: () => {
if (manualUnsubscribe) {
subscriptions.forEach((sub) => sub.unsubscribe());
}
},
};
}
/**
* Subscribes to the latest events from multiple keys with an asynchronous callback,
* invoking the callback with an array of events whenever any key emits a new event,
* provided all keys have emitted at least once.
*
* @param options - Options including keys, callback, and optional settings.
* @returns A Promise resolving to a HubSubscription object with an unsubscribe method.
* @throws Error if keys array is empty or contains invalid keys.
*/
async onCombineLatestAsync({ keys, callback, destroyRef, replayLatest, onError, }) {
if (!keys.length)
throw new Error('Keys array cannot be empty');
if (keys.some((key) => !key))
throw new Error('All keys must be non-empty');
const safeCallback = callback;
const safeOnError = onError;
// Check if all keys have events for replay
if (replayLatest) {
const registry = this.eventRegistry();
const latestEvents = keys.map((key) => registry.get(key)).filter(Boolean);
if (latestEvents.length === keys.length) {
try {
await safeCallback(latestEvents);
}
catch (error) {
if (safeOnError) {
safeOnError(error, latestEvents);
}
else {
console.warn(`Replay error for combineLatestAsync:`, error);
}
}
}
}
// Subscribe to each key
const subscriptions = keys.map((key) => this.on({
key,
callback: async () => {
// Check if all keys have events
const registry = this.eventRegistry();
const events = keys.map((k) => registry.get(k)).filter(Boolean);
if (events.length === keys.length) {
try {
await safeCallback(events);
}
catch (error) {
if (safeOnError) {
safeOnError(error, events);
}
else {
console.warn(`Callback error for combineLatestAsync:`, error);
}
}
}
},
// Adapt onError for single event
onError: safeOnError ? (error, event) => safeOnError(error, [event]) : undefined,
}));
// Handle unsubscribe
let manualUnsubscribe = true;
if (destroyRef) {
manualUnsubscribe = false;
destroyRef.onDestroy(() => subscriptions.forEach((sub) => sub.unsubscribe()));
}
return {
unsubscribe: () => {
if (manualUnsubscribe) {
subscriptions.forEach((sub) => sub.unsubscribe());
}
},
};
}
/**
* Returns a Signal for observing the latest event for a specific key, or null if none exists.
*
* @param key - The event key to observe (e.g., 'user:login').
* @returns A Signal emitting the latest HubEvent or null.
* @throws Error if key is empty.
*
* @example
* ```typescript
* const userLogin = signalHub.toSignal('user:login');
* effect(() => console.log(userLogin()?.data));
* ```
*/
toSignal(key) {
if (!key)
throw new Error('Key cannot be empty');
return computed(() => this.eventRegistry().get(key));
}
/**
* Returns a Signal for observing the latest events for multiple keys or wildcard patterns,
* returning an array of matching events.
*
* @param keys - Array of event keys or wildcard patterns to observe (e.g., `['user:login', 'user:*']`).
* @param options - Optional configuration for sorting the returned events.
* @param options.sortBy - Sorts the events by `'timestamp'` (descending, latest first) or `'key'` (alphabetically).
* @returns A Signal emitting an array of matching HubEvents, deduplicated by key.
* @throws Error if the keys array is empty or contains invalid (empty) keys.
*
* @example
* ```typescript
* // Observe specific and wildcard keys
* const events = signalHub.toSignalMultiple(['user:login', 'user:*'], { sortBy: 'timestamp' });
* effect(() => console.log(events().map(e => `${e.key}: ${e.data}`)));
*
* // Publish events
* signalHub.publish('user:login', { id: 123 });
* signalHub.publish('user:logout', { id: 456 });
*
* // Output might be: ["user:logout: {id: 456}", "user:login: {id: 123}"] (sorted by timestamp)
* ```
*/
toSignalMultiple(keys, options = {}) {
if (!keys.length)
throw new Error('Keys array cannot be empty');
if (keys.some((key) => !key))
throw new Error('All keys must be non-empty');
return computed(() => {
const registry = this.eventRegistry();
const matchingEvents = [];
// Track unique event keys to avoid duplicates
const seenKeys = new Set();
for (const key of keys) {
if (!key.includes('*')) {
// Exact key match
const event = registry.get(key);
if (event && !seenKeys.has(event.key)) {
matchingEvents.push(event);
seenKeys.add(event.key);
}
}
else {
// Wildcard match
for (const [eventKey, event] of registry) {
if (this.matchQuery(eventKey, key) && !seenKeys.has(eventKey)) {
matchingEvents.push(event);
seenKeys.add(eventKey);
}
}
}
}
if (options.sortBy === 'timestamp') {
return matchingEvents.sort((a, b) => b.timestamp - a.timestamp);
}
else if (options.sortBy === 'key') {
return matchingEvents.sort((a, b) => a.key.localeCompare(b.key));
}
return matchingEvents;
});
}
/**
* Clears a specific event from the registry by its key.
*
* @param key - The event key to clear.
* @throws Error if key is empty.
*
* @example
* ```typescript
* signalHub.clearEvent('user:login');
* // Removes 'user:login' from the registry, affecting replayLatest and signals
* ```
*/
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;
});
}
/**
* Resets the event registry, optionally clearing all subscribers.
*
* @param options - Optional settings for the reset operation.
* @param options.clearSubscribers - If true, also clears all subscribers (default: false).
*
* @example
* ```typescript
* signalHub.reset(); // Clears event registry only
* signalHub.reset({ clearSubscribers: true }); // Clears registry and subscribers
* ```
*/
reset(options = {}) {
this.eventRegistry.set(new Map());
if (options.clearSubscribers) {
this.subscribers.clear();
}
}
/**
* Unsubscribes all callbacks for a specific key or all subscribers if no key is provided.
*
* @param key - Optional key to unsubscribe all callbacks for. If omitted, all subscribers are removed.
*
* @example
* ```typescript
* signalHub.unsubscribeAll('user:login'); // Remove all 'user:login' subscribers
* signalHub.unsubscribeAll(); // Remove all subscribers
* ```
*/
unsubscribeAll(key) {
if (key) {
this.subscribers.delete(key);
}
else {
this.subscribers.clear();
}
}
internalSubscribe(query, callback, destroyRef, onError) {
const safeCallback = callback;
const safeOnError = onError;
const callbacks = this.subscribers.get(query) ?? [];
callbacks.push({ callback: safeCallback, onError: safeOnError });
this.subscribers.set(query, callbacks);
let manualUnsubscribe = true;
if (destroyRef) {
manualUnsubscribe = false;
destroyRef.onDestroy(() => this.unsubscribe(query, safeCallback));
}
return {
unsubscribe: () => {
if (manualUnsubscribe) {
this.unsubscribe(query, safeCallback);
}
},
};
}
async notifySubscribers(event) {
const subscriberPromises = [];
this.subscribers.forEach((subscribers, query) => {
if (this.matchQuery(event.key, query)) {
subscribers.forEach(({ callback, onError }) => {
const promise = Promise.resolve()
.then(() => callback(event))
.catch((error) => {
if (onError) {
onError(error, event);
}
else {
console.warn(`Subscriber error for query "${query}":`, error);
}
});
subscriberPromises.push(promise);
});
}
});
await Promise.all(subscriberPromises);
}
unsubscribe(query, callback) {
const callbacks = this.subscribers.get(query);
if (!callbacks)
return;
const updatedCallbacks = callbacks.filter((sub) => sub.callback !== callback);
if (updatedCallbacks.length) {
this.subscribers.set(query, updatedCallbacks);
}
else {
this.subscribers.delete(query);
}
}
matchQuery(key, query) {
if (key === query)
return true;
if (query === '*')
return true;
if (key.includes(':') && query.includes(':')) {
const [keyPart1] = key.split(':');
const [queryPart1, queryPart2] = query.split(':');
if (keyPart1 === queryPart1 && queryPart2 === '*')
return true;
}
return false;
}
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