nostr package: various improvements #448

Merged
sistemd merged 5 commits from nostr-package-minor-improvements into nostr-package-eose 2023-03-27 09:06:57 +00:00
14 changed files with 436 additions and 410 deletions

View File

@ -10,6 +10,7 @@ module.exports = {
mocha: true, mocha: true,
}, },
rules: { rules: {
"require-await": "warn", "require-await": "error",
eqeqeq: "error",
}, },
} }

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: 5/34 (15%)._ _Progress: 6/34 (18%)._
- [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
@ -23,7 +23,7 @@ _Progress: 5/34 (15%)._
- [ ] 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
- [X] NIP-11: Relay Information Document - [X] NIP-11: Relay Information Document
- [ ] NIP-12: Generic Tag Queries - [X] 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
- [X] NIP-15: End of Stored Events Notice - [X] NIP-15: End of Stored Events Notice

View File

@ -1,8 +1,8 @@
import { ProtocolError } from "../error" import { NostrError } from "../common"
import { Filters, SubscriptionId } from "." import { SubscriptionId } from "."
import { EventId, RawEvent } from "../event" import { EventId, RawEvent } from "../event"
import WebSocket from "isomorphic-ws" import WebSocket from "isomorphic-ws"
import { unixTimestamp } from "../util" import { Filters } from "../filters"
/** /**
* The connection to a relay. This is the lowest layer of the nostr protocol. * The connection to a relay. This is the lowest layer of the nostr protocol.
@ -38,24 +38,24 @@ export class Conn {
onError, onError,
}: { }: {
url: URL url: URL
onMessage: (msg: IncomingMessage) => void onMessage: (msg: IncomingMessage) => Promise<void>
onOpen: () => void | Promise<void> onOpen: () => Promise<void>
onClose: () => void | Promise<void> onClose: () => 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", (msgData) => { this.#socket.addEventListener("message", async (msgData) => {
try { try {
const value = msgData.data.valueOf() const value = msgData.data.valueOf()
// Validate and parse the message. // Validate and parse the message.
if (typeof value !== "string") { if (typeof value !== "string") {
throw new ProtocolError(`invalid message data: ${value}`) throw new NostrError(`invalid message data: ${value}`)
} }
const msg = parseIncomingMessage(value) const msg = parseIncomingMessage(value)
onMessage(msg) await onMessage(msg)
} catch (err) { } catch (err) {
onError(err) onError(err)
} }
@ -68,21 +68,15 @@ export class Conn {
this.send(msg) this.send(msg)
} }
this.#pending = [] this.#pending = []
const result = onOpen() await onOpen()
if (result instanceof Promise) {
await result
}
} catch (e) { } catch (e) {
onError(e) onError(e)
} }
}) })
this.#socket.addEventListener("close", async () => { this.#socket.addEventListener("close", () => {
try { try {
const result = onClose() onClose()
if (result instanceof Promise) {
await result
}
} catch (e) { } catch (e) {
onError(e) onError(e)
} }
@ -91,11 +85,11 @@ export class Conn {
} }
send(msg: OutgoingMessage): void { send(msg: OutgoingMessage): void {
try {
if (this.#socket.readyState < WebSocket.OPEN) { if (this.#socket.readyState < WebSocket.OPEN) {
this.#pending.push(msg) this.#pending.push(msg)
return return
} }
try {
this.#socket.send(serializeOutgoingMessage(msg), (err) => { this.#socket.send(serializeOutgoingMessage(msg), (err) => {
if (err !== undefined && err !== null) { if (err !== undefined && err !== null) {
this.#onError?.(err) this.#onError?.(err)
@ -204,70 +198,39 @@ export interface OutgoingCloseSubscription {
id: SubscriptionId id: SubscriptionId
} }
interface RawFilters {
ids?: string[]
authors?: string[]
kinds?: number[]
["#e"]?: string[]
["#p"]?: string[]
since?: number
until?: number
limit?: number
}
function serializeOutgoingMessage(msg: OutgoingMessage): string { function serializeOutgoingMessage(msg: OutgoingMessage): string {
if (msg.kind === "event") { if (msg.kind === "event") {
return JSON.stringify(["EVENT", msg.event]) return JSON.stringify(["EVENT", msg.event])
} else if (msg.kind === "openSubscription") { } else if (msg.kind === "openSubscription") {
return JSON.stringify([ // If there are no filters, the client is expected to specify a single empty filter.
"REQ", const filters = msg.filters.length === 0 ? [{}] : msg.filters
msg.id.toString(), return JSON.stringify(["REQ", msg.id.toString(), ...filters])
...serializeFilters(msg.filters),
])
} else if (msg.kind === "closeSubscription") { } else if (msg.kind === "closeSubscription") {
return JSON.stringify(["CLOSE", msg.id.toString()]) return JSON.stringify(["CLOSE", msg.id.toString()])
} else { } else {
throw new Error(`invalid message: ${JSON.stringify(msg)}`) throw new NostrError(`invalid message: ${JSON.stringify(msg)}`)
} }
} }
function serializeFilters(filters: Filters[]): RawFilters[] {
if (filters.length === 0) {
return [{}]
}
return filters.map((filter) => ({
ids: filter.ids,
authors: filter.authors,
kinds: filter.kinds,
["#e"]: filter.eventTags,
["#p"]: filter.pubkeyTags,
since:
filter.since instanceof Date ? unixTimestamp(filter.since) : filter.since,
until:
filter.until instanceof Date ? unixTimestamp(filter.until) : filter.until,
limit: filter.limit,
}))
}
function parseIncomingMessage(data: string): IncomingMessage { 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)) {
throw new ProtocolError(`incoming message is not an array: ${data}`) throw new NostrError(`incoming message is not an array: ${data}`)
} }
if (json.length === 0) { if (json.length === 0) {
throw new ProtocolError(`incoming message is an empty array: ${data}`) throw new NostrError(`incoming message is an empty array: ${data}`)
} }
// Handle incoming events. // Handle incoming events.
if (json[0] === "EVENT") { if (json[0] === "EVENT") {
if (typeof json[1] !== "string") { if (typeof json[1] !== "string") {
throw new ProtocolError( throw new NostrError(
`second element of "EVENT" should be a string, but wasn't: ${data}` `second element of "EVENT" should be a string, but wasn't: ${data}`
) )
} }
if (typeof json[2] !== "object") { if (typeof json[2] !== "object") {
throw new ProtocolError( throw new NostrError(
`second element of "EVENT" should be an object, but wasn't: ${data}` `second element of "EVENT" should be an object, but wasn't: ${data}`
) )
} }
@ -282,7 +245,7 @@ function parseIncomingMessage(data: string): IncomingMessage {
// Handle incoming notices. // Handle incoming notices.
if (json[0] === "NOTICE") { if (json[0] === "NOTICE") {
if (typeof json[1] !== "string") { if (typeof json[1] !== "string") {
throw new ProtocolError( throw new NostrError(
`second element of "NOTICE" should be a string, but wasn't: ${data}` `second element of "NOTICE" should be a string, but wasn't: ${data}`
) )
} }
@ -295,17 +258,17 @@ function parseIncomingMessage(data: string): IncomingMessage {
// Handle incoming "OK" messages. // Handle incoming "OK" messages.
if (json[0] === "OK") { if (json[0] === "OK") {
if (typeof json[1] !== "string") { if (typeof json[1] !== "string") {
throw new ProtocolError( throw new NostrError(
`second element of "OK" should be a string, but wasn't: ${data}` `second element of "OK" should be a string, but wasn't: ${data}`
) )
} }
if (typeof json[2] !== "boolean") { if (typeof json[2] !== "boolean") {
throw new ProtocolError( throw new NostrError(
`third element of "OK" should be a boolean, but wasn't: ${data}` `third element of "OK" should be a boolean, but wasn't: ${data}`
) )
} }
if (typeof json[3] !== "string") { if (typeof json[3] !== "string") {
throw new ProtocolError( throw new NostrError(
`fourth element of "OK" should be a string, but wasn't: ${data}` `fourth element of "OK" should be a string, but wasn't: ${data}`
) )
} }
@ -320,7 +283,7 @@ function parseIncomingMessage(data: string): IncomingMessage {
// Handle incoming "EOSE" messages. // Handle incoming "EOSE" messages.
if (json[0] === "EOSE") { if (json[0] === "EOSE") {
if (typeof json[1] !== "string") { if (typeof json[1] !== "string") {
throw new ProtocolError( throw new NostrError(
`second element of "EOSE" should be a string, but wasn't: ${data}` `second element of "EOSE" should be a string, but wasn't: ${data}`
) )
} }
@ -338,7 +301,7 @@ function parseIncomingMessage(data: string): IncomingMessage {
} }
} }
throw new ProtocolError(`unknown incoming message: ${data}`) throw new NostrError(`unknown incoming message: ${data}`)
} }
function parseEventData(json: { [key: string]: unknown }): RawEvent { function parseEventData(json: { [key: string]: unknown }): RawEvent {
@ -354,7 +317,7 @@ function parseEventData(json: { [key: string]: unknown }): RawEvent {
typeof json["content"] !== "string" || typeof json["content"] !== "string" ||
typeof json["sig"] !== "string" typeof json["sig"] !== "string"
) { ) {
throw new ProtocolError(`invalid event: ${JSON.stringify(json)}`) throw new NostrError(`invalid event: ${JSON.stringify(json)}`)
} }
return json as unknown as RawEvent return json as unknown as RawEvent
} }
@ -363,6 +326,6 @@ function parseJson(data: string) {
try { try {
return JSON.parse(data) return JSON.parse(data)
} catch (e) { } catch (e) {
throw new ProtocolError(`invalid event json: ${data}`) throw new NostrError(`invalid event json: ${data}`)
} }
} }

View File

@ -177,7 +177,8 @@ export class EventEmitter extends Base {
// TODO Refactor the params to always 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 always include relay as well
// TODO Params should not include Nostr, `this` should be Nostr // TODO Params should not include Nostr, `this` should be Nostr
// TODO Ideas for events: "auth" for NIP-42 AUTH, "message" for the raw incoming messages // TODO Ideas for events: "auth" for NIP-42 AUTH, "message" for the raw incoming messages,
// "publish" for published events, "send" for sent messages
type EventName = type EventName =
| "newListener" | "newListener"
| "removeListener" | "removeListener"

View File

@ -1,13 +1,10 @@
import { ProtocolError } from "../error" import { NostrError } from "../common"
import { EventId, EventKind, RawEvent, parseEvent } from "../event" import { RawEvent, parseEvent } from "../event"
import { PublicKey } from "../crypto"
import { Conn } from "./conn" import { Conn } from "./conn"
import * as secp from "@noble/secp256k1" import * as secp from "@noble/secp256k1"
import { EventEmitter } from "./emitter" import { EventEmitter } from "./emitter"
import { fetchRelayInfo, ReadyState, Relay } from "./relay"
// TODO The EventEmitter will call "error" by default if errors are thrown, import { Filters } from "../filters"
// 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.
@ -31,7 +28,7 @@ export class Nostr extends EventEmitter {
/** /**
* Open connections to relays. * Open connections to relays.
*/ */
readonly #conns: Map<string, ConnState> = new Map() readonly #conns: ConnState[] = []
/** /**
* Mapping of subscription IDs to corresponding filters. * Mapping of subscription IDs to corresponding filters.
@ -48,13 +45,15 @@ export class Nostr extends EventEmitter {
url: URL | string, url: URL | string,
opts?: { read?: boolean; write?: boolean; fetchInfo?: boolean } opts?: { read?: boolean; write?: boolean; fetchInfo?: boolean }
): void { ): void {
const connUrl = new URL(url) const relayUrl = new URL(url)
// If the connection already exists, update the options. // If the connection already exists, update the options.
const existingConn = this.#conns.get(connUrl.toString()) const existingConn = this.#conns.find(
(c) => c.relay.url.toString() === relayUrl.toString()
)
if (existingConn !== undefined) { if (existingConn !== undefined) {
if (opts === undefined) { if (opts === undefined) {
throw new Error( throw new NostrError(
`called connect with existing connection ${url}, but options were not specified` `called connect with existing connection ${url}, but options were not specified`
) )
} }
@ -71,15 +70,17 @@ export class Nostr extends EventEmitter {
const fetchInfo = const fetchInfo =
opts?.fetchInfo === false opts?.fetchInfo === false
? Promise.resolve({}) ? Promise.resolve({})
: fetchRelayInfo(connUrl).catch((e) => this.emit("error", e, this)) : fetchRelayInfo(relayUrl).catch((e) => {
this.#error(e)
return {}
})
// If there is no existing connection, open a new one. // If there is no existing connection, open a new one.
const conn = new Conn({ const conn = new Conn({
url: connUrl, url: relayUrl,
// Handle messages on this connection. // Handle messages on this connection.
onMessage: async (msg) => { onMessage: async (msg) => {
try {
if (msg.kind === "event") { if (msg.kind === "event") {
this.emit( this.emit(
"event", "event",
@ -96,7 +97,7 @@ export class Nostr extends EventEmitter {
"ok", "ok",
{ {
eventId: msg.eventId, eventId: msg.eventId,
relay: connUrl, relay: relayUrl,
ok: msg.ok, ok: msg.ok,
message: msg.message, message: msg.message,
}, },
@ -107,76 +108,66 @@ export class Nostr extends EventEmitter {
} else if (msg.kind === "auth") { } else if (msg.kind === "auth") {
// TODO This is incomplete // TODO This is incomplete
} else { } else {
throw new ProtocolError(`invalid message ${JSON.stringify(msg)}`) this.#error(new NostrError(`invalid message ${JSON.stringify(msg)}`))
}
} catch (err) {
this.emit("error", err, this)
} }
}, },
// Handle "open" events. // Handle "open" events.
onOpen: async () => { onOpen: async () => {
// Update the connection readyState. // Update the connection readyState.
const conn = this.#conns.get(connUrl.toString()) const conn = this.#conns.find(
(c) => c.relay.url.toString() === relayUrl.toString()
)
if (conn === undefined) { if (conn === undefined) {
this.emit( this.#error(
"error", new NostrError(
new Error( `bug: expected connection to ${relayUrl.toString()} to be in the map`
`bug: expected connection to ${connUrl.toString()} to be in the map` )
),
this
) )
} else { } else {
if (conn.readyState !== ReadyState.CONNECTING) { if (conn.relay.readyState !== ReadyState.CONNECTING) {
this.emit( this.#error(
"error", new NostrError(
new Error( `bug: expected connection to ${relayUrl.toString()} to have readyState CONNECTING, got ${
`bug: expected connection to ${connUrl.toString()} to have readyState CONNECTING, got ${ conn.relay.readyState
conn.readyState
}` }`
), )
this
) )
} }
this.#conns.set(connUrl.toString(), { conn.relay = {
...conn, ...conn.relay,
readyState: ReadyState.OPEN, readyState: ReadyState.OPEN,
info: await fetchInfo, info: await fetchInfo,
}) }
} }
// Forward the event to the user. // Forward the event to the user.
this.emit("open", connUrl, this) this.emit("open", relayUrl, this)
}, },
// Handle "close" events. // Handle "close" events.
onClose: () => { onClose: () => {
// Update the connection readyState. // Update the connection readyState.
const conn = this.#conns.get(connUrl.toString()) const conn = this.#conns.find(
(c) => c.relay.url.toString() === relayUrl.toString()
)
if (conn === undefined) { if (conn === undefined) {
this.emit( this.#error(
"error", new NostrError(
new Error( `bug: expected connection to ${relayUrl.toString()} to be in the map`
`bug: expected connection to ${connUrl.toString()} to be in the map` )
),
this
) )
} else { } else {
this.#conns.set(connUrl.toString(), { conn.relay.readyState = ReadyState.CLOSED
...conn,
readyState: ReadyState.CLOSED,
info:
conn.readyState === ReadyState.CONNECTING ? undefined : conn.info,
})
} }
// Forward the event to the user. // Forward the event to the user.
this.emit("close", connUrl, this) this.emit("close", relayUrl, this)
}, },
// TODO If there is no error handler, this will silently swallow the error. Maybe have an // 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 // #onError method which re-throws if emit() returns false? This should at least make
// some noise. // some noise.
// Forward errors on this connection. // Forward errors on this connection.
onError: (err) => this.emit("error", err, this), onError: (err) => this.#error(err),
}) })
// Resend existing subscriptions to this connection. // Resend existing subscriptions to this connection.
@ -188,12 +179,15 @@ export class Nostr extends EventEmitter {
}) })
} }
this.#conns.set(connUrl.toString(), { this.#conns.push({
relay: {
url: relayUrl,
readyState: ReadyState.CONNECTING,
},
conn, conn,
auth: false, auth: false,
read: opts?.read ?? true, read: opts?.read ?? true,
write: opts?.write ?? true, write: opts?.write ?? true,
readyState: ReadyState.CONNECTING,
}) })
} }
@ -211,10 +205,12 @@ export class Nostr extends EventEmitter {
} }
return return
} }
const connUrl = new URL(url) const relayUrl = new URL(url)
const c = this.#conns.get(connUrl.toString()) const c = this.#conns.find(
(c) => c.relay.url.toString() === relayUrl.toString()
)
if (c === undefined) { if (c === undefined) {
throw new Error(`connection to ${url} doesn't exist`) throw new NostrError(`connection to ${url} doesn't exist`)
} }
c.conn.close() c.conn.close()
} }
@ -258,7 +254,7 @@ export class Nostr extends EventEmitter {
*/ */
unsubscribe(subscriptionId: SubscriptionId): 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 NostrError(`subscription ${subscriptionId} does not exist`)
} }
for (const { conn, read } of this.#conns.values()) { for (const { conn, read } of this.#conns.values()) {
if (!read) { if (!read) {
@ -290,172 +286,29 @@ export class Nostr extends EventEmitter {
* Get the relays which this client has tried to open connections to. * Get the relays which this client has tried to open connections to.
*/ */
get relays(): Relay[] { get relays(): Relay[] {
return [...this.#conns.entries()].map(([url, c]) => { return this.#conns.map(({ relay }) => {
if (c.readyState === ReadyState.CONNECTING) { if (relay.readyState === ReadyState.CONNECTING) {
return { return { ...relay }
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 { } else {
throw new Error("bug: unknown readyState") const info =
relay.info === undefined
? undefined
: // Deep copy of the info.
JSON.parse(JSON.stringify(relay.info))
return { ...relay, info }
} }
}) })
} }
#error(e: unknown) {
if (!this.emit("error", e, this)) {
throw e
}
}
} }
// TODO Keep in mind this should be part of the public API of the lib interface ConnState {
/** relay: Relay
* 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?
@ -471,47 +324,11 @@ interface ConnStateCommon {
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.
*/ */
export type SubscriptionId = string export type SubscriptionId = string
/**
* Subscription filters. All filters from the fields must pass for a message to get through.
*/
export interface Filters {
// TODO Document the filters, document that for the arrays only one is enough for the message to pass
ids?: EventId[]
authors?: string[]
kinds?: EventKind[]
/**
* Filters for the "e" tags.
*/
eventTags?: EventId[]
/**
* Filters for the "p" tags.
*/
pubkeyTags?: PublicKey[]
since?: Date | number
until?: Date | number
limit?: number
}
function randomSubscriptionId(): SubscriptionId { function randomSubscriptionId(): SubscriptionId {
return secp.utils.bytesToHex(secp.utils.randomBytes(32)) return secp.utils.bytesToHex(secp.utils.randomBytes(32))
} }

