@apihive/signals
v1.0.7
Published
<p align="center"> <img src="./logo.png" alt="APIHive Signals logo" width="120" /> </p>
Readme
APIHive Signals (formerly Signals.ts) is a modern, TypeScript-first pub-sub messaging system that enables completely decoupled communication between components. Built for applications that need reliable, type-safe message passing without tight coupling.
Breaking change from v1.0.4 to v1.0.5: Signals now resolve to a single value instead of an array when only one listener is attached.
Why Signals?
Event systems in web applications are nearly always embedded in the visual components, which forces the propagation and handling of events to be tied to the visual hierarchy. This works for many use cases but it fails to provide the flexibility needed when dealing with cross cutting concerns. They also have some overhead because the events need to travel up and down the visual hierarchy to reach their targets.
Furthermore they lack asyncronous bidirectional communication capabilities, which is often needed in modern applications.
Signals act as decoupled intermediaries between components, enabling bidirectional communication without tight coupling.
Inspired by JS-Signals and AS3-Signals, but built from the ground up for modern TypeScript applications.
Feature Highlights
Type-safe messaging
- Generic payload types with full IntelliSense support
- Typed return values from listeners
- Compile-time safety for message contracts
Advanced listener control
- Priority-based execution order
- One-time listeners with
addOnce() - Suspend/resume individual listeners
- Binding management with cleanup
Flexible propagation modes
all— Wait for all listeners (default)any— Stop after first successful responseany-fail— Stop after first failurenone— Expect all listeners to fail
Async-first design
- Promise-based dispatch with
await - Mixed sync/async listener support
- Sequential execution with proper error handling
- Promise-based dispatch with
Memory and lifecycle management
- Signal memoization for late subscribers
- Automatic cleanup and disposal
- Signal suspension for temporary disabling
Installation
Using npm:
npm install @apihive/signalsUsing yarn:
yarn add @apihive/signalsUsing pnpm:
pnpm add @apihive/signalsQuick Start
Basic messaging
import { Signal } from '@apihive/signals';
// Create a signal
const userLoggedIn = new Signal<{ userId: string, username: string }>();
// Add a listener
userLoggedIn.add((user) => {
console.log(`Welcome back, ${user.username}!`);
});
// Dispatch the signal
userLoggedIn.dispatch({ userId: '123', username: 'alice' });Request-response pattern
const showConfirmDialog = new Signal<{
message: string,
title?: string
}, boolean>();
// Listener returns a response
showConfirmDialog.add(async ({ message, title = 'Confirm' }) => {
return window.confirm(`${title}: ${message}`);
});
// Dispatcher awaits the response
const confirmed = await showConfirmDialog.dispatch({
message: 'Delete this item?',
title: 'Are you sure?'
});
if (confirmed) {
// Proceed with deletion
}Application lifecycle with memoization
// Signal that remembers its last dispatch
const appInitialized = new Signal<void>({ memoize: true });
// Bootstrap process
async function bootstrap() {
await loadConfig();
await connectDatabase();
appInitialized.dispatch(); // Signal initialization complete
}
// Components can listen even after initialization
appInitialized.addOnce(() => {
// This will fire immediately if app is already initialized
startPeriodicTasks();
});Real-World Examples
E-commerce Cart System
// Define your signals
const cartSignals = {
itemAdded: new Signal<{ productId: string, quantity: number }>(),
itemRemoved: new Signal<{ productId: string }>(),
cartCleared: new Signal<void>(),
checkoutStarted: new Signal<{ cartTotal: number }, boolean>()
};
// Cart component listens and updates UI
cartSignals.itemAdded.add(({ productId, quantity }) => {
updateCartBadge();
showNotification(`Added ${quantity}x ${productId} to cart`);
});
// Inventory component listens and updates stock
cartSignals.itemAdded.add(({ productId, quantity }) => {
updateInventoryCount(productId, -quantity);
});
// Analytics component tracks events
cartSignals.itemAdded.add(({ productId, quantity }) => {
analytics.track('cart_item_added', { productId, quantity });
});
// Checkout validation
cartSignals.checkoutStarted.add(async ({ cartTotal }) => {
const hasValidPayment = await validatePaymentMethod();
const hasStock = await validateInventory();
return hasValidPayment && hasStock;
});
// Usage
cartSignals.itemAdded.dispatch({ productId: 'laptop-123', quantity: 1 });
const canProceed = await cartSignals.checkoutStarted.dispatch({
cartTotal: 1299.99
});Game Event System
interface GameEvents {
playerMoved: Signal<{ x: number, y: number, playerId: string }>;
enemyDefeated: Signal<{ enemyType: string, experience: number }>;
levelCompleted: Signal<{ level: number, score: number }>;
gameOver: Signal<{ finalScore: number, reason: string }>;
}
const game: GameEvents = {
playerMoved: new Signal(),
enemyDefeated: new Signal(),
levelCompleted: new Signal(),
gameOver: new Signal({ memoize: true }) // Remember game over state
};
// Multiple systems can react independently
game.enemyDefeated.add(({ experience }) => {
player.addExperience(experience);
});
game.enemyDefeated.add(({ enemyType }) => {
achievements.checkEnemyKillAchievements(enemyType);
});
game.enemyDefeated.add(({ enemyType }) => {
audioSystem.playSound(`${enemyType}_death`);
});
// Priority-based listeners (higher priority = earlier execution)
game.levelCompleted.add(() => {
saveGameState(); // Critical - save first
}, null, 10);
game.levelCompleted.add(() => {
showLevelCompleteAnimation(); // Visual feedback after save
}, null, 5);Form Validation Pipeline
interface ValidationResult {
isValid: boolean;
errors: string[];
}
const formValidation = new Signal<
{ field: string, value: any },
ValidationResult
>({
propagate: 'any-fail', // Stop on first validation failure
listenerSuccessTest: (result: ValidationResult) => result.isValid
});
// Add validators with priority
formValidation.add(({ field, value }) => {
// Required field validation (highest priority)
if (!value || value.trim() === '') {
return { isValid: false, errors: [`${field} is required`] };
}
return { isValid: true, errors: [] };
}, null, 10);
formValidation.add(({ field, value }) => {
// Email format validation
if (field === 'email' && !isValidEmail(value)) {
return { isValid: false, errors: ['Invalid email format'] };
}
return { isValid: true, errors: [] };
}, null, 5);
// Usage
const result = await formValidation.dispatch({
field: 'email',
value: '[email protected]'
});
if (!result.isValid) {
displayErrors(result.errors);
}Advanced Configuration
Propagation Control
// Stop after first successful response
const findHandler = new Signal<string, boolean>({
propagate: 'any',
haltOnResolve: true
});
// Stop after first failure
const validateAll = new Signal<any, boolean>({
propagate: 'any-fail',
haltOnResolve: true,
listenerSuccessTest: (result) => result === true
});
// Wait for all listeners regardless of results
const notifyAll = new Signal<string>({
propagate: 'all',
haltOnResolve: false
});Listener Management
const signal = new Signal<string>();
// Add listener with binding control
const binding = signal.add((message) => {
console.log(message);
}, null, 5); // priority 5
// Temporarily suspend
binding.suspend();
signal.dispatch('This will not be logged');
// Resume
binding.resume();
signal.dispatch('This will be logged');
// Permanently remove
binding.detach();
signal.dispatch('This will not be logged');
// Check if listener exists
const hasListener = signal.has(myFunction, myContext);Signal Lifecycle
const signal = new Signal<string>({ memoize: true });
// Dispatch early
signal.dispatch('Early message');
// Late subscriber still receives the message
signal.addOnce((message) => {
console.log(message); // Logs: "Early message"
});
// Clear memoized value
signal.forget();
// Suspend entire signal
signal.suspend();
signal.dispatch('Will be rejected'); // Promise rejection
signal.resume();
signal.dispatch('Will work'); // Normal dispatch
// Clean up
signal.dispose(); // Removes all listeners and clears memoryTypeScript Integration
Signals provides excellent TypeScript support with full type inference:
// Payload and return types are fully typed
const typedSignal = new Signal<
{ userId: string, action: 'login' | 'logout' },
{ success: boolean, timestamp: number }
>();
// TypeScript knows the parameter types
typedSignal.add((data) => {
// data is typed as { userId: string, action: 'login' | 'logout' }
return {
success: true,
timestamp: Date.now()
}; // Return type is enforced
});
// Dispatch is type-safe
const result = await typedSignal.dispatch({
userId: '123',
action: 'login' // Only 'login' or 'logout' allowed
});
// Result is typed as { success: boolean, timestamp: number }
console.log(result.timestamp);Performance Considerations
- Lightweight: Zero dependencies, minimal runtime overhead
- Efficient: Listeners are sorted by priority only when needed
- Memory-conscious: Automatic cleanup of one-time listeners
- Async-optimized: Non-blocking execution with proper Promise handling
Migration Guide
From v1.0.4 to v1.0.5
The main breaking change is in return value handling:
// v1.0.4 - always returned array
const results = await signal.dispatch(); // Always array
// v1.0.5 - single value when one listener
const result = await signal.dispatch(); // Single value if one listener
const results = await signal.dispatch(); // Array if multiple listenersContributing
Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests.
License
Running Tests
yarn install
yarn test