alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
336 lines (335 loc) • 10.8 kB
JavaScript
import { $hook, $inject, $module, Alepha, AlephaError, KIND, PipelinePrimitive, Primitive, createPrimitive } from "alepha";
import { $logger } from "alepha/logger";
import { DateTimeProvider } from "alepha/datetime";
import { TemplatedPathParser } from "alepha/router";
//#region ../../src/topic/core/primitives/$subscriber.ts
/**
* Creates a subscriber primitive to listen for messages from a specific topic.
*
* Provides a dedicated message subscriber that connects to a topic and processes messages
* with custom handler logic, enabling scalable pub/sub architectures where multiple
* subscribers can react to the same events independently.
*
* **Key Features**
* - Seamless integration with any $topic primitive
* - Full type safety inherited from topic schema
* - Real-time message delivery when events are published
* - Error isolation between subscribers
* - Support for multiple independent subscribers per topic
*
* **Common Use Cases**
* - Notification services and audit logging
* - Analytics and metrics collection
* - Data synchronization and real-time UI updates
*
* @example
* ```ts
* class UserActivityService {
* userEvents = $topic({
* name: "user-activity",
* schema: {
* payload: z.object({
* userId: z.text(),
* action: z.enum(["login", "logout", "purchase"]),
* timestamp: z.number()
* })
* }
* });
*
* activityLogger = $subscriber({
* topic: this.userEvents,
* handler: async (message) => {
* const { userId, action, timestamp } = message.payload;
* await this.auditLogger.log({
* userId,
* action,
* timestamp
* });
* }
* });
*
* async trackUserLogin(userId: string) {
* await this.userEvents.publish({
* userId,
* action: "login",
* timestamp: Date.now()
* });
* }
* }
* ```
*/
const $subscriber = (options) => {
return createPrimitive(SubscriberPrimitive, options);
};
var SubscriberPrimitive = class extends PipelinePrimitive {};
$subscriber[KIND] = SubscriberPrimitive;
//#endregion
//#region ../../src/topic/core/errors/TopicTimeoutError.ts
var TopicTimeoutError = class extends AlephaError {
topic;
timeout;
constructor(topic, timeout) {
super(`Timeout of ${timeout}ms exceeded for topic ${topic}`);
this.timeout = timeout;
this.topic = topic;
}
};
//#endregion
//#region ../../src/topic/core/providers/TopicProvider.ts
/**
* Base class for topic providers.
*/
var TopicProvider = class {
alepha = $inject(Alepha);
log = $logger();
dateTimeProvider = $inject(DateTimeProvider);
/**
* Encode and publish a typed message to a topic.
*/
async publishMessage(name, schema, payload, options) {
let topicName = name;
if (options?.params) topicName = new TemplatedPathParser(name).interpolate(options.params);
await this.publish(topicName, JSON.stringify({ payload: this.alepha.codec.encode(schema, payload) }), options);
}
/**
* Parse a raw message string into a typed topic message.
*/
parseMessage(schema, message) {
const { payload } = JSON.parse(message);
return { payload: this.alepha.codec.decode(schema, payload) };
}
/**
* Subscribe a typed handler to a topic, with error wrapping and message parsing.
*/
async subscribeHandler(name, schema, handler, options) {
const parser = new TemplatedPathParser(name);
const subscribeTopic = parser.hasParams ? parser.wildcardize(this.wildcardChar()) : name;
return this.subscribe(subscribeTopic, async (message, receivedTopic) => {
try {
const parsed = this.parseMessage(schema, message);
if (parser.hasParams && receivedTopic) {
const params = parser.extract(receivedTopic) ?? {};
await handler({
...parsed,
params
});
} else await handler(parsed);
} catch (error) {
this.log.error("Message processing has failed", error);
}
}, options);
}
/**
* Wait for a single message matching an optional filter, with timeout.
*/
async waitForMessage(name, schema, options = {}) {
const filter = options.filter ?? (() => true);
return new Promise((resolve, reject) => {
const ref = {};
(async () => {
const clear = await this.subscribe(name, (raw) => {
const message = this.parseMessage(schema, raw);
if (!filter(message)) return;
ref.timeout?.clear();
if (ref.clear) ref.clear();
resolve(message);
});
ref.clear = clear;
const timeoutDuration = options.timeout ?? [10, "seconds"];
ref.timeout = this.dateTimeProvider.createTimeout(() => {
clear();
reject(new TopicTimeoutError(name, this.dateTimeProvider.duration(timeoutDuration).asMilliseconds()));
}, timeoutDuration);
})();
});
}
/**
* The wildcard character used for pattern subscriptions.
* Override in subclasses for provider-specific wildcards.
*/
wildcardChar() {
return "+";
}
/**
* Returns the list of $subscribers for this provider.
*/
subscribers() {
const handlers = [];
const topics = this.alepha.primitives($topic);
for (const topic of topics) {
if (topic.provider !== this) continue;
const handler = topic.options.handler;
if (handler && topic.provider === this) handlers.push(() => topic.subscribe(handler));
}
const subscribers = this.alepha.primitives($subscriber);
for (const subscriber of subscribers) {
if (subscriber.options.topic.provider !== this) continue;
const handler = subscriber.handler.run.bind(subscriber.handler);
handlers.push(() => subscriber.options.topic.subscribe(handler));
}
return handlers;
}
};
//#endregion
//#region ../../src/topic/core/providers/MemoryTopicProvider.ts
var MemoryTopicProvider = class extends TopicProvider {
log = $logger();
subscriptions = {};
retained = {};
start = $hook({
on: "start",
handler: async () => {
const subscribers = this.subscribers();
if (subscribers.length) {
await Promise.all(subscribers.map((fn) => fn()));
for (const subscriber of subscribers) this.log.debug(`Subscribed to topic '${subscriber.name}'`);
}
}
});
/**
* Publish a message to a topic.
*
* @param topic
* @param message
* @param options
*/
async publish(topic, message, options) {
if (options?.retain) this.retained[topic] = message;
for (const [pattern, callbacks] of Object.entries(this.subscriptions)) if (this.topicMatches(pattern, topic)) for (const callback of callbacks) await callback(message, topic);
}
/**
* Subscribe to a topic.
*
* @param topic - The topic to subscribe to.
* @param callback
*/
async subscribe(topic, callback, _options) {
if (!this.subscriptions[topic]) this.subscriptions[topic] = [];
this.subscriptions[topic].push(callback);
for (const [retainedTopic, retainedMessage] of Object.entries(this.retained)) if (this.topicMatches(topic, retainedTopic)) await callback(retainedMessage, retainedTopic);
return async () => {
const callbacks = this.subscriptions[topic];
if (!callbacks) return;
this.subscriptions[topic] = callbacks.filter((cb) => cb !== callback);
if (this.subscriptions[topic].length === 0) delete this.subscriptions[topic];
};
}
/**
* Unsubscribe from a topic.
*
* @param topic - The topic to unsubscribe from.
*/
async unsubscribe(topic) {
delete this.subscriptions[topic];
}
/**
* Check if a topic matches a subscription pattern.
* Supports `+` single-level wildcard.
*/
topicMatches(pattern, topic) {
if (pattern === topic) return true;
const patternParts = pattern.split("/");
const topicParts = topic.split("/");
if (patternParts.length !== topicParts.length) return false;
return patternParts.every((part, i) => part === "+" || part === topicParts[i]);
}
};
//#endregion
//#region ../../src/topic/core/primitives/$topic.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
* });
* }
* }
* ```
*/
const $topic = (options) => {
return createPrimitive(TopicPrimitive, options);
};
var TopicPrimitive = class extends Primitive {
provider = this.$provider();
get name() {
return this.options.name || this.config.propertyKey;
}
async publish(message) {
const hasParams = this.options.schema.params;
const payload = hasParams ? message.payload : message;
const params = hasParams ? message.params : void 0;
await this.provider.publishMessage(this.name, this.options.schema.payload, payload, {
retain: this.options.retain,
mqtt: this.options.mqtt,
params
});
}
async subscribe(handler) {
return this.provider.subscribeHandler(this.name, this.options.schema.payload, handler, { mqtt: this.options.mqtt });
}
async wait(options = {}) {
return this.provider.waitForMessage(this.name, this.options.schema.payload, options);
}
$provider() {
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;
//#endregion
//#region ../../src/topic/core/index.ts
/**
* Publish/subscribe messaging for event-driven architectures.
*
* **Features:**
* - Pub/sub topics with type-safe messages
* - Topic subscription handlers
* - Multiple subscriber support
* - Message filtering and routing
* - Providers: Memory (dev), Redis (production)
*
* @module alepha.topic
*/
const AlephaTopic = $module({
name: "alepha.topic",
primitives: [$topic, $subscriber],
services: [TopicProvider],
variants: [MemoryTopicProvider],
register: (alepha) => alepha.with({
optional: true,
provide: TopicProvider,
use: MemoryTopicProvider
})
});
//#endregion
export { $subscriber, $topic, AlephaTopic, MemoryTopicProvider, SubscriberPrimitive, TopicPrimitive, TopicProvider, TopicTimeoutError };
//# sourceMappingURL=index.js.map