Compare commits

..

2 Commits

Author SHA1 Message Date
fiatjaf
bfa40da316 nip46: improve fromURI() and implement "switch_relays". 2026-01-22 21:50:30 -03:00
fiatjaf
9078f45a64 optionally take an AbortSignal on subscriptions. 2026-01-22 21:49:39 -03:00
5 changed files with 103 additions and 121 deletions

View File

@@ -23,6 +23,7 @@ export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & { export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
maxWait?: number maxWait?: number
abort?: AbortSignal
onclose?: (reasons: string[]) => void onclose?: (reasons: string[]) => void
onauth?: (event: EventTemplate) => Promise<VerifiedEvent> onauth?: (event: EventTemplate) => Promise<VerifiedEvent>
id?: string id?: string
@@ -50,7 +51,13 @@ export class AbstractSimplePool {
this.automaticallyAuth = opts.automaticallyAuth this.automaticallyAuth = opts.automaticallyAuth
} }
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> { async ensureRelay(
url: string,
params?: {
connectionTimeout?: number
abort?: AbortSignal
},
): Promise<AbstractRelay> {
url = normalizeURL(url) url = normalizeURL(url)
let relay = this.relays.get(url) let relay = this.relays.get(url)
@@ -66,7 +73,6 @@ export class AbstractSimplePool {
this.relays.delete(url) this.relays.delete(url)
} }
} }
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
this.relays.set(url, relay) this.relays.set(url, relay)
} }
@@ -77,7 +83,10 @@ export class AbstractSimplePool {
} }
} }
await relay.connect() await relay.connect({
timeout: params?.connectionTimeout,
abort: params?.abort,
})
return relay return relay
} }
@@ -176,6 +185,7 @@ export class AbstractSimplePool {
try { try {
relay = await this.ensureRelay(url, { relay = await this.ensureRelay(url, {
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined, connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
abort: params.abort,
}) })
} catch (err) { } catch (err) {
handleClose(i, (err as any)?.message || String(err)) handleClose(i, (err as any)?.message || String(err))
@@ -198,6 +208,7 @@ export class AbstractSimplePool {
}, },
alreadyHaveEvent: localAlreadyHaveEventHandler, alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait, eoseTimeout: params.maxWait,
abort: params.abort,
}) })
}) })
.catch(err => { .catch(err => {
@@ -209,6 +220,7 @@ export class AbstractSimplePool {
}, },
alreadyHaveEvent: localAlreadyHaveEventHandler, alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait, eoseTimeout: params.maxWait,
abort: params.abort,
}) })
subs.push(subscription) subs.push(subscription)

View File

