nostr package: fetch relay info #446

Merged
sistemd merged 5 commits from nostr-package-relay-info into nostr-package-eose 2023-03-27 09:06:43 +00:00
12 changed files with 481 additions and 106 deletions

View File

@ -3,10 +3,13 @@ module.exports = {
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"], plugins: ["@typescript-eslint"],
root: true, root: true,
ignorePatterns: ["dist/"], ignorePatterns: ["dist/", "src/legacy"],
env: { env: {
browser: true, browser: true,
node: true, node: true,
mocha: true, mocha: true,
}, },
rules: {
"require-await": "warn",
},
} }

View File

@ -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 The goal of the project is to have all of the following implemented
and tested against a real-world relay implementation. and tested against a real-world relay implementation.
_Progress: 4/34 (12%)._ _Progress: 5/34 (15%)._
- [X] NIP-01: Basic protocol flow description - [X] NIP-01: Basic protocol flow description
- [ ] NIP-02: Contact List and Petnames - [ ] NIP-02: Contact List and Petnames
@ -22,7 +22,7 @@ _Progress: 4/34 (12%)._
- [ ] NIP-09: Event Deletion - [ ] NIP-09: Event Deletion
- [ ] NIP-10: Conventions for clients' use of `e` and `p` tags in text events - [ ] NIP-10: Conventions for clients' use of `e` and `p` tags in text events
- TODO Check if this applies - TODO Check if this applies
- [ ] NIP-11: Relay Information Document - [X] NIP-11: Relay Information Document
- [ ] NIP-12: Generic Tag Queries - [ ] NIP-12: Generic Tag Queries
- [ ] NIP-13: Proof of Work - [ ] NIP-13: Proof of Work
- [ ] NIP-14: Subject tag in text events - [ ] NIP-14: Subject tag in text events
@ -30,7 +30,7 @@ _Progress: 4/34 (12%)._
- [ ] NIP-19: bech32-encoded entities - [ ] NIP-19: bech32-encoded entities
- [X] `npub` - [X] `npub`
- [X] `nsec` - [X] `nsec`
- [ ] `nprofile` - [ ] `note`, `nprofile`, `nevent`, `nrelay`, `naddr`
- [X] NIP-20: Command Results - [X] NIP-20: Command Results
- [ ] NIP-21: `nostr:` URL scheme - [ ] NIP-21: `nostr:` URL scheme
- [ ] NIP-23: Long-form Content - [ ] NIP-23: Long-form Content

View File

@ -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] [authorization]
nip42_auth = true nip42_auth = true
# This seems to have no effect. # This seems to have no effect.

View File

