alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
341 lines (316 loc) • 11.2 kB
text/typescript
import {
createPrimitive,
KIND,
Primitive,
type Service,
type Static,
type TSchema,
} from "alepha";
import type { DurationLike } from "alepha/datetime";
import { MemoryTopicProvider } from "../providers/MemoryTopicProvider.ts";
import {
TopicProvider,
type TopicPublishOptions,
type UnSubscribeFn,
} from "../providers/TopicProvider.ts";
/**
* Creates a topic primitive for publish/subscribe messaging and event-driven architecture.
*
* Enables decoupled communication through a pub/sub pattern where publishers send messages
* and multiple subscribers receive them. Supports type-safe messages, real-time delivery,
* event filtering, and pluggable backends (memory, Redis, custom providers).
*
* **Use Cases**: User notifications, real-time chat, event broadcasting, microservice communication
*
* @example
* ```ts
* class NotificationService {
* userActivity = $topic({
* name: "user-activity",
* schema: {
* payload: z.object({
* userId: z.text(),
* action: z.enum(["login", "logout", "purchase"]),
* timestamp: z.number()
* })
* },
* handler: async (message) => {
* console.log(`User ${message.payload.userId}: ${message.payload.action}`);
* }
* });
*
* async trackLogin(userId: string) {
* await this.userActivity.publish({ userId, action: "login", timestamp: Date.now() });
* }
*
* async subscribeToEvents() {
* await this.userActivity.subscribe(async (message) => {
* // Additional subscriber logic
* });
* }
* }
* ```
*/
export const $topic = <T extends TopicMessageSchema>(
options: TopicPrimitiveOptions<T>,
): TopicPrimitive<T> => {
return createPrimitive(TopicPrimitive<T>, options);
};
// ---------------------------------------------------------------------------------------------------------------------
export interface TopicPrimitiveOptions<T extends TopicMessageSchema> {
/**
* Unique name identifier for the topic.
*
* This name is used for:
* - Topic identification across the pub/sub system
* - Message routing between publishers and subscribers
* - Logging and debugging topic-related operations
* - Provider-specific topic management (channels, keys, etc.)
*
* If not provided, defaults to the property key where the topic is declared.
*
* **Naming Conventions**:
* - Use descriptive, hierarchical names: "user.activity", "order.events"
* - Avoid spaces and special characters
* - Consider using dot notation for categorization
* - Keep names concise but meaningful
*
* @example "user-activity"
* @example "chat.messages"
* @example "system.health.checks"
* @example "payment.webhooks"
*/
name?: string;
/**
* Human-readable description of the topic's purpose and usage.
*
* Used for:
* - Documentation generation and API references
* - Developer onboarding and understanding
* - Monitoring dashboards and admin interfaces
* - Team communication about system architecture
*
* **Description Best Practices**:
* - Explain what events/messages this topic handles
* - Mention key use cases and subscribers
* - Include any important timing or ordering guarantees
* - Note any special processing requirements
*
* @example "Real-time user activity events for analytics and notifications"
* @example "Order lifecycle events from creation to delivery"
* @example "Chat messages broadcast to all room participants"
* @example "System health checks and service status updates"
*/
description?: string;
/**
* Topic provider configuration for message storage and delivery.
*
* Options:
* - **"memory"**: In-memory provider (default for development, lost on restart)
* - **Service<TopicProvider>**: Custom provider class (e.g., RedisTopicProvider)
* - **undefined**: Uses the default topic provider from dependency injection
*
* **Provider Selection Guidelines**:
* - **Development**: Use "memory" for fast, simple testing without external dependencies
* - **Production**: Use Redis or message brokers for persistence and scalability
* - **Distributed systems**: Use Redis/RabbitMQ for cross-service communication
* - **High-throughput**: Use specialized providers with connection pooling
* - **Real-time**: Ensure provider supports low-latency message delivery
*
* **Provider Capabilities**:
* - Message persistence and durability
* - Subscriber management and connection handling
* - Message ordering and delivery guarantees
* - Horizontal scaling and load distribution
*
* @default Uses injected TopicProvider
* @example "memory"
* @example RedisTopicProvider
* @example RabbitMQTopicProvider
*/
provider?: "memory" | Service<TopicProvider>;
/**
* TypeBox schema defining the structure of messages published to this topic.
*
* The schema must include:
* - **payload**: Required schema for the main message data
* - **headers**: Optional schema for message metadata
*
* This schema:
* - Validates all messages published to the topic
* - Provides full TypeScript type inference for subscribers
* - Ensures type safety between publishers and subscribers
* - Enables automatic serialization/deserialization
*
* **Schema Design Best Practices**:
* - Keep payload schemas focused and cohesive
* - Use optional fields for data that might not always be present
* - Include timestamp fields for event ordering
* - Consider versioning for schema evolution
* - Use union types for different event types in the same topic
*
* @example
* ```ts
* {
* payload: z.object({
* eventId: z.text(),
* eventType: z.enum(["created", "updated"]),
* data: z.record(z.text(), z.any()),
* timestamp: z.number(),
* userId: z.text().optional()
* }),
* headers: z.object({
* source: z.text(),
* correlationId: z.text()
* }).optional()
* }
* ```
*/
schema: T;
/**
* Default subscriber handler function that processes messages published to this topic.
*
* This handler:
* - Automatically subscribes when the topic is initialized
* - Receives all messages published to the topic
* - Runs for every message without additional subscription setup
* - Can be supplemented with additional subscribers via `subscribe()` method
* - Should handle errors gracefully to avoid breaking other subscribers
*
* **Handler Design Guidelines**:
* - Keep handlers focused on a single responsibility
* - Use proper error handling and logging
* - Consider performance impact for high-frequency topics
* - Make handlers idempotent when possible
* - Validate business rules within the handler logic
* - Log important processing steps for debugging
*
* **Error Handling Strategy**:
* - Log errors but don't re-throw to avoid affecting other subscribers
* - Use try-catch blocks for external service calls
* - Consider implementing circuit breakers for resilience
* - Monitor error rates and patterns for system health
*
* @param message - The topic message with validated payload and headers
* @param message.payload - The typed message data based on the schema
* @returns Promise that resolves when processing is complete
*
* @example
* ```ts
* handler: async (message) => {
* const { eventType, data, timestamp } = message.payload;
*
* try {
* // Log message receipt
* this.logger.info(`Processing ${eventType} event`, { timestamp, data });
*
* // Process based on event type
* switch (eventType) {
* case "created":
* await this.handleCreation(data);
* break;
* case "updated":
* await this.handleUpdate(data);
* break;
* default:
* this.logger.warn(`Unknown event type: ${eventType}`);
* }
*
* this.logger.info(`Successfully processed ${eventType} event`);
*
* } catch (error) {
* // Log error but don't re-throw to avoid affecting other subscribers
* this.logger.error(`Failed to process ${eventType} event`, {
* error: error.message,
* eventType,
* timestamp,
* data
* });
* }
* }
* ```
*/
handler?: TopicHandler<T>;
/**
* Whether the last published message should be retained and delivered to new subscribers.
*
* When enabled, the provider stores the last message for this topic.
* New subscribers immediately receive the retained message upon subscribing.
*
* Supported by Memory, Redis, and MQTT providers.
*
* @default false
*/
retain?: boolean;
}
// ---------------------------------------------------------------------------------------------------------------------
export class TopicPrimitive<T extends TopicMessageSchema> extends Primitive<
TopicPrimitiveOptions<T>
> {
public readonly provider = this.$provider();
public get name(): string {
return this.options.name || this.config.propertyKey;
}
public async publish(
message: T extends { params: TSchema }
? { params: Static<T["params"]>; payload: TopicMessage<T>["payload"] }
: TopicMessage<T>["payload"],
): Promise<void> {
const hasParams = this.options.schema.params;
const payload = hasParams ? (message as any).payload : message;
const params = hasParams ? (message as any).params : undefined;
await this.provider.publishMessage<T>(
this.name,
this.options.schema.payload,
payload,
{
retain: this.options.retain,
mqtt: (this.options as any).mqtt,
params,
} as TopicPublishOptions,
);
}
public async subscribe(handler: TopicHandler<T>): Promise<UnSubscribeFn> {
return this.provider.subscribeHandler<T>(
this.name,
this.options.schema.payload,
handler,
{ mqtt: (this.options as any).mqtt },
);
}
public async wait(
options: TopicWaitOptions<T> = {},
): Promise<TopicMessage<T>> {
return this.provider.waitForMessage<T>(
this.name,
this.options.schema.payload,
options,
);
}
protected $provider(): TopicProvider {
if (!this.options.provider) {
return this.alepha.inject(TopicProvider);
}
if (this.options.provider === "memory") {
return this.alepha.inject(MemoryTopicProvider);
}
return this.alepha.inject(this.options.provider);
}
}
$topic[KIND] = TopicPrimitive;
// ---------------------------------------------------------------------------------------------------------------------
export interface TopicMessage<T extends TopicMessageSchema> {
payload: Static<T["payload"]>;
params: T extends { params: TSchema } ? Static<T["params"]> : never;
}
export interface TopicWaitOptions<T extends TopicMessageSchema> {
timeout?: DurationLike;
filter?: (message: { payload: Static<T["payload"]> }) => boolean;
}
export interface TopicMessageSchema {
payload: TSchema;
params?: TSchema;
}
export type TopicHandler<T extends TopicMessageSchema = TopicMessageSchema> = (
message: TopicMessage<T>,
) => unknown;