Merge pull request #446 from v0l/nostr-package-relay-info

`nostr` package: fetch relay info
This commit is contained in:
Kieran 2023-03-27 10:06:43 +01:00 committed by GitHub
commit ed61f203a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 481 additions and 106 deletions

View File

@ -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",
},
}

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
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

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

View File

@ -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)) {

View File

@ -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

View File

@ -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.
*/

View File

@ -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

View File

@ -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)

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 { 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

View File

@ -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()
})