UNPKG

nostr-relaypool

Version:

A Nostr RelayPool implementation in TypeScript using only nostr-tools library as a dependency.

444 lines (409 loc) 12.4 kB
// allows sub/unsub and publishing before connection is established. // Much more refactoring is needed // Don't rely on Relay interface, it will change (I'll probably delete a lot of code from here, there's no need for // multiple listeners) import {type Event, verifySignature, validateEvent} from "nostr-tools"; import {type Filter, matchFilters} from "nostr-tools"; import WebSocket from "isomorphic-ws"; import {getHex64, getSubName} from "./fakejson"; type RelayEvent = "connect" | "disconnect" | "error" | "notice" | "auth"; export type Relay = { url: string; status: number; connect: () => Promise<void>; close: () => Promise<void>; sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub; publish: (event: Event) => Pub; auth: (event: Event) => Pub; on: (type: RelayEvent, cb: any) => void; off: (type: RelayEvent, cb: any) => void; }; export type Pub = { on: (type: "ok" | "seen" | "failed", cb: any) => void; off: (type: "ok" | "seen" | "failed", cb: any) => void; }; export type Sub = { sub: (filters: Filter[], opts: SubscriptionOptions) => Sub; unsub: () => void; on: (type: "event" | "eose", cb: any) => void; off: (type: "event" | "eose", cb: any) => void; }; type SubscriptionOptions = { skipVerification?: boolean; id?: string; eventIds?: Set<string>; }; export function relayInit( url: string, alreadyHaveEvent?: (id: string) => (Event & {id: string}) | undefined, autoReconnect?: boolean ): Relay { return new RelayC(url, alreadyHaveEvent, autoReconnect).relayInit(); } class RelayC { url: string; alreadyHaveEvent?: (id: string) => (Event & {id: string}) | undefined; logging: boolean = false; constructor( url: string, alreadyHaveEvent?: (id: string) => (Event & {id: string}) | undefined, autoReconnect?: boolean ) { this.url = url; this.alreadyHaveEvent = alreadyHaveEvent; this.autoReconnect = autoReconnect; } autoReconnect?: boolean; ws: WebSocket | undefined; sendOnConnect: string[] = []; openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}; closedByClient: boolean = false; listeners: { connect: Array<() => void>; disconnect: Array<() => void>; error: Array<() => void>; notice: Array<(msg: string) => void>; auth: Array<(challenge: string) => void>; } = { connect: [], disconnect: [], error: [], notice: [], auth: [], }; subListeners: { [subid: string]: | { event: Array<(event: Event) => void>; eose: Array<() => void>; } | undefined; } = {}; pubListeners: { [eventid: string]: { ok: Array<() => void>; seen: Array<() => void>; failed: Array<(reason: string) => void>; }; } = {}; incomingMessageQueue: string[] = []; handleNextInterval: any; #handleNext() { if (this.incomingMessageQueue.length === 0) { clearInterval(this.handleNextInterval); this.handleNextInterval = null; return; } this.#handleMessage({data: this.incomingMessageQueue.shift()}); } async trySend(params: [string, ...any]) { const msg = JSON.stringify(params); if (this.connected) { this.ws?.send(msg); } else { this.sendOnConnect.push(msg); } } resolveClose: (() => void) | undefined = undefined; async #onclose() { if (this.closedByClient) { this.listeners.disconnect.forEach((cb) => cb()); this.resolveClose && this.resolveClose(); } else { if (this.autoReconnect) { this.#reconnect(); } } } reconnectTimeout: number = 0; #reconnect() { setTimeout(() => { this.reconnectTimeout = Math.max(2000, this.reconnectTimeout * 3); console.log( this.url, "reconnecting after " + this.reconnectTimeout / 1000 + "s" ); this.connect(); }, this.reconnectTimeout); } async #onmessage(e: any) { this.incomingMessageQueue.push(e.data); if (!this.handleNextInterval) { this.handleNextInterval = setInterval(() => this.#handleNext(), 0); } } async #handleMessage(e: any) { let data; let json: string = e.data.toString(); if (!json) { return; } let eventId = getHex64(json, "id"); let event = this.alreadyHaveEvent?.(eventId); if (event) { const listener = this.subListeners[getSubName(json)]; if (!listener) { return; } return listener.event.forEach((cb) => cb(event!)); } try { data = JSON.parse(json); } catch (err) { data = e.data; } if (data.length >= 1) { switch (data[0]) { case "EVENT": if (this.logging) { console.log(data); } if (data.length !== 3) return; // ignore empty or malformed EVENT const id = data[1]; const event = data[2]; if (!this.openSubs[id]) { return; } if (this.openSubs[id].eventIds?.has(eventId)) { return; } this.openSubs[id].eventIds?.add(eventId); if ( validateEvent(event) && this.openSubs[id] && (this.openSubs[id].skipVerification || verifySignature(event)) && matchFilters(this.openSubs[id].filters, event) ) { this.openSubs[id]; (this.subListeners[id]?.event || []).forEach((cb) => cb(event)); } return; case "EOSE": { if (data.length !== 2) return; // ignore empty or malformed EOSE const id = data[1]; if (this.logging) { console.log("EOSE", this.url, id); } (this.subListeners[id]?.eose || []).forEach((cb) => cb()); return; } case "OK": { if (data.length < 3) return; // ignore empty or malformed OK const id: string = data[1]; const ok: boolean = data[2]; const reason: string = data[3] || ""; if (ok) this.pubListeners[id]?.ok.forEach((cb) => cb()); else this.pubListeners[id]?.failed.forEach((cb) => cb(reason)); return; } case "NOTICE": if (data.length !== 2) return; // ignore empty or malformed NOTICE const notice = data[1]; this.listeners.notice.forEach((cb) => cb(notice)); return; case "AUTH": if (data.length !== 2) return; const challenge = data[1]; this.listeners.auth.forEach((cb) => cb(challenge)); return; } } } #onopen(opened: () => void) { if (this.resolveClose) { this.resolveClose(); return; } // console.log("#onopen setting reconnectTimeout to 0"); // this.reconnectTimeout = 0; // TODO: Send ephereal messages after subscription, permament before for (const subid in this.openSubs) { if (this.logging) { console.log("REQ", this.url, subid, ...this.openSubs[subid].filters); } this.trySend(["REQ", subid, ...this.openSubs[subid].filters]); } for (const msg of this.sendOnConnect) { if (this.logging) { console.log("(Relay msg)", this.url, msg); } this.ws?.send(msg); } this.sendOnConnect = []; this.listeners.connect.forEach((cb) => cb()); opened(); } async connectRelay(): Promise<void> { return new Promise((resolve, reject) => { try { const ws = new WebSocket(this.url); this.ws = ws; } catch (err) { reject(err); return; } this.ws.onopen = this.#onopen.bind(this, resolve); this.ws.onerror = (e) => { this.listeners.error.forEach((cb) => cb()); reject(e); }; this.ws.onclose = this.#onclose.bind(this); this.ws.onmessage = this.#onmessage.bind(this); }); } async connect(): Promise<void> { if (this.ws?.readyState && this.ws.readyState < 2) return; // ws already open or connecting if (this.ws?.readyState === 2) { this.ws.close(); } this.ws = undefined; await this.connectRelay(); } relayInit(): Relay { const this2 = this; return { url: this2.url, sub: this2.sub.bind(this2), on: this2.on.bind(this2), off: this2.off.bind(this2), auth: this2.auth.bind(this2), publish: this2.publish.bind(this2), connect: this2.connect.bind(this2), close(): Promise<void> { return this2.close(); }, get status() { return this2.status; }, // @ts-ignore relay: this2, }; } get status() { return this.ws?.readyState ?? 3; } get connected() { return this.ws?.readyState === 1; } close(): Promise<void> { this.closedByClient = true; this.ws?.close(); return new Promise<void>((resolve) => { this.resolveClose = resolve; }); } on(type: RelayEvent, cb: any) { this.listeners[type].push(cb); if (type === "connect" && this.ws?.readyState === 1) { cb(); } } off(type: RelayEvent, cb: any) { const index = this.listeners[type].indexOf(cb); if (index !== -1) this.listeners[type].splice(index, 1); } publish(event: Event): Pub { return this._publish(event, "EVENT"); } auth(event: Event): Pub { return this._publish(event, "AUTH"); } private _publish(event: Event, type: string) { const this2 = this; if (!event.id) throw new Error(`event ${event} has no id`); const id = event.id; let sent = false; let mustMonitor = false; this2 .trySend([type, event]) .then(() => { sent = true; if (mustMonitor) { startMonitoring(); mustMonitor = false; } }) .catch(() => {}); const startMonitoring = () => { const monitor = this.sub([{ids: [id]}], { id: `monitor-${id.slice(0, 5)}`, }); const willUnsub = setTimeout(() => { (this2.pubListeners[id]?.failed || []).forEach((cb) => cb("event not seen after 5 seconds") ); monitor.unsub(); }, 5000); monitor.on("event", () => { clearTimeout(willUnsub); (this2.pubListeners[id]?.seen || []).forEach((cb) => cb()); }); }; return { on: (type: "ok" | "seen" | "failed", cb: any) => { this2.pubListeners[id] = this2.pubListeners[id] || { ok: [], seen: [], failed: [], }; this2.pubListeners[id][type].push(cb); if (type === "seen") { if (sent) startMonitoring(); else mustMonitor = true; } }, off: (type: "ok" | "seen" | "failed", cb: any) => { const listeners = this2.pubListeners[id]; if (!listeners) return; const idx = listeners[type].indexOf(cb); if (idx >= 0) listeners[type].splice(idx, 1); }, }; } sub(filters: Filter[], opts: SubscriptionOptions = {}): Sub { const this2 = this; const subid = opts.id || Math.random().toString().slice(2); const skipVerification = opts.skipVerification || false; this2.openSubs[subid] = { id: subid, filters, skipVerification, }; if (this2.connected) { if (this.logging) { console.log("REQ2", this.url, subid, ...filters); } this2.trySend(["REQ", subid, ...filters]); } return { sub: (newFilters, newOpts = {}) => this.sub(newFilters || filters, { skipVerification: newOpts.skipVerification || skipVerification, id: subid, }), unsub: () => { delete this2.openSubs[subid]; delete this2.subListeners[subid]; if (this2.connected) { if (this2.logging) { console.log("CLOSE", this.url, subid); } this2.trySend(["CLOSE", subid]); } }, on: (type: "event" | "eose", cb: any): void => { this2.subListeners[subid] = this2.subListeners[subid] || { event: [], eose: [], }; this2.subListeners[subid]![type].push(cb); }, off: (type: "event" | "eose", cb: any): void => { const listeners = this2.subListeners[subid]; if (!listeners) return; const idx = listeners[type].indexOf(cb); if (idx >= 0) listeners[type].splice(idx, 1); }, }; } }