feat(ws-client): implement WebSocket client event target adapter

This commit is contained in:
2026-05-08 07:04:04 +00:00
parent 60a51948f1
commit b2b07b179e
5 changed files with 610 additions and 1 deletions

View File

@@ -0,0 +1,100 @@
import type { TypedEventTarget, TypedEvent, EventEnvelope } from "./types.js";
export function createWebSocketClientEventTarget<TEvent extends TypedEvent>(
ws: WebSocket,
): TypedEventTarget<TEvent> {
const callbacksForTopic = new Map<string, Set<EventListener>>();
ws.onmessage = (event: MessageEvent) => {
let envelope: EventEnvelope;
try {
envelope = JSON.parse(event.data as string) as EventEnvelope;
} catch {
console.warn(
`Failed to parse WebSocket message: ${event.data}`,
);
return;
}
if (typeof envelope.type !== "string" || envelope.type.startsWith("__")) {
return;
}
const topic = `${envelope.type}:${envelope.id}`;
const callbacks = callbacksForTopic.get(topic);
if (callbacks === undefined) {
return;
}
const customEvent = new CustomEvent(topic, {
detail: envelope,
}) as TEvent;
for (const callback of callbacks) {
callback(customEvent);
}
};
function addCallback(topic: string, callback: EventListener) {
let callbacks = callbacksForTopic.get(topic);
const isFirst = callbacks === undefined;
if (isFirst) {
callbacks = new Set();
callbacksForTopic.set(topic, callbacks);
}
callbacks!.add(callback);
if (isFirst) {
ws.send(
JSON.stringify({
type: "__subscribe",
id: "",
payload: { topic },
}),
);
}
}
function removeCallback(topic: string, callback: EventListener) {
const callbacks = callbacksForTopic.get(topic);
if (callbacks === undefined) {
return;
}
const existed = callbacks.delete(callback);
if (!existed) {
return;
}
if (callbacks.size > 0) {
return;
}
callbacksForTopic.delete(topic);
ws.send(
JSON.stringify({
type: "__unsubscribe",
id: "",
payload: { topic },
}),
);
}
return {
addEventListener(topic, callbackOrOptions: EventListenerOrEventListenerObject) {
if (callbackOrOptions != null) {
const callback =
"handleEvent" in callbackOrOptions ? callbackOrOptions.handleEvent : callbackOrOptions;
addCallback(topic, callback);
}
},
dispatchEvent(event: TEvent) {
ws.send(JSON.stringify(event.detail));
return true;
},
removeEventListener(topic, callbackOrOptions: EventListenerOrEventListenerObject) {
if (callbackOrOptions != null) {
const callback =
"handleEvent" in callbackOrOptions ? callbackOrOptions.handleEvent : callbackOrOptions;
removeCallback(topic, callback);
}
},
};
}

View File

@@ -2,4 +2,5 @@ export { createPubSub, type PubSub, type PubSubConfig, type PubSubEvent, type Pu
export { type EventEnvelope, type TypedEvent, type TypedEventTarget, type TypedEventListener, type TypedEventListenerObject, type TypedEventListenerOrEventListenerObject } from "./types.js";
export { filter, map, pipe, take, reduce, toArray, batch, dedupe, window, flat, groupBy, chain, join } from "./operators.js";
export { Repeater, RepeaterOverflowError, type Push, type Stop, type RepeaterExecutor, type RepeaterBuffer } from "./repeater.js";
export { createRedisEventTarget, type CreateRedisEventTargetArgs } from "./event-target-redis.js";
export { createRedisEventTarget, type CreateRedisEventTargetArgs } from "./event-target-redis.js";
export { createWebSocketClientEventTarget } from "./event-target-websocket-client.js";