Compare commits

..

6 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
8 changed files with 296 additions and 71 deletions

View File

@@ -169,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
} }
@@ -179,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) {
@@ -195,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
@@ -211,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

@@ -78,14 +78,14 @@ export class AbstractSimplePool {
for (let i = 0; i < relays.length; i++) { for (let i = 0; i < relays.length; i++) {
const url = normalizeURL(relays[i]) const url = normalizeURL(relays[i])
if (!request.find(r => r.url === url)) { if (!request.find(r => r.url === url)) {
request.push({ url, filter }) request.push({ url, filter: filter })
} }
} }
return this.subscribeMap(request, params) 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 params.onauth = params.onauth || params.doauth
const request: { url: string; filter: Filter }[] = [] const request: { url: string; filter: Filter }[] = []
@@ -93,9 +93,8 @@ export class AbstractSimplePool {
for (let i = 0; i < relays.length; i++) { for (let i = 0; i < relays.length; i++) {
const url = normalizeURL(relays[i]) const url = normalizeURL(relays[i])
if (uniqUrls.indexOf(url) === -1) { if (uniqUrls.indexOf(url) === -1) {
for (let f = 0; f < filters.length; f++) { uniqUrls.push(url)
request.push({ url, filter: filters[f] }) request.push({ url, filter: filter })
}
} }
} }
@@ -105,6 +104,14 @@ export class AbstractSimplePool {
subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser { subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth 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)
@@ -152,7 +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) => {
let relay: AbstractRelay let relay: AbstractRelay
try { try {
relay = await this.ensureRelay(url, { relay = await this.ensureRelay(url, {
@@ -163,7 +170,7 @@ 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 => {
@@ -171,7 +178,7 @@ export class AbstractSimplePool {
relay relay
.auth(params.onauth) .auth(params.onauth)
.then(() => { .then(() => {
relay.subscribe([filter], { relay.subscribe(filters, {
...params, ...params,
oneose: () => handleEose(i), oneose: () => handleEose(i),
onclose: reason => { onclose: reason => {
@@ -224,12 +231,12 @@ export class AbstractSimplePool {
subscribeManyEose( subscribeManyEose(
relays: string[], relays: string[],
filters: Filter[], filter: Filter,
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>, params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
): SubCloser { ): SubCloser {
params.onauth = params.onauth || params.doauth params.onauth = params.onauth || params.doauth
const subcloser = this.subscribeMany(relays, filters, { const subcloser = this.subscribeMany(relays, filter, {
...params, ...params,
oneose() { oneose() {
subcloser.close('closed automatically on eose') subcloser.close('closed automatically on eose')

View File

@@ -121,23 +121,19 @@ export class AbstractRelay {
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)
@@ -187,8 +183,8 @@ export class AbstractRelay {
// pingpong closing socket // pingpong closing socket
this.closeAllSubscriptions('pingpong timed out') this.closeAllSubscriptions('pingpong timed out')
this._connected = false this._connected = false
this.ws?.close()
this.onclose?.() this.onclose?.()
this.ws?.close()
} }
} }
} }
@@ -378,8 +374,8 @@ 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.ws?.close()
this.onclose?.() this.onclose?.()
this.ws?.close()
} }
// this is the function assigned to this.ws.onmessage // this is the function assigned to this.ws.onmessage

View File

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

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

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 = { export type BunkerSignerParams = {
pool?: AbstractSimplePool pool?: AbstractSimplePool
onauth?: (url: string) => void onauth?: (url: string) => void
@@ -97,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
@@ -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 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) {
@@ -290,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

@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "nostr-tools", "name": "nostr-tools",
"version": "2.16.2", "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)
}, },