diff --git a/packages/nostr/.eslintrc.cjs b/packages/nostr/.eslintrc.cjs index f4b4dd57..d478b681 100644 --- a/packages/nostr/.eslintrc.cjs +++ b/packages/nostr/.eslintrc.cjs @@ -10,6 +10,7 @@ module.exports = { mocha: true, }, rules: { - "require-await": "warn", + "require-await": "error", + eqeqeq: "error", }, } diff --git a/packages/nostr/README.md b/packages/nostr/README.md index 55d81791..b65045f2 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: 5/34 (15%)._ +_Progress: 6/34 (18%)._ - [X] NIP-01: Basic protocol flow description - [ ] NIP-02: Contact List and Petnames @@ -23,7 +23,7 @@ _Progress: 5/34 (15%)._ - [ ] NIP-10: Conventions for clients' use of `e` and `p` tags in text events - TODO Check if this applies - [X] NIP-11: Relay Information Document -- [ ] NIP-12: Generic Tag Queries +- [X] NIP-12: Generic Tag Queries - [ ] NIP-13: Proof of Work - [ ] NIP-14: Subject tag in text events - [X] NIP-15: End of Stored Events Notice diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index 53c23717..736e3337 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -1,8 +1,8 @@ -import { ProtocolError } from "../error" -import { Filters, SubscriptionId } from "." +import { NostrError } from "../common" +import { SubscriptionId } from "." import { EventId, RawEvent } from "../event" import WebSocket from "isomorphic-ws" -import { unixTimestamp } from "../util" +import { Filters } from "../filters" /** * The connection to a relay. This is the lowest layer of the nostr protocol. @@ -38,24 +38,24 @@ export class Conn { onError, }: { url: URL - onMessage: (msg: IncomingMessage) => void - onOpen: () => void | Promise - onClose: () => void | Promise + onMessage: (msg: IncomingMessage) => Promise + onOpen: () => Promise + onClose: () => void onError: (err: unknown) => void }) { this.#onError = onError this.#socket = new WebSocket(url) // Handle incoming messages. - this.#socket.addEventListener("message", (msgData) => { + this.#socket.addEventListener("message", async (msgData) => { try { const value = msgData.data.valueOf() // Validate and parse the message. if (typeof value !== "string") { - throw new ProtocolError(`invalid message data: ${value}`) + throw new NostrError(`invalid message data: ${value}`) } const msg = parseIncomingMessage(value) - onMessage(msg) + await onMessage(msg) } catch (err) { onError(err) } @@ -68,21 +68,15 @@ export class Conn { this.send(msg) } this.#pending = [] - const result = onOpen() - if (result instanceof Promise) { - await result - } + await onOpen() } catch (e) { onError(e) } }) - this.#socket.addEventListener("close", async () => { + this.#socket.addEventListener("close", () => { try { - const result = onClose() - if (result instanceof Promise) { - await result - } + onClose() } catch (e) { onError(e) } @@ -91,11 +85,11 @@ export class Conn { } send(msg: OutgoingMessage): void { - if (this.#socket.readyState < WebSocket.OPEN) { - this.#pending.push(msg) - return - } try { + if (this.#socket.readyState < WebSocket.OPEN) { + this.#pending.push(msg) + return + } this.#socket.send(serializeOutgoingMessage(msg), (err) => { if (err !== undefined && err !== null) { this.#onError?.(err) @@ -204,70 +198,39 @@ export interface OutgoingCloseSubscription { id: SubscriptionId } -interface RawFilters { - ids?: string[] - authors?: string[] - kinds?: number[] - ["#e"]?: string[] - ["#p"]?: string[] - since?: number - until?: number - limit?: number -} - function serializeOutgoingMessage(msg: OutgoingMessage): string { if (msg.kind === "event") { return JSON.stringify(["EVENT", msg.event]) } else if (msg.kind === "openSubscription") { - return JSON.stringify([ - "REQ", - msg.id.toString(), - ...serializeFilters(msg.filters), - ]) + // If there are no filters, the client is expected to specify a single empty filter. + const filters = msg.filters.length === 0 ? [{}] : msg.filters + return JSON.stringify(["REQ", msg.id.toString(), ...filters]) } else if (msg.kind === "closeSubscription") { return JSON.stringify(["CLOSE", msg.id.toString()]) } else { - throw new Error(`invalid message: ${JSON.stringify(msg)}`) + throw new NostrError(`invalid message: ${JSON.stringify(msg)}`) } } -function serializeFilters(filters: Filters[]): RawFilters[] { - if (filters.length === 0) { - return [{}] - } - return filters.map((filter) => ({ - ids: filter.ids, - authors: filter.authors, - kinds: filter.kinds, - ["#e"]: filter.eventTags, - ["#p"]: filter.pubkeyTags, - since: - filter.since instanceof Date ? unixTimestamp(filter.since) : filter.since, - until: - filter.until instanceof Date ? unixTimestamp(filter.until) : filter.until, - limit: filter.limit, - })) -} - function parseIncomingMessage(data: string): IncomingMessage { // Parse the incoming data as a nonempty JSON array. const json = parseJson(data) if (!(json instanceof Array)) { - throw new ProtocolError(`incoming message is not an array: ${data}`) + throw new NostrError(`incoming message is not an array: ${data}`) } if (json.length === 0) { - throw new ProtocolError(`incoming message is an empty array: ${data}`) + throw new NostrError(`incoming message is an empty array: ${data}`) } // Handle incoming events. if (json[0] === "EVENT") { if (typeof json[1] !== "string") { - throw new ProtocolError( + throw new NostrError( `second element of "EVENT" should be a string, but wasn't: ${data}` ) } if (typeof json[2] !== "object") { - throw new ProtocolError( + throw new NostrError( `second element of "EVENT" should be an object, but wasn't: ${data}` ) } @@ -282,7 +245,7 @@ function parseIncomingMessage(data: string): IncomingMessage { // Handle incoming notices. if (json[0] === "NOTICE") { if (typeof json[1] !== "string") { - throw new ProtocolError( + throw new NostrError( `second element of "NOTICE" should be a string, but wasn't: ${data}` ) } @@ -295,17 +258,17 @@ function parseIncomingMessage(data: string): IncomingMessage { // Handle incoming "OK" messages. if (json[0] === "OK") { if (typeof json[1] !== "string") { - throw new ProtocolError( + throw new NostrError( `second element of "OK" should be a string, but wasn't: ${data}` ) } if (typeof json[2] !== "boolean") { - throw new ProtocolError( + throw new NostrError( `third element of "OK" should be a boolean, but wasn't: ${data}` ) } if (typeof json[3] !== "string") { - throw new ProtocolError( + throw new NostrError( `fourth element of "OK" should be a string, but wasn't: ${data}` ) } @@ -320,7 +283,7 @@ function parseIncomingMessage(data: string): IncomingMessage { // Handle incoming "EOSE" messages. if (json[0] === "EOSE") { if (typeof json[1] !== "string") { - throw new ProtocolError( + throw new NostrError( `second element of "EOSE" should be a string, but wasn't: ${data}` ) } @@ -338,7 +301,7 @@ function parseIncomingMessage(data: string): IncomingMessage { } } - throw new ProtocolError(`unknown incoming message: ${data}`) + throw new NostrError(`unknown incoming message: ${data}`) } function parseEventData(json: { [key: string]: unknown }): RawEvent { @@ -354,7 +317,7 @@ function parseEventData(json: { [key: string]: unknown }): RawEvent { typeof json["content"] !== "string" || typeof json["sig"] !== "string" ) { - throw new ProtocolError(`invalid event: ${JSON.stringify(json)}`) + throw new NostrError(`invalid event: ${JSON.stringify(json)}`) } return json as unknown as RawEvent } @@ -363,6 +326,6 @@ function parseJson(data: string) { try { return JSON.parse(data) } catch (e) { - throw new ProtocolError(`invalid event json: ${data}`) + throw new NostrError(`invalid event json: ${data}`) } } diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index 260b38ae..59663f4d 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -177,7 +177,8 @@ 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 +// TODO Ideas for events: "auth" for NIP-42 AUTH, "message" for the raw incoming messages, +// "publish" for published events, "send" for sent messages type EventName = | "newListener" | "removeListener" diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 42953537..df11d1bc 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -1,13 +1,10 @@ -import { ProtocolError } from "../error" -import { EventId, EventKind, RawEvent, parseEvent } from "../event" -import { PublicKey } from "../crypto" +import { NostrError } from "../common" +import { RawEvent, parseEvent } from "../event" 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. +import { fetchRelayInfo, ReadyState, Relay } from "./relay" +import { Filters } from "../filters" /** * A nostr client. @@ -31,7 +28,7 @@ export class Nostr extends EventEmitter { /** * Open connections to relays. */ - readonly #conns: Map = new Map() + readonly #conns: ConnState[] = [] /** * Mapping of subscription IDs to corresponding filters. @@ -48,13 +45,15 @@ export class Nostr extends EventEmitter { url: URL | string, opts?: { read?: boolean; write?: boolean; fetchInfo?: boolean } ): void { - const connUrl = new URL(url) + const relayUrl = new URL(url) // If the connection already exists, update the options. - const existingConn = this.#conns.get(connUrl.toString()) + const existingConn = this.#conns.find( + (c) => c.relay.url.toString() === relayUrl.toString() + ) if (existingConn !== undefined) { if (opts === undefined) { - throw new Error( + throw new NostrError( `called connect with existing connection ${url}, but options were not specified` ) } @@ -71,112 +70,104 @@ export class Nostr extends EventEmitter { const fetchInfo = opts?.fetchInfo === false ? Promise.resolve({}) - : fetchRelayInfo(connUrl).catch((e) => this.emit("error", e, this)) + : fetchRelayInfo(relayUrl).catch((e) => { + this.#error(e) + return {} + }) // If there is no existing connection, open a new one. const conn = new Conn({ - url: connUrl, + url: relayUrl, // Handle messages on this connection. onMessage: async (msg) => { - try { - if (msg.kind === "event") { - this.emit( - "event", - { - event: await parseEvent(msg.event), - subscriptionId: msg.subscriptionId, - }, - this - ) - } else if (msg.kind === "notice") { - this.emit("notice", msg.notice, this) - } else if (msg.kind === "ok") { - this.emit( - "ok", - { - eventId: msg.eventId, - relay: connUrl, - ok: msg.ok, - message: msg.message, - }, - this - ) - } else if (msg.kind === "eose") { - this.emit("eose", msg.subscriptionId, this) - } else if (msg.kind === "auth") { - // TODO This is incomplete - } else { - throw new ProtocolError(`invalid message ${JSON.stringify(msg)}`) - } - } catch (err) { - this.emit("error", err, this) + if (msg.kind === "event") { + this.emit( + "event", + { + event: await parseEvent(msg.event), + subscriptionId: msg.subscriptionId, + }, + this + ) + } else if (msg.kind === "notice") { + this.emit("notice", msg.notice, this) + } else if (msg.kind === "ok") { + this.emit( + "ok", + { + eventId: msg.eventId, + relay: relayUrl, + ok: msg.ok, + message: msg.message, + }, + this + ) + } else if (msg.kind === "eose") { + this.emit("eose", msg.subscriptionId, this) + } else if (msg.kind === "auth") { + // TODO This is incomplete + } else { + this.#error(new NostrError(`invalid message ${JSON.stringify(msg)}`)) } }, // Handle "open" events. onOpen: async () => { // Update the connection readyState. - const conn = this.#conns.get(connUrl.toString()) + const conn = this.#conns.find( + (c) => c.relay.url.toString() === relayUrl.toString() + ) if (conn === undefined) { - this.emit( - "error", - new Error( - `bug: expected connection to ${connUrl.toString()} to be in the map` - ), - this + this.#error( + new NostrError( + `bug: expected connection to ${relayUrl.toString()} to be in the map` + ) ) } else { - if (conn.readyState !== ReadyState.CONNECTING) { - this.emit( - "error", - new Error( - `bug: expected connection to ${connUrl.toString()} to have readyState CONNECTING, got ${ - conn.readyState + if (conn.relay.readyState !== ReadyState.CONNECTING) { + this.#error( + new NostrError( + `bug: expected connection to ${relayUrl.toString()} to have readyState CONNECTING, got ${ + conn.relay.readyState }` - ), - this + ) ) } - this.#conns.set(connUrl.toString(), { - ...conn, + conn.relay = { + ...conn.relay, readyState: ReadyState.OPEN, info: await fetchInfo, - }) + } } // Forward the event to the user. - this.emit("open", connUrl, this) + this.emit("open", relayUrl, this) }, // Handle "close" events. onClose: () => { // Update the connection readyState. - const conn = this.#conns.get(connUrl.toString()) + const conn = this.#conns.find( + (c) => c.relay.url.toString() === relayUrl.toString() + ) if (conn === undefined) { - this.emit( - "error", - new Error( - `bug: expected connection to ${connUrl.toString()} to be in the map` - ), - this + this.#error( + new NostrError( + `bug: expected connection to ${relayUrl.toString()} to be in the map` + ) ) } else { - this.#conns.set(connUrl.toString(), { - ...conn, - readyState: ReadyState.CLOSED, - info: - conn.readyState === ReadyState.CONNECTING ? undefined : conn.info, - }) + conn.relay.readyState = ReadyState.CLOSED } // Forward the event to the user. - this.emit("close", connUrl, this) + this.emit("close", relayUrl, 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), + onError: (err) => this.#error(err), }) // Resend existing subscriptions to this connection. @@ -188,12 +179,15 @@ export class Nostr extends EventEmitter { }) } - this.#conns.set(connUrl.toString(), { + this.#conns.push({ + relay: { + url: relayUrl, + readyState: ReadyState.CONNECTING, + }, conn, auth: false, read: opts?.read ?? true, write: opts?.write ?? true, - readyState: ReadyState.CONNECTING, }) } @@ -211,10 +205,12 @@ export class Nostr extends EventEmitter { } return } - const connUrl = new URL(url) - const c = this.#conns.get(connUrl.toString()) + const relayUrl = new URL(url) + const c = this.#conns.find( + (c) => c.relay.url.toString() === relayUrl.toString() + ) if (c === undefined) { - throw new Error(`connection to ${url} doesn't exist`) + throw new NostrError(`connection to ${url} doesn't exist`) } c.conn.close() } @@ -258,7 +254,7 @@ export class Nostr extends EventEmitter { */ unsubscribe(subscriptionId: SubscriptionId): void { if (!this.#subscriptions.delete(subscriptionId)) { - throw new Error(`subscription ${subscriptionId} does not exist`) + throw new NostrError(`subscription ${subscriptionId} does not exist`) } for (const { conn, read } of this.#conns.values()) { if (!read) { @@ -290,172 +286,29 @@ export class Nostr extends EventEmitter { * 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, - } + return this.#conns.map(({ relay }) => { + if (relay.readyState === ReadyState.CONNECTING) { + return { ...relay } } else { - throw new Error("bug: unknown readyState") + const info = + relay.info === undefined + ? undefined + : // Deep copy of the info. + JSON.parse(JSON.stringify(relay.info)) + return { ...relay, info } } }) } -} -// 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 - )}` - ) - } - } else { - info.supported_nips = undefined - throw new ProtocolError( - `invalid relay info, expected "supported_nips" to be an array: ${JSON.stringify( - info - )}` - ) + #error(e: unknown) { + if (!this.emit("error", e, this)) { + throw e } } - 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 } -/** - * 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, -} - -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. - */ -export interface RelayInfo { - name?: string - description?: string - pubkey?: PublicKey - contact?: string - supported_nips?: number[] - software?: string - version?: string - [key: string]: unknown -} - -interface ConnStateCommon { +interface ConnState { + relay: Relay conn: Conn /** * Has this connection been authenticated via NIP-44 AUTH? @@ -471,47 +324,11 @@ interface ConnStateCommon { 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. */ export type SubscriptionId = string -/** - * Subscription filters. All filters from the fields must pass for a message to get through. - */ -export interface Filters { - // TODO Document the filters, document that for the arrays only one is enough for the message to pass - ids?: EventId[] - authors?: string[] - kinds?: EventKind[] - /** - * Filters for the "e" tags. - */ - eventTags?: EventId[] - /** - * Filters for the "p" tags. - */ - pubkeyTags?: PublicKey[] - since?: Date | number - until?: Date | number - limit?: number -} - function randomSubscriptionId(): SubscriptionId { return secp.utils.bytesToHex(secp.utils.randomBytes(32)) } diff --git a/packages/nostr/src/client/relay.ts b/packages/nostr/src/client/relay.ts new file mode 100644 index 00000000..ac8f9137 --- /dev/null +++ b/packages/nostr/src/client/relay.ts @@ -0,0 +1,142 @@ +import { PublicKey } from "../crypto" +import { NostrError } from "../common" + +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. + */ +export interface RelayInfo { + name?: string + description?: string + pubkey?: PublicKey + contact?: string + supported_nips?: number[] + software?: string + version?: string + [key: string]: unknown +} + +/** + * 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, +} + +// 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 NostrError( + `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 NostrError( + `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 NostrError( + `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 NostrError( + `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 NostrError( + `invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify( + info + )}` + ) + } + } else { + info.supported_nips = undefined + throw new NostrError( + `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 NostrError( + `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 NostrError( + `invalid relay info, expected "version" to be a string: ${JSON.stringify( + info + )}` + ) + } + return info +} diff --git a/packages/nostr/src/util.ts b/packages/nostr/src/common.ts similarity index 56% rename from packages/nostr/src/util.ts rename to packages/nostr/src/common.ts index 7cd0fdba..f3154e2b 100644 --- a/packages/nostr/src/util.ts +++ b/packages/nostr/src/common.ts @@ -1,10 +1,13 @@ -import { ProtocolError } from "./error" +/** + * A UNIX timestamp. + */ +export type Timestamp = number /** * Calculate the unix timestamp (seconds since epoch) of the `Date`. If no date is specified, * return the current unix timestamp. */ -export function unixTimestamp(date?: Date): number { +export function unixTimestamp(date?: Date): Timestamp { return Math.floor((date ?? new Date()).getTime() / 1000) } @@ -13,7 +16,16 @@ export function unixTimestamp(date?: Date): number { */ export function defined(v: T | undefined | null): T { if (v === undefined || v === null) { - throw new ProtocolError("bug: unexpected undefined") + throw new NostrError("bug: unexpected undefined") } return v } + +/** + * The error thrown by this library. + */ +export class NostrError extends Error { + constructor(message?: string) { + super(message) + } +} diff --git a/packages/nostr/src/crypto.ts b/packages/nostr/src/crypto.ts index aab08508..712b91fa 100644 --- a/packages/nostr/src/crypto.ts +++ b/packages/nostr/src/crypto.ts @@ -1,6 +1,7 @@ import * as secp from "@noble/secp256k1" import base64 from "base64-js" import { bech32 } from "bech32" +import { NostrError } from "./common" // TODO Use toHex as well as toString? Might be more explicit // Or maybe replace toString with toHex @@ -158,7 +159,7 @@ export async function aesDecryptBase64( const sharedKey = sharedPoint.slice(1, 33) if (typeof window === "object") { // TODO Can copy this from the legacy code - throw new Error("todo") + throw new NostrError("todo") } else { const crypto = await import("crypto") const decipher = crypto.createDecipheriv( diff --git a/packages/nostr/src/error.ts b/packages/nostr/src/error.ts deleted file mode 100644 index 075c677e..00000000 --- a/packages/nostr/src/error.ts +++ /dev/null @@ -1,10 +0,0 @@ -// TODO Rename to NostrError and move to util.ts, always throw NostrError and never throw Error -/** - * An error in the protocol. This error is thrown when a relay sends invalid or - * unexpected data, or otherwise behaves in an unexpected way. - */ -export class ProtocolError extends Error { - constructor(message?: string) { - super(message) - } -} diff --git a/packages/nostr/src/event.ts b/packages/nostr/src/event.ts index 7337a5f4..0bdbcc99 100644 --- a/packages/nostr/src/event.ts +++ b/packages/nostr/src/event.ts @@ -1,4 +1,3 @@ -import { ProtocolError } from "./error" import { PublicKey, PrivateKey, @@ -12,7 +11,7 @@ import { parsePrivateKey, aesEncryptBase64, } from "./crypto" -import { defined, unixTimestamp } from "./util" +import { defined, Timestamp, unixTimestamp, NostrError } from "./common" // TODO Add remaining event types @@ -40,11 +39,13 @@ export enum EventKind { export interface RawEvent { id: string pubkey: PublicKey - created_at: number + created_at: Timestamp kind: EventKind tags: string[][] content: string sig: string + + [key: string]: unknown } interface SetMetadata extends RawEvent { @@ -100,20 +101,24 @@ export type EventId = string /** * An unsigned event. */ -export type Unsigned = Omit< - T, - "id" | "pubkey" | "sig" | "created_at" -> & { - id?: EventId +export type Unsigned = { + [Property in keyof UnsignedWithPubkey as Exclude< + Property, + "pubkey" + >]: T[Property] +} & { pubkey?: PublicKey - sig?: string - created_at?: number } -type UnsignedWithPubkey = Omit< - T, - "id" | "sig" | "created_at" -> & { +/** + * Same as @see {@link Unsigned}, but with the pubkey field. + */ +type UnsignedWithPubkey = { + [Property in keyof T as Exclude< + Property, + "id" | "sig" | "created_at" + >]: T[Property] +} & { id?: EventId sig?: string created_at?: number @@ -131,13 +136,16 @@ export async function signEvent( if (priv !== undefined) { priv = parsePrivateKey(priv) event.pubkey = getPublicKey(priv) - const id = await serializeEventId(event as UnsignedWithPubkey) + const id = await serializeEventId( + // This conversion is safe because the pubkey field is set above. + event as unknown as UnsignedWithPubkey + ) event.id = id event.sig = await schnorrSign(id, priv) return event as T } else { // TODO Try to use NIP-07, otherwise throw - throw new Error("todo") + throw new NostrError("todo") } } @@ -195,16 +203,14 @@ export async function createDirectMessage({ export async function parseEvent(event: RawEvent): Promise { // TODO Validate all the fields. Lowercase hex fields, etc. Make sure everything is correct. if (event.id !== (await serializeEventId(event))) { - throw new ProtocolError( + throw new NostrError( `invalid id ${event.id} for event ${JSON.stringify( event )}, expected ${await serializeEventId(event)}` ) } if (!(await schnorrVerify(event.sig, event.id, event.pubkey))) { - throw new ProtocolError( - `invalid signature for event ${JSON.stringify(event)}` - ) + throw new NostrError(`invalid signature for event ${JSON.stringify(event)}`) } if (event.kind === EventKind.TextNote) { @@ -259,7 +265,7 @@ function getUserMetadata(this: Unsigned): UserMetadata { typeof userMetadata.about !== "string" || typeof userMetadata.picture !== "string" ) { - throw new ProtocolError( + throw new NostrError( `invalid user metadata ${userMetadata} in ${JSON.stringify(this)}` ) } @@ -275,11 +281,11 @@ async function getMessage( } const [data, iv] = this.content.split("?iv=") if (data === undefined || iv === undefined) { - throw new ProtocolError(`invalid direct message content ${this.content}`) + throw new NostrError(`invalid direct message content ${this.content}`) } if (priv === undefined) { // TODO Try to use NIP-07 - throw new Error("todo") + throw new NostrError("todo") } else if (getPublicKey(priv) === this.getRecipient()) { return await aesDecryptBase64(this.pubkey, priv, { data, iv }) } @@ -289,7 +295,7 @@ async function getMessage( function getRecipient(this: Unsigned): PublicKey { const recipientTag = this.tags.find((tag) => tag[0] === "p") if (typeof recipientTag?.[1] !== "string") { - throw new ProtocolError( + throw new NostrError( `expected "p" tag to be of type string, but got ${ recipientTag?.[1] } in ${JSON.stringify(this)}` @@ -301,7 +307,7 @@ function getRecipient(this: Unsigned): PublicKey { function getPrevious(this: Unsigned): EventId | undefined { const previousTag = this.tags.find((tag) => tag[0] === "e") if (typeof previousTag?.[1] !== "string") { - throw new ProtocolError( + throw new NostrError( `expected "e" tag to be of type string, but got ${ previousTag?.[1] } in ${JSON.stringify(this)}` @@ -314,7 +320,7 @@ function parseJson(data: string) { try { return JSON.parse(data) } catch (e) { - throw new ProtocolError(`invalid json: ${e}: ${data}`) + throw new NostrError(`invalid json: ${e}: ${data}`) } } diff --git a/packages/nostr/src/filters.ts b/packages/nostr/src/filters.ts new file mode 100644 index 00000000..6211fc99 --- /dev/null +++ b/packages/nostr/src/filters.ts @@ -0,0 +1,81 @@ +import { PublicKey } from "./crypto" +import { EventId, EventKind } from "./event" +import { Timestamp } from "./common" + +/** + * Subscription filters. All filters from the fields must pass for a message to get through. + */ +export interface Filters extends TagFilters { + // TODO Document the filters, document that for the arrays only one is enough for the message to pass + ids?: EventId[] + authors?: string[] + kinds?: EventKind[] + since?: Timestamp + until?: Timestamp + limit?: number + + /** + * Allows for arbitrary, nonstandard extensions. + */ + [key: string]: unknown +} + +/** + * Generic tag queries as defined by NIP-12. + */ +interface TagFilters { + ["#e"]: EventId[] + ["#p"]: PublicKey[] + + ["#a"]: string[] + ["#b"]: string[] + ["#c"]: string[] + ["#d"]: string[] + ["#f"]: string[] + ["#g"]: string[] + ["#h"]: string[] + ["#i"]: string[] + ["#j"]: string[] + ["#k"]: string[] + ["#l"]: string[] + ["#m"]: string[] + ["#n"]: string[] + ["#o"]: string[] + ["#q"]: string[] + ["#r"]: string[] + ["#s"]: string[] + ["#t"]: string[] + ["#u"]: string[] + ["#v"]: string[] + ["#w"]: string[] + ["#x"]: string[] + ["#y"]: string[] + ["#z"]: string[] + + ["#A"]: string[] + ["#B"]: string[] + ["#C"]: string[] + ["#D"]: string[] + ["#E"]: string[] + ["#F"]: string[] + ["#G"]: string[] + ["#H"]: string[] + ["#I"]: string[] + ["#J"]: string[] + ["#K"]: string[] + ["#L"]: string[] + ["#M"]: string[] + ["#N"]: string[] + ["#O"]: string[] + ["#P"]: string[] + ["#Q"]: string[] + ["#R"]: string[] + ["#S"]: string[] + ["#T"]: string[] + ["#U"]: string[] + ["#V"]: string[] + ["#W"]: string[] + ["#X"]: string[] + ["#Y"]: string[] + ["#Z"]: string[] +} diff --git a/packages/nostr/test/dm.ts b/packages/nostr/test/dm.ts index e7db5feb..f826a22d 100644 --- a/packages/nostr/test/dm.ts +++ b/packages/nostr/test/dm.ts @@ -24,18 +24,21 @@ describe("dm", () => { subscriber.on( "event", async ({ event, subscriptionId: actualSubscriptionId }, nostr) => { - assert.equal(nostr, subscriber) - assert.equal(event.kind, EventKind.DirectMessage) - assert.equal(event.pubkey, parsePublicKey(publisherPubkey)) - assert.equal(actualSubscriptionId, subscriptionId) + assert.strictEqual(nostr, subscriber) + assert.strictEqual(event.kind, EventKind.DirectMessage) + assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey)) + assert.strictEqual(actualSubscriptionId, subscriptionId) assert.ok(event.created_at >= timestamp) if (event.kind === EventKind.DirectMessage) { - assert.equal( + assert.strictEqual( event.getRecipient(), parsePublicKey(subscriberPubkey) ) - assert.equal(await event.getMessage(subscriberSecret), message) + assert.strictEqual( + await event.getMessage(subscriberSecret), + message + ) } done() @@ -81,14 +84,14 @@ describe("dm", () => { "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.strictEqual(nostr, subscriber) + assert.strictEqual(event.kind, EventKind.DirectMessage) + assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey)) + assert.strictEqual(actualSubscriptionId, subscriptionId) assert.ok(event.created_at >= timestamp) if (event.kind === EventKind.DirectMessage) { - assert.equal( + assert.strictEqual( event.getRecipient(), parsePublicKey(recipientPubkey) ) diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts index 8ccccba3..f8d50e25 100644 --- a/packages/nostr/test/setup.ts +++ b/packages/nostr/test/setup.ts @@ -1,5 +1,5 @@ import { Nostr } from "../src/client" -import { unixTimestamp } from "../src/util" +import { Timestamp, unixTimestamp } from "../src/common" export const relayUrl = new URL("ws://localhost:12648") @@ -10,7 +10,7 @@ export interface Setup { subscriber: Nostr subscriberSecret: string subscriberPubkey: string - timestamp: number + timestamp: Timestamp url: URL /** * Signal that the test is done. Call this instead of the callback provided by diff --git a/packages/nostr/test/text-note.ts b/packages/nostr/test/text-note.ts index 9b7b23eb..fdc8b060 100644 --- a/packages/nostr/test/text-note.ts +++ b/packages/nostr/test/text-note.ts @@ -1,4 +1,10 @@ -import { createTextNote, EventKind, signEvent } from "../src/event" +import { + createTextNote, + EventKind, + signEvent, + TextNote, + Unsigned, +} from "../src/event" import { parsePublicKey } from "../src/crypto" import assert from "assert" import { setup } from "./setup" @@ -41,7 +47,10 @@ describe("text note", () => { // TODO No signEvent, have a convenient way to do this signEvent( - { ...createTextNote(note), created_at: timestamp }, + { + ...createTextNote(note), + created_at: timestamp, + } as Unsigned, publisherSecret ).then((event) => publisher.publish(event)) }) @@ -55,10 +64,10 @@ describe("text note", () => { // TODO No signEvent, have a convenient way to do this signEvent(createTextNote(note), publisherSecret).then((event) => { publisher.on("ok", (params, nostr) => { - assert.equal(nostr, publisher) - assert.equal(params.eventId, event.id) - assert.equal(params.relay.toString(), url.toString()) - assert.equal(params.ok, true) + assert.strictEqual(nostr, publisher) + assert.strictEqual(params.eventId, event.id) + assert.strictEqual(params.relay.toString(), url.toString()) + assert.strictEqual(params.ok, true) done() })