156 lines
4.9 KiB
TypeScript
156 lines
4.9 KiB
TypeScript
import { WS_GATEWAY_CODES, WsGatewayError } from "../errors/errors.js";
|
|
import type { ChannelConfig, Message } from "../types/types.js";
|
|
|
|
/**
|
|
* Manages pub/sub channels with topic-based pattern matching.
|
|
*
|
|
* Channels support both exact topic subscriptions and wildcard pattern
|
|
* matching using `*` (single segment) and `**` (multi-segment) wildcards
|
|
* separated by `.` delimiters. Subscriptions are per-client; the daemon
|
|
* dispatches the publish recipients itself.
|
|
*/
|
|
export class Channel {
|
|
readonly #channelName: string;
|
|
readonly #allowPatterns: boolean;
|
|
/** Map of clientId -> Set of topic patterns. */
|
|
readonly #subscriptions: Map<string, Set<string>> = new Map();
|
|
|
|
public constructor(config: ChannelConfig) {
|
|
if (!config.name || config.name.length === 0) {
|
|
throw new WsGatewayError(
|
|
WS_GATEWAY_CODES.CHANNEL_INVALID,
|
|
"ws_gateway.channel.name-empty"
|
|
);
|
|
}
|
|
this.#channelName = config.name;
|
|
this.#allowPatterns = config.allowPatterns;
|
|
}
|
|
|
|
public get name(): string {
|
|
return this.#channelName;
|
|
}
|
|
|
|
public get allowPatterns(): boolean {
|
|
return this.#allowPatterns;
|
|
}
|
|
|
|
public get subscriberCount(): number {
|
|
return this.#subscriptions.size;
|
|
}
|
|
|
|
/**
|
|
* Subscribe a client to a topic within this channel. Throws
|
|
* `WS_GATEWAY.CHANNEL_INVALID` for malformed topics (empty, or
|
|
* wildcard when `allowPatterns: false`).
|
|
*/
|
|
public subscribe(clientId: string, topic: string): boolean {
|
|
if (!this.#isValidTopic(topic)) {
|
|
throw new WsGatewayError(
|
|
WS_GATEWAY_CODES.CHANNEL_INVALID,
|
|
"ws_gateway.channel.topic-invalid",
|
|
{ context: { channel: this.#channelName, topic } }
|
|
);
|
|
}
|
|
let topics = this.#subscriptions.get(clientId);
|
|
if (!topics) {
|
|
topics = new Set();
|
|
this.#subscriptions.set(clientId, topics);
|
|
}
|
|
if (topics.has(topic)) return false;
|
|
topics.add(topic);
|
|
return true;
|
|
}
|
|
|
|
public unsubscribe(clientId: string, topic: string): boolean {
|
|
const topics = this.#subscriptions.get(clientId);
|
|
if (!topics) return false;
|
|
const removed = topics.delete(topic);
|
|
if (topics.size === 0) this.#subscriptions.delete(clientId);
|
|
return removed;
|
|
}
|
|
|
|
public unsubscribeAll(clientId: string): boolean {
|
|
return this.#subscriptions.delete(clientId);
|
|
}
|
|
|
|
/**
|
|
* Find every client whose topic patterns match `topic`. Optionally
|
|
* exclude one sender client.
|
|
*/
|
|
public publish(
|
|
topic: string,
|
|
_message: Message,
|
|
excludeSender?: string
|
|
): string[] {
|
|
const recipients: string[] = [];
|
|
for (const [clientId, topics] of this.#subscriptions) {
|
|
if (excludeSender && clientId === excludeSender) continue;
|
|
for (const pattern of topics) {
|
|
if (this.topicMatches(pattern, topic)) {
|
|
recipients.push(clientId);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return recipients;
|
|
}
|
|
|
|
public getSubscriptions(clientId: string): string[] {
|
|
const topics = this.#subscriptions.get(clientId);
|
|
return topics ? Array.from(topics) : [];
|
|
}
|
|
|
|
public getSubscribers(): string[] {
|
|
return Array.from(this.#subscriptions.keys());
|
|
}
|
|
|
|
public hasSubscriber(clientId: string): boolean {
|
|
return this.#subscriptions.has(clientId);
|
|
}
|
|
|
|
/**
|
|
* Check whether `pattern` matches `topic`. Supports `*`
|
|
* (single segment) and `**` (multi-segment) wildcards.
|
|
*/
|
|
public topicMatches(pattern: string, topic: string): boolean {
|
|
if (pattern === topic) return true;
|
|
if (!this.#allowPatterns) return false;
|
|
const patternParts = pattern.split(".");
|
|
const topicParts = topic.split(".");
|
|
return this.#matchParts(patternParts, 0, topicParts, 0);
|
|
}
|
|
|
|
#matchParts(
|
|
pattern: string[],
|
|
pi: number,
|
|
topic: string[],
|
|
ti: number
|
|
): boolean {
|
|
if (pi === pattern.length && ti === topic.length) return true;
|
|
if (pi === pattern.length) return false;
|
|
const segment = pattern[pi];
|
|
if (segment === "**") {
|
|
for (let i = ti; i <= topic.length; i++) {
|
|
if (this.#matchParts(pattern, pi + 1, topic, i)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
if (ti === topic.length) return false;
|
|
if (segment === "*" || segment === topic[ti]) {
|
|
return this.#matchParts(pattern, pi + 1, topic, ti + 1);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
#isValidTopic(topic: string): boolean {
|
|
if (!topic || topic.length === 0) return false;
|
|
if (
|
|
!this.#allowPatterns &&
|
|
(topic.includes("*") || topic.includes("**"))
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|