nostr-fetch
Version:
A utility library that allows JS/TS apps to effortlessly fetch past events from Nostr relays
357 lines (287 loc) • 10.7 kB
text/typescript
import { Deferred } from "@nostr-fetch/kernel/channel";
import { setupMockRelayServer } from "@nostr-fetch/testutil/mockRelayServer";
import type {
RelayConnectCb,
RelayDisconnectCb,
RelayErrorCb,
RelayNoticeCb,
RelayOptions,
SubClosedCb,
SubEoseCb,
SubEventCb,
SubscriptionOptions,
} from "./relay";
import { initRelay } from "./relay";
import { setTimeout as delay } from "node:timers/promises";
import { verifyEventSig } from "@nostr-fetch/kernel/crypto";
import type { NostrEvent } from "@nostr-fetch/kernel/nostr";
import { type WSCloseEvent, WebSocketReadyState } from "@nostr-fetch/kernel/webSocket";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import WS from "vitest-websocket-mock";
import WebSocket from "ws";
vi.mock("ws");
const defaultRelayOpts: RelayOptions = {
connectTimeoutMs: 5000,
webSocketConstructor: WebSocket,
};
describe("Relay", () => {
test(".url returns relay's URL", () => {
const r = initRelay("wss://example.com", { ...defaultRelayOpts, connectTimeoutMs: 0 });
expect(r.url).toBe("wss://example.com");
});
describe("connect/close", () => {
const rurl = "ws://localhost:8000";
afterEach(() => {
WS.clean();
});
test("succeeds", async () => {
const ws = new WS(rurl);
const r = initRelay(rurl, defaultRelayOpts);
await expect(
r.connect().then((r) => {
return { url: r.url, readyState: r.wsReadyState };
}),
).resolves.toStrictEqual({
url: rurl,
readyState: WebSocketReadyState.OPEN,
});
r.close();
await expect(ws.closed).resolves.toBeUndefined();
});
test("connect() fails if relay is unreachable", async () => {
const r = initRelay(rurl, defaultRelayOpts);
await expect(r.connect()).rejects.toThrow("WebSocket error");
});
// TODO: how to test connection timeout?
});
describe("listeners", () => {
const rurl = "ws://localhost:8000";
let ws: WS;
let spyCbs: {
connect: RelayConnectCb;
disconnect: RelayDisconnectCb;
error: RelayErrorCb;
notice: RelayNoticeCb;
};
beforeEach(() => {
ws = new WS(rurl, { jsonProtocol: true });
spyCbs = {
connect: vi.fn(() => {}),
disconnect: vi.fn((_: WSCloseEvent) => {}),
error: vi.fn(() => {}),
notice: vi.fn((_) => {}),
};
});
afterEach(() => {
WS.clean();
});
test("normal case", async () => {
const r = initRelay(rurl, defaultRelayOpts);
r.on("connect", spyCbs.connect);
r.on("disconnect", spyCbs.disconnect);
r.on("error", spyCbs.error);
r.on("notice", spyCbs.notice);
await r.connect();
await ws.connected;
const evClose = { code: 1000, reason: "closed", wasClean: true };
ws.close(evClose);
await ws.closed;
expect(spyCbs.connect).toBeCalledTimes(1);
expect(spyCbs.disconnect).toBeCalledTimes(1);
expect(spyCbs.disconnect).toBeCalledWith(expect.objectContaining(evClose));
expect(spyCbs.error).not.toBeCalled();
expect(spyCbs.notice).not.toBeCalled();
});
test("error case", async () => {
const r = initRelay(rurl, defaultRelayOpts);
r.on("connect", spyCbs.connect);
r.on("disconnect", spyCbs.disconnect);
r.on("error", spyCbs.error);
r.on("notice", spyCbs.notice);
await r.connect();
await ws.connected;
const evClose = { code: 1006, reason: "error", wasClean: false };
ws.error(evClose);
await ws.closed;
expect(spyCbs.connect).toBeCalledTimes(1);
expect(spyCbs.disconnect).toBeCalledTimes(1);
expect(spyCbs.disconnect).toBeCalledWith(expect.objectContaining(evClose));
expect(spyCbs.error).toBeCalledTimes(1);
expect(spyCbs.notice).not.toBeCalled();
});
test("notice", async () => {
const r = initRelay(rurl, defaultRelayOpts);
r.on("notice", spyCbs.notice);
await r.connect();
await ws.connected;
ws.send(["NOTICE", "dummy notice"]);
ws.close();
await ws.closed;
// wait a bit since it's possible that the callback have not been called yet.
await delay(100);
expect(spyCbs.notice).toBeCalledTimes(1);
expect(spyCbs.notice).toBeCalledWith(expect.stringContaining("dummy notice"));
});
});
describe("subscription", () => {
const rurl = "ws://localhost:8000";
const defaultOpts: SubscriptionOptions = {
eventVerifier: verifyEventSig,
skipVerification: false,
skipFilterMatching: false,
abortSubBeforeEoseTimeoutMs: 1000,
};
const optsWithDefault = (opts: Partial<SubscriptionOptions>) => {
return {
...defaultOpts,
...opts,
};
};
let server: WS;
let spyCbs: {
event: SubEventCb;
eose: SubEoseCb;
closed: SubClosedCb;
};
beforeEach(() => {
server = new WS(rurl, { jsonProtocol: true });
spyCbs = {
event: vi.fn((_) => {}),
eose: vi.fn((_) => {}),
closed: vi.fn((_) => {}),
};
});
afterEach(() => {
WS.clean();
});
test("normal case", async () => {
const r = initRelay(rurl, defaultRelayOpts);
setupMockRelayServer(server, [{ type: "events", eventsSpec: { content: "test", n: 5 } }]);
await r.connect();
const waitEose = new Deferred<void>();
// mock it to check if the eventVerifier is actually called
const eventVerifier = vi.fn((ev: NostrEvent) => verifyEventSig(ev));
const sub = r.prepareSub([{}], optsWithDefault({ eventVerifier, subId: "normal" }));
sub.on("event", spyCbs.event);
sub.on("eose", spyCbs.eose);
sub.on("eose", () => waitEose.resolve());
sub.on("closed", spyCbs.closed);
sub.req();
await expect(server).toReceiveMessage(["REQ", "normal", {}]);
await waitEose.promise;
sub.close();
await expect(server).toReceiveMessage(["CLOSE", "normal"]);
// mock relay sends: 5 EVENTs then EOSE
expect(spyCbs.event).toBeCalledTimes(5);
expect(spyCbs.eose).toBeCalledTimes(1);
expect(spyCbs.closed).not.toBeCalled();
// specified eventVerifier should be called for each event
expect(eventVerifier).toBeCalledTimes(5);
});
test("CLOSED by relay", async () => {
const r = initRelay(rurl, defaultRelayOpts);
setupMockRelayServer(server, [{ type: "closed", message: "closed by relay" }]);
await r.connect();
const waitClosed = new Deferred<void>();
const sub = r.prepareSub([{}], defaultOpts);
sub.on("event", spyCbs.event);
sub.on("eose", spyCbs.eose);
sub.on("closed", spyCbs.closed);
sub.on("closed", () => waitClosed.resolve());
sub.req();
await waitClosed.promise;
expect(spyCbs.closed).toBeCalledTimes(1);
expect(spyCbs.closed).toBeCalledWith("closed by relay");
expect(spyCbs.event).not.toBeCalled();
expect(spyCbs.eose).not.toBeCalled();
});
test("aborts before EOSE if relay doesn't return events for a while", async () => {
const r = initRelay(rurl, defaultRelayOpts);
setupMockRelayServer(server, [
{ type: "events", eventsSpec: { content: "test" } },
{ type: "delay", delayMs: 2000 },
{ type: "events", eventsSpec: { content: "delayed" } },
]);
await r.connect();
const waitEose = new Deferred<void>();
const sub = r.prepareSub([{}], defaultOpts);
sub.on("event", spyCbs.event);
sub.on("eose", spyCbs.eose);
sub.on("eose", () => waitEose.resolve());
sub.req();
await waitEose.promise;
sub.close();
// the subscription should be aborted before 2nd event is sent
expect(spyCbs.event).toBeCalledTimes(1);
expect(spyCbs.eose).toBeCalledTimes(1);
});
test("verifies signature by default", async () => {
const r = initRelay(rurl, defaultRelayOpts);
setupMockRelayServer(server, [
{ type: "events", eventsSpec: { content: "test", n: 5 } },
{ type: "events", eventsSpec: { content: "invalid", invalidSig: true } },
]);
await r.connect();
const waitEose = new Deferred<void>();
const sub = r.prepareSub([{}], defaultOpts);
sub.on("event", spyCbs.event);
sub.on("eose", () => waitEose.resolve());
sub.req();
await waitEose.promise;
sub.close();
// 5 valid events only
expect(spyCbs.event).toBeCalledTimes(5);
});
test("skips signature verification if enabled", async () => {
const r = initRelay(rurl, defaultRelayOpts);
setupMockRelayServer(server, [
{ type: "events", eventsSpec: { content: "test", n: 5 } },
{ type: "events", eventsSpec: { content: "invalid", invalidSig: true } },
]);
await r.connect();
const waitEose = new Deferred<void>();
const sub = r.prepareSub([{}], optsWithDefault({ skipVerification: true }));
sub.on("event", spyCbs.event);
sub.on("eose", () => waitEose.resolve());
sub.req();
await waitEose.promise;
sub.close();
// 5 valid events + 1 invalid event
expect(spyCbs.event).toBeCalledTimes(6);
});
test("match events with filters by default", async () => {
const r = initRelay(rurl, defaultRelayOpts);
setupMockRelayServer(server, [
{ type: "events", eventsSpec: { kind: 1, content: "test", n: 5 } },
{ type: "events", eventsSpec: { kind: 0, content: "malicious", n: 1 } },
]);
await r.connect();
const waitEose = new Deferred<void>();
const sub = r.prepareSub([{ kinds: [1] }], defaultOpts);
sub.on("event", spyCbs.event);
sub.on("eose", () => waitEose.resolve());
sub.req();
await waitEose.promise;
sub.close();
// 5 valid events only
expect(spyCbs.event).toBeCalledTimes(5);
});
test("skips matching events with filters if enabled", async () => {
const r = initRelay(rurl, defaultRelayOpts);
setupMockRelayServer(server, [
{ type: "events", eventsSpec: { kind: 1, content: "test", n: 5 } },
{ type: "events", eventsSpec: { kind: 0, content: "malicious", n: 1 } },
]);
await r.connect();
const waitEose = new Deferred<void>();
const sub = r.prepareSub([{ kinds: [1] }], optsWithDefault({ skipFilterMatching: true }));
sub.on("event", spyCbs.event);
sub.on("eose", () => waitEose.resolve());
sub.req();
await waitEose.promise;
sub.close();
// 5 valid events + 1 invalid event
expect(spyCbs.event).toBeCalledTimes(6);
});
});
});