@ -14,13 +14,12 @@ import { unixTimestamp } from "../util"
*/ */
export class Conn { export class Conn {
readonly #socket: WebSocket readonly #socket: WebSocket
// 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. * Messages which were requested to be sent before the websocket was ready.
* Once the websocket becomes ready, these messages will be sent and cleared. * 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[] = [] #pending: OutgoingMessage[] = []
/** /**
* Callback for errors. * Callback for errors.
@ -35,27 +34,27 @@ export class Conn {
url, url,
onMessage, onMessage,
onOpen, onOpen,
onClose,
onError, onError,
}: { }: {
url: URL url: URL
onMessage: (msg: IncomingMessage) => void onMessage: (msg: IncomingMessage) => void
onOpen: () => void onOpen: () => void | Promise<void>
onClose: () => void | Promise<void>
onError: (err: unknown) => void onError: (err: unknown) => void
}) { }) {
this.#onError = onError this.#onError = onError
this.#socket = new WebSocket(url) this.#socket = new WebSocket(url)
// Handle incoming messages. // Handle incoming messages.
this.#socket.addEventListener("message", async (msgData) => { this.#socket.addEventListener("message", (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 { try {
const msg = await parseIncomingMessage(value) const value = msgData.data.valueOf()
// Validate and parse the message.
if (typeof value !== "string") {
throw new ProtocolError(`invalid message data: ${value}`)
}
const msg = parseIncomingMessage(value)
onMessage(msg) onMessage(msg)
} catch (err) { } catch (err) {
onError(err) onError(err)
@ -63,17 +62,32 @@ export class Conn {
}) })
// When the connection is ready, send any outstanding messages. // When the connection is ready, send any outstanding messages.
this.#socket.addEventListener("open", () => { this.#socket.addEventListener("open", async () => {
for (const msg of this.#pending) { try {
this.send(msg) 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("error", (err) => { this.#socket.addEventListener("close", async () => {
onError(err) try {
const result = onClose()
if (result instanceof Promise) {
await result
}
} catch (e) {
onError(e)
}
}) })
this.#socket.addEventListener("error", onError)
} }
send(msg: OutgoingMessage): void { send(msg: OutgoingMessage): void {
@ -235,7 +249,7 @@ function serializeFilters(filters: Filters[]): RawFilters[] {
})) }))
} }
async function parseIncomingMessage(data: string): Promise<IncomingMessage> { function parseIncomingMessage(data: string): IncomingMessage {
// Parse the incoming data as a nonempty JSON array. // Parse the incoming data as a nonempty JSON array.
const json = parseJson(data) const json = parseJson(data)
if (!(json instanceof Array)) { if (!(json instanceof Array)) {

View File

@ -6,12 +6,17 @@ import { Event, EventId } from "../event"
* Overrides providing better types for EventEmitter methods. * Overrides providing better types for EventEmitter methods.
*/ */
export class EventEmitter extends Base { export class EventEmitter extends Base {
constructor() {
super({ captureRejections: true })
}
override addListener(eventName: "newListener", listener: NewListener): this override addListener(eventName: "newListener", listener: NewListener): this
override addListener( override addListener(
eventName: "removeListener", eventName: "removeListener",
listener: RemoveListener listener: RemoveListener
): this ): this
override addListener(eventName: "open", listener: OpenListener): 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: "event", listener: EventListener): this
override addListener(eventName: "notice", listener: NoticeListener): this override addListener(eventName: "notice", listener: NoticeListener): this
override addListener(eventName: "ok", listener: OkListener): 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: "newListener", listener: NewListener): boolean
override emit(eventName: "removeListener", listener: RemoveListener): boolean override emit(eventName: "removeListener", listener: RemoveListener): boolean
override emit(eventName: "open", relay: URL, nostr: Nostr): 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: "event", params: EventParams, nostr: Nostr): boolean
override emit(eventName: "notice", notice: string, nostr: Nostr): boolean override emit(eventName: "notice", notice: string, nostr: Nostr): boolean
override emit(eventName: "ok", params: OkParams, 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: "newListener"): EventListener[]
override listeners(eventName: "removeListener"): EventListener[] override listeners(eventName: "removeListener"): EventListener[]
override listeners(eventName: "open"): OpenListener[] override listeners(eventName: "open"): OpenListener[]
override listeners(eventName: "close"): CloseListener[]
override listeners(eventName: "event"): EventListener[] override listeners(eventName: "event"): EventListener[]
override listeners(eventName: "notice"): NoticeListener[] override listeners(eventName: "notice"): NoticeListener[]
override listeners(eventName: "ok"): OkListener[] override listeners(eventName: "ok"): OkListener[]
@ -56,6 +63,7 @@ export class EventEmitter extends Base {
override off(eventName: "newListener", listener: NewListener): this override off(eventName: "newListener", listener: NewListener): this
override off(eventName: "removeListener", listener: RemoveListener): this override off(eventName: "removeListener", listener: RemoveListener): this
override off(eventName: "open", listener: OpenListener): 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: "event", listener: EventListener): this
override off(eventName: "notice", listener: NoticeListener): this override off(eventName: "notice", listener: NoticeListener): this
override off(eventName: "ok", listener: OkListener): 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: "newListener", listener: NewListener): this
override on(eventName: "removeListener", listener: RemoveListener): this override on(eventName: "removeListener", listener: RemoveListener): this
override on(eventName: "open", listener: OpenListener): 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: "event", listener: EventListener): this
override on(eventName: "notice", listener: NoticeListener): this override on(eventName: "notice", listener: NoticeListener): this
override on(eventName: "ok", listener: OkListener): 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: "newListener", listener: NewListener): this
override once(eventName: "removeListener", listener: RemoveListener): this override once(eventName: "removeListener", listener: RemoveListener): this
override once(eventName: "open", listener: OpenListener): 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: "event", listener: EventListener): this
override once(eventName: "notice", listener: NoticeListener): this override once(eventName: "notice", listener: NoticeListener): this
override once(eventName: "ok", listener: OkListener): this override once(eventName: "ok", listener: OkListener): this
@ -98,6 +108,7 @@ export class EventEmitter extends Base {
listener: RemoveListener listener: RemoveListener
): this ): this
override prependListener(eventName: "open", listener: OpenListener): 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: "event", listener: EventListener): this
override prependListener(eventName: "notice", listener: NoticeListener): this override prependListener(eventName: "notice", listener: NoticeListener): this
override prependListener(eventName: "ok", listener: OkListener): this override prependListener(eventName: "ok", listener: OkListener): this
@ -116,6 +127,10 @@ export class EventEmitter extends Base {
listener: RemoveListener listener: RemoveListener
): this ): this
override prependOnceListener(eventName: "open", listener: OpenListener): this override prependOnceListener(eventName: "open", listener: OpenListener): this
override prependOnceListener(
eventName: "close",
listener: CloseListener
): this
override prependOnceListener( override prependOnceListener(
eventName: "event", eventName: "event",
listener: EventListener listener: EventListener
@ -144,6 +159,7 @@ export class EventEmitter extends Base {
listener: RemoveListener listener: RemoveListener
): this ): this
override removeListener(eventName: "open", listener: OpenListener): 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: "event", listener: EventListener): this
override removeListener(eventName: "notice", listener: NoticeListener): this override removeListener(eventName: "notice", listener: NoticeListener): this
override removeListener(eventName: "ok", listener: OkListener): this override removeListener(eventName: "ok", listener: OkListener): this
@ -156,16 +172,17 @@ export class EventEmitter extends Base {
override rawListeners(eventName: EventName): Listener[] { override rawListeners(eventName: EventName): Listener[] {
return super.rawListeners(eventName) as 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
// TODO Ideas for events: "auth" for NIP-42 AUTH, "message" for the raw incoming messages
type EventName = type EventName =
| "newListener" | "newListener"
| "removeListener" | "removeListener"
| "open" | "open"
| "close"
| "event" | "event"
| "notice" | "notice"
| "ok" | "ok"
@ -175,6 +192,7 @@ type EventName =
type NewListener = (eventName: EventName, listener: Listener) => void type NewListener = (eventName: EventName, listener: Listener) => void
type RemoveListener = (eventName: EventName, listener: Listener) => void type RemoveListener = (eventName: EventName, listener: Listener) => void
type OpenListener = (relay: URL, nostr: Nostr) => void type OpenListener = (relay: URL, nostr: Nostr) => void
type CloseListener = (relay: URL, nostr: Nostr) => void
type EventListener = (params: EventParams, nostr: Nostr) => void type EventListener = (params: EventParams, nostr: Nostr) => void
type NoticeListener = (notice: string, nostr: Nostr) => void type NoticeListener = (notice: string, nostr: Nostr) => void
type OkListener = (params: OkParams, nostr: Nostr) => void type OkListener = (params: OkParams, nostr: Nostr) => void
@ -185,6 +203,7 @@ type Listener =
| NewListener | NewListener
| RemoveListener | RemoveListener
| OpenListener | OpenListener
| CloseListener
| EventListener | EventListener
| NoticeListener | NoticeListener
| OkListener | OkListener

View File

@ -5,13 +5,29 @@ import { Conn } from "./conn"
import * as secp from "@noble/secp256k1" import * as secp from "@noble/secp256k1"
import { EventEmitter } from "./emitter" 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. * A nostr client.
* *
* TODO Document the events here * 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 { export class Nostr extends EventEmitter {
// TODO NIP-44 AUTH, leave this for later static get CONNECTING(): ReadyState.CONNECTING {
return ReadyState.CONNECTING
}
static get OPEN(): ReadyState.OPEN {
return ReadyState.OPEN
}
static get CLOSED(): ReadyState.CLOSED {
return ReadyState.CLOSED
}
/** /**
* Open connections to relays. * Open connections to relays.
*/ */
@ -28,9 +44,14 @@ export class Nostr extends EventEmitter {
* this method will only update it with the new options, and an exception will be thrown * this method will only update it with the new options, and an exception will be thrown
* if no options are specified. * if no options are specified.
*/ */
open(url: URL | string, opts?: { read?: boolean; write?: boolean }): void { open(
url: URL | string,
opts?: { read?: boolean; write?: boolean; fetchInfo?: boolean }
): void {
const connUrl = new URL(url)
// If the connection already exists, update the options. // 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 (existingConn !== undefined) {
if (opts === undefined) { if (opts === undefined) {
throw new Error( throw new Error(
@ -46,11 +67,16 @@ export class Nostr extends EventEmitter {
return return
} }
const connUrl = new URL(url) // Fetch the relay info in parallel to opening the WebSocket connection.
const fetchInfo =
opts?.fetchInfo === false
? Promise.resolve({})
: fetchRelayInfo(connUrl).catch((e) => this.emit("error", e, this))
// If there is no existing connection, open a new one. // If there is no existing connection, open a new one.
const conn = new Conn({ const conn = new Conn({
url: connUrl, url: connUrl,
// Handle messages on this connection. // Handle messages on this connection.
onMessage: async (msg) => { onMessage: async (msg) => {
try { try {
@ -87,8 +113,68 @@ export class Nostr extends EventEmitter {
this.emit("error", err, this) 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)
},
// 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. // Forward errors on this connection.
onError: (err) => this.emit("error", err, this), onError: (err) => this.emit("error", err, this),
}) })
@ -102,11 +188,12 @@ export class Nostr extends EventEmitter {
}) })
} }
this.#conns.set(url.toString(), { this.#conns.set(connUrl.toString(), {
conn, conn,
auth: false, auth: false,
read: opts?.read ?? true, read: opts?.read ?? true,
write: opts?.write ?? true, write: opts?.write ?? true,
readyState: ReadyState.CONNECTING,
}) })
} }
@ -116,23 +203,19 @@ export class Nostr extends EventEmitter {
* @param url If specified, only close the connection to this relay. If the connection does * @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 * not exist, an exception will be thrown. If this parameter is not specified, all connections
* will be closed. * 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 { close(url?: URL | string): void {
if (url === undefined) { if (url === undefined) {
for (const { conn } of this.#conns.values()) { for (const { conn } of this.#conns.values()) {
conn.close() conn.close()
} }
this.#conns.clear()
return return
} }
const c = this.#conns.get(url.toString()) const connUrl = new URL(url)
const c = this.#conns.get(connUrl.toString())
if (c === undefined) { if (c === undefined) {
throw new Error(`connection to ${url} doesn't exist`) throw new Error(`connection to ${url} doesn't exist`)
} }
this.#conns.delete(url.toString())
c.conn.close() c.conn.close()
} }
@ -173,7 +256,7 @@ export class Nostr extends EventEmitter {
* *
* TODO Reference subscribed() * TODO Reference subscribed()
*/ */
async unsubscribe(subscriptionId: SubscriptionId): Promise<void> { unsubscribe(subscriptionId: SubscriptionId): void {
if (!this.#subscriptions.delete(subscriptionId)) { if (!this.#subscriptions.delete(subscriptionId)) {
throw new Error(`subscription ${subscriptionId} does not exist`) throw new Error(`subscription ${subscriptionId} does not exist`)
} }
@ -191,7 +274,7 @@ export class Nostr extends EventEmitter {
/** /**
* Publish an event. * Publish an event.
*/ */
async publish(event: RawEvent): Promise<void> { publish(event: RawEvent): void {
for (const { conn, write } of this.#conns.values()) { for (const { conn, write } of this.#conns.values()) {
if (!write) { if (!write) {
continue continue
@ -202,9 +285,177 @@ 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,
}
} else {
throw new Error("bug: unknown readyState")
}
})
}
} }
interface ConnState { // 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<RelayInfo> {
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
)}`
)
}
}
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 {
conn: Conn conn: Conn
/** /**
* Has this connection been authenticated via NIP-44 AUTH? * Has this connection been authenticated via NIP-44 AUTH?
@ -220,6 +471,21 @@ interface ConnState {
write: boolean 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. * A string uniquely identifying a client subscription.
*/ */

View File

@ -90,7 +90,7 @@ export async function schnorrSign(data: Hex, priv: PrivateKey): Promise<Hex> {
/** /**
* Verify that the elliptic curve signature is correct. * Verify that the elliptic curve signature is correct.
*/ */
export async function schnorrVerify( export function schnorrVerify(
sig: Hex, sig: Hex,
data: Hex, data: Hex,
key: PublicKey key: PublicKey

View File

@ -3,12 +3,13 @@ import { parsePublicKey } from "../src/crypto"
import assert from "assert" import assert from "assert"
import { setup } from "./setup" import { setup } from "./setup"
describe("dm", async function () { describe("dm", () => {
const message = "for your eyes only" const message = "for your eyes only"
// Test that the intended recipient can receive and decrypt the direct message. // Test that the intended recipient can receive and decrypt the direct message.
it("to intended recipient", (done) => { it("to intended recipient", (done) => {
setup(done).then( setup(
done,
({ ({
publisher, publisher,
publisherPubkey, publisherPubkey,
@ -17,33 +18,27 @@ describe("dm", async function () {
subscriberPubkey, subscriberPubkey,
subscriberSecret, subscriberSecret,
timestamp, timestamp,
done,
}) => { }) => {
// Expect the direct message. // Expect the direct message.
subscriber.on( subscriber.on(
"event", "event",
async ({ event, subscriptionId: actualSubscriptionId }, nostr) => { async ({ event, subscriptionId: actualSubscriptionId }, nostr) => {
try { assert.equal(nostr, subscriber)
assert.equal(nostr, subscriber) assert.equal(event.kind, EventKind.DirectMessage)
assert.equal(event.kind, EventKind.DirectMessage) assert.equal(event.pubkey, parsePublicKey(publisherPubkey))
assert.equal(event.pubkey, parsePublicKey(publisherPubkey)) assert.equal(actualSubscriptionId, subscriptionId)
assert.equal(actualSubscriptionId, subscriptionId) assert.ok(event.created_at >= timestamp)
assert.ok(event.created_at >= timestamp)
if (event.kind === EventKind.DirectMessage) { if (event.kind === EventKind.DirectMessage) {
assert.equal( assert.equal(
event.getRecipient(), event.getRecipient(),
parsePublicKey(subscriberPubkey) parsePublicKey(subscriberPubkey)
) )
assert.equal(await event.getMessage(subscriberSecret), message) assert.equal(await event.getMessage(subscriberSecret), message)
}
publisher.close()
subscriber.close()
done()
} catch (e) {
done(e)
} }
done()
} }
) )
@ -67,7 +62,8 @@ describe("dm", async function () {
// Test that an unintended recipient still receives the direct message event, but cannot decrypt it. // Test that an unintended recipient still receives the direct message event, but cannot decrypt it.
it("to unintended recipient", (done) => { it("to unintended recipient", (done) => {
setup(done).then( setup(
done,
({ ({
publisher, publisher,
publisherPubkey, publisherPubkey,
@ -75,6 +71,7 @@ describe("dm", async function () {
subscriber, subscriber,
subscriberSecret, subscriberSecret,
timestamp, timestamp,
done,
}) => { }) => {
const recipientPubkey = const recipientPubkey =
"npub1u2dl3scpzuwyd45flgtm3wcjgv20j4azuzgevdpgtsvvmqzvc63sz327gc" "npub1u2dl3scpzuwyd45flgtm3wcjgv20j4azuzgevdpgtsvvmqzvc63sz327gc"
@ -101,9 +98,6 @@ describe("dm", async function () {
) )
} }
publisher.close()
subscriber.close()
done() done()
} catch (e) { } catch (e) {
done(e) done(e)

View File

@ -0,0 +1,24 @@
import assert from "assert"
import { Nostr } from "../src/client"
import { relayUrl } from "./setup"
describe("ready state", () => {
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)
})
})

View File

@ -0,0 +1,26 @@
import assert from "assert"
import { Nostr } from "../src/client"
import { setup } from "./setup"
describe("relay info", () => {
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()
})
})
})

View File

@ -1,6 +1,8 @@
import { Nostr } from "../src/client" import { Nostr } from "../src/client"
import { unixTimestamp } from "../src/util" import { unixTimestamp } from "../src/util"
export const relayUrl = new URL("ws://localhost:12648")
export interface Setup { export interface Setup {
publisher: Nostr publisher: Nostr
publisherSecret: string publisherSecret: string
@ -10,33 +12,59 @@ export interface Setup {
subscriberPubkey: string subscriberPubkey: string
timestamp: number timestamp: number
url: URL 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<Setup> { export async function setup(
await restartRelay() done: jest.DoneCallback,
const publisher = new Nostr() test: (setup: Setup) => void | Promise<void>
const subscriber = new Nostr() ) {
const url = new URL("ws://localhost:12648") try {
await restartRelay()
const publisher = new Nostr()
const subscriber = new Nostr()
publisher.on("error", done) publisher.on("error", done)
subscriber.on("error", done) subscriber.on("error", done)
publisher.open(url) const openPromise = Promise.all([
subscriber.open(url) new Promise((resolve) => publisher.on("open", resolve)),
new Promise((resolve) => subscriber.on("open", resolve)),
])
return { publisher.open(relayUrl)
publisher, subscriber.open(relayUrl)
publisherSecret:
"nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363", await openPromise
publisherPubkey:
"npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7", const result = test({
subscriber, publisher,
subscriberSecret: publisherSecret:
"nsec1fxvlyqn3rugvxwaz6dr5h8jcfn0fe0lxyp7pl4mgntxfzqr7dmgst7z9ps", "nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363",
subscriberPubkey: publisherPubkey:
"npub1mtwskm558jugtj724nsgf3jf80c5adl39ttydngrn48250l6xmjqa00yxd", "npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7",
timestamp: unixTimestamp(), subscriber,
url, subscriberSecret:
"nsec1fxvlyqn3rugvxwaz6dr5h8jcfn0fe0lxyp7pl4mgntxfzqr7dmgst7z9ps",
subscriberPubkey:
"npub1mtwskm558jugtj724nsgf3jf80c5adl39ttydngrn48250l6xmjqa00yxd",
timestamp: unixTimestamp(),
url: relayUrl,
done: (e?: unknown) => {
publisher.close()
subscriber.close()
done(e)
},
})
if (result instanceof Promise) {
await result
}
} catch (e) {
done(e)
} }
} }
@ -60,7 +88,7 @@ async function restartRelay() {
nostr.close() nostr.close()
resolve(true) resolve(true)
}) })
nostr.open("ws://localhost:12648") nostr.open("ws://localhost:12648", { fetchInfo: false })
}) })
if (ok) { if (ok) {
break break

View File

@ -3,18 +3,20 @@ import { parsePublicKey } from "../src/crypto"
import assert from "assert" import assert from "assert"
import { setup } from "./setup" import { setup } from "./setup"
describe("text note", async function () { describe("text note", () => {
const note = "hello world" const note = "hello world"
// Test that a text note can be published by one client and received by the other. // Test that a text note can be published by one client and received by the other.
it("publish and receive", (done) => { it("publish and receive", (done) => {
setup(done).then( setup(
done,
({ ({
publisher, publisher,
publisherSecret, publisherSecret,
publisherPubkey, publisherPubkey,
subscriber, subscriber,
timestamp, timestamp,
done,
}) => { }) => {
// Expect the test event. // Expect the test event.
subscriber.on( subscriber.on(
@ -26,10 +28,6 @@ describe("text note", async function () {
assert.strictEqual(event.created_at, timestamp) assert.strictEqual(event.created_at, timestamp)
assert.strictEqual(event.content, note) assert.strictEqual(event.content, note)
assert.strictEqual(actualSubscriptionId, subscriptionId) assert.strictEqual(actualSubscriptionId, subscriptionId)
subscriber.close()
publisher.close()
done() done()
} }
) )
@ -53,7 +51,7 @@ describe("text note", async function () {
// Test that a client interprets an "OK" message after publishing a text note. // Test that a client interprets an "OK" message after publishing a text note.
it("publish and ok", function (done) { 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 // TODO No signEvent, have a convenient way to do this
signEvent(createTextNote(note), publisherSecret).then((event) => { signEvent(createTextNote(note), publisherSecret).then((event) => {
publisher.on("ok", (params, nostr) => { publisher.on("ok", (params, nostr) => {
@ -61,10 +59,6 @@ describe("text note", async function () {
assert.equal(params.eventId, event.id) assert.equal(params.eventId, event.id)
assert.equal(params.relay.toString(), url.toString()) assert.equal(params.relay.toString(), url.toString())
assert.equal(params.ok, true) assert.equal(params.ok, true)
publisher.close()
subscriber.close()
done() done()
}) })