diff options
Diffstat (limited to 'shim/ws-shim/src/client.ts')
-rw-r--r-- | shim/ws-shim/src/client.ts | 209 |
1 files changed, 209 insertions, 0 deletions
diff --git a/shim/ws-shim/src/client.ts b/shim/ws-shim/src/client.ts new file mode 100644 index 0000000..b7df3e9 --- /dev/null +++ b/shim/ws-shim/src/client.ts @@ -0,0 +1,209 @@ +import type { + NostrEvent, + ClientMessage, + RelayMessage, + Filter, + Subscription, +} from "./types"; + +export class Relay { + private url: string; + private ws: WebSocket | null = null; + private subscriptions: Map<string, Subscription> = new Map(); + private messageQueue: ClientMessage[] = []; + private reconnectTimer: Timer | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; + + public status: "connecting" | "connected" | "disconnected" | "error" = + "disconnected"; + + public onconnect?: () => void; + public ondisconnect?: () => void; + public onerror?: (error: Error) => void; + public onnotice?: (message: string) => void; + + constructor(url: string) { + this.url = url; + } + + async connect(): Promise<void> { + return new Promise((resolve, reject) => { + if (this.ws?.readyState === WebSocket.OPEN) { + resolve(); + return; + } + + this.status = "connecting"; + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + this.status = "connected"; + this.reconnectAttempts = 0; + this.flushMessageQueue(); + this.onconnect?.(); + resolve(); + }; + + this.ws.onclose = () => { + this.status = "disconnected"; + this.ondisconnect?.(); + this.attemptReconnect(); + }; + + this.ws.onerror = (event) => { + this.status = "error"; + const error = new Error(`WebSocket error: ${event.type}`); + this.onerror?.(error); + reject(error); + }; + + this.ws.onmessage = (event) => { + this.handleMessage(event.data); + }; + }); + } + + disconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.status = "disconnected"; + this.subscriptions.clear(); + this.messageQueue = []; + } + + private attemptReconnect(): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.status = "error"; + this.onerror?.(new Error("Max reconnection attempts reached")); + return; + } + + this.reconnectAttempts++; + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); + + this.reconnectTimer = setTimeout(() => { + this.connect().catch((error) => { + console.error("Reconnection failed:", error); + }); + }, delay); + } + + private flushMessageQueue(): void { + while (this.messageQueue.length > 0) { + const message = this.messageQueue.shift(); + if (message) { + this.send(message); + } + } + } + + private send(message: ClientMessage): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } else { + this.messageQueue.push(message); + } + } + + private handleMessage(data: string): void { + try { + const message = JSON.parse(data) as RelayMessage; + + switch (message[0]) { + case "EVENT": { + const [, subscriptionId, event] = message; + const subscription = this.subscriptions.get(subscriptionId); + + if (subscription) { + subscription.onevent?.(event); + } + break; + } + + case "OK": { + const [, eventId, success, messag] = message; + if (!success) { + console.error(`Event ${eventId} rejected: ${messag}`); + } + break; + } + + case "EOSE": { + const [, subscriptionId] = message; + const subscription = this.subscriptions.get(subscriptionId); + subscription?.oneose?.(); + break; + } + + case "CLOSED": { + const [, subscriptionId, messag] = message; + this.subscriptions.delete(subscriptionId); + console.log(`Subscription ${subscriptionId} closed: ${messag}`); + break; + } + + case "NOTICE": { + const [, messag] = message; + this.onnotice?.(messag); + break; + } + + case "AUTH": { + console.warn("AUTH not implemented"); + break; + } + } + } catch (error) { + console.error("Failed to handle message:", error); + } + } + + publishEvent(event: NostrEvent): void { + this.send(["EVENT", event]); + } + + subscribe( + id: string, + filters: Filter[], + handlers: { + onevent?: (event: NostrEvent) => void; + oneose?: () => void; + }, + ): () => void { + const subscription: Subscription = { + id, + filters, + ...handlers, + }; + + this.subscriptions.set(id, subscription); + this.send(["REQ", id, ...filters]); + + return () => { + this.unsubscribe(id); + }; + } + + unsubscribe(id: string): void { + this.subscriptions.delete(id); + this.send(["CLOSE", id]); + } + + getStatus(): string { + return this.status; + } + + getUrl(): string { + return this.url; + } +} |