@@ -35,7 +35,6 @@ export class AbstractRelay {
public onauth: undefined | ((evt: EventTemplate) => Promise<VerifiedEvent>) public onauth: undefined | ((evt: EventTemplate) => Promise<VerifiedEvent>)
public baseEoseTimeout: number = 4400 public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400
public publishTimeout: number = 4400 public publishTimeout: number = 4400
public pingFrequency: number = 29000 public pingFrequency: number = 29000
public pingTimeout: number = 20000 public pingTimeout: number = 20000
@@ -43,7 +42,6 @@ export class AbstractRelay {
public openSubs: Map<string, Subscription> = new Map() public openSubs: Map<string, Subscription> = new Map()
public enablePing: boolean | undefined public enablePing: boolean | undefined
public enableReconnect: boolean public enableReconnect: boolean
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private reconnectTimeoutHandle: ReturnType<typeof setTimeout> | undefined private reconnectTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private pingIntervalHandle: ReturnType<typeof setInterval> | undefined private pingIntervalHandle: ReturnType<typeof setInterval> | undefined
private reconnectAttempts: number = 0 private reconnectAttempts: number = 0
@@ -70,9 +68,12 @@ export class AbstractRelay {
this.enableReconnect = opts.enableReconnect || false this.enableReconnect = opts.enableReconnect || false
} }
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> { static async connect(
url: string,
opts: AbstractRelayConstructorOptions & Parameters<AbstractRelay['connect']>[0],
): Promise<AbstractRelay> {
const relay = new AbstractRelay(url, opts) const relay = new AbstractRelay(url, opts)
await relay.connect() await relay.connect(opts)
return relay return relay
} }
@@ -131,23 +132,31 @@ export class AbstractRelay {
} }
} }
public async connect(): Promise<void> { public async connect(opts?: { timeout?: number; abort?: AbortSignal }): Promise<void> {
let connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
if (this.connectionPromise) return this.connectionPromise if (this.connectionPromise) return this.connectionPromise
this.challenge = undefined this.challenge = undefined
this.authPromise = undefined this.authPromise = undefined
this.connectionPromise = new Promise((resolve, reject) => { this.connectionPromise = new Promise((resolve, reject) => {
this.connectionTimeoutHandle = setTimeout(() => { if (opts?.timeout) {
reject('connection timed out') connectionTimeoutHandle = setTimeout(() => {
this.connectionPromise = undefined reject('connection timed out')
this.onclose?.() this.connectionPromise = undefined
this.closeAllSubscriptions('relay connection timed out') this.onclose?.()
}, this.connectionTimeout) this.closeAllSubscriptions('relay connection timed out')
}, opts.timeout)
}
if (opts?.abort) {
opts.abort.onabort = reject
}
try { try {
this.ws = new this._WebSocket(this.url) this.ws = new this._WebSocket(this.url)
} catch (err) { } catch (err) {
clearTimeout(this.connectionTimeoutHandle) clearTimeout(connectionTimeoutHandle)
reject(err) reject(err)
return return
} }
@@ -157,7 +166,7 @@ export class AbstractRelay {
clearTimeout(this.reconnectTimeoutHandle) clearTimeout(this.reconnectTimeoutHandle)
this.reconnectTimeoutHandle = undefined this.reconnectTimeoutHandle = undefined
} }
clearTimeout(this.connectionTimeoutHandle) clearTimeout(connectionTimeoutHandle)
this._connected = true this._connected = true
const isReconnection = this.reconnectAttempts > 0 const isReconnection = this.reconnectAttempts > 0
@@ -183,13 +192,13 @@ export class AbstractRelay {
} }
this.ws.onerror = ev => { this.ws.onerror = ev => {
clearTimeout(this.connectionTimeoutHandle) clearTimeout(connectionTimeoutHandle)
reject((ev as any).message || 'websocket error') reject((ev as any).message || 'websocket error')
this.handleHardClose('relay connection errored') this.handleHardClose('relay connection errored')
} }
this.ws.onclose = ev => { this.ws.onclose = ev => {
clearTimeout(this.connectionTimeoutHandle) clearTimeout(connectionTimeoutHandle)
reject((ev as any).message || 'websocket closed') reject((ev as any).message || 'websocket closed')
this.handleHardClose('relay connection closed') this.handleHardClose('relay connection closed')
} }
@@ -437,6 +446,11 @@ export class AbstractRelay {
): Subscription { ): Subscription {
const sub = this.prepareSubscription(filters, params) const sub = this.prepareSubscription(filters, params)
sub.fire() sub.fire()
if (params.abort) {
params.abort.onabort = () => sub.close(String(params.abort!.reason || '<aborted>'))
}
return sub return sub
} }
@@ -563,6 +577,7 @@ export type SubscriptionParams = {
alreadyHaveEvent?: (id: string) => boolean alreadyHaveEvent?: (id: string) => boolean
receivedEvent?: (relay: AbstractRelay, id: string) => void receivedEvent?: (relay: AbstractRelay, id: string) => void
eoseTimeout?: number eoseTimeout?: number
abort?: AbortSignal
} }
export type CountResolver = { export type CountResolver = {

View File

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

157
nip46.ts
View File

@@ -87,31 +87,7 @@ export type NostrConnectParams = {
image?: 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 { 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() const queryParams = new URLSearchParams()
params.relays.forEach(relay => { params.relays.forEach(relay => {
@@ -136,55 +112,6 @@ export function createNostrConnectURI(params: NostrConnectParams): string {
return `nostrconnect://${params.clientPubkey}?${queryParams.toString()}` 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
@@ -238,7 +165,7 @@ export class BunkerSigner implements Signer {
params: BunkerSignerParams = {}, params: BunkerSignerParams = {},
): BunkerSigner { ): BunkerSigner {
if (bp.relays.length === 0) { if (bp.relays.length === 0) {
throw new Error('No relays specified for this bunker') throw new Error('no relays specified for this bunker')
} }
const signer = new BunkerSigner(clientSecretKey, params) const signer = new BunkerSigner(clientSecretKey, params)
@@ -246,7 +173,7 @@ export class BunkerSigner implements Signer {
signer.conversationKey = getConversationKey(clientSecretKey, bp.pubkey) signer.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
signer.bp = bp signer.bp = bp
signer.setupSubscription(params) signer.setupSubscription()
return signer return signer
} }
@@ -257,22 +184,22 @@ export class BunkerSigner implements Signer {
public static async fromURI( public static async fromURI(
clientSecretKey: Uint8Array, clientSecretKey: Uint8Array,
connectionURI: string, connectionURI: string,
params: BunkerSignerParams = {}, bunkerParams: BunkerSignerParams = {},
maxWait: number = 300_000, maxWaitOrAbort: number | AbortSignal = 300_000,
): Promise<BunkerSigner> { ): Promise<BunkerSigner> {
const signer = new BunkerSigner(clientSecretKey, params) const signer = new BunkerSigner(clientSecretKey, bunkerParams)
const parsedURI = parseNostrConnectURI(connectionURI) const uri = new URL(connectionURI)
const clientPubkey = getPublicKey(clientSecretKey) const clientPubkey = getPublicKey(clientSecretKey)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timer = setTimeout(() => { let success = false
sub.close()
reject(new Error(`Connection timed out after ${maxWait / 1000} seconds`))
}, maxWait)
const sub = signer.pool.subscribe( const sub = signer.pool.subscribe(
parsedURI.params.relays, uri.searchParams.getAll('relay'),
{ kinds: [NostrConnect], '#p': [clientPubkey] }, {
kinds: [NostrConnect],
'#p': [clientPubkey],
limit: 0,
},
{ {
onevent: async (event: NostrEvent) => { onevent: async (event: NostrEvent) => {
try { try {
@@ -281,41 +208,48 @@ export class BunkerSigner implements Signer {
const response = JSON.parse(decryptedContent) const response = JSON.parse(decryptedContent)
if (response.result === parsedURI.params.secret) { if (response.result === uri.searchParams.get('secret')) {
clearTimeout(timer)
sub.close() sub.close()
signer.bp = { signer.bp = {
pubkey: event.pubkey, pubkey: event.pubkey,
relays: parsedURI.params.relays, relays: uri.searchParams.getAll('relay'),
secret: parsedURI.params.secret, secret: uri.searchParams.get('secret'),
} }
signer.conversationKey = getConversationKey(clientSecretKey, event.pubkey) signer.conversationKey = getConversationKey(clientSecretKey, event.pubkey)
signer.setupSubscription(params) signer.setupSubscription()
success = true
await Promise.race([new Promise(resolve => setTimeout(resolve, 1000)), signer.switchRelays()])
resolve(signer) resolve(signer)
} }
} catch (e) { } catch (e) {
console.warn('Failed to process potential connection event', e) console.warn('failed to process potential connection event', e)
} }
}, },
onclose: () => { onclose: () => {
clearTimeout(timer) if (!success) reject(new Error('subscription closed before connection was established.'))
reject(new Error('Subscription closed before connection was established.'))
}, },
maxWait, maxWait: typeof maxWaitOrAbort === 'number' ? maxWaitOrAbort : undefined,
abort: typeof maxWaitOrAbort !== 'number' ? maxWaitOrAbort : undefined,
}, },
) )
}) })
} }
private setupSubscription(params: BunkerSignerParams) { private setupSubscription() {
const listeners = this.listeners const listeners = this.listeners
const waitingForAuth = this.waitingForAuth const waitingForAuth = this.waitingForAuth
const convKey = this.conversationKey const convKey = this.conversationKey
this.subCloser = this.pool.subscribe( this.subCloser = this.pool.subscribe(
this.bp.relays, this.bp.relays,
{ kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] }, {
kinds: [NostrConnect],
authors: [this.bp.pubkey],
'#p': [getPublicKey(this.secretKey)],
limit: 0,
},
{ {
onevent: async (event: NostrEvent) => { onevent: async (event: NostrEvent) => {
const o = JSON.parse(decrypt(event.content, convKey)) const o = JSON.parse(decrypt(event.content, convKey))
@@ -324,8 +258,8 @@ export class BunkerSigner implements Signer {
if (result === 'auth_url' && waitingForAuth[id]) { if (result === 'auth_url' && waitingForAuth[id]) {
delete waitingForAuth[id] delete waitingForAuth[id]
if (params.onauth) { if (this.params.onauth) {
params.onauth(error) this.params.onauth(error)
} else { } else {
console.warn( console.warn(
`nostr-tools/nip46: remote signer ${this.bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`, `nostr-tools/nip46: remote signer ${this.bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`,
@@ -349,6 +283,27 @@ export class BunkerSigner implements Signer {
this.isOpen = true this.isOpen = true
} }
async switchRelays(): Promise<boolean> {
try {
const switchResp = await this.sendRequest('switch_relays', [])
let relays = JSON.parse(switchResp) as string[] | null
if (!relays) return false
if (JSON.stringify(relays.sort()) === JSON.stringify(this.bp.relays)) return false
this.bp.relays = relays
let previousCloser = this.subCloser!
setTimeout(() => {
previousCloser.close()
}, 5000)
this.subCloser = undefined
this.setupSubscription()
return true
} catch {
return false
}
}
// closes the subscription -- this object can't be used anymore after this // closes the subscription -- this object can't be used anymore after this
async close() { async close() {
this.isOpen = false this.isOpen = false
@@ -359,7 +314,7 @@ export class BunkerSigner implements Signer {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
try { try {
if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one') if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one')
if (!this.subCloser) this.setupSubscription(this.params) if (!this.subCloser) this.setupSubscription()
this.serial++ this.serial++
const id = `${this.idPrefix}-${this.serial}` const id = `${this.idPrefix}-${this.serial}`
@@ -469,7 +424,7 @@ export async function createAccount(
email?: string, email?: string,
localSecretKey: Uint8Array = generateSecretKey(), localSecretKey: Uint8Array = generateSecretKey(),
): 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 = BunkerSigner.fromBunker(localSecretKey, bunker.bunkerPointer, params) let rpc = BunkerSigner.fromBunker(localSecretKey, bunker.bunkerPointer, params)

View File

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