UNPKG

rx-nostr

Version:

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

592 lines (532 loc) 16 kB
import * as Nostr from "nostr-typedef"; import { filter, finalize, first, firstValueFrom, identity, map, merge, mergeAll, type MonoTypeOperatorFunction, Observable, Subject, switchAll, takeUntil, takeWhile, tap, timeout, } from "rxjs"; import { type FilledRxNostrConfig, makeRxNostrConfig, type RxNostrConfig, } from "../config/index.js"; import { NostrConnection, type REQMode } from "../connection/index.js"; import { FinPacket } from "../connection/index.js"; import { RxNostrAlreadyDisposedError, RxNostrInvalidUsageError, RxNostrLogicError, } from "../error.js"; import { isExpired } from "../nostr/nip40.js"; import { completeOnTimeout, filterAsync, filterBySubId } from "../operator.js"; import type { ConnectionState, ConnectionStatePacket, ErrorPacket, EventPacket, LazyREQ, MessagePacket, OkPacketAgainstEvent, OutgoingMessagePacket, ReqPacket, } from "../packet.js"; import { subtract } from "../utils/array-operation.js"; import { fill } from "../utils/config.js"; import { UrlMap } from "../utils/url-map.js"; import { type AcceptableDefaultRelaysConfig, type DefaultRelayConfig, RelayStatus, type RxNostr, type RxNostrSendOptions, type RxNostrUseOptions, } from "./interface.js"; import type { RxReq } from "./rx-req.js"; import { getMethodScopeRelays, makeLazyREQ, normalizeRelaysConfig, } from "./utils.js"; /** Create a RxNostr object. This is the only way to create that. */ export function createRxNostr(config: RxNostrConfig): RxNostr { return new RxNostrImpl(makeRxNostrConfig(config ?? {})); } class RxNostrImpl implements RxNostr { private connections = new UrlMap<NostrConnection>(); private defaultRelays = new UrlMap<DefaultRelayConfig>(); private get defaultReadables(): NostrConnection[] { const conns: NostrConnection[] = []; for (const { url, read } of this.defaultRelays.values()) { const conn = this.connections.get(url); if (read && conn) { conns.push(conn); } } return conns; } private get defaultWritables(): NostrConnection[] { const conns: NostrConnection[] = []; for (const { url, write } of this.defaultRelays.values()) { const conn = this.connections.get(url); if (write && conn) { conns.push(conn); } } return conns; } private defaultSubscriptions: Map< string, { req: LazyREQ; autoclose: boolean } > = new Map(); private event$ = new Subject<EventPacket>(); private fin$ = new Subject<FinPacket>(); private ok$ = new Subject<OkPacketAgainstEvent>(); private all$ = new Subject<MessagePacket>(); private error$ = new Subject<ErrorPacket>(); private connectionState$ = new Subject<ConnectionStatePacket>(); private outgoing$ = new Subject<OutgoingMessagePacket>(); private dispose$ = new Subject<void>(); private disposed = false; constructor(private config: FilledRxNostrConfig) {} // #region defaultRelays getter/setter getDefaultRelays(): Record<string, DefaultRelayConfig> { if (this.disposed) { throw new RxNostrAlreadyDisposedError(); } return this.defaultRelays.toObject(); } getDefaultRelay(url: string): DefaultRelayConfig | undefined { return this.defaultRelays.get(url); } setDefaultRelays(relays: AcceptableDefaultRelaysConfig): void { if (this.disposed) { throw new RxNostrAlreadyDisposedError(); } const nextDefaultRelays = new UrlMap(normalizeRelaysConfig(relays)); const nextReadableConnections: NostrConnection[] = []; for (const { read, url } of nextDefaultRelays.values()) { const conn = this.ensureNostrConnection(url); conn.setConnectionStrategy(this.config.connectionStrategy); if (read) { nextReadableConnections.push(conn); } } this.updateDefaultSubscriptions(nextReadableConnections); this.defaultRelays = nextDefaultRelays; } private ensureNostrConnection(url: string): NostrConnection { let conn = this.connections.get(url); if (conn) { return conn; } conn = new NostrConnection(url, this.config); this.attachNostrConnection(conn); this.connections.set(url, conn); return conn; } private attachNostrConnection(conn: NostrConnection): void { conn.getEventObservable().subscribe(this.event$); conn.getFinObservable().subscribe(this.fin$); conn.getOkAgainstEventObservable().subscribe(this.ok$); conn.getAllMessageObservable().subscribe(this.all$); conn.getConnectionStateObservable().subscribe(this.connectionState$); conn.getErrorObservable().subscribe(this.error$); conn.getOutgoingMessageObservable().subscribe(this.outgoing$); } private updateDefaultSubscriptions( nextReadableConnections: NostrConnection[], ): void { const noLongerNeededConnections = subtract( this.defaultReadables, nextReadableConnections, ); for (const conn of noLongerNeededConnections) { conn.markAsDefault(false); } for (const conn of nextReadableConnections) { conn.markAsDefault(true); for (const { req, autoclose } of this.defaultSubscriptions.values()) { conn?.subscribe(req, { mode: "default", overwrite: false, autoclose, }); } } } addDefaultRelays(relays: AcceptableDefaultRelaysConfig): void { const additionalDefaultRelays = normalizeRelaysConfig(relays); this.setDefaultRelays({ ...this.defaultRelays.toObject(), ...additionalDefaultRelays, }); } removeDefaultRelays(urls: string | string[]): void { const defaultRelays = this.defaultRelays.copy(); const targets = Array.isArray(urls) ? urls : [urls]; for (const url of targets) { defaultRelays.delete(url); } this.setDefaultRelays(defaultRelays.toObject()); } // #endregion // #region connection state getter getAllRelayStatus(): Record<string, RelayStatus> { return Object.fromEntries( Array.from(this.connections.values()).map((e) => [ e.url, { connection: e.connectionState }, ]), ); } getRelayStatus(url: string): RelayStatus | undefined { const conn = this.connections.get(url); if (!conn) { return undefined; } return { connection: conn.connectionState }; } // #endregion reconnect(url: string): void { const relay = this.getDefaultRelay(url); if (!relay) { throw new RxNostrInvalidUsageError( `The relay (${url}) is not a default relay. \`reconnect()\` can be used only for a readable default relay.`, ); } if (!relay.read) { throw new RxNostrInvalidUsageError( `The relay (${url}) is not readable. \`reconnect()\` can be used only for a readable default relay.`, ); } const conn = this.connections.get(url); if (!conn) { throw new RxNostrLogicError(); } if ( conn.connectionState === "error" || conn.connectionState === "rejected" ) { conn.connectManually(); } } // #region use use( rxReq: RxReq, options?: Partial<RxNostrUseOptions>, ): Observable<EventPacket> { const useScopeRelays = getMethodScopeRelays(this, options); interface OrderPacket { subId: string; req: LazyREQ; targetConnections: NostrConnection[]; mode: REQMode; } const makeOrderPacket = ( { filters, relays }: ReqPacket, index: number, ): OrderPacket => { const emitScopeRelays = rxReq.strategy === "backward" ? relays : undefined; const req = makeLazyREQ({ rxReq, filters, index }); const subId = req[1]; return { subId, req, targetConnections: (emitScopeRelays ?? useScopeRelays)?.map((url) => this.ensureNostrConnection(url), ) ?? this.defaultReadables, mode: emitScopeRelays === undefined && useScopeRelays === undefined ? "default" : "temporary", }; }; const startSubscription = ({ req, targetConnections, mode, }: OrderPacket) => { this.startSubscription({ req, targetConnections, mode, overwrite: rxReq.strategy === "forward", autoclose: rxReq.strategy === "backward", }); }; const teardownSubscription = ({ subId, targetConnections, mode, }: OrderPacket) => { this.teardownSubscription({ subId, targetConnections, mode, }); }; const createEventObservable = ({ req, targetConnections }: OrderPacket) => { if (rxReq.strategy === "forward") { return this.createForwardEventObservable({ req, }).pipe(takeUntil(this.dispose$)); } else { return this.createBackwardEventObservable({ req, targetConnections, }).pipe(takeUntil(this.dispose$)); } }; const order$ = rxReq.getReqPacketObservable().pipe( filter(({ filters }) => filters.length > 0), map(makeOrderPacket), takeUntil(this.dispose$), ); const validate = () => filterAsync<EventPacket>(async ({ event }) => { return ( (this.config.skipVerify || (await this.config.verifier(event))) && (this.config.skipExpirationCheck || !isExpired(event)) ); }); if (rxReq.strategy === "forward") { let firstOrder: OrderPacket | undefined; return order$.pipe( tap((order) => { firstOrder = order; }), tap(startSubscription), map(createEventObservable), finalize(() => { if (!firstOrder) { return; } // Because subId, targetConnections and mode keeps their value under forward strategy teardownSubscription(firstOrder); }), switchAll(), validate(), ); } else { return order$.pipe( tap(startSubscription), map((order) => createEventObservable(order).pipe( finalize(() => { teardownSubscription(order); }), ), ), mergeAll(), validate(), ); } } private createForwardEventObservable(params: { req: LazyREQ; }): Observable<EventPacket> { const { req } = params; const subId = req[1]; return this.event$.pipe(filterBySubId(subId)); } private createBackwardEventObservable(params: { req: LazyREQ; targetConnections: NostrConnection[]; }): Observable<EventPacket> { const { req, targetConnections } = params; const subId = req[1]; const finishedRelays = new Set<string>(); const isDown = (state: ConnectionState): boolean => state === "error" || state === "rejected" || state === "terminated"; const shouldComplete = () => targetConnections.every( ({ connectionState, url }) => isDown(connectionState) || finishedRelays.has(url), ); const fin$ = this.fin$.pipe( filterBySubId(subId), tap(({ from }) => { finishedRelays.add(from); }), ); const complete$ = merge(fin$, this.connectionState$.asObservable()).pipe( filter(() => shouldComplete()), first(null, undefined), ); return this.event$.pipe( takeUntil(complete$), completeOnTimeout(this.config.eoseTimeout), filterBySubId(subId), filter((e) => !finishedRelays.has(e.from)), ); } private startSubscription(params: { req: LazyREQ; targetConnections: NostrConnection[]; mode: REQMode; overwrite: boolean; autoclose: boolean; }) { const { req, targetConnections, mode, overwrite, autoclose } = params; const subId = req[1]; if (mode === "default") { this.defaultSubscriptions.set(subId, { req, autoclose }); } for (const conn of targetConnections) { conn.subscribe(req, { mode, overwrite, autoclose, }); } } private teardownSubscription(params: { subId: string; targetConnections: NostrConnection[]; mode: REQMode; }): void { const { subId, targetConnections, mode } = params; if (mode === "default") { this.defaultSubscriptions.delete(subId); } for (const conn of targetConnections) { conn.unsubscribe(subId); } } // #endregion use // #region createObservable createAllEventObservable(): Observable<EventPacket> { return this.event$.asObservable(); } createAllErrorObservable(): Observable<ErrorPacket> { return this.error$.asObservable(); } createAllMessageObservable(): Observable<MessagePacket> { return this.all$.asObservable(); } createConnectionStateObservable(): Observable<ConnectionStatePacket> { return this.connectionState$.asObservable(); } createOutgoingMessageObservable(): Observable<OutgoingMessagePacket> { return this.outgoing$.asObservable(); } // #endregion send( params: Nostr.EventParameters, options?: Partial<RxNostrSendOptions>, ): Observable<OkPacketAgainstEvent> { const { signer, errorOnTimeout, completeOn } = fill(options ?? {}, { signer: this.config.signer, errorOnTimeout: false, completeOn: "all-ok", }); const relays = getMethodScopeRelays(this, options); const targetRelays = relays === undefined ? this.defaultWritables : relays.map((url) => this.ensureNostrConnection(url)); const subject = new Subject<OkPacketAgainstEvent>(); const finishedRelays = new Set<string>(); let eventId = ""; const teardown = () => { if (!subject.closed) { subject.complete(); } for (const conn of targetRelays) { conn.confirmOK(eventId); } }; signer .signEvent(params) .then(async (event) => { if (subject.closed) { return; } eventId = event.id; this.ok$ .pipe(filter(({ eventId }) => eventId === event.id)) .subscribe(subject); await Promise.all(targetRelays.map((conn) => conn.publish(event))); if (completeOn === "sent") { subject.complete(); } }) .catch((err) => { teardown(); throw new RxNostrInvalidUsageError( err instanceof Error ? err.message : "Failed to sign the given event", ); }); const completeManager = ((): MonoTypeOperatorFunction<OkPacketAgainstEvent> => { switch (completeOn) { case "sent": return identity; case "all-ok": return takeWhile(({ from, done }) => { if (done) { finishedRelays.add(from); } return finishedRelays.size < targetRelays.length; }, true); case "any-ok": return first((p) => p.ok); } })(); return subject.pipe( completeManager, takeUntil(this.dispose$), errorOnTimeout ? timeout(this.config.okTimeout) : completeOnTimeout(this.config.okTimeout), finalize(teardown), ); } async cast( params: Nostr.EventParameters<number>, options?: Partial<Omit<RxNostrSendOptions, "completeOn">> | undefined, ): Promise<void> { await firstValueFrom(this.send(params, { ...options, completeOn: "sent" })); } dispose() { this[Symbol.dispose](); } [Symbol.dispose](): void { if (this.disposed) { return; } this.disposed = true; for (const conn of this.connections.values()) { conn.dispose(); } this.connections.clear(); const subjects = [ this.event$, this.ok$, this.fin$, this.all$, this.connectionState$, this.error$, this.outgoing$, ]; for (const sub of subjects) { sub.complete(); } this.dispose$.next(); this.dispose$.complete(); } }