Compare commits

...

25 Commits

Author SHA1 Message Date
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
fiatjaf
4b6cc19b9c cleanup. 2025-07-23 16:22:25 -03:00
fiatjaf
b2f3a01439 nip46: remove deprecated getRelays() 2025-07-23 16:22:16 -03:00
Chris McCormick
6ec19b618c WIP: pingpong with logging. 2025-07-23 16:16:12 -03:00
Chris McCormick
b3cc9f50e5 WIP: hack in pingpong #495
TypeScript does not like the duck typing of .on and .ping (only valid on Node ws).
2025-07-23 16:16:12 -03:00
vornis101
de1cf0ed60 Fix JSON syntax of jsr.json 2025-07-19 08:58:05 -03:00
fiatjaf
d706ef961f pool: closed relays must be eliminated. 2025-07-17 23:39:16 -03:00
SondreB
2f529b3f8a enhance parseConnectionString to support double slash URL format 2025-07-13 11:59:04 -03:00
fiatjaf
f0357805c3 catch errors on function passed to auth() and log them. 2025-06-10 10:20:20 -03:00
fiatjaf
ffa7fb926e remove deprecated unused _onauth hook. 2025-06-10 10:16:11 -03:00
fiatjaf
12acb900ab SubCloser.close() can take a reason string optionally. 2025-06-10 10:15:58 -03:00
fiatjaf
d773012658 proper auth support on pool.publish(). 2025-06-06 22:36:07 -03:00
fiatjaf
b8f91c37fa and there was an error in jsr.json 2025-06-05 14:37:56 -03:00
15 changed files with 493 additions and 262 deletions

View File

