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> = 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; } }