From 81b4fd0ce89ca90eedf4b8e190fb7c685eac2e2d Mon Sep 17 00:00:00 2001 From: ennmichael Date: Fri, 17 Mar 2023 11:35:14 +0100 Subject: [PATCH 1/5] fetch relay info --- packages/nostr/README.md | 6 +- packages/nostr/relay/config.toml | 7 + packages/nostr/src/client/conn.ts | 12 +- packages/nostr/src/client/emitter.ts | 26 +- packages/nostr/src/client/index.ts | 340 +++++++++++++++++++++++++-- packages/nostr/test/dm.ts | 44 ++-- packages/nostr/test/relay-info.ts | 26 ++ packages/nostr/test/setup.ts | 73 ++++-- packages/nostr/test/text-note.ts | 14 +- 9 files changed, 458 insertions(+), 90 deletions(-) create mode 100644 packages/nostr/test/relay-info.ts diff --git a/packages/nostr/README.md b/packages/nostr/README.md index fa6296fd..55d81791 100644 --- a/packages/nostr/README.md +++ b/packages/nostr/README.md @@ -9,7 +9,7 @@ A strongly-typed nostr client for Node and the browser. The goal of the project is to have all of the following implemented and tested against a real-world relay implementation. -_Progress: 4/34 (12%)._ +_Progress: 5/34 (15%)._ - [X] NIP-01: Basic protocol flow description - [ ] NIP-02: Contact List and Petnames @@ -22,7 +22,7 @@ _Progress: 4/34 (12%)._ - [ ] NIP-09: Event Deletion - [ ] NIP-10: Conventions for clients' use of `e` and `p` tags in text events - TODO Check if this applies -- [ ] NIP-11: Relay Information Document +- [X] NIP-11: Relay Information Document - [ ] NIP-12: Generic Tag Queries - [ ] NIP-13: Proof of Work - [ ] NIP-14: Subject tag in text events @@ -30,7 +30,7 @@ _Progress: 4/34 (12%)._ - [ ] NIP-19: bech32-encoded entities - [X] `npub` - [X] `nsec` - - [ ] `nprofile` + - [ ] `note`, `nprofile`, `nevent`, `nrelay`, `naddr` - [X] NIP-20: Command Results - [ ] NIP-21: `nostr:` URL scheme - [ ] NIP-23: Long-form Content diff --git a/packages/nostr/relay/config.toml b/packages/nostr/relay/config.toml index f7ea106c..10bacad1 100644 --- a/packages/nostr/relay/config.toml +++ b/packages/nostr/relay/config.toml @@ -1,3 +1,10 @@ +[info] +relay_url = "wss://nostr.example.com/" +name = "nostr-rs-relay" +description = "nostr-rs-relay description" +contact = "mailto:contact@example.com" +favicon = "favicon.ico" + [authorization] nip42_auth = true # This seems to have no effect. diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index a2d8eac1..21c8bf20 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -14,13 +14,12 @@ import { unixTimestamp } from "../util" */ export class Conn { readonly #socket: WebSocket + // TODO This should probably be moved to Nostr because deciding whether or not to send a message + // requires looking at relay info which the Conn should know nothing about. /** * Messages which were requested to be sent before the websocket was ready. * Once the websocket becomes ready, these messages will be sent and cleared. */ - // TODO Another reason why pending messages might be required is when the user tries to send a message - // before NIP-44 auth. The legacy code reuses the same array for these two but I think they should be - // different, and the NIP-44 stuff should be handled by Nostr. #pending: OutgoingMessage[] = [] /** * Callback for errors. @@ -35,11 +34,13 @@ export class Conn { url, onMessage, onOpen, + onClose, onError, }: { url: URL onMessage: (msg: IncomingMessage) => void onOpen: () => void + onClose: () => void onError: (err: unknown) => void }) { this.#onError = onError @@ -71,9 +72,8 @@ export class Conn { onOpen() }) - this.#socket.addEventListener("error", (err) => { - onError(err) - }) + this.#socket.addEventListener("close", onClose) + this.#socket.addEventListener("error", onError) } send(msg: OutgoingMessage): void { diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index b08d100e..a57b5bdc 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -6,12 +6,17 @@ import { Event, EventId } from "../event" * Overrides providing better types for EventEmitter methods. */ export class EventEmitter extends Base { + constructor() { + super({ captureRejections: true }) + } + override addListener(eventName: "newListener", listener: NewListener): this override addListener( eventName: "removeListener", listener: RemoveListener ): this override addListener(eventName: "open", listener: OpenListener): this + override addListener(eventName: "close", listener: CloseListener): this override addListener(eventName: "event", listener: EventListener): this override addListener(eventName: "notice", listener: NoticeListener): this override addListener(eventName: "ok", listener: OkListener): this @@ -24,6 +29,7 @@ export class EventEmitter extends Base { override emit(eventName: "newListener", listener: NewListener): boolean override emit(eventName: "removeListener", listener: RemoveListener): boolean override emit(eventName: "open", relay: URL, nostr: Nostr): boolean + override emit(eventName: "close", relay: URL, nostr: Nostr): boolean override emit(eventName: "event", params: EventParams, nostr: Nostr): boolean override emit(eventName: "notice", notice: string, nostr: Nostr): boolean override emit(eventName: "ok", params: OkParams, nostr: Nostr): boolean @@ -44,6 +50,7 @@ export class EventEmitter extends Base { override listeners(eventName: "newListener"): EventListener[] override listeners(eventName: "removeListener"): EventListener[] override listeners(eventName: "open"): OpenListener[] + override listeners(eventName: "close"): CloseListener[] override listeners(eventName: "event"): EventListener[] override listeners(eventName: "notice"): NoticeListener[] override listeners(eventName: "ok"): OkListener[] @@ -56,6 +63,7 @@ export class EventEmitter extends Base { override off(eventName: "newListener", listener: NewListener): this override off(eventName: "removeListener", listener: RemoveListener): this override off(eventName: "open", listener: OpenListener): this + override off(eventName: "close", listener: CloseListener): this override off(eventName: "event", listener: EventListener): this override off(eventName: "notice", listener: NoticeListener): this override off(eventName: "ok", listener: OkListener): this @@ -68,6 +76,7 @@ export class EventEmitter extends Base { override on(eventName: "newListener", listener: NewListener): this override on(eventName: "removeListener", listener: RemoveListener): this override on(eventName: "open", listener: OpenListener): this + override on(eventName: "close", listener: CloseListener): this override on(eventName: "event", listener: EventListener): this override on(eventName: "notice", listener: NoticeListener): this override on(eventName: "ok", listener: OkListener): this @@ -80,6 +89,7 @@ export class EventEmitter extends Base { override once(eventName: "newListener", listener: NewListener): this override once(eventName: "removeListener", listener: RemoveListener): this override once(eventName: "open", listener: OpenListener): this + override once(eventName: "close", listener: CloseListener): this override once(eventName: "event", listener: EventListener): this override once(eventName: "notice", listener: NoticeListener): this override once(eventName: "ok", listener: OkListener): this @@ -98,6 +108,7 @@ export class EventEmitter extends Base { listener: RemoveListener ): this override prependListener(eventName: "open", listener: OpenListener): this + override prependListener(eventName: "close", listener: CloseListener): this override prependListener(eventName: "event", listener: EventListener): this override prependListener(eventName: "notice", listener: NoticeListener): this override prependListener(eventName: "ok", listener: OkListener): this @@ -116,6 +127,10 @@ export class EventEmitter extends Base { listener: RemoveListener ): this override prependOnceListener(eventName: "open", listener: OpenListener): this + override prependOnceListener( + eventName: "close", + listener: CloseListener + ): this override prependOnceListener( eventName: "event", listener: EventListener @@ -144,6 +159,7 @@ export class EventEmitter extends Base { listener: RemoveListener ): this override removeListener(eventName: "open", listener: OpenListener): this + override removeListener(eventName: "close", listener: CloseListener): this override removeListener(eventName: "event", listener: EventListener): this override removeListener(eventName: "notice", listener: NoticeListener): this override removeListener(eventName: "ok", listener: OkListener): this @@ -156,16 +172,16 @@ export class EventEmitter extends Base { override rawListeners(eventName: EventName): Listener[] { return super.rawListeners(eventName) as Listener[] } - - // TODO - // emitter[Symbol.for('nodejs.rejection')](err, eventName[, ...args]) shenanigans? } -// TODO Refactor the params to be a single interface +// TODO Refactor the params to always be a single interface +// TODO Params should always include relay as well +// TODO Params should not include Nostr, `this` should be Nostr type EventName = | "newListener" | "removeListener" | "open" + | "close" | "event" | "notice" | "ok" @@ -175,6 +191,7 @@ type EventName = type NewListener = (eventName: EventName, listener: Listener) => void type RemoveListener = (eventName: EventName, listener: Listener) => void type OpenListener = (relay: URL, nostr: Nostr) => void +type CloseListener = (relay: URL, nostr: Nostr) => void type EventListener = (params: EventParams, nostr: Nostr) => void type NoticeListener = (notice: string, nostr: Nostr) => void type OkListener = (params: OkParams, nostr: Nostr) => void @@ -185,6 +202,7 @@ type Listener = | NewListener | RemoveListener | OpenListener + | CloseListener | EventListener | NoticeListener | OkListener diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 364c1375..447b10a3 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -5,12 +5,29 @@ import { Conn } from "./conn" import * as secp from "@noble/secp256k1" import { EventEmitter } from "./emitter" +// TODO The EventEmitter will call "error" by default if errors are thrown, +// but if there is no error listener it actually rethrows the error. Revisit +// the try/catch stuff to be consistent with this. + /** * A nostr client. * * TODO Document the events here + * TODO When document this type, remember to explicitly say that promise rejections will also be routed to "error"! */ export class Nostr extends EventEmitter { + static get CONNECTING(): ReadyState.CONNECTING { + return ReadyState.CONNECTING + } + + static get OPEN(): ReadyState.OPEN { + return ReadyState.OPEN + } + + static get CLOSED(): ReadyState.CLOSED { + return ReadyState.CLOSED + } + // TODO NIP-44 AUTH, leave this for later /** * Open connections to relays. @@ -28,9 +45,14 @@ export class Nostr extends EventEmitter { * this method will only update it with the new options, and an exception will be thrown * if no options are specified. */ - open(url: URL | string, opts?: { read?: boolean; write?: boolean }): void { + async open( + url: URL | string, + opts?: { read?: boolean; write?: boolean; fetchInfo?: boolean } + ): Promise { + const connUrl = new URL(url) + // If the connection already exists, update the options. - const existingConn = this.#conns.get(url.toString()) + const existingConn = this.#conns.get(connUrl.toString()) if (existingConn !== undefined) { if (opts === undefined) { throw new Error( @@ -46,7 +68,11 @@ export class Nostr extends EventEmitter { return } - const connUrl = new URL(url) + // Fetch the relay info in parallel to opening the WebSocket connection. + const fetchInfo = + opts?.fetchInfo === false + ? Promise.resolve({}) + : this.#fetchRelayInfo(connUrl) // If there is no existing connection, open a new one. const conn = new Conn({ @@ -87,12 +113,74 @@ export class Nostr extends EventEmitter { this.emit("error", err, this) } }, - // Forward "open" events. - onOpen: () => this.emit("open", connUrl, this), + // Handle "open" events. + onOpen: async () => { + // Update the connection readyState. + const conn = this.#conns.get(connUrl.toString()) + if (conn === undefined) { + this.emit( + "error", + new Error( + `bug: expected connection to ${connUrl.toString()} to be in the map` + ), + this + ) + } else { + if (conn.readyState !== ReadyState.CONNECTING) { + this.emit( + "error", + new Error( + `bug: expected connection to ${connUrl.toString()} to have readyState CONNECTING, got ${ + conn.readyState + }` + ), + this + ) + } + this.#conns.set(connUrl.toString(), { + ...conn, + readyState: ReadyState.OPEN, + info: await fetchInfo, + }) + } + // Forward the event to the user. + this.emit("open", connUrl, this) + }, + // Handle "close" events. + onClose: () => { + // Update the connection readyState. + const conn = this.#conns.get(connUrl.toString()) + if (conn === undefined) { + this.emit( + "error", + new Error( + `bug: expected connection to ${connUrl.toString()} to be in the map` + ), + this + ) + } else { + this.#conns.set(connUrl.toString(), { + ...conn, + readyState: ReadyState.CLOSED, + info: + conn.readyState === ReadyState.CONNECTING ? undefined : conn.info, + }) + } + // Forward the event to the user. + this.emit("close", connUrl, this) + }, // Forward errors on this connection. onError: (err) => this.emit("error", err, this), }) + this.#conns.set(connUrl.toString(), { + conn, + auth: false, + read: opts?.read ?? true, + write: opts?.write ?? true, + readyState: ReadyState.CONNECTING, + }) + // Resend existing subscriptions to this connection. for (const [key, filters] of this.#subscriptions.entries()) { conn.send({ @@ -101,13 +189,6 @@ export class Nostr extends EventEmitter { filters, }) } - - this.#conns.set(url.toString(), { - conn, - auth: false, - read: opts?.read ?? true, - write: opts?.write ?? true, - }) } /** @@ -116,23 +197,19 @@ export class Nostr extends EventEmitter { * @param url If specified, only close the connection to this relay. If the connection does * not exist, an exception will be thrown. If this parameter is not specified, all connections * will be closed. - * - * TODO There needs to be a way to check connection state. isOpen(), isReady(), isClosing() maybe? - * Because of how WebSocket states work this isn't as simple as it seems. */ close(url?: URL | string): void { if (url === undefined) { for (const { conn } of this.#conns.values()) { conn.close() } - this.#conns.clear() return } - const c = this.#conns.get(url.toString()) + const connUrl = new URL(url) + const c = this.#conns.get(connUrl.toString()) if (c === undefined) { throw new Error(`connection to ${url} doesn't exist`) } - this.#conns.delete(url.toString()) c.conn.close() } @@ -202,9 +279,219 @@ export class Nostr extends EventEmitter { }) } } + + // TODO Test the readyState stuff + /** + * Get the relays which this client has tried to open connections to. + */ + get relays(): Relay[] { + return [...this.#conns.entries()].map(([url, c]) => { + if (c.readyState === ReadyState.CONNECTING) { + return { + url: new URL(url), + readyState: ReadyState.CONNECTING, + } + } else if (c.readyState === ReadyState.OPEN) { + return { + url: new URL(url), + readyState: ReadyState.OPEN, + info: c.info, + } + } else if (c.readyState === ReadyState.CLOSED) { + return { + url: new URL(url), + readyState: ReadyState.CLOSED, + info: c.info, + } + } else { + throw new Error("bug: unknown readyState") + } + }) + } + + /** + * Fetch the NIP-11 relay info with some reasonable timeout. + */ + async #fetchRelayInfo(url: URL): Promise { + url = new URL(url.toString().replace(/^ws/, "http")) + try { + const abort = new AbortController() + const timeout = setTimeout(() => abort.abort(), 15_000) + const res = await fetch(url, { + signal: abort.signal, + headers: { + Accept: "application/nostr+json", + }, + }) + clearTimeout(timeout) + const info = await res.json() + // Validate the known fields in the JSON. + if (info.name !== undefined && typeof info.name !== "string") { + info.name = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "name" to be a string: ${JSON.stringify( + info + )}` + ), + this + ) + } + if ( + info.description !== undefined && + typeof info.description !== "string" + ) { + info.description = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "description" to be a string: ${JSON.stringify( + info + )}` + ), + this + ) + } + if (info.pubkey !== undefined && typeof info.pubkey !== "string") { + info.pubkey = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "pubkey" to be a string: ${JSON.stringify( + info + )}` + ), + this + ) + } + if (info.contact !== undefined && typeof info.contact !== "string") { + info.contact = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "contact" to be a string: ${JSON.stringify( + info + )}` + ), + this + ) + } + if (info.supported_nips !== undefined) { + if (info.supported_nips instanceof Array) { + if (info.supported_nips.some((e: unknown) => typeof e !== "number")) { + info.supported_nips = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify( + info + )}` + ), + this + ) + } + } else { + info.supported_nips = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "supported_nips" to be an array: ${JSON.stringify( + info + )}` + ), + this + ) + } + } + if (info.software !== undefined && typeof info.software !== "string") { + info.software = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "software" to be a string: ${JSON.stringify( + info + )}` + ), + this + ) + } + if (info.version !== undefined && typeof info.version !== "string") { + info.version = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "version" to be a string: ${JSON.stringify( + info + )}` + ), + this + ) + } + return info + } catch (e) { + this.emit("error", e, this) + return {} + } + } } -interface ConnState { +/** + * The state of a relay connection. + */ +export enum ReadyState { + /** + * The connection has not been established yet. + */ + CONNECTING = 0, + /** + * The connection has been established. + */ + OPEN = 1, + /** + * The connection has been closed, forcefully or gracefully, by either party. + */ + CLOSED = 2, +} + +interface RelayCommon { + url: URL +} + +export type Relay = RelayCommon & + ( + | { + readyState: ReadyState.CONNECTING + } + | { + readyState: ReadyState.OPEN + info: RelayInfo + } + | { + readyState: ReadyState.CLOSED + /** + * If the relay is closed before the opening process is fully finished, + * the relay info may be undefined. + */ + info?: RelayInfo + } + ) + +/** + * The information that a relay broadcasts about itself. + */ +export interface RelayInfo { + name?: string + description?: string + pubkey?: PublicKey + contact?: string + supported_nips?: number[] + software?: string + version?: string + [key: string]: unknown +} + +interface ConnStateCommon { conn: Conn /** * Has this connection been authenticated via NIP-44 AUTH? @@ -220,6 +507,21 @@ interface ConnState { write: boolean } +type ConnState = ConnStateCommon & + ( + | { + readyState: ReadyState.CONNECTING + } + | { + readyState: ReadyState.OPEN + info: RelayInfo + } + | { + readyState: ReadyState.CLOSED + info?: RelayInfo + } + ) + /** * A string uniquely identifying a client subscription. */ diff --git a/packages/nostr/test/dm.ts b/packages/nostr/test/dm.ts index 1d024a89..104345c6 100644 --- a/packages/nostr/test/dm.ts +++ b/packages/nostr/test/dm.ts @@ -8,7 +8,8 @@ describe("dm", async function () { // Test that the intended recipient can receive and decrypt the direct message. it("to intended recipient", (done) => { - setup(done).then( + setup( + done, ({ publisher, publisherPubkey, @@ -17,33 +18,27 @@ describe("dm", async function () { subscriberPubkey, subscriberSecret, timestamp, + done, }) => { // Expect the direct message. subscriber.on( "event", async ({ event, subscriptionId: actualSubscriptionId }, nostr) => { - try { - assert.equal(nostr, subscriber) - assert.equal(event.kind, EventKind.DirectMessage) - assert.equal(event.pubkey, parsePublicKey(publisherPubkey)) - assert.equal(actualSubscriptionId, subscriptionId) - assert.ok(event.created_at >= timestamp) + assert.equal(nostr, subscriber) + assert.equal(event.kind, EventKind.DirectMessage) + assert.equal(event.pubkey, parsePublicKey(publisherPubkey)) + assert.equal(actualSubscriptionId, subscriptionId) + assert.ok(event.created_at >= timestamp) - if (event.kind === EventKind.DirectMessage) { - assert.equal( - event.getRecipient(), - parsePublicKey(subscriberPubkey) - ) - assert.equal(await event.getMessage(subscriberSecret), message) - } - - publisher.close() - subscriber.close() - - done() - } catch (e) { - done(e) + if (event.kind === EventKind.DirectMessage) { + assert.equal( + event.getRecipient(), + parsePublicKey(subscriberPubkey) + ) + assert.equal(await event.getMessage(subscriberSecret), message) } + + done() } ) @@ -67,7 +62,8 @@ describe("dm", async function () { // Test that an unintended recipient still receives the direct message event, but cannot decrypt it. it("to unintended recipient", (done) => { - setup(done).then( + setup( + done, ({ publisher, publisherPubkey, @@ -75,6 +71,7 @@ describe("dm", async function () { subscriber, subscriberSecret, timestamp, + done, }) => { const recipientPubkey = "npub1u2dl3scpzuwyd45flgtm3wcjgv20j4azuzgevdpgtsvvmqzvc63sz327gc" @@ -101,9 +98,6 @@ describe("dm", async function () { ) } - publisher.close() - subscriber.close() - done() } catch (e) { done(e) diff --git a/packages/nostr/test/relay-info.ts b/packages/nostr/test/relay-info.ts new file mode 100644 index 00000000..382ffe75 --- /dev/null +++ b/packages/nostr/test/relay-info.ts @@ -0,0 +1,26 @@ +import assert from "assert" +import { Nostr } from "../src/client" +import { setup } from "./setup" + +describe("relay info", async function () { + it("fetching relay info", (done) => { + setup(done, ({ publisher, done }) => { + assert.strictEqual(publisher.relays.length, 1) + const relay = publisher.relays[0] + assert.strictEqual(relay.readyState, Nostr.OPEN) + if (relay.readyState === Nostr.OPEN) { + assert.strictEqual(relay.info.name, "nostr-rs-relay") + assert.strictEqual(relay.info.description, "nostr-rs-relay description") + assert.strictEqual(relay.info.pubkey, undefined) + assert.strictEqual(relay.info.contact, "mailto:contact@example.com") + assert.ok((relay.info.supported_nips?.length ?? 0) > 0) + assert.strictEqual( + relay.info.software, + "https://git.sr.ht/~gheartsfield/nostr-rs-relay" + ) + assert.strictEqual(relay.info.version, "0.8.8") + } + done() + }) + }) +}) diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts index 1505fed3..cd2cb639 100644 --- a/packages/nostr/test/setup.ts +++ b/packages/nostr/test/setup.ts @@ -10,33 +10,60 @@ export interface Setup { subscriberPubkey: string timestamp: number url: URL + /** + * Signal that the test is done. Call this instead of the callback provided by + * mocha. This will also take care of test cleanup. + */ + done: (e?: unknown) => void } -export async function setup(done: jest.DoneCallback): Promise { - await restartRelay() - const publisher = new Nostr() - const subscriber = new Nostr() - const url = new URL("ws://localhost:12648") +export async function setup( + done: jest.DoneCallback, + test: (setup: Setup) => void | Promise +) { + try { + await restartRelay() + const publisher = new Nostr() + const subscriber = new Nostr() + const url = new URL("ws://localhost:12648") - publisher.on("error", done) - subscriber.on("error", done) + publisher.on("error", done) + subscriber.on("error", done) - publisher.open(url) - subscriber.open(url) + const openPromise = Promise.all([ + new Promise((resolve) => publisher.on("open", resolve)), + new Promise((resolve) => subscriber.on("open", resolve)), + ]) - return { - publisher, - publisherSecret: - "nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363", - publisherPubkey: - "npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7", - subscriber, - subscriberSecret: - "nsec1fxvlyqn3rugvxwaz6dr5h8jcfn0fe0lxyp7pl4mgntxfzqr7dmgst7z9ps", - subscriberPubkey: - "npub1mtwskm558jugtj724nsgf3jf80c5adl39ttydngrn48250l6xmjqa00yxd", - timestamp: unixTimestamp(), - url, + await publisher.open(url) + await subscriber.open(url) + + await openPromise + + const result = test({ + publisher, + publisherSecret: + "nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363", + publisherPubkey: + "npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7", + subscriber, + subscriberSecret: + "nsec1fxvlyqn3rugvxwaz6dr5h8jcfn0fe0lxyp7pl4mgntxfzqr7dmgst7z9ps", + subscriberPubkey: + "npub1mtwskm558jugtj724nsgf3jf80c5adl39ttydngrn48250l6xmjqa00yxd", + timestamp: unixTimestamp(), + url, + done: (e?: unknown) => { + publisher.close() + subscriber.close() + done(e) + }, + }) + if (result instanceof Promise) { + await result + } + } catch (e) { + done(e) } } @@ -60,7 +87,7 @@ async function restartRelay() { nostr.close() resolve(true) }) - nostr.open("ws://localhost:12648") + nostr.open("ws://localhost:12648", { fetchInfo: false }) }) if (ok) { break diff --git a/packages/nostr/test/text-note.ts b/packages/nostr/test/text-note.ts index 1f581862..4d28dee5 100644 --- a/packages/nostr/test/text-note.ts +++ b/packages/nostr/test/text-note.ts @@ -8,13 +8,15 @@ describe("text note", async function () { // Test that a text note can be published by one client and received by the other. it("publish and receive", (done) => { - setup(done).then( + setup( + done, ({ publisher, publisherSecret, publisherPubkey, subscriber, timestamp, + done, }) => { // Expect the test event. subscriber.on( @@ -26,10 +28,6 @@ describe("text note", async function () { assert.strictEqual(event.created_at, timestamp) assert.strictEqual(event.content, note) assert.strictEqual(actualSubscriptionId, subscriptionId) - - subscriber.close() - publisher.close() - done() } ) @@ -53,7 +51,7 @@ describe("text note", async function () { // Test that a client interprets an "OK" message after publishing a text note. it("publish and ok", function (done) { - setup(done).then(({ publisher, subscriber, publisherSecret, url }) => { + setup(done, ({ publisher, publisherSecret, url, done }) => { // TODO No signEvent, have a convenient way to do this signEvent(createTextNote(note), publisherSecret).then((event) => { publisher.on("ok", (params, nostr) => { @@ -61,10 +59,6 @@ describe("text note", async function () { assert.equal(params.eventId, event.id) assert.equal(params.relay.toString(), url.toString()) assert.equal(params.ok, true) - - publisher.close() - subscriber.close() - done() }) -- 2.45.2 From 94d10a07be627da58194b234d6094cc7ad8637fb Mon Sep 17 00:00:00 2001 From: ennmichael Date: Fri, 17 Mar 2023 21:29:52 +0100 Subject: [PATCH 2/5] test readyState --- packages/nostr/test/ready-state.ts | 24 ++++++++++++++++++++++++ packages/nostr/test/setup.ts | 9 +++++---- 2 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 packages/nostr/test/ready-state.ts diff --git a/packages/nostr/test/ready-state.ts b/packages/nostr/test/ready-state.ts new file mode 100644 index 00000000..22820ccd --- /dev/null +++ b/packages/nostr/test/ready-state.ts @@ -0,0 +1,24 @@ +import assert from "assert" +import { Nostr } from "../src/client" +import { relayUrl } from "./setup" + +describe("ready state", async function () { + it("ready state transitions", (done) => { + const nostr = new Nostr() + + nostr.on("error", done) + + nostr.on("open", () => { + assert.strictEqual(nostr.relays[0].readyState, Nostr.OPEN) + nostr.close() + }) + + nostr.on("close", () => { + assert.strictEqual(nostr.relays[0].readyState, Nostr.CLOSED) + done() + }) + + nostr.open(relayUrl) + assert.strictEqual(nostr.relays[0].readyState, Nostr.CONNECTING) + }) +}) diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts index cd2cb639..38b45360 100644 --- a/packages/nostr/test/setup.ts +++ b/packages/nostr/test/setup.ts @@ -1,6 +1,8 @@ import { Nostr } from "../src/client" import { unixTimestamp } from "../src/util" +export const relayUrl = new URL("ws://localhost:12648") + export interface Setup { publisher: Nostr publisherSecret: string @@ -25,7 +27,6 @@ export async function setup( await restartRelay() const publisher = new Nostr() const subscriber = new Nostr() - const url = new URL("ws://localhost:12648") publisher.on("error", done) subscriber.on("error", done) @@ -35,8 +36,8 @@ export async function setup( new Promise((resolve) => subscriber.on("open", resolve)), ]) - await publisher.open(url) - await subscriber.open(url) + await publisher.open(relayUrl) + await subscriber.open(relayUrl) await openPromise @@ -52,7 +53,7 @@ export async function setup( subscriberPubkey: "npub1mtwskm558jugtj724nsgf3jf80c5adl39ttydngrn48250l6xmjqa00yxd", timestamp: unixTimestamp(), - url, + url: relayUrl, done: (e?: unknown) => { publisher.close() subscriber.close() -- 2.45.2 From 9c6d80db5e273333222fb7159883c00d3216ad59 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Fri, 17 Mar 2023 21:43:55 +0100 Subject: [PATCH 3/5] export fetchRelayInfo --- packages/nostr/src/client/index.ts | 209 ++++++++++++----------------- 1 file changed, 85 insertions(+), 124 deletions(-) diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 447b10a3..f8c50515 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -28,7 +28,6 @@ export class Nostr extends EventEmitter { return ReadyState.CLOSED } - // TODO NIP-44 AUTH, leave this for later /** * Open connections to relays. */ @@ -72,7 +71,7 @@ export class Nostr extends EventEmitter { const fetchInfo = opts?.fetchInfo === false ? Promise.resolve({}) - : this.#fetchRelayInfo(connUrl) + : fetchRelayInfo(connUrl).catch((e) => this.emit("error", e, this)) // If there is no existing connection, open a new one. const conn = new Conn({ @@ -308,132 +307,94 @@ export class Nostr extends EventEmitter { } }) } +} - /** - * Fetch the NIP-11 relay info with some reasonable timeout. - */ - async #fetchRelayInfo(url: URL): Promise { - url = new URL(url.toString().replace(/^ws/, "http")) - try { - const abort = new AbortController() - const timeout = setTimeout(() => abort.abort(), 15_000) - const res = await fetch(url, { - signal: abort.signal, - headers: { - Accept: "application/nostr+json", - }, - }) - clearTimeout(timeout) - const info = await res.json() - // Validate the known fields in the JSON. - if (info.name !== undefined && typeof info.name !== "string") { - info.name = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "name" to be a string: ${JSON.stringify( - info - )}` - ), - this +// TODO Keep in mind this should be part of the public API of the lib +/** + * Fetch the NIP-11 relay info with some reasonable timeout. Throw an error if + * the info is invalid. + */ +export async function fetchRelayInfo(url: URL | string): Promise { + url = new URL(url.toString().trim().replace(/^ws/, "http")) + const abort = new AbortController() + const timeout = setTimeout(() => abort.abort(), 15_000) + const res = await fetch(url, { + signal: abort.signal, + headers: { + Accept: "application/nostr+json", + }, + }) + clearTimeout(timeout) + const info = await res.json() + // Validate the known fields in the JSON. + if (info.name !== undefined && typeof info.name !== "string") { + info.name = undefined + throw new ProtocolError( + `invalid relay info, expected "name" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.description !== undefined && typeof info.description !== "string") { + info.description = undefined + throw new ProtocolError( + `invalid relay info, expected "description" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.pubkey !== undefined && typeof info.pubkey !== "string") { + info.pubkey = undefined + throw new ProtocolError( + `invalid relay info, expected "pubkey" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.contact !== undefined && typeof info.contact !== "string") { + info.contact = undefined + throw new ProtocolError( + `invalid relay info, expected "contact" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.supported_nips !== undefined) { + if (info.supported_nips instanceof Array) { + if (info.supported_nips.some((e: unknown) => typeof e !== "number")) { + info.supported_nips = undefined + throw new ProtocolError( + `invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify( + info + )}` ) } - if ( - info.description !== undefined && - typeof info.description !== "string" - ) { - info.description = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "description" to be a string: ${JSON.stringify( - info - )}` - ), - this - ) - } - if (info.pubkey !== undefined && typeof info.pubkey !== "string") { - info.pubkey = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "pubkey" to be a string: ${JSON.stringify( - info - )}` - ), - this - ) - } - if (info.contact !== undefined && typeof info.contact !== "string") { - info.contact = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "contact" to be a string: ${JSON.stringify( - info - )}` - ), - this - ) - } - if (info.supported_nips !== undefined) { - if (info.supported_nips instanceof Array) { - if (info.supported_nips.some((e: unknown) => typeof e !== "number")) { - info.supported_nips = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify( - info - )}` - ), - this - ) - } - } else { - info.supported_nips = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "supported_nips" to be an array: ${JSON.stringify( - info - )}` - ), - this - ) - } - } - if (info.software !== undefined && typeof info.software !== "string") { - info.software = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "software" to be a string: ${JSON.stringify( - info - )}` - ), - this - ) - } - if (info.version !== undefined && typeof info.version !== "string") { - info.version = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "version" to be a string: ${JSON.stringify( - info - )}` - ), - this - ) - } - return info - } catch (e) { - this.emit("error", e, this) - return {} + } else { + info.supported_nips = undefined + throw new ProtocolError( + `invalid relay info, expected "supported_nips" to be an array: ${JSON.stringify( + info + )}` + ) } } + if (info.software !== undefined && typeof info.software !== "string") { + info.software = undefined + throw new ProtocolError( + `invalid relay info, expected "software" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.version !== undefined && typeof info.version !== "string") { + info.version = undefined + throw new ProtocolError( + `invalid relay info, expected "version" to be a string: ${JSON.stringify( + info + )}` + ) + } + return info } /** @@ -478,7 +439,7 @@ export type Relay = RelayCommon & ) /** - * The information that a relay broadcasts about itself. + * The information that a relay broadcasts about itself as defined in NIP-11. */ export interface RelayInfo { name?: string -- 2.45.2 From f7cf0a7b779359ea5bd433184a7d281d5edcfffc Mon Sep 17 00:00:00 2001 From: ennmichael Date: Fri, 17 Mar 2023 22:12:22 +0100 Subject: [PATCH 4/5] cleanup --- packages/nostr/src/client/conn.ts | 46 ++++++++++++------- packages/nostr/src/client/emitter.ts | 1 + packages/nostr/src/client/index.ts | 69 +++++++++++++++------------- packages/nostr/test/setup.ts | 4 +- 4 files changed, 69 insertions(+), 51 deletions(-) diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index 21c8bf20..47642e6d 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -14,7 +14,7 @@ import { unixTimestamp } from "../util" */ export class Conn { readonly #socket: WebSocket - // TODO This should probably be moved to Nostr because deciding whether or not to send a message + // TODO This should probably be moved to Nostr (ConnState) because deciding whether or not to send a message // requires looking at relay info which the Conn should know nothing about. /** * Messages which were requested to be sent before the websocket was ready. @@ -39,8 +39,8 @@ export class Conn { }: { url: URL onMessage: (msg: IncomingMessage) => void - onOpen: () => void - onClose: () => void + onOpen: () => void | Promise + onClose: () => void | Promise onError: (err: unknown) => void }) { this.#onError = onError @@ -48,14 +48,12 @@ export class Conn { // Handle incoming messages. this.#socket.addEventListener("message", async (msgData) => { - const value = msgData.data.valueOf() - // Validate and parse the message. - if (typeof value !== "string") { - const err = new ProtocolError(`invalid message data: ${value}`) - onError(err) - return - } try { + const value = msgData.data.valueOf() + // Validate and parse the message. + if (typeof value !== "string") { + throw new ProtocolError(`invalid message data: ${value}`) + } const msg = await parseIncomingMessage(value) onMessage(msg) } catch (err) { @@ -64,15 +62,31 @@ export class Conn { }) // When the connection is ready, send any outstanding messages. - this.#socket.addEventListener("open", () => { - for (const msg of this.#pending) { - this.send(msg) + this.#socket.addEventListener("open", async () => { + try { + for (const msg of this.#pending) { + this.send(msg) + } + this.#pending = [] + const result = onOpen() + if (result instanceof Promise) { + await result + } + } catch (e) { + onError(e) } - this.#pending = [] - onOpen() }) - this.#socket.addEventListener("close", onClose) + this.#socket.addEventListener("close", async () => { + try { + const result = onClose() + if (result instanceof Promise) { + await result + } + } catch (e) { + onError(e) + } + }) this.#socket.addEventListener("error", onError) } diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index a57b5bdc..260b38ae 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -177,6 +177,7 @@ export class EventEmitter extends Base { // TODO Refactor the params to always be a single interface // TODO Params should always include relay as well // TODO Params should not include Nostr, `this` should be Nostr +// TODO Ideas for events: "auth" for NIP-42 AUTH, "message" for the raw incoming messages type EventName = | "newListener" | "removeListener" diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index f8c50515..8036955b 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -44,10 +44,10 @@ export class Nostr extends EventEmitter { * this method will only update it with the new options, and an exception will be thrown * if no options are specified. */ - async open( + open( url: URL | string, opts?: { read?: boolean; write?: boolean; fetchInfo?: boolean } - ): Promise { + ): void { const connUrl = new URL(url) // If the connection already exists, update the options. @@ -76,6 +76,7 @@ export class Nostr extends EventEmitter { // If there is no existing connection, open a new one. const conn = new Conn({ url: connUrl, + // Handle messages on this connection. onMessage: async (msg) => { try { @@ -112,6 +113,7 @@ export class Nostr extends EventEmitter { this.emit("error", err, this) } }, + // Handle "open" events. onOpen: async () => { // Update the connection readyState. @@ -145,6 +147,7 @@ export class Nostr extends EventEmitter { // Forward the event to the user. this.emit("open", connUrl, this) }, + // Handle "close" events. onClose: () => { // Update the connection readyState. @@ -168,18 +171,14 @@ export class Nostr extends EventEmitter { // Forward the event to the user. this.emit("close", connUrl, this) }, + + // TODO If there is no error handler, this will silently swallow the error. Maybe have an + // #onError method which re-throws if emit() returns false? This should at least make + // some noise. // Forward errors on this connection. onError: (err) => this.emit("error", err, this), }) - this.#conns.set(connUrl.toString(), { - conn, - auth: false, - read: opts?.read ?? true, - write: opts?.write ?? true, - readyState: ReadyState.CONNECTING, - }) - // Resend existing subscriptions to this connection. for (const [key, filters] of this.#subscriptions.entries()) { conn.send({ @@ -188,6 +187,14 @@ export class Nostr extends EventEmitter { filters, }) } + + this.#conns.set(connUrl.toString(), { + conn, + auth: false, + read: opts?.read ?? true, + write: opts?.write ?? true, + readyState: ReadyState.CONNECTING, + }) } /** @@ -279,7 +286,6 @@ export class Nostr extends EventEmitter { } } - // TODO Test the readyState stuff /** * Get the relays which this client has tried to open connections to. */ @@ -415,28 +421,25 @@ export enum ReadyState { CLOSED = 2, } -interface RelayCommon { - url: URL -} - -export type Relay = RelayCommon & - ( - | { - readyState: ReadyState.CONNECTING - } - | { - readyState: ReadyState.OPEN - info: RelayInfo - } - | { - readyState: ReadyState.CLOSED - /** - * If the relay is closed before the opening process is fully finished, - * the relay info may be undefined. - */ - info?: RelayInfo - } - ) +export type Relay = + | { + url: URL + readyState: ReadyState.CONNECTING + } + | { + url: URL + readyState: ReadyState.OPEN + info: RelayInfo + } + | { + url: URL + readyState: ReadyState.CLOSED + /** + * If the relay is closed before the opening process is fully finished, + * the relay info may be undefined. + */ + info?: RelayInfo + } /** * The information that a relay broadcasts about itself as defined in NIP-11. diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts index 38b45360..8ccccba3 100644 --- a/packages/nostr/test/setup.ts +++ b/packages/nostr/test/setup.ts @@ -36,8 +36,8 @@ export async function setup( new Promise((resolve) => subscriber.on("open", resolve)), ]) - await publisher.open(relayUrl) - await subscriber.open(relayUrl) + publisher.open(relayUrl) + subscriber.open(relayUrl) await openPromise -- 2.45.2 From 3d09ca18c58bd667bc6c81b7a0aafa5ad4d0c643 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Fri, 17 Mar 2023 22:16:03 +0100 Subject: [PATCH 5/5] better async/await linting --- packages/nostr/.eslintrc.cjs | 5 ++++- packages/nostr/src/client/conn.ts | 6 +++--- packages/nostr/src/client/index.ts | 4 ++-- packages/nostr/src/crypto.ts | 2 +- packages/nostr/test/dm.ts | 2 +- packages/nostr/test/ready-state.ts | 2 +- packages/nostr/test/relay-info.ts | 2 +- packages/nostr/test/text-note.ts | 2 +- 8 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/nostr/.eslintrc.cjs b/packages/nostr/.eslintrc.cjs index 1e571d86..f4b4dd57 100644 --- a/packages/nostr/.eslintrc.cjs +++ b/packages/nostr/.eslintrc.cjs @@ -3,10 +3,13 @@ module.exports = { parser: "@typescript-eslint/parser", plugins: ["@typescript-eslint"], root: true, - ignorePatterns: ["dist/"], + ignorePatterns: ["dist/", "src/legacy"], env: { browser: true, node: true, mocha: true, }, + rules: { + "require-await": "warn", + }, } diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index 47642e6d..53c23717 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -47,14 +47,14 @@ export class Conn { this.#socket = new WebSocket(url) // Handle incoming messages. - this.#socket.addEventListener("message", async (msgData) => { + this.#socket.addEventListener("message", (msgData) => { try { const value = msgData.data.valueOf() // Validate and parse the message. if (typeof value !== "string") { throw new ProtocolError(`invalid message data: ${value}`) } - const msg = await parseIncomingMessage(value) + const msg = parseIncomingMessage(value) onMessage(msg) } catch (err) { onError(err) @@ -249,7 +249,7 @@ function serializeFilters(filters: Filters[]): RawFilters[] { })) } -async function parseIncomingMessage(data: string): Promise { +function parseIncomingMessage(data: string): IncomingMessage { // Parse the incoming data as a nonempty JSON array. const json = parseJson(data) if (!(json instanceof Array)) { diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 8036955b..42953537 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -256,7 +256,7 @@ export class Nostr extends EventEmitter { * * TODO Reference subscribed() */ - async unsubscribe(subscriptionId: SubscriptionId): Promise { + unsubscribe(subscriptionId: SubscriptionId): void { if (!this.#subscriptions.delete(subscriptionId)) { throw new Error(`subscription ${subscriptionId} does not exist`) } @@ -274,7 +274,7 @@ export class Nostr extends EventEmitter { /** * Publish an event. */ - async publish(event: RawEvent): Promise { + publish(event: RawEvent): void { for (const { conn, write } of this.#conns.values()) { if (!write) { continue diff --git a/packages/nostr/src/crypto.ts b/packages/nostr/src/crypto.ts index 53dcbbb6..aab08508 100644 --- a/packages/nostr/src/crypto.ts +++ b/packages/nostr/src/crypto.ts @@ -90,7 +90,7 @@ export async function schnorrSign(data: Hex, priv: PrivateKey): Promise { /** * Verify that the elliptic curve signature is correct. */ -export async function schnorrVerify( +export function schnorrVerify( sig: Hex, data: Hex, key: PublicKey diff --git a/packages/nostr/test/dm.ts b/packages/nostr/test/dm.ts index 104345c6..e7db5feb 100644 --- a/packages/nostr/test/dm.ts +++ b/packages/nostr/test/dm.ts @@ -3,7 +3,7 @@ import { parsePublicKey } from "../src/crypto" import assert from "assert" import { setup } from "./setup" -describe("dm", async function () { +describe("dm", () => { const message = "for your eyes only" // Test that the intended recipient can receive and decrypt the direct message. diff --git a/packages/nostr/test/ready-state.ts b/packages/nostr/test/ready-state.ts index 22820ccd..3e341ea2 100644 --- a/packages/nostr/test/ready-state.ts +++ b/packages/nostr/test/ready-state.ts @@ -2,7 +2,7 @@ import assert from "assert" import { Nostr } from "../src/client" import { relayUrl } from "./setup" -describe("ready state", async function () { +describe("ready state", () => { it("ready state transitions", (done) => { const nostr = new Nostr() diff --git a/packages/nostr/test/relay-info.ts b/packages/nostr/test/relay-info.ts index 382ffe75..3551f7b5 100644 --- a/packages/nostr/test/relay-info.ts +++ b/packages/nostr/test/relay-info.ts @@ -2,7 +2,7 @@ import assert from "assert" import { Nostr } from "../src/client" import { setup } from "./setup" -describe("relay info", async function () { +describe("relay info", () => { it("fetching relay info", (done) => { setup(done, ({ publisher, done }) => { assert.strictEqual(publisher.relays.length, 1) diff --git a/packages/nostr/test/text-note.ts b/packages/nostr/test/text-note.ts index 4d28dee5..9b7b23eb 100644 --- a/packages/nostr/test/text-note.ts +++ b/packages/nostr/test/text-note.ts @@ -3,7 +3,7 @@ import { parsePublicKey } from "../src/crypto" import assert from "assert" import { setup } from "./setup" -describe("text note", async function () { +describe("text note", () => { const note = "hello world" // Test that a text note can be published by one client and received by the other. -- 2.45.2