Initial Commit
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user