summaryrefslogtreecommitdiff
path: root/shim/ws-shim/src/client.ts
diff options
context:
space:
mode:
Diffstat (limited to 'shim/ws-shim/src/client.ts')
-rw-r--r--shim/ws-shim/src/client.ts209
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;
+ }
+}