Compare commits

...

15 Commits

Author SHA1 Message Date
Chris McCormick bc1294e4e6
Reconnect with exponential backoff flag: `enableReconnect` (#507)
https://github.com/nbd-wtf/nostr-tools/pull/507
2025-09-30 10:01:07 -03:00
Chris McCormick 226d7d07e2
Improvements to enablePing() & tests (#506)
https://github.com/nbd-wtf/nostr-tools/pull/506
2025-09-29 10:41:40 -03:00
fiatjaf c9ff51e278 subscribeMap() now sends multiple filters to the same relay in the same REQ.
because the initiative to get rid of multiple filters went down.
2025-09-20 16:54:12 -03:00
Anderson Juhasc 23aebbd341 update NIP-27 example in README 2025-08-27 10:32:45 -03:00
Anderson Juhasc a3fcd79545 ensures consistency for .jpg/.JPG, .mp4/.MP4, etc 2025-08-27 10:32:45 -03:00
tajava2006 0e6e7af934 chore: Bump version and document NIP-46 usage 2025-08-25 11:00:06 -03:00
codytseng 8866042edf relay: ensure onclose callback is triggered 2025-08-24 22:22:38 -03:00
hoppe ebe7df7b9e
feat(nip46): Add support for client-initiated connections in BunkerSigner (#502)
* add: nostrconnect

* fix: typo
2025-08-24 15:53:01 -03:00
fiatjaf 86235314c4 deduplicate relay URLs in pool.subscribe() and pool.subscribeMany() 2025-08-06 10:37:36 -03:00
Don b39dac3551 nip57: include "e" tag. 2025-08-04 15:23:29 -03:00
fiatjaf 929d62bbbb nip57: cleanup useless tests. 2025-08-01 20:28:49 -03:00
fiatjaf b575e47844 nip57: include "k" tag. 2025-08-01 19:38:03 -03:00
fiatjaf b076c34a2f tag new minor because of the pingpong stuff. 2025-08-01 14:12:53 -03:00
fiatjaf 4bb3eb2d40 remove unnecessary normalizeURL() call that can throw sometimes. 2025-08-01 14:11:44 -03:00
Chris McCormick 87f2c74bb3
Get pingpong working in the browser with dummy REQ (#499) 2025-07-24 11:22:15 -03:00
15 changed files with 876 additions and 247 deletions

101
README.md
View File

@ -133,6 +133,44 @@ import WebSocket from 'ws'
useWebSocketImplementation(WebSocket)
```
#### enablePing
You can enable regular pings of connected relays with the `enablePing` option. This will set up a heartbeat that closes the websocket if it doesn't receive a response in time. Some platforms, like Node.js, don't report websocket disconnections due to network issues, and enabling this can increase the reliability of the `onclose` event.
```js
import { SimplePool } from 'nostr-tools/pool'
const pool = new SimplePool({ enablePing: true })
```
#### enableReconnect
You can also enable automatic reconnection with the `enableReconnect` option. This will make the pool try to reconnect to relays with an exponential backoff delay if the connection is lost unexpectedly.
```js
import { SimplePool } from 'nostr-tools/pool'
const pool = new SimplePool({ enableReconnect: true })
```
Using both `enablePing: true` and `enableReconnect: true` is recommended as it will improve the reliability and timeliness of the reconnection (at the expense of slighly higher bandwidth due to the ping messages).
```js
// on Node.js
const pool = new SimplePool({ enablePing: true, enableReconnect: true })
```
The `enableReconnect` option can also be a callback function which will receive the current subscription filters and should return a new set of filters. This is useful if you want to modify the subscription on reconnect, for example, to update the `since` parameter to fetch only new events.
```js
const pool = new SimplePool({
enableReconnect: (filters) => {
const newSince = Math.floor(Date.now() / 1000)
return filters.map(filter => ({ ...filter, since: newSince }))
}
})
```
### Parsing references (mentions) from a content based on NIP-27
```js
@ -161,8 +199,10 @@ for (let block of nip27.parse(evt.content)) {
case 'video':
case 'audio':
console.log("it's a media url:", block.url)
break
case 'relay':
console.log("it's a websocket url, probably a relay address:", block.url)
break
default:
break
}
@ -171,14 +211,24 @@ for (let block of nip27.parse(evt.content)) {
### Connecting to a bunker using NIP-46
`BunkerSigner` allows your application to request signatures and other actions from a remote NIP-46 signer, often called a "bunker". There are two primary ways to establish a connection, depending on whether the client or the bunker initiates the connection.
A local secret key is required for the client to communicate securely with the bunker. This key should generally be persisted for the user's session.
```js
import { generateSecretKey } from '@nostr/tools/pure'
const localSecretKey = generateSecretKey()
```
### Method 1: Using a Bunker URI (`bunker://`)
This is the bunker-initiated flow. Your client receives a `bunker://` string or a NIP-05 identifier from the user. You use `BunkerSigner.fromBunker()` to create an instance, which returns immediately. For the **initial connection** with a new bunker, you must explicitly call `await bunker.connect()` to establish the connection and receive authorization.
```js
import { generateSecretKey, getPublicKey } from '@nostr/tools/pure'
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
import { SimplePool } from '@nostr/tools/pool'
// the client needs a local secret key (which is generally persisted) for communicating with the bunker
const localSecretKey = generateSecretKey()
// parse a bunker URI
const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com')
if (!bunkerPointer) {
@ -187,7 +237,7 @@ if (!bunkerPointer) {
// create the bunker instance
const pool = new SimplePool()
const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool })
const bunker = BunkerSigner.fromBunker(localSecretKey, bunkerPointer, { pool })
await bunker.connect()
// and use it
@ -203,6 +253,47 @@ const event = await bunker.signEvent({
await signer.close()
pool.close([])
```
> **Note on Reconnecting:** Once a connection has been successfully established and the `BunkerPointer` is stored, you do **not** need to call `await bunker.connect()` on subsequent sessions.
### Method 2: Using a Client-generated URI (`nostrconnect://`)
This is the client-initiated flow, which generally provides a better user experience for first-time connections (e.g., via QR code). Your client generates a `nostrconnect://` URI and waits for the bunker to connect to it.
`BunkerSigner.fromURI()` is an **asynchronous** method. It returns a `Promise` that resolves only after the bunker has successfully connected. Therefore, the returned signer instance is already fully connected and ready to use, so you **do not** need to call `.connect()` on it.
```js
import { getPublicKey } from '@nostr/tools/pure'
import { BunkerSigner, createNostrConnectURI } from '@nostr/tools/nip46'
import { SimplePool } from '@nostr/tools/pool'
const clientPubkey = getPublicKey(localSecretKey)
// generate a connection URI for the bunker to scan
const connectionUri = createNostrConnectURI({
clientPubkey,
relays: ['wss://relay.damus.io', 'wss://relay.primal.net'],
secret: 'a-random-secret-string', // A secret to verify the bunker's response
name: 'My Awesome App'
})
// wait for the bunker to connect
const pool = new SimplePool()
const signer = await BunkerSigner.fromURI(localSecretKey, connectionUri, { pool })
// and use it
const pubkey = await signer.getPublicKey()
const event = await signer.signEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Hello from a client-initiated connection!'
})
// cleanup
await signer.close()
pool.close([])
```
> **Note on Persistence:** This method is ideal for the initial sign-in. To allow users to stay logged in across sessions, you should store the connection details and use `Method 1` for subsequent reconnections.
### Parsing thread from any note based on NIP-10

View File

@ -32,6 +32,8 @@ export class AbstractSimplePool {
public trackRelays: boolean = false
public verifyEvent: Nostr['verifyEvent']
public enablePing: boolean | undefined
public enableReconnect: boolean | ((filters: Filter[]) => Filter[]) | undefined
public trustedRelayURLs: Set<string> = new Set()
private _WebSocket?: typeof WebSocket
@ -39,6 +41,8 @@ export class AbstractSimplePool {
constructor(opts: AbstractPoolConstructorOptions) {
this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation
this.enablePing = opts.enablePing
this.enableReconnect = opts.enableReconnect
}
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
@ -49,9 +53,13 @@ export class AbstractSimplePool {
relay = new AbstractRelay(url, {
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
websocketImplementation: this._WebSocket,
enablePing: this.enablePing,
enableReconnect: this.enableReconnect,
})
relay.onclose = () => {
this.relays.delete(url)
if (relay && !relay.enableReconnect) {
this.relays.delete(url)
}
}
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
this.relays.set(url, relay)
@ -71,24 +79,44 @@ export class AbstractSimplePool {
subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth
return this.subscribeMap(
relays.map(url => ({ url, filter })),
params,
)
const request: { url: string; filter: Filter }[] = []
for (let i = 0; i < relays.length; i++) {
const url = normalizeURL(relays[i])
if (!request.find(r => r.url === url)) {
request.push({ url, filter: filter })
}
}
return this.subscribeMap(request, params)
}
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
subscribeMany(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth
return this.subscribeMap(
relays.flatMap(url => filters.map(filter => ({ url, filter }))),
params,
)
const request: { url: string; filter: Filter }[] = []
const uniqUrls: string[] = []
for (let i = 0; i < relays.length; i++) {
const url = normalizeURL(relays[i])
if (uniqUrls.indexOf(url) === -1) {
uniqUrls.push(url)
request.push({ url, filter: filter })
}
}
return this.subscribeMap(request, params)
}
subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth
const grouped = new Map<string, Filter[]>()
for (const req of requests) {
const { url, filter } = req
if (!grouped.has(url)) grouped.set(url, [])
grouped.get(url)!.push(filter)
}
const groupedRequests = Array.from(grouped.entries()).map(([url, filters]) => ({ url, filters }))
if (this.trackRelays) {
params.receivedEvent = (relay: AbstractRelay, id: string) => {
let set = this.seenOn.get(id)
@ -136,9 +164,7 @@ export class AbstractSimplePool {
// open a subscription in all given relays
const allOpened = Promise.all(
requests.map(async ({ url, filter }, i) => {
url = normalizeURL(url)
groupedRequests.map(async ({ url, filters }, i) => {
let relay: AbstractRelay
try {
relay = await this.ensureRelay(url, {
@ -149,7 +175,7 @@ export class AbstractSimplePool {
return
}
let subscription = relay.subscribe([filter], {
let subscription = relay.subscribe(filters, {
...params,
oneose: () => handleEose(i),
onclose: reason => {
@ -157,7 +183,7 @@ export class AbstractSimplePool {
relay
.auth(params.onauth)
.then(() => {
relay.subscribe([filter], {
relay.subscribe(filters, {
...params,
oneose: () => handleEose(i),
onclose: reason => {
@ -210,12 +236,12 @@ export class AbstractSimplePool {
subscribeManyEose(
relays: string[],
filters: Filter[],
filter: Filter,
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
): SubCloser {
params.onauth = params.onauth || params.doauth
const subcloser = this.subscribeMany(relays, filters, {
const subcloser = this.subscribeMany(relays, filter, {
...params,
oneose() {
subcloser.close('closed automatically on eose')

View File

@ -15,6 +15,8 @@ type RelayWebSocket = WebSocket & {
export type AbstractRelayConstructorOptions = {
verifyEvent: Nostr['verifyEvent']
websocketImplementation?: typeof WebSocket
enablePing?: boolean
enableReconnect?: boolean | ((filters: Filter[]) => Filter[])
}
export class SendingOnClosedConnection extends Error {
@ -34,8 +36,17 @@ export class AbstractRelay {
public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400
public publishTimeout: number = 4400
public pingFrequency: number = 20000
public pingTimeout: number = 20000
public resubscribeBackoff: number[] = [10000, 10000, 10000, 20000, 20000, 30000, 60000]
public openSubs: Map<string, Subscription> = new Map()
public enablePing: boolean | undefined
public enableReconnect: boolean | ((filters: Filter[]) => Filter[])
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private reconnectTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private pingTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private reconnectAttempts: number = 0
private closedIntentionally: boolean = false
private connectionPromise: Promise<void> | undefined
private openCountRequests = new Map<string, CountResolver>()
@ -54,9 +65,8 @@ export class AbstractRelay {
this.url = normalizeURL(url)
this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation || WebSocket
// this.pingHeartBeat = opts.pingHeartBeat
// this.pingFrequency = opts.pingFrequency
// this.pingTimeout = opts.pingTimeout
this.enablePing = opts.enablePing
this.enableReconnect = opts.enableReconnect || false
}
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
@ -86,6 +96,40 @@ export class AbstractRelay {
return this._connected
}
private async reconnect(): Promise<void> {
const backoff = this.resubscribeBackoff[Math.min(this.reconnectAttempts, this.resubscribeBackoff.length - 1)]
this.reconnectAttempts++
this.reconnectTimeoutHandle = setTimeout(async () => {
try {
await this.connect()
} catch (err) {
// this will be called again through onclose/onerror
}
}, backoff)
}
private handleHardClose(reason: string) {
if (this.pingTimeoutHandle) {
clearTimeout(this.pingTimeoutHandle)
this.pingTimeoutHandle = undefined
}
this._connected = false
this.connectionPromise = undefined
const wasIntentional = this.closedIntentionally
this.closedIntentionally = false // reset for next time
this.onclose?.()
if (this.enableReconnect && !wasIntentional) {
this.reconnect()
} else {
this.closeAllSubscriptions(reason)
}
}
public async connect(): Promise<void> {
if (this.connectionPromise) return this.connectionPromise
@ -108,10 +152,24 @@ export class AbstractRelay {
}
this.ws.onopen = () => {
if (this.reconnectTimeoutHandle) {
clearTimeout(this.reconnectTimeoutHandle)
this.reconnectTimeoutHandle = undefined
}
clearTimeout(this.connectionTimeoutHandle)
this._connected = true
if (this.ws && this.ws.ping) {
// && this.pingHeartBeat
this.reconnectAttempts = 0
// resubscribe to all open subscriptions
for (const sub of this.openSubs.values()) {
sub.eosed = false
if (typeof this.enableReconnect === 'function') {
sub.filters = this.enableReconnect(sub.filters)
}
sub.fire()
}
if (this.enablePing) {
this.pingpong()
}
resolve()
@ -120,23 +178,13 @@ export class AbstractRelay {
this.ws.onerror = ev => {
clearTimeout(this.connectionTimeoutHandle)
reject((ev as any).message || 'websocket error')
if (this._connected) {
this._connected = false
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection errored')
}
this.handleHardClose('relay connection errored')
}
this.ws.onclose = ev => {
clearTimeout(this.connectionTimeoutHandle)
reject((ev as any).message || 'websocket closed')
if (this._connected) {
this._connected = false
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection closed')
}
this.handleHardClose('relay connection closed')
}
this.ws.onmessage = this._onmessage.bind(this)
@ -145,9 +193,26 @@ export class AbstractRelay {
return this.connectionPromise
}
private async receivePong() {
private async waitForPingPong() {
return new Promise((res, err) => {
;(this.ws && this.ws.on && this.ws.on('pong', () => res(true))) || err("ws can't listen for pong")
// listen for pong
this.ws && this.ws.on ? this.ws.on('pong', () => res(true)) : err("ws can't listen for pong")
// send a ping
this.ws && this.ws.ping && this.ws.ping()
})
}
private async waitForDummyReq() {
return new Promise((resolve, _) => {
// make a dummy request with expected empty eose reply
// ["REQ", "_", {"ids":["aaaa...aaaa"]}]
const sub = this.subscribe([{ ids: ['a'.repeat(64)] }], {
oneose: () => {
sub.close()
resolve(true)
},
eoseTimeout: this.pingTimeout + 1000,
})
})
}
@ -155,21 +220,21 @@ export class AbstractRelay {
// in browsers it's done automatically. see https://github.com/nbd-wtf/nostr-tools/issues/491
private async pingpong() {
// if the websocket is connected
if (this.ws?.readyState == 1) {
// send a ping
this.ws && this.ws.ping && this.ws.ping()
// wait for either a pong or a timeout
if (this.ws?.readyState === 1) {
// wait for either a ping-pong reply or a timeout
const result = await Promise.any([
this.receivePong(),
new Promise(res => setTimeout(() => res(false), 10000)), // TODO: opts.pingTimeout
// browsers don't have ping so use a dummy req
this.ws && this.ws.ping && this.ws.on ? this.waitForPingPong() : this.waitForDummyReq(),
new Promise(res => setTimeout(() => res(false), this.pingTimeout)),
])
console.error('pingpong result', result)
if (result) {
// schedule another pingpong
setTimeout(() => this.pingpong(), 10000) // TODO: opts.pingFrequency
this.pingTimeoutHandle = setTimeout(() => this.pingpong(), this.pingFrequency)
} else {
// pingpong closing socket
this.ws && this.ws.close()
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws?.close()
}
}
}
}
@ -357,10 +422,21 @@ export class AbstractRelay {
}
public close() {
this.closedIntentionally = true
if (this.reconnectTimeoutHandle) {
clearTimeout(this.reconnectTimeoutHandle)
this.reconnectTimeoutHandle = undefined
}
if (this.pingTimeoutHandle) {
clearTimeout(this.pingTimeoutHandle)
this.pingTimeoutHandle = undefined
}
this.closeAllSubscriptions('relay connection closed by us')
this._connected = false
this.ws?.close()
this.onclose?.()
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws?.close()
}
}
// this is the function assigned to this.ws.onmessage

View File

@ -1,6 +1,6 @@
{
"name": "@nostr/tools",
"version": "2.15.2",
"version": "2.17.0",
"exports": {
".": "./index.ts",
"./core": "./core.ts",

View File

@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test'
// prettier-ignore
import {
decode,
naddrEncode,

View File

@ -90,35 +90,19 @@ export function* parse(content: string): Iterable<Block> {
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
}
if (
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.jpg') ||
url.pathname.endsWith('.jpeg') ||
url.pathname.endsWith('.gif') ||
url.pathname.endsWith('.webp')
) {
if (/\.(png|jpe?g|gif|webp)$/i.test(url.pathname)) {
yield { type: 'image', url: url.toString() }
index = end
prevIndex = index
continue
}
if (
url.pathname.endsWith('.mp4') ||
url.pathname.endsWith('.avi') ||
url.pathname.endsWith('.webm') ||
url.pathname.endsWith('.mkv')
) {
if (/\.(mp4|avi|webm|mkv)$/i.test(url.pathname)) {
yield { type: 'video', url: url.toString() }
index = end
prevIndex = index
continue
}
if (
url.pathname.endsWith('.mp3') ||
url.pathname.endsWith('.aac') ||
url.pathname.endsWith('.ogg') ||
url.pathname.endsWith('.opus')
) {
if (/\.(mp3|aac|ogg|opus)$/i.test(url.pathname)) {
yield { type: 'audio', url: url.toString() }
index = end
prevIndex = index

203
nip46.ts
View File

@ -77,6 +77,114 @@ export async function queryBunkerProfile(nip05: string): Promise<BunkerPointer |
}
}
export type NostrConnectParams = {
clientPubkey: string
relays: string[]
secret: string
perms?: string[]
name?: string
url?: string
image?: string
}
export type ParsedNostrConnectURI = {
protocol: 'nostrconnect'
clientPubkey: string
params: {
relays: string[]
secret: string
perms?: string[]
name?: string
url?: string
image?: string
}
originalString: string
}
export function createNostrConnectURI(params: NostrConnectParams): string {
if (!params.clientPubkey) {
throw new Error('clientPubkey is required.')
}
if (!params.relays || params.relays.length === 0) {
throw new Error('At least one relay is required.')
}
if (!params.secret) {
throw new Error('secret is required.')
}
const queryParams = new URLSearchParams()
params.relays.forEach(relay => {
queryParams.append('relay', relay)
})
queryParams.append('secret', params.secret)
if (params.perms && params.perms.length > 0) {
queryParams.append('perms', params.perms.join(','))
}
if (params.name) {
queryParams.append('name', params.name)
}
if (params.url) {
queryParams.append('url', params.url)
}
if (params.image) {
queryParams.append('image', params.image)
}
return `nostrconnect://${params.clientPubkey}?${queryParams.toString()}`
}
export function parseNostrConnectURI(uri: string): ParsedNostrConnectURI {
if (!uri.startsWith('nostrconnect://')) {
throw new Error('Invalid nostrconnect URI: Must start with "nostrconnect://".')
}
const [protocolAndPubkey, queryString] = uri.split('?')
if (!protocolAndPubkey || !queryString) {
throw new Error('Invalid nostrconnect URI: Missing query string.')
}
const clientPubkey = protocolAndPubkey.substring('nostrconnect://'.length)
if (!clientPubkey) {
throw new Error('Invalid nostrconnect URI: Missing client-pubkey.')
}
const queryParams = new URLSearchParams(queryString)
const relays = queryParams.getAll('relay')
if (relays.length === 0) {
throw new Error('Invalid nostrconnect URI: Missing "relay" parameter.')
}
const secret = queryParams.get('secret')
if (!secret) {
throw new Error('Invalid nostrconnect URI: Missing "secret" parameter.')
}
const permsString = queryParams.get('perms')
const perms = permsString ? permsString.split(',') : undefined
const name = queryParams.get('name') || undefined
const url = queryParams.get('url') || undefined
const image = queryParams.get('image') || undefined
return {
protocol: 'nostrconnect',
clientPubkey,
params: {
relays,
secret,
perms,
name,
url,
image,
},
originalString: uri,
}
}
export type BunkerSignerParams = {
pool?: AbstractSimplePool
onauth?: (url: string) => void
@ -97,8 +205,9 @@ export class BunkerSigner implements Signer {
}
private waitingForAuth: { [id: string]: boolean }
private secretKey: Uint8Array
private conversationKey: Uint8Array
public bp: BunkerPointer
// If the client initiates the connection, the two variables below can be filled in later.
private conversationKey!: Uint8Array
public bp!: BunkerPointer
private cachedPubKey: string | undefined
@ -108,23 +217,95 @@ export class BunkerSigner implements Signer {
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
* @param secretKey - An optional key pair.
*/
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) {
if (bp.relays.length === 0) {
throw new Error('no relays are specified for this bunker')
}
private constructor(clientSecretKey: Uint8Array, params: BunkerSignerParams) {
this.params = params
this.pool = params.pool || new SimplePool()
this.secretKey = clientSecretKey
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
this.bp = bp
this.isOpen = false
this.idPrefix = Math.random().toString(36).substring(7)
this.serial = 0
this.listeners = {}
this.waitingForAuth = {}
}
this.setupSubscription(params)
/**
* [Factory Method 1] Creates a Signer using bunker information (bunker:// URL or NIP-05).
* This method is used when the public key of the bunker is known in advance.
*/
public static fromBunker(
clientSecretKey: Uint8Array,
bp: BunkerPointer,
params: BunkerSignerParams = {},
): BunkerSigner {
if (bp.relays.length === 0) {
throw new Error('No relays specified for this bunker')
}
const signer = new BunkerSigner(clientSecretKey, params)
signer.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
signer.bp = bp
signer.setupSubscription(params)
return signer
}
/**
* [Factory Method 2] Creates a Signer using a nostrconnect:// URI generated by the client.
* In this method, the bunker initiates the connection by scanning the URI.
*/
public static async fromURI(
clientSecretKey: Uint8Array,
connectionURI: string,
params: BunkerSignerParams = {},
maxWait: number = 300_000,
): Promise<BunkerSigner> {
const signer = new BunkerSigner(clientSecretKey, params)
const parsedURI = parseNostrConnectURI(connectionURI)
const clientPubkey = getPublicKey(clientSecretKey)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
sub.close()
reject(new Error(`Connection timed out after ${maxWait / 1000} seconds`))
}, maxWait)
const sub = signer.pool.subscribe(
parsedURI.params.relays,
{ kinds: [NostrConnect], '#p': [clientPubkey] },
{
onevent: async (event: NostrEvent) => {
try {
const tempConvKey = getConversationKey(clientSecretKey, event.pubkey)
const decryptedContent = decrypt(event.content, tempConvKey)
const response = JSON.parse(decryptedContent)
if (response.result === parsedURI.params.secret) {
clearTimeout(timer)
sub.close()
signer.bp = {
pubkey: event.pubkey,
relays: parsedURI.params.relays,
secret: parsedURI.params.secret,
}
signer.conversationKey = getConversationKey(clientSecretKey, event.pubkey)
signer.setupSubscription(params)
resolve(signer)
}
} catch (e) {
console.warn('Failed to process potential connection event', e)
}
},
onclose: () => {
clearTimeout(timer)
reject(new Error('Subscription closed before connection was established.'))
},
maxWait,
},
)
})
}
private setupSubscription(params: BunkerSignerParams) {
@ -290,7 +471,7 @@ export async function createAccount(
): Promise<BunkerSigner> {
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params)
let rpc = BunkerSigner.fromBunker(localSecretKey, bunker.bunkerPointer, params)
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])

View File

@ -1,112 +1,7 @@
import { describe, test, expect, mock } from 'bun:test'
import { describe, test, expect } from 'bun:test'
import { finalizeEvent } from './pure.ts'
import { getPublicKey, generateSecretKey } from './pure.ts'
import {
getSatoshisAmountFromBolt11,
getZapEndpoint,
makeZapReceipt,
makeZapRequest,
useFetchImplementation,
validateZapRequest,
} from './nip57.ts'
import { buildEvent } from './test-helpers.ts'
describe('getZapEndpoint', () => {
test('returns null if neither lud06 nor lud16 is present', async () => {
const metadata = buildEvent({ kind: 0, content: '{}' })
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
})
test('returns null if fetch fails', async () => {
const fetchImplementation = mock(() => Promise.reject(new Error()))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
})
test('returns null if the response does not allow Nostr payments', async () => {
const fetchImplementation = mock(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
})
test('returns the callback URL if the response allows Nostr payments', async () => {
const fetchImplementation = mock(() =>
Promise.resolve({
json: () => ({
allowsNostr: true,
nostrPubkey: 'pubkey',
callback: 'callback',
}),
}),
)
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
expect(result).toBe('callback')
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
})
})
describe('makeZapRequest', () => {
test('throws an error if amount is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
profile: 'profile',
event: null,
relays: [],
comment: '',
}),
).toThrow()
})
test('throws an error if profile is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
event: null,
amount: 100,
relays: [],
comment: '',
}),
).toThrow()
})
test('returns a valid Zap request', () => {
const result = makeZapRequest({
profile: 'profile',
event: 'event',
amount: 100,
relays: ['relay1', 'relay2'],
comment: 'comment',
})
expect(result.kind).toBe(9734)
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
expect(result.content).toBe('comment')
expect(result.tags).toEqual(
expect.arrayContaining([
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
['e', 'event'],
]),
)
})
})
import { getSatoshisAmountFromBolt11, makeZapReceipt, validateZapRequest } from './nip57.ts'
describe('validateZapRequest', () => {
test('returns an error message for invalid JSON', () => {

View File

@ -1,6 +1,6 @@
import { bech32 } from '@scure/base'
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
import { NostrEvent, validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
import { utf8Decoder } from './utils.ts'
import { isReplaceableKind, isAddressableKind } from './kinds.ts'
@ -42,48 +42,44 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
return null
}
export function makeZapRequest({
profile,
event,
amount,
relays,
comment = '',
}: {
profile: string
event: string | Event | null
type ProfileZap = {
pubkey: string
amount: number
comment: string
comment?: string
relays: string[]
}): EventTemplate {
if (!amount) throw new Error('amount not given')
if (!profile) throw new Error('profile not given')
}
type EventZap = {
event: NostrEvent
amount: number
comment?: string
relays: string[]
}
export function makeZapRequest(params: ProfileZap | EventZap): EventTemplate {
let zr: EventTemplate = {
kind: 9734,
created_at: Math.round(Date.now() / 1000),
content: comment,
content: params.comment || '',
tags: [
['p', profile],
['amount', amount.toString()],
['relays', ...relays],
['p', 'pubkey' in params ? params.pubkey : params.event.pubkey],
['amount', params.amount.toString()],
['relays', ...params.relays],
],
}
if (event && typeof event === 'string') {
zr.tags.push(['e', event])
}
if (event && typeof event === 'object') {
// replacable event
if (isReplaceableKind(event.kind)) {
const a = ['a', `${event.kind}:${event.pubkey}:`]
if ('event' in params) {
zr.tags.push(['e', params.event.id])
if (isReplaceableKind(params.event.kind)) {
const a = ['a', `${params.event.kind}:${params.event.pubkey}:`]
zr.tags.push(a)
// addressable event
} else if (isAddressableKind(event.kind)) {
let d = event.tags.find(([t, v]) => t === 'd' && v)
} else if (isAddressableKind(params.event.kind)) {
let d = params.event.tags.find(([t, v]) => t === 'd' && v)
if (!d) throw new Error('d tag not found or is empty')
const a = ['a', `${event.kind}:${event.pubkey}:${d[1]}`]
const a = ['a', `${params.event.kind}:${params.event.pubkey}:${d[1]}`]
zr.tags.push(a)
}
zr.tags.push(['k', params.event.kind.toString()])
}
return zr

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "nostr-tools",
"version": "2.15.2",
"version": "2.17.0",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",

View File

@ -35,14 +35,18 @@ test('removing duplicates when subscribing', async () => {
priv,
)
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
onevent(event: Event) {
// this should be called only once even though we're listening
// to multiple relays because the events will be caught and
// deduplicated efficiently (without even being parsed)
received.push(event)
pool.subscribeMany(
relayURLs,
{ authors: [pub] },
{
onevent(event: Event) {
// this should be called only once even though we're listening
// to multiple relays because the events will be caught and
// deduplicated efficiently (without even being parsed)
received.push(event)
},
},
})
)
await Promise.any(pool.publish(relayURLs, event))
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
@ -55,16 +59,24 @@ test('same with double subs', async () => {
let priv = generateSecretKey()
let pub = getPublicKey(priv)
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
pool.subscribeMany(
relayURLs,
{ authors: [pub] },
{
onevent(event) {
received.push(event)
},
},
})
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
)
pool.subscribeMany(
relayURLs,
{ authors: [pub] },
{
onevent(event) {
received.push(event)
},
},
})
)
let received: Event[] = []
@ -168,12 +180,16 @@ test('query a bunch of events and cancel on eose', async () => {
let events = new Set<string>()
await new Promise<void>(resolve => {
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], {
onevent(event) {
events.add(event.id)
pool.subscribeManyEose(
relayURLs,
{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 },
{
onevent(event) {
events.add(event.id)
},
onclose: resolve as any,
},
onclose: resolve as any,
})
)
})
expect(events.size).toBeGreaterThan(50)
@ -206,6 +222,151 @@ test('get()', async () => {
expect(event).toHaveProperty('id', ids[0])
})
test('ping-pong timeout in pool', async () => {
const mockRelay = mockRelays[0]
pool = new SimplePool({ enablePing: true })
const relay = await pool.ensureRelay(mockRelay.url)
relay.pingTimeout = 50
relay.pingFrequency = 50
let closed = false
const closedPromise = new Promise<void>(resolve => {
relay.onclose = () => {
closed = true
resolve()
}
})
expect(relay.connected).toBeTrue()
// wait for the first ping to succeed
await new Promise(resolve => setTimeout(resolve, 75))
expect(closed).toBeFalse()
// now make it unresponsive
mockRelay.unresponsive = true
// wait for the second ping to fail
await closedPromise
expect(relay.connected).toBeFalse()
expect(closed).toBeTrue()
})
test('reconnect on disconnect in pool', async () => {
const mockRelay = mockRelays[0]
pool = new SimplePool({ enablePing: true, enableReconnect: true })
const relay = await pool.ensureRelay(mockRelay.url)
relay.pingTimeout = 50
relay.pingFrequency = 50
relay.resubscribeBackoff = [50, 100]
let closes = 0
relay.onclose = () => {
closes++
}
expect(relay.connected).toBeTrue()
// wait for the first ping to succeed
await new Promise(resolve => setTimeout(resolve, 75))
expect(closes).toBe(0)
// now make it unresponsive
mockRelay.unresponsive = true
// wait for the second ping to fail, which will trigger a close
await new Promise(resolve => {
const interval = setInterval(() => {
if (closes > 0) {
clearInterval(interval)
resolve(null)
}
}, 10)
})
expect(closes).toBe(1)
expect(relay.connected).toBeFalse()
// now make it responsive again
mockRelay.unresponsive = false
// wait for reconnect
await new Promise(resolve => {
const interval = setInterval(() => {
if (relay.connected) {
clearInterval(interval)
resolve(null)
}
}, 10)
})
expect(relay.connected).toBeTrue()
expect(closes).toBe(1)
})
test('reconnect with filter update in pool', async () => {
const mockRelay = mockRelays[0]
const newSince = Math.floor(Date.now() / 1000)
pool = new SimplePool({
enablePing: true,
enableReconnect: filters => {
return filters.map(f => ({ ...f, since: newSince }))
},
})
const relay = await pool.ensureRelay(mockRelay.url)
relay.pingTimeout = 50
relay.pingFrequency = 50
relay.resubscribeBackoff = [50, 100]
let closes = 0
relay.onclose = () => {
closes++
}
expect(relay.connected).toBeTrue()
const sub = relay.subscribe([{ kinds: [1], since: 0 }], { onevent: () => {} })
expect(sub.filters[0].since).toBe(0)
// wait for the first ping to succeed
await new Promise(resolve => setTimeout(resolve, 75))
expect(closes).toBe(0)
// now make it unresponsive
mockRelay.unresponsive = true
// wait for the second ping to fail, which will trigger a close
await new Promise(resolve => {
const interval = setInterval(() => {
if (closes > 0) {
clearInterval(interval)
resolve(null)
}
}, 10)
})
expect(closes).toBe(1)
expect(relay.connected).toBeFalse()
// now make it responsive again
mockRelay.unresponsive = false
// wait for reconnect
await new Promise(resolve => {
const interval = setInterval(() => {
if (relay.connected) {
clearInterval(interval)
resolve(null)
}
}, 10)
})
expect(relay.connected).toBeTrue()
expect(closes).toBe(1)
// check if filter was updated
expect(sub.filters[0].since).toBe(newSince)
})
test('track relays when publishing', async () => {
let event1 = finalizeEvent(
{

View File

@ -1,7 +1,7 @@
/* global WebSocket */
import { verifyEvent } from './pure.ts'
import { AbstractSimplePool } from './abstract-pool.ts'
import { AbstractSimplePool, type AbstractPoolConstructorOptions } from './abstract-pool.ts'
var _WebSocket: typeof WebSocket
@ -14,8 +14,8 @@ export function useWebSocketImplementation(websocketImplementation: any) {
}
export class SimplePool extends AbstractSimplePool {
constructor() {
super({ verifyEvent, websocketImplementation: _WebSocket })
constructor(options?: Pick<AbstractPoolConstructorOptions, 'enablePing' | 'enableReconnect'>) {
super({ verifyEvent, websocketImplementation: _WebSocket, ...options })
}
}

View File

@ -117,3 +117,216 @@ test('publish timeout', async () => {
),
).rejects.toThrow('publish timed out')
})
test('ping-pong timeout (with native ping)', async () => {
const mockRelay = new MockRelay()
let pingCalled = false
// mock a native ping/pong mechanism
;(MockWebSocketClient.prototype as any).ping = function (this: any) {
pingCalled = true
if (!mockRelay.unresponsive) {
this.dispatchEvent(new Event('pong'))
}
}
;(MockWebSocketClient.prototype as any).on = function (this: any, event: string, listener: () => void) {
if (event === 'pong') {
this.addEventListener('pong', listener)
}
}
try {
const relay = new Relay(mockRelay.url, { enablePing: true })
relay.pingTimeout = 50
relay.pingFrequency = 50
let closed = false
const closedPromise = new Promise<void>(resolve => {
relay.onclose = () => {
closed = true
resolve()
}
})
await relay.connect()
expect(relay.connected).toBeTrue()
// wait for the first ping to succeed
await new Promise(resolve => setTimeout(resolve, 75))
expect(pingCalled).toBeTrue()
expect(closed).toBeFalse()
// now make it unresponsive
mockRelay.unresponsive = true
// wait for the second ping to fail
await closedPromise
expect(relay.connected).toBeFalse()
expect(closed).toBeTrue()
} finally {
delete (MockWebSocketClient.prototype as any).ping
delete (MockWebSocketClient.prototype as any).on
}
})
test('ping-pong timeout (no-ping browser environment)', async () => {
// spy on send to ensure the fallback dummy REQ is used, since MockWebSocketClient has no ping
const originalSend = MockWebSocketClient.prototype.send
let dummyReqSent = false
try {
MockWebSocketClient.prototype.send = function (message: string) {
if (message.includes('REQ') && message.includes('a'.repeat(64))) {
dummyReqSent = true
}
originalSend.call(this, message)
}
const mockRelay = new MockRelay()
const relay = new Relay(mockRelay.url, { enablePing: true })
relay.pingTimeout = 50
relay.pingFrequency = 50
let closed = false
const closedPromise = new Promise<void>(resolve => {
relay.onclose = () => {
closed = true
resolve()
}
})
await relay.connect()
expect(relay.connected).toBeTrue()
// wait for the first ping to succeed
await new Promise(resolve => setTimeout(resolve, 75))
expect(dummyReqSent).toBeTrue()
expect(closed).toBeFalse()
// now make it unresponsive
mockRelay.unresponsive = true
// wait for the second ping to fail
await closedPromise
expect(relay.connected).toBeFalse()
expect(closed).toBeTrue()
} finally {
MockWebSocketClient.prototype.send = originalSend
}
})
test('reconnect on disconnect', async () => {
const mockRelay = new MockRelay()
const relay = new Relay(mockRelay.url, { enablePing: true, enableReconnect: true })
relay.pingTimeout = 50
relay.pingFrequency = 50
relay.resubscribeBackoff = [50, 100] // short backoff for testing
let closes = 0
relay.onclose = () => {
closes++
}
await relay.connect()
expect(relay.connected).toBeTrue()
// wait for the first ping to succeed
await new Promise(resolve => setTimeout(resolve, 75))
expect(closes).toBe(0)
// now make it unresponsive
mockRelay.unresponsive = true
// wait for the second ping to fail, which will trigger a close
await new Promise(resolve => {
const interval = setInterval(() => {
if (closes > 0) {
clearInterval(interval)
resolve(null)
}
}, 10)
})
expect(closes).toBe(1)
expect(relay.connected).toBeFalse()
// now make it responsive again
mockRelay.unresponsive = false
// wait for reconnect
await new Promise(resolve => {
const interval = setInterval(() => {
if (relay.connected) {
clearInterval(interval)
resolve(null)
}
}, 10)
})
expect(relay.connected).toBeTrue()
expect(closes).toBe(1) // should not have closed again
})
test('reconnect with filter update', async () => {
const mockRelay = new MockRelay()
const newSince = Math.floor(Date.now() / 1000)
const relay = new Relay(mockRelay.url, {
enablePing: true,
enableReconnect: filters => {
return filters.map(f => ({ ...f, since: newSince }))
},
})
relay.pingTimeout = 50
relay.pingFrequency = 50
relay.resubscribeBackoff = [50, 100]
let closes = 0
relay.onclose = () => {
closes++
}
await relay.connect()
expect(relay.connected).toBeTrue()
const sub = relay.subscribe([{ kinds: [1], since: 0 }], { onevent: () => {} })
expect(sub.filters[0].since).toBe(0)
// wait for the first ping to succeed
await new Promise(resolve => setTimeout(resolve, 75))
expect(closes).toBe(0)
// now make it unresponsive
mockRelay.unresponsive = true
// wait for the second ping to fail, which will trigger a close
await new Promise(resolve => {
const interval = setInterval(() => {
if (closes > 0) {
clearInterval(interval)
resolve(null)
}
}, 10)
})
expect(closes).toBe(1)
expect(relay.connected).toBeFalse()
// now make it responsive again
mockRelay.unresponsive = false
// wait for reconnect
await new Promise(resolve => {
const interval = setInterval(() => {
if (relay.connected) {
clearInterval(interval)
resolve(null)
}
}, 10)
})
expect(relay.connected).toBeTrue()
expect(closes).toBe(1)
// check if filter was updated
expect(sub.filters[0].since).toBe(newSince)
})

View File

@ -1,7 +1,7 @@
/* global WebSocket */
import { verifyEvent } from './pure.ts'
import { AbstractRelay } from './abstract-relay.ts'
import { AbstractRelay, type AbstractRelayConstructorOptions } from './abstract-relay.ts'
var _WebSocket: typeof WebSocket
@ -14,12 +14,15 @@ export function useWebSocketImplementation(websocketImplementation: any) {
}
export class Relay extends AbstractRelay {
constructor(url: string) {
super(url, { verifyEvent, websocketImplementation: _WebSocket })
constructor(url: string, options?: Pick<AbstractRelayConstructorOptions, 'enablePing' | 'enableReconnect'>) {
super(url, { verifyEvent, websocketImplementation: _WebSocket, ...options })
}
static async connect(url: string): Promise<Relay> {
const relay = new Relay(url)
static async connect(
url: string,
options?: Pick<AbstractRelayConstructorOptions, 'enablePing' | 'enableReconnect'>,
): Promise<Relay> {
const relay = new Relay(url, options)
await relay.connect()
return relay
}

View File

@ -26,6 +26,7 @@ export class MockRelay {
public url: string
public secretKeys: Uint8Array[]
public preloadedEvents: Event[]
public unresponsive: boolean = false
constructor(url?: string | undefined) {
serial++
@ -48,6 +49,7 @@ export class MockRelay {
let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {}
conn.on('message', (message: string) => {
if (this.unresponsive) return
const data = JSON.parse(message)
switch (data[0]) {