UNPKG

ngx-signal-hub

Version:

[![npm version](https://badge.fury.io/js/ngx-signal-hub.svg)](https://badge.fury.io/js/ngx-signal-hub) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) A lightweight, reactive signal hub service f

667 lines (662 loc) 25.3 kB
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