nostr-fetch
Version:
A utility library that allows JS/TS apps to effortlessly fetch past events from Nostr relays
187 lines (168 loc) • 5.86 kB
text/typescript
import { Channel } from "@nostr-fetch/kernel/channel";
import { DebugLogger } from "@nostr-fetch/kernel/debugLogger";
import {
type EnsureRelaysOptions,
FetchTillEoseAbortedSignal,
FetchTillEoseFailedSignal,
type FetchTillEoseOptions,
type NostrFetcherBackend,
type NostrFetcherCommonOptions,
} from "@nostr-fetch/kernel/fetcherBackend";
import { type Filter, type NostrEvent, isNoticeForReqError } from "@nostr-fetch/kernel/nostr";
import type { WebSocketMinCtor } from "@nostr-fetch/kernel/webSocket";
import { type RelayPool, initRelayPool } from "./relayPool";
export type DefaultFetcherBackendOptions = {
webSocketConstructor?: WebSocketMinCtor;
};
export const defaultDefaultFetcherBackendOptions: Required<DefaultFetcherBackendOptions> = {
webSocketConstructor: globalThis.WebSocket,
};
/**
* Default implementation of `NostrFetchBackend`.
*/
export class DefaultFetcherBackend implements NostrFetcherBackend {
#relayPool: RelayPool;
#wsCtor: WebSocketMinCtor;
#debugLogger: DebugLogger | undefined;
public constructor(opts: Required<NostrFetcherCommonOptions & DefaultFetcherBackendOptions>) {
this.#relayPool = initRelayPool(opts);
this.#wsCtor = opts.webSocketConstructor;
if (opts.minLogLevel !== "none") {
this.#debugLogger = new DebugLogger(opts.minLogLevel);
}
}
/**
* Ensures connections to the relays prior to an event subscription.
*
* Returns URLs of relays *successfully connected to*.
*
* It should *normalize* the passed `relayUrls` before establishing connections to relays.
*/
public async ensureRelays(relayUrls: string[], options: EnsureRelaysOptions): Promise<string[]> {
return this.#relayPool.ensureRelays(relayUrls, {
...options,
webSocketConstructor: this.#wsCtor,
});
}
/**
* Closes all the connections to relays and clean up the internal relay pool.
*/
public shutdown(): void {
this.#relayPool.shutdown();
}
/**
* Fetches Nostr events matching `filters` from the relay specified by `relayUrl` until EOSE.
*
* The result is an `AsyncIterable` of Nostr events.
* You can think that it's an asynchronous channel which conveys events.
* The channel will be closed once EOSE is reached.
*
* If one of the following situations occurs, it is regarded as "failure".
* In such a case, it should throw `FetchTillEoseFailedSignal`.
*
* - It couldn't establish connection to the relay
* - Received a NOTICE message during the fetch
* - A WebSocket error occurred during the fetch
*
* If the fetch was aborted (due to AbortController or auto abortion timer), it should throw `FetchTillEoseAbortedSignal`.
*/
public async *fetchTillEose(
relayUrl: string,
filter: Filter,
options: FetchTillEoseOptions,
): AsyncIterable<NostrEvent> {
const logger = this.#debugLogger?.subLogger(relayUrl);
const relay = await this.#relayPool.ensureSingleRelay(relayUrl, {
connectTimeoutMs: options.connectTimeoutMs,
webSocketConstructor: this.#wsCtor,
});
if (relay === undefined) {
throw new FetchTillEoseFailedSignal("failed to ensure connection to the relay");
}
const [tx, chIter] = Channel.make<NostrEvent>();
// relay error handlings
const onNotice = (n: string) => {
// ignore if the message seems to have nothing to do with REQs by fetcher
if (!isNoticeForReqError(n)) {
return;
}
try {
sub.close();
} catch (err) {
logger?.log("error", `failed to close subscription (id: ${sub.subId}): ${err}`);
}
removeRelayListeners();
tx.error(new FetchTillEoseFailedSignal(`NOTICE: ${n}`));
};
const onError = () => {
// WebSocket error closes the connection, so calling close() is meaningless
removeRelayListeners();
tx.error(new FetchTillEoseFailedSignal("WebSocket error"));
};
const removeRelayListeners = () => {
relay.off("notice", onNotice);
relay.off("error", onError);
};
relay.on("notice", onNotice);
relay.on("error", onError);
// prepare a subscription
const sub = relay.prepareSub([filter], options);
// handle subscription events
sub.on("event", (ev: NostrEvent) => {
tx.send(ev);
});
sub.on("eose", ({ aborted }) => {
closeSub();
if (aborted) {
tx.error(
new FetchTillEoseAbortedSignal(
`subscription (id: ${sub.subId}) aborted before EOSE due to timeout`,
),
);
} else {
tx.close();
}
});
sub.on("closed", (msg) => {
tx.error(
new FetchTillEoseFailedSignal(`subscription (id: ${sub.subId}) closed by relay: ${msg}`),
);
});
// common process to close subscription
const closeSub = () => {
try {
sub.close();
} catch (err) {
logger?.log("error", `failed to close subscription (id: ${sub.subId}): ${err}`);
}
removeRelayListeners();
logger?.log("verbose", `CLOSE: subId=${options.subId ?? "<auto>"}`);
};
// start the subscription
logger?.log("verbose", `REQ: subId=${options.subId ?? "<auto>"}, filter=%O`, filter);
try {
sub.req();
} catch (err) {
tx.error(new FetchTillEoseFailedSignal("failed to send REQ", { cause: err }));
removeRelayListeners();
}
// handle abortion
if (options.signal?.aborted) {
closeSub();
tx.error(
new FetchTillEoseAbortedSignal(
`subscription (id: ${sub.subId}) aborted by AbortController`,
),
);
}
options.signal?.addEventListener("abort", () => {
closeSub();
tx.error(
new FetchTillEoseAbortedSignal(
`subscription (id: ${sub.subId}) aborted by AbortController`,
),
);
});
yield* chIter;
}
}