UNPKG

rx-nostr

Version:

A library based on RxJS, which allows Nostr applications to easily communicate with relays.

475 lines (429 loc) 11.9 kB
import * as Nostr from "nostr-typedef"; import { distinctUntilChanged, EMPTY, filter, map, Observable, of, Subject, Subscription, timer, } from "rxjs"; import type { FilledRxNostrConfig, RetryConfig } from "../config/index.js"; import { RxNostrInvalidUsageError, RxNostrLogicError, RxNostrWebSocketError, } from "../error.js"; import { Nip11Registry } from "../nip11.js"; import { AuthPacket, ClosedPacket, ConnectionState, ConnectionStatePacket, EosePacket, EventPacket, MessagePacket, OkPacket, OutgoingMessagePacket, } from "../packet.js"; import { ICloseEvent, IMessageEvent, IWebSocket, IWebSocketConstructor, ReadyState, } from "../websocket.js"; import { NotifySubject } from "./utils.js"; export class RelayConnection { private socket: IWebSocket | null = null; private buffer: Nostr.ToRelayMessage.Any[] = []; private unsent: Nostr.ToRelayMessage.Any[] = []; private reconnected$ = new Subject<Nostr.ToRelayMessage.Any[]>(); private outgoing$ = new Subject<OutgoingMessagePacket>(); private message$ = new Subject<MessagePacket>(); private error$ = new Subject<unknown>(); private retryTimer: Subscription | null = null; private sendAttempted$ = new NotifySubject(); private isFirstTry = true; private maybeDown = false; private disposed = false; private state$ = new Subject<ConnectionState>(); private _state: ConnectionState = "initialized"; get state(): ConnectionState { return this._state; } private setState(state: ConnectionState) { this._state = state; this.state$.next(state); } constructor( public url: string, private config: FilledRxNostrConfig, ) { // Caching if (!config.skipFetchNip11) { Nip11Registry.getOrFetch(url); } this.setState("initialized"); } connectManually() { this.connect(); } private connect(retryCount?: number) { if (this.state === "terminated") { return; } const isRetry = typeof retryCount === "number"; const canConnect = this.state === "initialized" || this.state === "dormant" || this.state === "error" || this.state === "rejected" || isRetry; if (!canConnect) { return; } this.socket = this.createSocket(retryCount ?? 0); } private createSocket(retryCount: number) { const isFirstTry = this.isFirstTry; this.isFirstTry = false; let hasConnected = false; const isAutoRetry = retryCount > 0; const isManualRetry = !isAutoRetry && (this.state === "error" || this.state === "rejected"); if (isAutoRetry) { this.setState("retrying"); } else { this.setState("connecting"); } const onopen = async () => { if (this.state === "terminated") { socket?.close(WebSocketCloseCode.RX_NOSTR_DISPOSED); return; } this.setState("connected"); hasConnected = true; retryCount = 0; if (isAutoRetry || isManualRetry) { this.reconnected$.next(this.unsent); this.unsent = []; } try { for (const message of this.buffer) { this.send(message); } } catch (err) { this.error$.next(err); } finally { this.buffer = []; } }; const onmessage = ({ data }: IMessageEvent) => { if (this.state === "terminated") { return; } try { this.message$.next(this.pack(JSON.parse(data))); } catch (err) { this.error$.next(err); } }; const onclose = ({ code }: ICloseEvent) => { socket?.removeEventListener("open", onopen); socket?.removeEventListener("message", onmessage); socket?.removeEventListener("close", onclose); if (this.socket === socket) { this.socket = null; } if ( this.state === "terminated" || code === WebSocketCloseCode.RX_NOSTR_DISPOSED ) { this.unsent = []; this.buffer = []; return; } if (code === WebSocketCloseCode.RX_NOSTR_IDLE) { this.setState("dormant"); if (this.buffer.length > 0) { this.connect(); } } else if (code === WebSocketCloseCode.DONT_RETRY) { this.unsent = []; this.buffer = []; this.error$.next(new RxNostrWebSocketError(code)); this.setState("rejected"); } else { if (isFirstTry && !hasConnected) { this.maybeDown = true; } this.unsent.push(...this.buffer); this.buffer = []; this.error$.next(new RxNostrWebSocketError(code)); const nextRetry = retryCount + 1; const shouldRetry = this.config.retry.strategy !== "off" && !(this.config.retry.polite && this.maybeDown) && nextRetry <= this.config.retry.maxCount; if (shouldRetry) { this.setState("waiting-for-retrying"); this.retryTimer?.unsubscribe(); this.retryTimer = retryTimer(this.config.retry, nextRetry).subscribe( () => { if (!this.disposed) { this.connect(nextRetry); } }, ); } else { this.setState("error"); } } }; const WebSocket: IWebSocketConstructor = this.config.websocketCtor ?? globalThis.WebSocket; if (!WebSocket) { throw new RxNostrInvalidUsageError("WebSocket constructor is missing"); } const socket = (() => { try { return new WebSocket(this.url); } catch (err: unknown) { // When the given URL is invalid, Deno runtime throws SyntaxError. // Here, we fire the error event in a pseudo manner, just as a normal browser runtime would do. onclose({ type: "close", code: 0, reason: `${err}`, }); return null; } })(); socket?.addEventListener("open", onopen); socket?.addEventListener("message", onmessage); socket?.addEventListener("close", onclose); return socket; } private pack(message: Nostr.ToClientMessage.Any): MessagePacket { const type = message[0]; const from = this.url; switch (type) { case "EVENT": return { from, type, message, subId: message[1], event: message[2], }; case "EOSE": return { from, type, message, subId: message[1], }; case "OK": return { from, type, message, eventId: message[1], ok: message[2], notice: message[3], }; case "CLOSED": return { from, type, message, subId: message[1], notice: message[2], }; case "NOTICE": return { from, type, message, notice: message[1], }; case "AUTH": return { from, type, message, challenge: message[1], }; case "COUNT": return { from, type, message, subId: message[1], count: message[2], }; default: return { from, type: "unknown", message, }; } } disconnect(code: WebSocketCloseCode): void { if (this.socket?.readyState === ReadyState.OPEN) { this.socket.close(code); } } send(message: Nostr.ToRelayMessage.Any): Promise<void> { const done = this.sendAttempted$.waitNext(); switch (this.state) { case "terminated": case "rejected": { this.sendAttempted$.next(); return done; } case "initialized": case "connecting": case "dormant": { this.buffer.push(message); this.connect(); return done; } case "connected": { if (!this.socket) { throw new RxNostrLogicError(); } if (this.socket.readyState === ReadyState.OPEN) { this.outgoing$.next({ to: this.url, message }); this.socket.send(JSON.stringify(message)); this.sendAttempted$.next(); return done; } else { this.buffer.push(message); } return done; } case "waiting-for-retrying": case "retrying": case "error": { this.sendAttempted$.next(); this.unsent.push(message); return done; } } } getEVENTObservable(): Observable<EventPacket> { return this.message$.pipe( filter((p): p is EventPacket => p.type === "EVENT"), ); } getEOSEObservable(): Observable<EosePacket> { return this.message$.pipe( filter((p): p is EosePacket => p.type === "EOSE"), ); } getCLOSEDObservable(): Observable<ClosedPacket> { return this.message$.pipe( filter((p): p is ClosedPacket => p.type === "CLOSED"), ); } getOKObservable(): Observable<OkPacket> { return this.message$.pipe(filter((p): p is OkPacket => p.type === "OK")); } getAUTHObservable(): Observable<AuthPacket> { return this.message$.pipe( filter((p): p is AuthPacket => p.type === "AUTH"), ); } getAllMessageObservable(): Observable<MessagePacket> { return this.message$.asObservable(); } getOutgoingMessageObservable(): Observable<OutgoingMessagePacket> { return this.outgoing$.asObservable(); } getReconnectedObservable(): Observable<Nostr.ToRelayMessage.Any[]> { return this.reconnected$.asObservable(); } getConnectionStateObservable(): Observable<ConnectionStatePacket> { return this.state$.pipe( distinctUntilChanged(), map((state) => ({ from: this.url, state, })), ); } getErrorObservable(): Observable<unknown> { return this.error$.asObservable(); } dispose() { this[Symbol.dispose](); } [Symbol.dispose](): void { if (this.disposed) { return; } this.disposed = true; this.setState("terminated"); this.retryTimer?.unsubscribe(); this.socket?.close(WebSocketCloseCode.RX_NOSTR_DISPOSED); this.socket = null; const subjects = [ this.state$, this.outgoing$, this.message$, this.error$, this.reconnected$, this.sendAttempted$, ]; for (const sub of subjects) { sub.complete(); } } } function retryTimer(config: RetryConfig, count: number) { switch (config.strategy) { case "exponential": { const time = Math.max( config.initialDelay * 2 ** (count - 1) + (Math.random() - 0.5) * 1000, 1000, ); return timer(time); } case "immediately": return of(0); case "linear": return timer(config.interval); case "off": return EMPTY; } } export const WebSocketCloseCode = { /** * 1006 is a reserved value and MUST NOT be set as a status code in a * Close control frame by an endpoint. It is designated for use in * applications expecting a status code to indicate that the * connection was closed abnormally, e.g., without sending or * receiving a Close control frame. * * See also: https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1 */ ABNORMAL_CLOSURE: 1006, /** * When a websocket is closed by the relay with a status code 4000 * that means the client shouldn't try to connect again. * * See also: https://github.com/nostr-protocol/nips/blob/fab6a21a779460f696f11169ddf343b437327592/01.md?plain=1#L113 */ DONT_RETRY: 4000, /** @internal rx-nostr uses it internally. */ RX_NOSTR_IDLE: 4537, /** @internal rx-nostr uses it internally. */ RX_NOSTR_DISPOSED: 4538, } as const; type WebSocketCloseCode = (typeof WebSocketCloseCode)[keyof typeof WebSocketCloseCode];