nostr
package: fetch relay info
#446
@ -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",
|
||||
},
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -14,13 +14,12 @@ import { unixTimestamp } from "../util"
|
||||
*/
|
||||
export class Conn {
|
||||
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.
|
||||
* 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,27 +34,27 @@ export class Conn {
|
||||
url,
|
||||
onMessage,
|
||||
onOpen,
|
||||
onClose,
|
||||
onError,
|
||||
}: {
|
||||
url: URL
|
||||
onMessage: (msg: IncomingMessage) => void
|
||||
onOpen: () => void
|
||||
onOpen: () => void | Promise<void>
|
||||
onClose: () => void | Promise<void>
|
||||
onError: (err: unknown) => void
|
||||
}) {
|
||||
this.#onError = onError
|
||||
this.#socket = new WebSocket(url)
|
||||
|
||||
// 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
|
||||
}
|
||||
this.#socket.addEventListener("message", (msgData) => {
|
||||
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)
|
||||
} catch (err) {
|
||||
onError(err)
|
||||
@ -63,17 +62,32 @@ 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("error", (err) => {
|
||||
onError(err)
|
||||
this.#socket.addEventListener("close", async () => {
|
||||
try {
|
||||
const result = onClose()
|
||||
if (result instanceof Promise) {
|
||||
await result
|
||||
}
|
||||
} catch (e) {
|
||||
onError(e)
|
||||
}
|
||||
})
|
||||
this.#socket.addEventListener("error", onError)
|
||||
}
|
||||
|
||||
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.
|
||||
const json = parseJson(data)
|
||||
if (!(json instanceof Array)) {
|
||||
|
@ -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,17 @@ 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
|
||||
// TODO Ideas for events: "auth" for NIP-42 AUTH, "message" for the raw incoming messages
|
||||
type EventName =
|
||||
| "newListener"
|
||||
| "removeListener"
|
||||
| "open"
|
||||
| "close"
|
||||
| "event"
|
||||
| "notice"
|
||||
| "ok"
|
||||
@ -175,6 +192,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 +203,7 @@ type Listener =
|
||||
| NewListener
|
||||
| RemoveListener
|
||||
| OpenListener
|
||||
| CloseListener
|
||||
| EventListener
|
||||
| NoticeListener
|
||||
| OkListener
|
||||
|
@ -5,13 +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 {
|
||||
// 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.
|
||||
*/
|
||||
@ -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
|
||||
* 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.
|
||||
const existingConn = this.#conns.get(url.toString())
|
||||
const existingConn = this.#conns.get(connUrl.toString())
|
||||
if (existingConn !== undefined) {
|
||||
if (opts === undefined) {
|
||||
throw new Error(
|
||||
@ -46,11 +67,16 @@ 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({})
|
||||
: fetchRelayInfo(connUrl).catch((e) => this.emit("error", e, this))
|
||||
|
||||
// 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 {
|
||||
@ -87,8 +113,68 @@ 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)
|
||||
},
|
||||
|
||||
// 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),
|
||||
})
|
||||
@ -102,11 +188,12 @@ export class Nostr extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
this.#conns.set(url.toString(), {
|
||||
this.#conns.set(connUrl.toString(), {
|
||||
conn,
|
||||
auth: false,
|
||||
read: opts?.read ?? 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
|
||||
* 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()
|
||||
}
|
||||
|
||||
@ -173,7 +256,7 @@ export class Nostr extends EventEmitter {
|
||||
*
|
||||
* TODO Reference subscribed()
|
||||
*/
|
||||
async unsubscribe(subscriptionId: SubscriptionId): Promise<void> {
|
||||
unsubscribe(subscriptionId: SubscriptionId): void {
|
||||
if (!this.#subscriptions.delete(subscriptionId)) {
|
||||
throw new Error(`subscription ${subscriptionId} does not exist`)
|
||||
}
|
||||
@ -191,7 +274,7 @@ export class Nostr extends EventEmitter {
|
||||
/**
|
||||
* Publish an event.
|
||||
*/
|
||||
async publish(event: RawEvent): Promise<void> {
|
||||
publish(event: RawEvent): void {
|
||||
for (const { conn, write } of this.#conns.values()) {
|
||||
if (!write) {
|
||||
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
|
||||
/**
|
||||
* Has this connection been authenticated via NIP-44 AUTH?
|
||||
@ -220,6 +471,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.
|
||||
*/
|
||||
|
@ -90,7 +90,7 @@ export async function schnorrSign(data: Hex, priv: PrivateKey): Promise<Hex> {
|
||||
/**
|
||||
* Verify that the elliptic curve signature is correct.
|
||||
*/
|
||||
export async function schnorrVerify(
|
||||
export function schnorrVerify(
|
||||
sig: Hex,
|
||||
data: Hex,
|
||||
key: PublicKey
|
||||
|
@ -3,12 +3,13 @@ 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.
|
||||
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)
|
||||
|
24
packages/nostr/test/ready-state.ts
Normal file
24
packages/nostr/test/ready-state.ts
Normal 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)
|
||||
})
|
||||
})
|
26
packages/nostr/test/relay-info.ts
Normal file
26
packages/nostr/test/relay-info.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
@ -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
|
||||
@ -10,33 +12,59 @@ 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<Setup> {
|
||||
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<void>
|
||||
) {
|
||||
try {
|
||||
await restartRelay()
|
||||
const publisher = new Nostr()
|
||||
const subscriber = new Nostr()
|
||||
|
||||
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,
|
||||
publisher.open(relayUrl)
|
||||
subscriber.open(relayUrl)
|
||||
|
||||
await openPromise
|
||||
|
||||
const result = test({
|
||||
publisher,
|
||||
publisherSecret:
|
||||
"nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363",
|
||||
publisherPubkey:
|
||||
"npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7",
|
||||
subscriber,
|
||||
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()
|
||||
resolve(true)
|
||||
})
|
||||
nostr.open("ws://localhost:12648")
|
||||
nostr.open("ws://localhost:12648", { fetchInfo: false })
|
||||
})
|
||||
if (ok) {
|
||||
break
|
||||
|
@ -3,18 +3,20 @@ 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.
|
||||
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()
|
||||
})
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user