nostr
package: various improvements
#448
@ -10,6 +10,7 @@ module.exports = {
|
|||||||
mocha: true,
|
mocha: true,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"require-await": "warn",
|
"require-await": "error",
|
||||||
|
eqeqeq: "error",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
@ -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))
|
||||||
}
|
}
|
||||||
|
142
packages/nostr/src/client/relay.ts
Normal file
142
packages/nostr/src/client/relay.ts
Normal 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
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -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(
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
81
packages/nostr/src/filters.ts
Normal file
81
packages/nostr/src/filters.ts
Normal 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[]
|
||||||
|
}
|
@ -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)
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user