Files
suaveplan/packages/networking/network/websocket-gateway/src/channel/channel.ts
2026-05-27 23:47:53 +01:00

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