View File

@ -0,0 +1,142 @@
import { PublicKey } from "../crypto"
import { NostrError } from "../common"
export type Relay =
| {
url: URL
readyState: ReadyState.CONNECTING
}
| {
url: URL
readyState: ReadyState.OPEN
info: RelayInfo
}
| {
url: URL
readyState: ReadyState.CLOSED
/**
* If the relay is closed before the opening process is fully finished,
* the relay info may be undefined.
*/
info?: RelayInfo
}
/**
* The information that a relay broadcasts about itself as defined in NIP-11.
*/
export interface RelayInfo {
name?: string
description?: string
pubkey?: PublicKey
contact?: string
supported_nips?: number[]
software?: string
version?: string
[key: string]: unknown
}
/**
* The state of a relay connection.
*/
export enum ReadyState {
/**
* The connection has not been established yet.
*/
CONNECTING = 0,
/**
* The connection has been established.
*/
OPEN = 1,
/**
* The connection has been closed, forcefully or gracefully, by either party.
*/
CLOSED = 2,
}
// TODO Keep in mind this should be part of the public API of the lib
/**
* Fetch the NIP-11 relay info with some reasonable timeout. Throw an error if
* the info is invalid.
*/
export async function fetchRelayInfo(url: URL | string): Promise<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 NostrError(
`invalid relay info, expected "name" to be a string: ${JSON.stringify(
info
)}`
)
}
if (info.description !== undefined && typeof info.description !== "string") {
info.description = undefined
throw new NostrError(
`invalid relay info, expected "description" to be a string: ${JSON.stringify(
info
)}`
)
}
if (info.pubkey !== undefined && typeof info.pubkey !== "string") {
info.pubkey = undefined
throw new NostrError(
`invalid relay info, expected "pubkey" to be a string: ${JSON.stringify(
info
)}`
)
}
if (info.contact !== undefined && typeof info.contact !== "string") {
info.contact = undefined
throw new NostrError(
`invalid relay info, expected "contact" to be a string: ${JSON.stringify(
info
)}`
)
}
if (info.supported_nips !== undefined) {
if (info.supported_nips instanceof Array) {
if (info.supported_nips.some((e: unknown) => typeof e !== "number")) {
info.supported_nips = undefined
throw new NostrError(
`invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify(
info
)}`
)
}
} else {
info.supported_nips = undefined
throw new NostrError(
`invalid relay info, expected "supported_nips" to be an array: ${JSON.stringify(
info
)}`
)
}
}
if (info.software !== undefined && typeof info.software !== "string") {
info.software = undefined
throw new NostrError(
`invalid relay info, expected "software" to be a string: ${JSON.stringify(
info
)}`
)
}
if (info.version !== undefined && typeof info.version !== "string") {
info.version = undefined
throw new NostrError(
`invalid relay info, expected "version" to be a string: ${JSON.stringify(
info
)}`
)
}
return info
}