@@ -4,7 +4,7 @@ Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
Only depends on _@scure_ and _@noble_ packages. Only depends on _@scure_ and _@noble_ packages.
This package is only providing lower-level functionality. If you want more higher-level features, take a look at [Nostrify](https://nostrify.dev), or if you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system). This package is only providing lower-level functionality. If you want higher-level features, take a look at [@nostr/gadgets](https://jsr.io/@nostr/gadgets) which is based on this library and expands upon it and has other goodies (it's only available on jsr).
## Installation ## Installation
@@ -133,6 +133,14 @@ import WebSocket from 'ws'
useWebSocketImplementation(WebSocket) useWebSocketImplementation(WebSocket)
``` ```
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 don't report websocket disconnections due to network issues, and enabling this can increase reliability.
```js
import { SimplePool } from 'nostr-tools/pool'
const pool = new SimplePool({ enablePing: true })
```
### Parsing references (mentions) from a content based on NIP-27 ### Parsing references (mentions) from a content based on NIP-27
```js ```js
@@ -161,8 +169,10 @@ for (let block of nip27.parse(evt.content)) {
case 'video': case 'video':
case 'audio': case 'audio':
console.log("it's a media url:", block.url) console.log("it's a media url:", block.url)
break
case 'relay': case 'relay':
console.log("it's a websocket url, probably a relay address:", block.url) console.log("it's a websocket url, probably a relay address:", block.url)
break
default: default:
break break
} }
@@ -171,14 +181,24 @@ for (let block of nip27.parse(evt.content)) {
### Connecting to a bunker using NIP-46 ### 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 ```js
import { generateSecretKey, getPublicKey } from '@nostr/tools/pure'
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46' import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
import { SimplePool } from '@nostr/tools/pool' 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 // parse a bunker URI
const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com') const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com')
if (!bunkerPointer) { if (!bunkerPointer) {
@@ -187,7 +207,7 @@ if (!bunkerPointer) {
// create the bunker instance // create the bunker instance
const pool = new SimplePool() const pool = new SimplePool()
const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool }) const bunker = BunkerSigner.fromBunker(localSecretKey, bunkerPointer, { pool })
await bunker.connect() await bunker.connect()
// and use it // and use it
@@ -203,6 +223,47 @@ const event = await bunker.signEvent({
await signer.close() await signer.close()
pool.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 ### Parsing thread from any note based on NIP-10

View File

@@ -12,13 +12,15 @@ import type { Event, EventTemplate, Nostr, VerifiedEvent } from './core.ts'
import { type Filter } from './filter.ts' import { type Filter } from './filter.ts'
import { alwaysTrue } from './helpers.ts' import { alwaysTrue } from './helpers.ts'
export type SubCloser = { close: () => void } export type SubCloser = { close: (reason?: string) => void }
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {} export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {}
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & { export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
maxWait?: number maxWait?: number
onclose?: (reasons: string[]) => void onclose?: (reasons: string[]) => void
onauth?: (event: EventTemplate) => Promise<VerifiedEvent>
// Deprecated: use onauth instead
doauth?: (event: EventTemplate) => Promise<VerifiedEvent> doauth?: (event: EventTemplate) => Promise<VerifiedEvent>
id?: string id?: string
label?: string label?: string
@@ -30,6 +32,7 @@ export class AbstractSimplePool {
public trackRelays: boolean = false public trackRelays: boolean = false
public verifyEvent: Nostr['verifyEvent'] public verifyEvent: Nostr['verifyEvent']
public enablePing: boolean | undefined
public trustedRelayURLs: Set<string> = new Set() public trustedRelayURLs: Set<string> = new Set()
private _WebSocket?: typeof WebSocket private _WebSocket?: typeof WebSocket
@@ -37,6 +40,7 @@ export class AbstractSimplePool {
constructor(opts: AbstractPoolConstructorOptions) { constructor(opts: AbstractPoolConstructorOptions) {
this.verifyEvent = opts.verifyEvent this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation this._WebSocket = opts.websocketImplementation
this.enablePing = opts.enablePing
} }
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> { async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
@@ -47,7 +51,11 @@ export class AbstractSimplePool {
relay = new AbstractRelay(url, { relay = new AbstractRelay(url, {
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent, verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
websocketImplementation: this._WebSocket, websocketImplementation: this._WebSocket,
enablePing: this.enablePing,
}) })
relay.onclose = () => {
this.relays.delete(url)
}
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
this.relays.set(url, relay) this.relays.set(url, relay)
} }
@@ -59,24 +67,51 @@ export class AbstractSimplePool {
close(relays: string[]) { close(relays: string[]) {
relays.map(normalizeURL).forEach(url => { relays.map(normalizeURL).forEach(url => {
this.relays.get(url)?.close() this.relays.get(url)?.close()
this.relays.delete(url)
}) })
} }
subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser { subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
return this.subscribeMap( params.onauth = params.onauth || params.doauth
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 {
return this.subscribeMap( params.onauth = params.onauth || params.doauth
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 { 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) { if (this.trackRelays) {
params.receivedEvent = (relay: AbstractRelay, id: string) => { params.receivedEvent = (relay: AbstractRelay, id: string) => {
let set = this.seenOn.get(id) let set = this.seenOn.get(id)
@@ -124,9 +159,7 @@ export class AbstractSimplePool {
// open a subscription in all given relays // open a subscription in all given relays
const allOpened = Promise.all( const allOpened = Promise.all(
requests.map(async ({ url, filter }, i) => { groupedRequests.map(async ({ url, filters }, i) => {
url = normalizeURL(url)
let relay: AbstractRelay let relay: AbstractRelay
try { try {
relay = await this.ensureRelay(url, { relay = await this.ensureRelay(url, {
@@ -137,15 +170,15 @@ export class AbstractSimplePool {
return return
} }
let subscription = relay.subscribe([filter], { let subscription = relay.subscribe(filters, {
...params, ...params,
oneose: () => handleEose(i), oneose: () => handleEose(i),
onclose: reason => { onclose: reason => {
if (reason.startsWith('auth-required:') && params.doauth) { if (reason.startsWith('auth-required: ') && params.onauth) {
relay relay
.auth(params.doauth) .auth(params.onauth)
.then(() => { .then(() => {
relay.subscribe([filter], { relay.subscribe(filters, {
...params, ...params,
oneose: () => handleEose(i), oneose: () => handleEose(i),
onclose: reason => { onclose: reason => {
@@ -171,10 +204,10 @@ export class AbstractSimplePool {
) )
return { return {
async close() { async close(reason?: string) {
await allOpened await allOpened
subs.forEach(sub => { subs.forEach(sub => {
sub.close() sub.close(reason)
}) })
}, },
} }
@@ -183,12 +216,14 @@ export class AbstractSimplePool {
subscribeEose( subscribeEose(
relays: string[], relays: string[],
filter: Filter, filter: Filter,
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'doauth'>, params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
): SubCloser { ): SubCloser {
params.onauth = params.onauth || params.doauth
const subcloser = this.subscribe(relays, filter, { const subcloser = this.subscribe(relays, filter, {
...params, ...params,
oneose() { oneose() {
subcloser.close() subcloser.close('closed automatically on eose')
}, },
}) })
return subcloser return subcloser
@@ -196,13 +231,15 @@ export class AbstractSimplePool {
subscribeManyEose( subscribeManyEose(
relays: string[], relays: string[],
filters: Filter[], filter: Filter,
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'doauth'>, params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
): SubCloser { ): SubCloser {
const subcloser = this.subscribeMany(relays, filters, { params.onauth = params.onauth || params.doauth
const subcloser = this.subscribeMany(relays, filter, {
...params, ...params,
oneose() { oneose() {
subcloser.close() subcloser.close('closed automatically on eose')
}, },
}) })
return subcloser return subcloser
@@ -238,7 +275,11 @@ export class AbstractSimplePool {
return events[0] || null return events[0] || null
} }
publish(relays: string[], event: Event): Promise<string>[] { publish(
relays: string[],
event: Event,
options?: { onauth?: (evt: EventTemplate) => Promise<VerifiedEvent> },
): Promise<string>[] {
return relays.map(normalizeURL).map(async (url, i, arr) => { return relays.map(normalizeURL).map(async (url, i, arr) => {
if (arr.indexOf(url) !== i) { if (arr.indexOf(url) !== i) {
// duplicate // duplicate
@@ -246,17 +287,26 @@ export class AbstractSimplePool {
} }
let r = await this.ensureRelay(url) let r = await this.ensureRelay(url)
return r.publish(event).then(reason => { return r
if (this.trackRelays) { .publish(event)
let set = this.seenOn.get(event.id) .catch(async err => {
if (!set) { if (err instanceof Error && err.message.startsWith('auth-required: ') && options?.onauth) {
set = new Set() await r.auth(options.onauth)
this.seenOn.set(event.id, set) return r.publish(event) // retry
} }
set.add(r) throw err
} })
return reason .then(reason => {
}) if (this.trackRelays) {
let set = this.seenOn.get(event.id)
if (!set) {
set = new Set()
this.seenOn.set(event.id, set)
}
set.add(r)
}
return reason
})
}) })
} }

View File

@@ -7,9 +7,15 @@ import { Queue, normalizeURL } from './utils.ts'
import { makeAuthEvent } from './nip42.ts' import { makeAuthEvent } from './nip42.ts'
import { yieldThread } from './helpers.ts' import { yieldThread } from './helpers.ts'
type RelayWebSocket = WebSocket & {
ping?(): void
on?(event: 'pong', listener: () => void): any
}
export type AbstractRelayConstructorOptions = { export type AbstractRelayConstructorOptions = {
verifyEvent: Nostr['verifyEvent'] verifyEvent: Nostr['verifyEvent']
websocketImplementation?: typeof WebSocket websocketImplementation?: typeof WebSocket
enablePing?: boolean
} }
export class SendingOnClosedConnection extends Error { export class SendingOnClosedConnection extends Error {
@@ -26,19 +32,19 @@ export class AbstractRelay {
public onclose: (() => void) | null = null public onclose: (() => void) | null = null
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`) public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
// this is exposed just to help in ndk migration, shouldn't be relied upon
public _onauth: ((challenge: string) => void) | null = null
public baseEoseTimeout: number = 4400 public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400 public connectionTimeout: number = 4400
public publishTimeout: number = 4400 public publishTimeout: number = 4400
public pingFrequency: number = 20000
public pingTimeout: number = 20000
public openSubs: Map<string, Subscription> = new Map() public openSubs: Map<string, Subscription> = new Map()
public enablePing: boolean | undefined
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private connectionPromise: Promise<void> | undefined private connectionPromise: Promise<void> | undefined
private openCountRequests = new Map<string, CountResolver>() private openCountRequests = new Map<string, CountResolver>()
private openEventPublishes = new Map<string, EventPublishResolver>() private openEventPublishes = new Map<string, EventPublishResolver>()
private ws: WebSocket | undefined private ws: RelayWebSocket | undefined
private incomingMessageQueue = new Queue<string>() private incomingMessageQueue = new Queue<string>()
private queueRunning = false private queueRunning = false
private challenge: string | undefined private challenge: string | undefined
@@ -52,6 +58,7 @@ export class AbstractRelay {
this.url = normalizeURL(url) this.url = normalizeURL(url)
this.verifyEvent = opts.verifyEvent this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation || WebSocket this._WebSocket = opts.websocketImplementation || WebSocket
this.enablePing = opts.enablePing
} }
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> { static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
@@ -105,29 +112,28 @@ export class AbstractRelay {
this.ws.onopen = () => { this.ws.onopen = () => {
clearTimeout(this.connectionTimeoutHandle) clearTimeout(this.connectionTimeoutHandle)
this._connected = true this._connected = true
if (this.enablePing) {
this.pingpong()
}
resolve() resolve()
} }
this.ws.onerror = ev => { this.ws.onerror = ev => {
clearTimeout(this.connectionTimeoutHandle) clearTimeout(this.connectionTimeoutHandle)
reject((ev as any).message || 'websocket error') reject((ev as any).message || 'websocket error')
if (this._connected) { this._connected = false
this._connected = false this.connectionPromise = undefined
this.connectionPromise = undefined this.onclose?.()
this.onclose?.() this.closeAllSubscriptions('relay connection errored')
this.closeAllSubscriptions('relay connection errored')
}
} }
this.ws.onclose = ev => { this.ws.onclose = ev => {
clearTimeout(this.connectionTimeoutHandle) clearTimeout(this.connectionTimeoutHandle)
reject((ev as any).message || 'websocket closed') reject((ev as any).message || 'websocket closed')
if (this._connected) { this._connected = false
this._connected = false this.connectionPromise = undefined
this.connectionPromise = undefined this.onclose?.()
this.onclose?.() this.closeAllSubscriptions('relay connection closed')
this.closeAllSubscriptions('relay connection closed')
}
} }
this.ws.onmessage = this._onmessage.bind(this) this.ws.onmessage = this._onmessage.bind(this)
@@ -136,6 +142,53 @@ export class AbstractRelay {
return this.connectionPromise return this.connectionPromise
} }
private async waitForPingPong() {
return new Promise((res, err) => {
// 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,
})
})
}
// nodejs requires this magic here to ensure connections are closed when internet goes off and stuff
// 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) {
// wait for either a ping-pong reply or a timeout
const result = await Promise.any([
// 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)),
])
if (result) {
// schedule another pingpong
setTimeout(() => this.pingpong(), this.pingFrequency)
} else {
// pingpong closing socket
this.closeAllSubscriptions('pingpong timed out')
this._connected = false
this.onclose?.()
this.ws?.close()
}
}
}
private async runQueue() { private async runQueue() {
this.queueRunning = true this.queueRunning = true
while (true) { while (true) {
@@ -233,7 +286,6 @@ export class AbstractRelay {
return return
case 'AUTH': { case 'AUTH': {
this.challenge = data[1] as string this.challenge = data[1] as string
this._onauth?.(data[1] as string)
return return
} }
} }
@@ -256,16 +308,20 @@ export class AbstractRelay {
if (this.authPromise) return this.authPromise if (this.authPromise) return this.authPromise
this.authPromise = new Promise<string>(async (resolve, reject) => { this.authPromise = new Promise<string>(async (resolve, reject) => {
const evt = await signAuthEvent(makeAuthEvent(this.url, challenge)) try {
const timeout = setTimeout(() => { let evt = await signAuthEvent(makeAuthEvent(this.url, challenge))
const ep = this.openEventPublishes.get(evt.id) as EventPublishResolver let timeout = setTimeout(() => {
if (ep) { let ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
ep.reject(new Error('auth timed out')) if (ep) {
this.openEventPublishes.delete(evt.id) ep.reject(new Error('auth timed out'))
} this.openEventPublishes.delete(evt.id)
}, this.publishTimeout) }
this.openEventPublishes.set(evt.id, { resolve, reject, timeout }) }, this.publishTimeout)
this.send('["AUTH",' + JSON.stringify(evt) + ']') this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
this.send('["AUTH",' + JSON.stringify(evt) + ']')
} catch (err) {
console.warn('subscribe auth function failed:', err)
}
}) })
return this.authPromise return this.authPromise
} }
@@ -318,6 +374,7 @@ export class AbstractRelay {
public close() { public close() {
this.closeAllSubscriptions('relay connection closed by us') this.closeAllSubscriptions('relay connection closed by us')
this._connected = false this._connected = false
this.onclose?.()
this.ws?.close() this.ws?.close()
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nostr/tools", "name": "@nostr/tools",
"version": "2.14.1", "version": "2.17.0",
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./core": "./core.ts", "./core": "./core.ts",
@@ -42,9 +42,9 @@
"./nip94": "./nip94.ts", "./nip94": "./nip94.ts",
"./nip98": "./nip98.ts", "./nip98": "./nip98.ts",
"./nip99": "./nip99.ts", "./nip99": "./nip99.ts",
"./nip99": "./nipb7.ts", "./nipb7": "./nipb7.ts",
"./fakejson": "./fakejson.ts", "./fakejson": "./fakejson.ts",
"./utils": "./utils.ts" "./utils": "./utils.ts",
"./signer": "./signer.ts" "./signer": "./signer.ts"
} }
} }

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { AbstractSimplePool } from './abstract-pool.ts'
import { Subscription } from './abstract-relay.ts' import { Subscription } from './abstract-relay.ts'
import type { Event, EventTemplate } from './core.ts' import type { Event, EventTemplate } from './core.ts'
import { fetchRelayInformation, RelayInformation } from './nip11.ts' import { fetchRelayInformation, RelayInformation } from './nip11.ts'
import { AddressPointer, decode, NostrTypeGuard } from './nip19.ts' import { decode, NostrTypeGuard } from './nip19.ts'
import { normalizeURL } from './utils.ts' import { normalizeURL } from './utils.ts'
/** /**

211
nip46.ts
View File

@@ -5,7 +5,6 @@ import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { NIP05_REGEX } from './nip05.ts' import { NIP05_REGEX } from './nip05.ts'
import { SimplePool } from './pool.ts' import { SimplePool } from './pool.ts'
import { Handlerinformation, NostrConnect } from './kinds.ts' import { Handlerinformation, NostrConnect } from './kinds.ts'
import type { RelayRecord } from './relay.ts'
import { Signer } from './signer.ts' import { Signer } from './signer.ts'
var _fetch: any var _fetch: any
@@ -78,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 = { export type BunkerSignerParams = {
pool?: AbstractSimplePool pool?: AbstractSimplePool
onauth?: (url: string) => void onauth?: (url: string) => void
@@ -98,8 +205,9 @@ export class BunkerSigner implements Signer {
} }
private waitingForAuth: { [id: string]: boolean } private waitingForAuth: { [id: string]: boolean }
private secretKey: Uint8Array private secretKey: Uint8Array
private conversationKey: Uint8Array // If the client initiates the connection, the two variables below can be filled in later.
public bp: BunkerPointer private conversationKey!: Uint8Array
public bp!: BunkerPointer
private cachedPubKey: string | undefined private cachedPubKey: string | undefined
@@ -109,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 remotePubkey - An optional remote public key. This is the key you want to sign as.
* @param secretKey - An optional key pair. * @param secretKey - An optional key pair.
*/ */
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) { private constructor(clientSecretKey: Uint8Array, params: BunkerSignerParams) {
if (bp.relays.length === 0) {
throw new Error('no relays are specified for this bunker')
}
this.params = params this.params = params
this.pool = params.pool || new SimplePool() this.pool = params.pool || new SimplePool()
this.secretKey = clientSecretKey this.secretKey = clientSecretKey
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
this.bp = bp
this.isOpen = false this.isOpen = false
this.idPrefix = Math.random().toString(36).substring(7) this.idPrefix = Math.random().toString(36).substring(7)
this.serial = 0 this.serial = 0
this.listeners = {} this.listeners = {}
this.waitingForAuth = {} 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) { private setupSubscription(params: BunkerSignerParams) {
@@ -238,13 +418,6 @@ export class BunkerSigner implements Signer {
return this.cachedPubKey return this.cachedPubKey
} }
/**
* @deprecated removed from NIP
*/
async getRelays(): Promise<RelayRecord> {
return JSON.parse(await this.sendRequest('get_relays', []))
}
/** /**
* Signs an event using the remote private key. * Signs an event using the remote private key.
* @param event - The event to sign. * @param event - The event to sign.
@@ -298,7 +471,7 @@ export async function createAccount(
): Promise<BunkerSigner> { ): Promise<BunkerSigner> {
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email') 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 || '']) let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])

View File

@@ -5,6 +5,16 @@ import { decrypt } from './nip04.ts'
import { NWCWalletRequest } from './kinds.ts' import { NWCWalletRequest } from './kinds.ts'
describe('parseConnectionString', () => { describe('parseConnectionString', () => {
test('returns pubkey, relay, and secret if connection string has double slash', () => {
const connectionString =
'nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
const { pubkey, relay, secret } = parseConnectionString(connectionString)
expect(pubkey).toBe('b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4')
expect(relay).toBe('wss://relay.damus.io')
expect(secret).toBe('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
})
test('returns pubkey, relay, and secret if connection string is valid', () => { test('returns pubkey, relay, and secret if connection string is valid', () => {
const connectionString = const connectionString =
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c' 'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'

View File

@@ -9,8 +9,8 @@ interface NWCConnection {
} }
export function parseConnectionString(connectionString: string): NWCConnection { export function parseConnectionString(connectionString: string): NWCConnection {
const { pathname, searchParams } = new URL(connectionString) const { host, pathname, searchParams } = new URL(connectionString)
const pubkey = pathname const pubkey = pathname || host
const relay = searchParams.get('relay') const relay = searchParams.get('relay')
const secret = searchParams.get('secret') const secret = searchParams.get('secret')

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 { finalizeEvent } from './pure.ts'
import { getPublicKey, generateSecretKey } from './pure.ts' import { getPublicKey, generateSecretKey } from './pure.ts'
import { import { getSatoshisAmountFromBolt11, makeZapReceipt, validateZapRequest } from './nip57.ts'
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'],
]),
)
})
})
describe('validateZapRequest', () => { describe('validateZapRequest', () => {
test('returns an error message for invalid JSON', () => { test('returns an error message for invalid JSON', () => {

View File

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

View File

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

View File

@@ -35,14 +35,18 @@ test('removing duplicates when subscribing', async () => {
priv, priv,
) )
pool.subscribeMany(relayURLs, [{ authors: [pub] }], { pool.subscribeMany(
onevent(event: Event) { relayURLs,
// this should be called only once even though we're listening { authors: [pub] },
// to multiple relays because the events will be caught and {
// deduplicated efficiently (without even being parsed) onevent(event: Event) {
received.push(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 Promise.any(pool.publish(relayURLs, event))
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
@@ -55,12 +59,12 @@ test('same with double subs', async () => {
let priv = generateSecretKey() let priv = generateSecretKey()
let pub = getPublicKey(priv) let pub = getPublicKey(priv)
pool.subscribeMany(relayURLs, [{ authors: [pub] }], { pool.subscribeMany(relayURLs, { authors: [pub] }, {
onevent(event) { onevent(event) {
received.push(event) received.push(event)
}, },
}) })
pool.subscribeMany(relayURLs, [{ authors: [pub] }], { pool.subscribeMany(relayURLs, { authors: [pub] }, {
onevent(event) { onevent(event) {
received.push(event) received.push(event)
}, },
@@ -168,7 +172,7 @@ test('query a bunch of events and cancel on eose', async () => {
let events = new Set<string>() let events = new Set<string>()
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], { pool.subscribeManyEose(relayURLs, { kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }, {
onevent(event) { onevent(event) {
events.add(event.id) events.add(event.id)
}, },

View File

@@ -14,8 +14,8 @@ export function useWebSocketImplementation(websocketImplementation: any) {
} }
export class SimplePool extends AbstractSimplePool { export class SimplePool extends AbstractSimplePool {
constructor() { constructor(options?: { enablePing?: boolean }) {
super({ verifyEvent, websocketImplementation: _WebSocket }) super({ verifyEvent, websocketImplementation: _WebSocket, ...options })
} }
} }