alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
227 lines (197 loc) • 6.13 kB
text/typescript
import { $inject, Alepha } from "alepha";
import { DateTimeProvider, type Timeout } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { TemplatedPathParser } from "alepha/router";
import { TopicTimeoutError } from "../errors/TopicTimeoutError.ts";
import { $subscriber } from "../primitives/$subscriber.ts";
import {
$topic,
type TopicHandler,
type TopicMessage,
type TopicMessageSchema,
type TopicWaitOptions,
} from "../primitives/$topic.ts";
/**
* Base class for topic providers.
*/
export abstract class TopicProvider {
protected readonly alepha = $inject(Alepha);
protected readonly log = $logger();
protected readonly dateTimeProvider = $inject(DateTimeProvider);
/**
* Publish a raw message to a topic.
*
* @param topic - The topic to publish to.
* @param message - The message to publish.
*/
public abstract publish(
topic: string,
message: string,
options?: TopicPublishOptions,
): Promise<void>;
/**
* Subscribe to a topic with a raw callback.
*
* @param topic - The topic to subscribe to.
* @param callback - The callback to call when a message is received.
*/
public abstract subscribe(
topic: string,
callback: SubscribeCallback,
options?: TopicSubscribeOptions,
): Promise<UnSubscribeFn>;
/**
* Unsubscribe from a topic.
*
* @param topic - The topic to unsubscribe from.
*/
public abstract unsubscribe(topic: string): Promise<void>;
/**
* Encode and publish a typed message to a topic.
*/
public async publishMessage<T extends TopicMessageSchema>(
name: string,
schema: T["payload"],
payload: TopicMessage<T>["payload"],
options?: TopicPublishOptions,
): Promise<void> {
let topicName = name;
if (options?.params) {
const parser = new TemplatedPathParser(name);
topicName = parser.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.
*/
public parseMessage<T extends TopicMessageSchema>(
schema: T["payload"],
message: string,
): TopicMessage<T> {
const { payload } = JSON.parse(message);
return {
payload: this.alepha.codec.decode(
schema,
payload,
) as TopicMessage<T>["payload"],
} as TopicMessage<T>;
}
/**
* Subscribe a typed handler to a topic, with error wrapping and message parsing.
*/
public async subscribeHandler<T extends TopicMessageSchema>(
name: string,
schema: T["payload"],
handler: TopicHandler<T>,
options?: TopicSubscribeOptions,
): Promise<UnSubscribeFn> {
const parser = new TemplatedPathParser(name);
const subscribeTopic = parser.hasParams
? parser.wildcardize(this.wildcardChar())
: name;
return this.subscribe(
subscribeTopic,
async (message, receivedTopic?: string) => {
try {
const parsed = this.parseMessage<T>(schema, message);
if (parser.hasParams && receivedTopic) {
const params = parser.extract(receivedTopic) ?? {};
await handler({ ...parsed, params } as TopicMessage<T>);
} 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.
*/
public async waitForMessage<T extends TopicMessageSchema>(
name: string,
schema: T["payload"],
options: TopicWaitOptions<T> = {},
): Promise<TopicMessage<T>> {
const filter = options.filter ?? (() => true);
return new Promise((resolve, reject) => {
const ref: { timeout?: Timeout; clear?: () => void } = {};
(async () => {
const clear = await this.subscribe(name, (raw) => {
const message = this.parseMessage<T>(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.
*/
protected wildcardChar(): string {
return "+";
}
/**
* Returns the list of $subscribers for this provider.
*/
protected subscribers(): Array<() => Promise<unknown>> {
const handlers: Array<() => Promise<unknown>> = [];
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;
}
}
export type SubscribeCallback = (
message: string,
topic?: string,
) => Promise<void> | void;
export type UnSubscribeFn = () => Promise<void>;
export interface TopicPublishOptions {
retain?: boolean;
params?: Record<string, string>;
}
// biome-ignore lint/suspicious/noEmptyInterface: augmented by provider-specific modules (e.g. MqttTopicProvider)
export interface TopicSubscribeOptions {}