rx-nostr
Version:
A library based on RxJS, which allows Nostr applications to easily communicate with relays.
592 lines (532 loc) • 16 kB
text/typescript
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();
}
}