View File

@ -1,10 +1,13 @@
import { ProtocolError } from "./error" /**
* A UNIX timestamp.
*/
export type Timestamp = number
/** /**
* Calculate the unix timestamp (seconds since epoch) of the `Date`. If no date is specified, * Calculate the unix timestamp (seconds since epoch) of the `Date`. If no date is specified,
* return the current unix timestamp. * return the current unix timestamp.
*/ */
export function unixTimestamp(date?: Date): number { export function unixTimestamp(date?: Date): Timestamp {
return Math.floor((date ?? new Date()).getTime() / 1000) return Math.floor((date ?? new Date()).getTime() / 1000)
} }
@ -13,7 +16,16 @@ export function unixTimestamp(date?: Date): number {
*/ */
export function defined<T>(v: T | undefined | null): T { export function defined<T>(v: T | undefined | null): T {
if (v === undefined || v === null) { if (v === undefined || v === null) {
throw new ProtocolError("bug: unexpected undefined") throw new NostrError("bug: unexpected undefined")
} }
return v return v
} }
/**
* The error thrown by this library.
*/
export class NostrError extends Error {
constructor(message?: string) {
super(message)
}
}

View File

@ -1,6 +1,7 @@
import * as secp from "@noble/secp256k1" import * as secp from "@noble/secp256k1"
import base64 from "base64-js" import base64 from "base64-js"
import { bech32 } from "bech32" import { bech32 } from "bech32"
import { NostrError } from "./common"
// TODO Use toHex as well as toString? Might be more explicit // TODO Use toHex as well as toString? Might be more explicit
// Or maybe replace toString with toHex // Or maybe replace toString with toHex
@ -158,7 +159,7 @@ export async function aesDecryptBase64(
const sharedKey = sharedPoint.slice(1, 33) const sharedKey = sharedPoint.slice(1, 33)
if (typeof window === "object") { if (typeof window === "object") {
// TODO Can copy this from the legacy code // TODO Can copy this from the legacy code
throw new Error("todo") throw new NostrError("todo")
} else { } else {
const crypto = await import("crypto") const crypto = await import("crypto")
const decipher = crypto.createDecipheriv( const decipher = crypto.createDecipheriv(

View File

@ -1,10 +0,0 @@
// TODO Rename to NostrError and move to util.ts, always throw NostrError and never throw Error
/**
* An error in the protocol. This error is thrown when a relay sends invalid or
* unexpected data, or otherwise behaves in an unexpected way.
*/
export class ProtocolError extends Error {
constructor(message?: string) {
super(message)
}
}

View File

@ -1,4 +1,3 @@
import { ProtocolError } from "./error"
import { import {
PublicKey, PublicKey,
PrivateKey, PrivateKey,
@ -12,7 +11,7 @@ import {
parsePrivateKey, parsePrivateKey,
aesEncryptBase64, aesEncryptBase64,
} from "./crypto" } from "./crypto"
import { defined, unixTimestamp } from "./util" import { defined, Timestamp, unixTimestamp, NostrError } from "./common"
// TODO Add remaining event types // TODO Add remaining event types
@ -40,11 +39,13 @@ export enum EventKind {
export interface RawEvent { export interface RawEvent {
id: string id: string
pubkey: PublicKey pubkey: PublicKey
created_at: number created_at: Timestamp
kind: EventKind kind: EventKind
tags: string[][] tags: string[][]
content: string content: string
sig: string sig: string
[key: string]: unknown
} }
interface SetMetadata extends RawEvent { interface SetMetadata extends RawEvent {
@ -100,20 +101,24 @@ export type EventId = string
/** /**
* An unsigned event. * An unsigned event.
*/ */
export type Unsigned<T extends Event | RawEvent> = Omit< export type Unsigned<T extends Event | RawEvent> = {
T, [Property in keyof UnsignedWithPubkey<T> as Exclude<
"id" | "pubkey" | "sig" | "created_at" Property,
> & { "pubkey"
id?: EventId >]: T[Property]
} & {
pubkey?: PublicKey pubkey?: PublicKey
sig?: string
created_at?: number
} }
type UnsignedWithPubkey<T extends Event | RawEvent> = Omit< /**
T, * Same as @see {@link Unsigned}, but with the pubkey field.
*/
type UnsignedWithPubkey<T extends Event | RawEvent> = {
[Property in keyof T as Exclude<
Property,
"id" | "sig" | "created_at" "id" | "sig" | "created_at"
> & { >]: T[Property]
} & {
id?: EventId id?: EventId
sig?: string sig?: string
created_at?: number created_at?: number
@ -131,13 +136,16 @@ export async function signEvent<T extends Event | RawEvent>(
if (priv !== undefined) { if (priv !== undefined) {
priv = parsePrivateKey(priv) priv = parsePrivateKey(priv)
event.pubkey = getPublicKey(priv) event.pubkey = getPublicKey(priv)
const id = await serializeEventId(event as UnsignedWithPubkey<T>) const id = await serializeEventId(
// This conversion is safe because the pubkey field is set above.
event as unknown as UnsignedWithPubkey<T>
)
event.id = id event.id = id
event.sig = await schnorrSign(id, priv) event.sig = await schnorrSign(id, priv)
return event as T return event as T
} else { } else {
// TODO Try to use NIP-07, otherwise throw // TODO Try to use NIP-07, otherwise throw
throw new Error("todo") throw new NostrError("todo")
} }
} }
@ -195,16 +203,14 @@ export async function createDirectMessage({
export async function parseEvent(event: RawEvent): Promise<Event> { export async function parseEvent(event: RawEvent): Promise<Event> {
// TODO Validate all the fields. Lowercase hex fields, etc. Make sure everything is correct. // TODO Validate all the fields. Lowercase hex fields, etc. Make sure everything is correct.
if (event.id !== (await serializeEventId(event))) { if (event.id !== (await serializeEventId(event))) {
throw new ProtocolError( throw new NostrError(
`invalid id ${event.id} for event ${JSON.stringify( `invalid id ${event.id} for event ${JSON.stringify(
event event
)}, expected ${await serializeEventId(event)}` )}, expected ${await serializeEventId(event)}`
) )
} }
if (!(await schnorrVerify(event.sig, event.id, event.pubkey))) { if (!(await schnorrVerify(event.sig, event.id, event.pubkey))) {
throw new ProtocolError( throw new NostrError(`invalid signature for event ${JSON.stringify(event)}`)
`invalid signature for event ${JSON.stringify(event)}`
)
} }
if (event.kind === EventKind.TextNote) { if (event.kind === EventKind.TextNote) {
@ -259,7 +265,7 @@ function getUserMetadata(this: Unsigned<RawEvent>): UserMetadata {
typeof userMetadata.about !== "string" || typeof userMetadata.about !== "string" ||
typeof userMetadata.picture !== "string" typeof userMetadata.picture !== "string"
) { ) {
throw new ProtocolError( throw new NostrError(
`invalid user metadata ${userMetadata} in ${JSON.stringify(this)}` `invalid user metadata ${userMetadata} in ${JSON.stringify(this)}`
) )
} }
@ -275,11 +281,11 @@ async function getMessage(
} }
const [data, iv] = this.content.split("?iv=") const [data, iv] = this.content.split("?iv=")
if (data === undefined || iv === undefined) { if (data === undefined || iv === undefined) {
throw new ProtocolError(`invalid direct message content ${this.content}`) throw new NostrError(`invalid direct message content ${this.content}`)
} }
if (priv === undefined) { if (priv === undefined) {
// TODO Try to use NIP-07 // TODO Try to use NIP-07
throw new Error("todo") throw new NostrError("todo")
} else if (getPublicKey(priv) === this.getRecipient()) { } else if (getPublicKey(priv) === this.getRecipient()) {
return await aesDecryptBase64(this.pubkey, priv, { data, iv }) return await aesDecryptBase64(this.pubkey, priv, { data, iv })
} }
@ -289,7 +295,7 @@ async function getMessage(
function getRecipient(this: Unsigned<RawEvent>): PublicKey { function getRecipient(this: Unsigned<RawEvent>): PublicKey {
const recipientTag = this.tags.find((tag) => tag[0] === "p") const recipientTag = this.tags.find((tag) => tag[0] === "p")
if (typeof recipientTag?.[1] !== "string") { if (typeof recipientTag?.[1] !== "string") {
throw new ProtocolError( throw new NostrError(
`expected "p" tag to be of type string, but got ${ `expected "p" tag to be of type string, but got ${
recipientTag?.[1] recipientTag?.[1]
} in ${JSON.stringify(this)}` } in ${JSON.stringify(this)}`
@ -301,7 +307,7 @@ function getRecipient(this: Unsigned<RawEvent>): PublicKey {
function getPrevious(this: Unsigned<RawEvent>): EventId | undefined { function getPrevious(this: Unsigned<RawEvent>): EventId | undefined {
const previousTag = this.tags.find((tag) => tag[0] === "e") const previousTag = this.tags.find((tag) => tag[0] === "e")
if (typeof previousTag?.[1] !== "string") { if (typeof previousTag?.[1] !== "string") {
throw new ProtocolError( throw new NostrError(
`expected "e" tag to be of type string, but got ${ `expected "e" tag to be of type string, but got ${
previousTag?.[1] previousTag?.[1]
} in ${JSON.stringify(this)}` } in ${JSON.stringify(this)}`
@ -314,7 +320,7 @@ function parseJson(data: string) {
try { try {
return JSON.parse(data) return JSON.parse(data)
} catch (e) { } catch (e) {
throw new ProtocolError(`invalid json: ${e}: ${data}`) throw new NostrError(`invalid json: ${e}: ${data}`)
} }
} }

View File

@ -0,0 +1,81 @@
import { PublicKey } from "./crypto"
import { EventId, EventKind } from "./event"
import { Timestamp } from "./common"
/**
* Subscription filters. All filters from the fields must pass for a message to get through.
*/
export interface Filters extends TagFilters {
// TODO Document the filters, document that for the arrays only one is enough for the message to pass
ids?: EventId[]
authors?: string[]
kinds?: EventKind[]
since?: Timestamp
until?: Timestamp
limit?: number
/**
* Allows for arbitrary, nonstandard extensions.
*/
[key: string]: unknown
}
/**
* Generic tag queries as defined by NIP-12.
*/
interface TagFilters {
["#e"]: EventId[]
["#p"]: PublicKey[]
["#a"]: string[]
["#b"]: string[]
["#c"]: string[]
["#d"]: string[]
["#f"]: string[]
["#g"]: string[]
["#h"]: string[]
["#i"]: string[]
["#j"]: string[]
["#k"]: string[]
["#l"]: string[]
["#m"]: string[]
["#n"]: string[]
["#o"]: string[]
["#q"]: string[]
["#r"]: string[]
["#s"]: string[]
["#t"]: string[]
["#u"]: string[]
["#v"]: string[]
["#w"]: string[]
["#x"]: string[]
["#y"]: string[]
["#z"]: string[]
["#A"]: string[]
["#B"]: string[]
["#C"]: string[]
["#D"]: string[]
["#E"]: string[]
["#F"]: string[]
["#G"]: string[]
["#H"]: string[]
["#I"]: string[]
["#J"]: string[]
["#K"]: string[]
["#L"]: string[]
["#M"]: string[]
["#N"]: string[]
["#O"]: string[]
["#P"]: string[]
["#Q"]: string[]
["#R"]: string[]
["#S"]: string[]
["#T"]: string[]
["#U"]: string[]
["#V"]: string[]
["#W"]: string[]
["#X"]: string[]
["#Y"]: string[]
["#Z"]: string[]
}

View File

@ -24,18 +24,21 @@ describe("dm", () => {
subscriber.on( subscriber.on(
"event", "event",
async ({ event, subscriptionId: actualSubscriptionId }, nostr) => { async ({ event, subscriptionId: actualSubscriptionId }, nostr) => {
assert.equal(nostr, subscriber) assert.strictEqual(nostr, subscriber)
assert.equal(event.kind, EventKind.DirectMessage) assert.strictEqual(event.kind, EventKind.DirectMessage)
assert.equal(event.pubkey, parsePublicKey(publisherPubkey)) assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey))
assert.equal(actualSubscriptionId, subscriptionId) assert.strictEqual(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.strictEqual(
event.getRecipient(), event.getRecipient(),
parsePublicKey(subscriberPubkey) parsePublicKey(subscriberPubkey)
) )
assert.equal(await event.getMessage(subscriberSecret), message) assert.strictEqual(
await event.getMessage(subscriberSecret),
message
)
} }
done() done()
@ -81,14 +84,14 @@ describe("dm", () => {
"event", "event",
async ({ event, subscriptionId: actualSubscriptionId }, nostr) => { async ({ event, subscriptionId: actualSubscriptionId }, nostr) => {
try { try {
assert.equal(nostr, subscriber) assert.strictEqual(nostr, subscriber)
assert.equal(event.kind, EventKind.DirectMessage) assert.strictEqual(event.kind, EventKind.DirectMessage)
assert.equal(event.pubkey, parsePublicKey(publisherPubkey)) assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey))
assert.equal(actualSubscriptionId, subscriptionId) assert.strictEqual(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.strictEqual(
event.getRecipient(), event.getRecipient(),
parsePublicKey(recipientPubkey) parsePublicKey(recipientPubkey)
) )

View File

@ -1,5 +1,5 @@
import { Nostr } from "../src/client" import { Nostr } from "../src/client"
import { unixTimestamp } from "../src/util" import { Timestamp, unixTimestamp } from "../src/common"
export const relayUrl = new URL("ws://localhost:12648") export const relayUrl = new URL("ws://localhost:12648")
@ -10,7 +10,7 @@ export interface Setup {
subscriber: Nostr subscriber: Nostr
subscriberSecret: string subscriberSecret: string
subscriberPubkey: string subscriberPubkey: string
timestamp: number timestamp: Timestamp
url: URL url: URL
/** /**
* Signal that the test is done. Call this instead of the callback provided by * Signal that the test is done. Call this instead of the callback provided by

View File

@ -1,4 +1,10 @@
import { createTextNote, EventKind, signEvent } from "../src/event" import {
createTextNote,
EventKind,
signEvent,
TextNote,
Unsigned,
} from "../src/event"
import { parsePublicKey } from "../src/crypto" import { parsePublicKey } from "../src/crypto"
import assert from "assert" import assert from "assert"
import { setup } from "./setup" import { setup } from "./setup"
@ -41,7 +47,10 @@ describe("text note", () => {
// TODO No signEvent, have a convenient way to do this // TODO No signEvent, have a convenient way to do this
signEvent( signEvent(
{ ...createTextNote(note), created_at: timestamp }, {
...createTextNote(note),
created_at: timestamp,
} as Unsigned<TextNote>,
publisherSecret publisherSecret
).then((event) => publisher.publish(event)) ).then((event) => publisher.publish(event))
}) })
@ -55,10 +64,10 @@ describe("text note", () => {
// 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) => {
assert.equal(nostr, publisher) assert.strictEqual(nostr, publisher)
assert.equal(params.eventId, event.id) assert.strictEqual(params.eventId, event.id)
assert.equal(params.relay.toString(), url.toString()) assert.strictEqual(params.relay.toString(), url.toString())
assert.equal(params.ok, true) assert.strictEqual(params.ok, true)
done() done()
}) })