diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index 3019710..8b24b0b 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -1,6 +1,6 @@ import { ProtocolError } from "../error" import { Filters, SubscriptionId } from "." -import { RawEvent, SignedEvent } from "../event" +import { EventId, RawEvent, SignedEvent } from "../event" import WebSocket from "ws" import { unixTimestamp } from "../util" @@ -14,7 +14,6 @@ import { unixTimestamp } from "../util" */ export class Conn { readonly #socket: WebSocket - /** * Messages which were requested to be sent before the websocket was ready. * Once the websocket becomes ready, these messages will be sent and cleared. @@ -23,16 +22,26 @@ export class Conn { // 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[] = [] - - #msgCallback?: IncomingMessageCallback - #errorCallback?: ErrorCallback + /** + * Callback for errors. + */ + readonly #onError: (err: unknown) => void get url(): string { return this.#socket.url } - constructor(endpoint: string | URL) { - this.#socket = new WebSocket(endpoint) + constructor({ + url, + onMessage, + onError, + }: { + url: URL + onMessage: (msg: IncomingMessage) => void + onError: (err: unknown) => void + }) { + this.#onError = onError + this.#socket = new WebSocket(url) // Handle incoming messages. this.#socket.addEventListener("message", async (msgData) => { @@ -40,14 +49,14 @@ export class Conn { // Validate and parse the message. if (typeof value !== "string") { const err = new ProtocolError(`invalid message data: ${value}`) - this.#errorCallback?.(err) + onError(err) return } try { - const msg = await parseIncomingMessage(value) - this.#msgCallback?.(msg) + const msg = await Conn.#parseIncomingMessage(value) + onMessage(msg) } catch (err) { - this.#errorCallback?.(err) + onError(err) } }) @@ -60,22 +69,10 @@ export class Conn { }) this.#socket.addEventListener("error", (err) => { - this.#errorCallback?.(err) + onError(err) }) } - on(on: "message", cb: IncomingMessageCallback): void - on(on: "error", cb: ErrorCallback): void - on(on: "message" | "error", cb: IncomingMessageCallback | ErrorCallback) { - if (on === "message") { - this.#msgCallback = cb as IncomingMessageCallback - } else if (on === "error") { - this.#errorCallback = cb as ErrorCallback - } else { - throw new Error(`unexpected input: ${on}`) - } - } - send(msg: OutgoingMessage): void { if (this.#socket.readyState < WebSocket.OPEN) { this.#pending.push(msg) @@ -84,11 +81,11 @@ export class Conn { try { this.#socket.send(serializeOutgoingMessage(msg), (err) => { if (err !== undefined && err !== null) { - this.#errorCallback?.(err) + this.#onError?.(err) } }) } catch (err) { - this.#errorCallback?.(err) + this.#onError?.(err) } } @@ -96,26 +93,87 @@ export class Conn { try { this.#socket.close() } catch (err) { - this.#errorCallback?.(err) + this.#onError?.(err) } } + + static async #parseIncomingMessage(data: string): Promise { + const json = parseJson(data) + if (!(json instanceof Array)) { + throw new ProtocolError(`incoming message is not an array: ${data}`) + } + if (json.length === 0) { + throw new ProtocolError(`incoming message is an empty array: ${data}`) + } + if (json[0] === "EVENT") { + if (typeof json[1] !== "string") { + throw new ProtocolError( + `second element of "EVENT" should be a string, but wasn't: ${data}` + ) + } + if (typeof json[2] !== "object") { + throw new ProtocolError( + `second element of "EVENT" should be an object, but wasn't: ${data}` + ) + } + const raw = parseEventData(json[2]) + return { + kind: "event", + subscriptionId: new SubscriptionId(json[1]), + signed: await SignedEvent.verify(raw), + raw, + } + } + if (json[0] === "NOTICE") { + if (typeof json[1] !== "string") { + throw new ProtocolError( + `second element of "NOTICE" should be a string, but wasn't: ${data}` + ) + } + return { + kind: "notice", + notice: json[1], + } + } + if (json[0] === "OK") { + if (typeof json[1] !== "string") { + throw new ProtocolError( + `second element of "OK" should be a string, but wasn't: ${data}` + ) + } + if (typeof json[2] !== "boolean") { + throw new ProtocolError( + `third element of "OK" should be a boolean, but wasn't: ${data}` + ) + } + if (typeof json[3] !== "string") { + throw new ProtocolError( + `fourth element of "OK" should be a string, but wasn't: ${data}` + ) + } + return { + kind: "ok", + eventId: new EventId(json[1]), + ok: json[2], + message: json[3], + } + } + throw new ProtocolError(`unknown incoming message: ${data}`) + } } /** * A message sent from a relay to the client. */ -export type IncomingMessage = IncomingEvent | IncomingNotice +export type IncomingMessage = IncomingEvent | IncomingNotice | IncomingOk -export const enum IncomingKind { - Event, - Notice, -} +export type IncomingKind = "event" | "notice" | "ok" /** * Incoming "EVENT" message. */ export interface IncomingEvent { - kind: IncomingKind.Event + kind: "event" subscriptionId: SubscriptionId signed: SignedEvent raw: RawEvent @@ -125,10 +183,20 @@ export interface IncomingEvent { * Incoming "NOTICE" message. */ export interface IncomingNotice { - kind: IncomingKind.Notice + kind: "notice" notice: string } +/** + * Incoming "OK" message. + */ +export interface IncomingOk { + kind: "ok" + eventId: EventId + ok: boolean + message: string +} + /** * A message sent from the client to a relay. */ @@ -137,17 +205,13 @@ export type OutgoingMessage = | OutgoingOpenSubscription | OutgoingCloseSubscription -export const enum OutgoingKind { - Event, - OpenSubscription, - CloseSubscription, -} +export type OutgoingKind = "event" | "openSubscription" | "closeSubscription" /** * Outgoing "EVENT" message. */ export interface OutgoingEvent { - kind: OutgoingKind.Event + kind: "event" event: SignedEvent | RawEvent } @@ -155,7 +219,7 @@ export interface OutgoingEvent { * Outgoing "REQ" message, which opens a subscription. */ export interface OutgoingOpenSubscription { - kind: OutgoingKind.OpenSubscription + kind: "openSubscription" id: SubscriptionId filters: Filters[] } @@ -164,13 +228,10 @@ export interface OutgoingOpenSubscription { * Outgoing "CLOSE" message, which closes a subscription. */ export interface OutgoingCloseSubscription { - kind: OutgoingKind.CloseSubscription + kind: "closeSubscription" id: SubscriptionId } -type IncomingMessageCallback = (message: IncomingMessage) => unknown -type ErrorCallback = (error: unknown) => unknown - interface RawFilters { ids?: string[] authors?: string[] @@ -182,59 +243,18 @@ interface RawFilters { limit?: number } -async function parseIncomingMessage(data: string): Promise { - const json = parseJson(data) - if (!(json instanceof Array)) { - throw new ProtocolError(`incoming message is not an array: ${data}`) - } - if (json.length === 0) { - throw new ProtocolError(`incoming message is an empty array: ${data}`) - } - if (json[0] === "EVENT") { - if (typeof json[1] !== "string") { - throw new ProtocolError( - `second element of "EVENT" should be a string, but wasn't: ${data}` - ) - } - if (typeof json[2] !== "object") { - throw new ProtocolError( - `second element of "EVENT" should be an object, but wasn't: ${data}` - ) - } - const raw = parseEventData(json[2]) - return { - kind: IncomingKind.Event, - subscriptionId: new SubscriptionId(json[1]), - signed: await SignedEvent.verify(raw), - raw, - } - } - if (json[0] === "NOTICE") { - if (typeof json[1] !== "string") { - throw new ProtocolError( - `second element of "NOTICE" should be a string, but wasn't: ${data}` - ) - } - return { - kind: IncomingKind.Notice, - notice: json[1], - } - } - throw new ProtocolError(`unknown incoming message: ${data}`) -} - function serializeOutgoingMessage(msg: OutgoingMessage): string { - if (msg.kind === OutgoingKind.Event) { + if (msg.kind === "event") { const raw = msg.event instanceof SignedEvent ? msg.event.serialize() : msg.event return JSON.stringify(["EVENT", raw]) - } else if (msg.kind === OutgoingKind.OpenSubscription) { + } else if (msg.kind === "openSubscription") { return JSON.stringify([ "REQ", msg.id.toString(), ...serializeFilters(msg.filters), ]) - } else if (msg.kind === OutgoingKind.CloseSubscription) { + } else if (msg.kind === "closeSubscription") { return JSON.stringify(["CLOSE", msg.id.toString()]) } else { throw new Error(`invalid message: ${JSON.stringify(msg)}`) diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index 3c98021..2c9eb78 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -1,5 +1,6 @@ import Base from "events" -import { EventParams, Nostr } from "." +import { Nostr, SubscriptionId } from "." +import { EventId, RawEvent, SignedEvent } from "../event" /** * Overrides providing better types for EventEmitter methods. @@ -11,6 +12,7 @@ export class EventEmitter extends Base { listener: RemoveListener ): this override addListener(eventName: "notice", listener: NoticeListener): this + override addListener(eventName: "ok", listener: OkListener): this override addListener(eventName: "error", listener: ErrorListener): this override addListener(eventName: "newListener", listener: ErrorListener): this override addListener(eventName: EventName, listener: Listener): this { @@ -21,6 +23,7 @@ export class EventEmitter extends Base { override emit(eventName: "removeListener", listener: RemoveListener): 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 override emit(eventName: "error", err: unknown, nostr: Nostr): boolean override emit(eventName: EventName, ...args: unknown[]): boolean { return super.emit(eventName, ...args) @@ -34,6 +37,7 @@ export class EventEmitter extends Base { override listeners(eventName: "removeListener"): EventListener[] override listeners(eventName: "event"): EventListener[] override listeners(eventName: "notice"): NoticeListener[] + override listeners(eventName: "ok"): OkListener[] override listeners(eventName: "error"): ErrorListener[] override listeners(eventName: EventName): Listener[] { return super.listeners(eventName) as Listener[] @@ -43,8 +47,9 @@ export class EventEmitter extends Base { override off(eventName: "removeListener", listener: RemoveListener): this override off(eventName: "event", listener: EventListener): this override off(eventName: "notice", listener: NoticeListener): this + override off(eventName: "ok", listener: OkListener): this override off(eventName: "error", listener: ErrorListener): this - override off(eventName: string, listener: Listener): this { + override off(eventName: EventName, listener: Listener): this { return super.off(eventName, listener) } @@ -52,6 +57,7 @@ export class EventEmitter extends Base { override on(eventName: "removeListener", listener: RemoveListener): this override on(eventName: "event", listener: EventListener): this override on(eventName: "notice", listener: NoticeListener): this + override on(eventName: "ok", listener: OkListener): this override on(eventName: "error", listener: ErrorListener): this override on(eventName: EventName, listener: Listener): this { return super.on(eventName, listener) @@ -61,6 +67,7 @@ export class EventEmitter extends Base { override once(eventName: "removeListener", listener: RemoveListener): this override once(eventName: "event", listener: EventListener): this override once(eventName: "notice", listener: NoticeListener): this + override once(eventName: "ok", listener: OkListener): this override once(eventName: "error", listener: ErrorListener): this override once(eventName: EventName, listener: Listener): this { return super.once(eventName, listener) @@ -76,6 +83,7 @@ export class EventEmitter extends Base { ): this override prependListener(eventName: "event", listener: EventListener): this override prependListener(eventName: "notice", listener: NoticeListener): this + override prependListener(eventName: "ok", listener: OkListener): this override prependListener(eventName: "error", listener: ErrorListener): this override prependListener(eventName: EventName, listener: Listener): this { return super.prependListener(eventName, listener) @@ -97,6 +105,7 @@ export class EventEmitter extends Base { eventName: "notice", listener: NoticeListener ): this + override prependOnceListener(eventName: "ok", listener: OkListener): this override prependOnceListener( eventName: "error", listener: ErrorListener @@ -116,6 +125,7 @@ export class EventEmitter extends Base { ): this override removeListener(eventName: "event", listener: EventListener): this override removeListener(eventName: "notice", listener: NoticeListener): this + override removeListener(eventName: "ok", listener: OkListener): this override removeListener(eventName: "error", listener: ErrorListener): this override removeListener(eventName: EventName, listener: Listener): this { return super.removeListener(eventName, listener) @@ -131,15 +141,40 @@ export class EventEmitter extends Base { // TODO Also add on: ("subscribed", subscriptionId) which checks "OK"/"NOTICE" and makes a callback? // TODO Also add on: ("ok", boolean, eventId) which checks "OK"/"NOTICE" and makes a callback? -type EventName = "newListener" | "removeListener" | "event" | "notice" | "error" +type EventName = + | "newListener" + | "removeListener" + | "event" + | "notice" + | "ok" + | "error" + type NewListener = (eventName: EventName, listener: Listener) => void type RemoveListener = (eventName: EventName, listener: Listener) => void type EventListener = (params: EventParams, nostr: Nostr) => void +type OkListener = (params: OkParams, nostr: Nostr) => void type NoticeListener = (notice: string, nostr: Nostr) => void type ErrorListener = (error: unknown, nostr: Nostr) => void + type Listener = | NewListener | RemoveListener | EventListener | NoticeListener + | OkListener | ErrorListener + +// TODO Document this +export interface EventParams { + signed: SignedEvent + subscriptionId: SubscriptionId + raw: RawEvent +} + +// TODO Document this +export interface OkParams { + eventId: EventId + relay: URL + ok: boolean + message: string +} diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 48be246..d83e189 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -1,7 +1,7 @@ import { ProtocolError } from "../error" import { EventId, Event, EventKind, SignedEvent, RawEvent } from "../event" import { PrivateKey, PublicKey } from "../keypair" -import { Conn, IncomingKind, OutgoingKind } from "./conn" +import { Conn } from "./conn" import * as secp from "@noble/secp256k1" import { EventEmitter } from "./emitter" @@ -46,42 +46,53 @@ export class Nostr extends EventEmitter { return } + const connUrl = new URL(url) + // If there is no existing connection, open a new one. - const conn = new Conn(url) - - // Handle messages on this connection. - conn.on("message", async (msg) => { - try { - if (msg.kind === IncomingKind.Event) { - this.emit( - "event", - { - signed: msg.signed, - subscriptionId: msg.subscriptionId, - raw: msg.raw, - }, - this - ) - } else if (msg.kind === IncomingKind.Notice) { - this.emit("notice", msg.notice, this) - } else { - throw new ProtocolError(`invalid message ${msg}`) + const conn = new Conn({ + url: connUrl, + // Handle messages on this connection. + onMessage: (msg) => { + try { + if (msg.kind === "event") { + this.emit( + "event", + { + signed: msg.signed, + subscriptionId: msg.subscriptionId, + raw: msg.raw, + }, + this + ) + } else if (msg.kind === "notice") { + this.emit("notice", msg.notice, this) + } else if (msg.kind === "ok") { + this.emit( + "ok", + { + eventId: msg.eventId, + relay: connUrl, + ok: msg.ok, + message: msg.message, + }, + this + ) + } else { + throw new ProtocolError(`invalid message ${msg}`) + } + } catch (err) { + this.emit("error", err, this) } - } catch (err) { - this.emit("error", err, this) - } - }) - - // Forward connection errors to the error callbacks. - conn.on("error", (err) => { - this.emit("error", err, this) + }, + // Forward errors on this connection. + onError: (err) => this.emit("error", err, this), }) // Resend existing subscriptions to this connection. for (const [key, filters] of this.#subscriptions.entries()) { const subscriptionId = new SubscriptionId(key) conn.send({ - kind: OutgoingKind.OpenSubscription, + kind: "openSubscription", id: subscriptionId, filters, }) @@ -145,7 +156,7 @@ export class Nostr extends EventEmitter { continue } conn.send({ - kind: OutgoingKind.OpenSubscription, + kind: "openSubscription", id: subscriptionId, filters, }) @@ -167,7 +178,7 @@ export class Nostr extends EventEmitter { continue } conn.send({ - kind: OutgoingKind.CloseSubscription, + kind: "closeSubscription", id: subscriptionId, }) } @@ -210,7 +221,7 @@ export class Nostr extends EventEmitter { event = await SignedEvent.sign(event, key) } conn.send({ - kind: OutgoingKind.Event, + kind: "event", event, }) } @@ -289,10 +300,3 @@ export interface Filters { until?: Date limit?: number } - -// TODO Document this -export interface EventParams { - signed: SignedEvent - subscriptionId: SubscriptionId - raw: RawEvent -} diff --git a/packages/nostr/src/event.ts b/packages/nostr/src/event.ts index 5dd4e02..8dcfb29 100644 --- a/packages/nostr/src/event.ts +++ b/packages/nostr/src/event.ts @@ -87,7 +87,8 @@ export class EventId { } } - private constructor(hex: string) { + constructor(hex: string) { + // TODO Validate that this is 32-byte hex this.#hex = hex } diff --git a/packages/nostr/test/simple-communication.ts b/packages/nostr/test/simple-communication.ts index 86c6618..4841a6e 100644 --- a/packages/nostr/test/simple-communication.ts +++ b/packages/nostr/test/simple-communication.ts @@ -1,26 +1,37 @@ -import { EventParams, Nostr } from "../src/client" -import { EventKind } from "../src/event" +import { Nostr } from "../src/client" +import { EventKind, SignedEvent } from "../src/event" import { PrivateKey } from "../src/keypair" import assert from "assert" +import { EventParams } from "../src/client/emitter" // TODO Switch out the relay implementation and see if the issue persists +// TODO Do on("error", done) for all of these -describe("single event communication", function () { - it("ok", function (done) { - const secret = new PrivateKey( - "nsec1xlu55y6fqfgrq448xslt6a8j2rh7lj08hyhgs94ryq04yf6surwsjl0kzh" - ) - const pubkey = secret.pubkey +describe("simple communication", function () { + const secret = new PrivateKey( + "nsec1xlu55y6fqfgrq448xslt6a8j2rh7lj08hyhgs94ryq04yf6surwsjl0kzh" + ) + const pubkey = secret.pubkey + const timestamp = new Date() + const note = "hello world" + const url = new URL("ws://localhost:12648") - const timestamp = new Date() - const note = "hello world" + const publisher = new Nostr() + const subscriber = new Nostr() - const publisher = new Nostr() - publisher.open("ws://localhost:12648") - const subscriber = new Nostr() - subscriber.open("ws://localhost:12648") + beforeEach(() => { + publisher.open(url) + subscriber.open(url) + }) - function listener({ signed: { event } }: EventParams) { + afterEach(() => { + publisher.close() + subscriber.close() + }) + + it("publish and receive", function (done) { + function listener({ signed: { event } }: EventParams, nostr: Nostr) { + assert.equal(nostr, subscriber) assert.equal(event.kind, EventKind.TextNote) assert.equal(event.pubkey.toString(), pubkey.toString()) assert.equal(event.createdAt.toString(), timestamp.toString()) @@ -34,16 +45,11 @@ describe("single event communication", function () { // for future events. subscriber.off("event", listener) - publisher.close() - subscriber.close() - done() } subscriber.on("event", listener) - subscriber.subscribe([]) - publisher.publish( { kind: EventKind.TextNote, @@ -54,4 +60,26 @@ describe("single event communication", function () { secret ) }) + + it("publish and ok", function (done) { + SignedEvent.sign( + { + kind: EventKind.TextNote, + createdAt: timestamp, + content: note, + pubkey, + }, + secret + ).then((event) => { + publisher.on("ok", (params, nostr) => { + assert.equal(nostr, publisher) + assert.equal(params.eventId.toString(), event.eventId.toString()) + assert.equal(params.relay.toString(), url.toString()) + assert.equal(params.ok, true) + done() + }) + publisher.on("error", done) + publisher.publish(event) + }) + }) })