Compare commits

...

32 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
fiatjaf
2da3528362 forgot to expose blossom, as usual. 2025-06-05 01:29:54 -03:00
fiatjaf
315e9a472c expose signer module. 2025-06-04 21:47:17 -03:00
fiatjaf
a2b1bf0338 blossom test. 2025-06-04 21:45:43 -03:00
fiatjaf
861a77e2b3 nipB7 (blossom) and a generic signer interface. 2025-06-04 21:28:33 -03:00
António Conselheiro
9132b722f3 improve signature for decode function (#489) 2025-06-01 11:08:57 -03:00
fiatjaf
ae2f97655b remove two deprecated things. 2025-05-31 20:04:46 -03:00
fiatjaf
5b78a829c7 ignore error when sending on a CLOSE to a closed connection. 2025-05-31 12:29:24 -03:00
22 changed files with 862 additions and 320 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 })
}
} }
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser { return this.subscribeMap(request, params)
return this.subscribeMap( }
relays.flatMap(url => filters.map(filter => ({ url, filter }))),
params, subscribeMany(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
) params.onauth = params.onauth || params.doauth
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,7 +287,16 @@ export class AbstractSimplePool {
} }
let r = await this.ensureRelay(url) let r = await this.ensureRelay(url)
return r.publish(event).then(reason => { return r
.publish(event)
.catch(async err => {
if (err instanceof Error && err.message.startsWith('auth-required: ') && options?.onauth) {
await r.auth(options.onauth)
return r.publish(event) // retry
}
throw err
})
.then(reason => {
if (this.trackRelays) { if (this.trackRelays) {
let set = this.seenOn.get(event.id) let set = this.seenOn.get(event.id)
if (!set) { if (!set) {

View File

@@ -1,15 +1,28 @@
/* global WebSocket */ /* global WebSocket */
import type { Event, EventTemplate, VerifiedEvent, Nostr } from './core.ts' import type { Event, EventTemplate, VerifiedEvent, Nostr, NostrEvent } from './core.ts'
import { matchFilters, type Filter } from './filter.ts' import { matchFilters, type Filter } from './filter.ts'
import { getHex64, getSubscriptionId } from './fakejson.ts' import { getHex64, getSubscriptionId } from './fakejson.ts'
import { Queue, normalizeURL } from './utils.ts' 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 {
constructor(message: string, relay: string) {
super(`Tried to send message '${message} on a closed connection to ${relay}.`)
this.name = 'SendingOnClosedConnection'
}
} }
export class AbstractRelay { export class AbstractRelay {
@@ -19,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
@@ -45,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> {
@@ -98,30 +112,29 @@ 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)
}) })
@@ -129,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) {
@@ -178,7 +238,7 @@ export class AbstractRelay {
switch (data[0]) { switch (data[0]) {
case 'EVENT': { case 'EVENT': {
const so = this.openSubs.get(data[1] as string) as Subscription const so = this.openSubs.get(data[1] as string) as Subscription
const event = data[2] as Event const event = data[2] as NostrEvent
if (this.verifyEvent(event) && matchFilters(so.filters, event)) { if (this.verifyEvent(event) && matchFilters(so.filters, event)) {
so.onevent(event) so.onevent(event)
} }
@@ -226,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
} }
} }
@@ -236,7 +295,7 @@ export class AbstractRelay {
} }
public async send(message: string) { public async send(message: string) {
if (!this.connectionPromise) throw new Error('sending on closed connection') if (!this.connectionPromise) throw new SendingOnClosedConnection(message, this.url)
this.connectionPromise.then(() => { this.connectionPromise.then(() => {
this.ws?.send(message) this.ws?.send(message)
@@ -249,9 +308,10 @@ 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(() => {
let ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
if (ep) { if (ep) {
ep.reject(new Error('auth timed out')) ep.reject(new Error('auth timed out'))
this.openEventPublishes.delete(evt.id) this.openEventPublishes.delete(evt.id)
@@ -259,6 +319,9 @@ export class AbstractRelay {
}, this.publishTimeout) }, this.publishTimeout)
this.openEventPublishes.set(evt.id, { resolve, reject, timeout }) this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
this.send('["AUTH",' + JSON.stringify(evt) + ']') this.send('["AUTH",' + JSON.stringify(evt) + ']')
} catch (err) {
console.warn('subscribe auth function failed:', err)
}
}) })
return this.authPromise return this.authPromise
} }
@@ -311,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()
} }
@@ -379,7 +443,15 @@ export class Subscription {
if (!this.closed && this.relay.connected) { if (!this.closed && this.relay.connected) {
// if the connection was closed by the user calling .close() we will send a CLOSE message // if the connection was closed by the user calling .close() we will send a CLOSE message
// otherwise this._open will be already set to false so we will skip this // otherwise this._open will be already set to false so we will skip this
try {
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']') this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
} catch (err) {
if (err instanceof SendingOnClosedConnection) {
/* doesn't matter, it's ok */
} else {
throw err
}
}
this.closed = true this.closed = true
} }
this.relay.openSubs.delete(this.id) this.relay.openSubs.delete(this.id)

View File

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

View File

@@ -20,9 +20,6 @@ export function isAddressableKind(kind: number): boolean {
return 30000 <= kind && kind < 40000 return 30000 <= kind && kind < 40000
} }
/** @deprecated use isAddressableKind instead */
export const isParameterizedReplaceableKind = isAddressableKind
/** Classification of the event kind. */ /** Classification of the event kind. */
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown' export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'

View File

@@ -4,7 +4,7 @@ import { decode } from './nip19.ts'
import { wrapEvent, wrapManyEvents, unwrapEvent } from './nip17.ts' import { wrapEvent, wrapManyEvents, unwrapEvent } from './nip17.ts'
import { hexToBytes } from '@noble/hashes/utils' import { hexToBytes } from '@noble/hashes/utils'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data as Uint8Array const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
const sk1 = hexToBytes('f09ac9b695d0a4c6daa418fe95b977eea20f54d9545592bc36a4f9e14f3eb840') const sk1 = hexToBytes('f09ac9b695d0a4c6daa418fe95b977eea20f54d9545592bc36a4f9e14f3eb840')
const sk2 = hexToBytes('5393a825e5892d8e18d4a5ea61ced105e8bb2a106f42876be3a40522e0b13747') const sk2 = hexToBytes('5393a825e5892d8e18d4a5ea61ced105e8bb2a106f42876be3a40522e0b13747')

View File

@@ -1,17 +1,15 @@
import { test, expect, describe } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { generateSecretKey, getPublicKey } from './pure.ts' // prettier-ignore
import { import {
decode, decode,
naddrEncode, naddrEncode,
neventEncode,
NostrTypeGuard,
nprofileEncode, nprofileEncode,
npubEncode, npubEncode,
nsecEncode, nsecEncode
neventEncode,
type AddressPointer,
type ProfilePointer,
EventPointer,
NostrTypeGuard,
} from './nip19.ts' } from './nip19.ts'
import { generateSecretKey, getPublicKey } from './pure.ts'
test('encode and decode nsec', () => { test('encode and decode nsec', () => {
let sk = generateSecretKey() let sk = generateSecretKey()
@@ -38,7 +36,7 @@ test('encode and decode nprofile', () => {
expect(nprofile).toMatch(/nprofile1\w+/) expect(nprofile).toMatch(/nprofile1\w+/)
let { type, data } = decode(nprofile) let { type, data } = decode(nprofile)
expect(type).toEqual('nprofile') expect(type).toEqual('nprofile')
const pointer = data as ProfilePointer const pointer = data
expect(pointer.pubkey).toEqual(pk) expect(pointer.pubkey).toEqual(pk)
expect(pointer.relays).toContain(relays[0]) expect(pointer.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[1]) expect(pointer.relays).toContain(relays[1])
@@ -67,7 +65,7 @@ test('encode and decode naddr', () => {
expect(naddr).toMatch(/naddr1\w+/) expect(naddr).toMatch(/naddr1\w+/)
let { type, data } = decode(naddr) let { type, data } = decode(naddr)
expect(type).toEqual('naddr') expect(type).toEqual('naddr')
const pointer = data as AddressPointer const pointer = data
expect(pointer.pubkey).toEqual(pk) expect(pointer.pubkey).toEqual(pk)
expect(pointer.relays).toContain(relays[0]) expect(pointer.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[1]) expect(pointer.relays).toContain(relays[1])
@@ -86,7 +84,7 @@ test('encode and decode nevent', () => {
expect(nevent).toMatch(/nevent1\w+/) expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent) let { type, data } = decode(nevent)
expect(type).toEqual('nevent') expect(type).toEqual('nevent')
const pointer = data as EventPointer const pointer = data
expect(pointer.id).toEqual(pk) expect(pointer.id).toEqual(pk)
expect(pointer.relays).toContain(relays[0]) expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(30023) expect(pointer.kind).toEqual(30023)
@@ -103,7 +101,7 @@ test('encode and decode nevent with kind 0', () => {
expect(nevent).toMatch(/nevent1\w+/) expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent) let { type, data } = decode(nevent)
expect(type).toEqual('nevent') expect(type).toEqual('nevent')
const pointer = data as EventPointer const pointer = data
expect(pointer.id).toEqual(pk) expect(pointer.id).toEqual(pk)
expect(pointer.relays).toContain(relays[0]) expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(0) expect(pointer.kind).toEqual(0)
@@ -121,7 +119,7 @@ test('encode and decode naddr with empty "d"', () => {
expect(naddr).toMatch(/naddr\w+/) expect(naddr).toMatch(/naddr\w+/)
let { type, data } = decode(naddr) let { type, data } = decode(naddr)
expect(type).toEqual('naddr') expect(type).toEqual('naddr')
const pointer = data as AddressPointer const pointer = data
expect(pointer.identifier).toEqual('') expect(pointer.identifier).toEqual('')
expect(pointer.relays).toContain(relays[0]) expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(3) expect(pointer.kind).toEqual(3)
@@ -133,7 +131,7 @@ test('decode naddr from habla.news', () => {
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5', 'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
) )
expect(type).toEqual('naddr') expect(type).toEqual('naddr')
const pointer = data as AddressPointer const pointer = data
expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194') expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
expect(pointer.kind).toEqual(30023) expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('references') expect(pointer.identifier).toEqual('references')
@@ -145,7 +143,7 @@ test('decode naddr from go-nostr with different TLV ordering', () => {
) )
expect(type).toEqual('naddr') expect(type).toEqual('naddr')
const pointer = data as AddressPointer const pointer = data
expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d') expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com') expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
expect(pointer.relays).toContain('wss://nostr.banana.com') expect(pointer.relays).toContain('wss://nostr.banana.com')

View File

@@ -70,31 +70,46 @@ export function decodeNostrURI(nip19code: string): ReturnType<typeof decode> | {
} }
} }
export function decode(code: string): export type DecodedNevent = {
| {
type: 'nevent' type: 'nevent'
data: EventPointer data: EventPointer
} }
| {
export type DecodedNprofile = {
type: 'nprofile' type: 'nprofile'
data: ProfilePointer data: ProfilePointer
} }
| {
export type DecodedNaddr = {
type: 'naddr' type: 'naddr'
data: AddressPointer data: AddressPointer
} }
| {
type: 'npub' export type DecodedNsec = {
data: string
}
| {
type: 'nsec' type: 'nsec'
data: Uint8Array data: Uint8Array
} }
| {
export type DecodedNpub = {
type: 'npub'
data: string
}
export type DecodedNote = {
type: 'note' type: 'note'
data: string data: string
} { }
export type DecodedResult = DecodedNevent | DecodedNprofile | DecodedNaddr | DecodedNpub | DecodedNsec | DecodedNote
export function decode(nip19: NEvent): DecodedNevent
export function decode(nip19: NProfile): DecodedNprofile
export function decode(nip19: NAddr): DecodedNaddr
export function decode(nip19: NSec): DecodedNsec
export function decode(nip19: NPub): DecodedNpub
export function decode(nip19: Note): DecodedNote
export function decode(code: string): DecodedResult
export function decode(code: string): DecodedResult {
let { prefix, words } = bech32.decode(code, Bech32MaxSize) let { prefix, words } = bech32.decode(code, Bech32MaxSize)
let data = new Uint8Array(bech32.fromWords(words)) let data = new Uint8Array(bech32.fromWords(words))

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 } from './nip19.ts' import { decode, NostrTypeGuard } from './nip19.ts'
import { normalizeURL } from './utils.ts' import { normalizeURL } from './utils.ts'
/** /**
@@ -518,11 +518,11 @@ export async function loadGroupFromCode(pool: AbstractSimplePool, code: string):
* @returns A GroupReference object if the code is valid, otherwise null. * @returns A GroupReference object if the code is valid, otherwise null.
*/ */
export function parseGroupCode(code: string): null | GroupReference { export function parseGroupCode(code: string): null | GroupReference {
if (code.startsWith('naddr1')) { if (NostrTypeGuard.isNAddr(code)) {
try { try {
let { data } = decode(code) let { data } = decode(code)
let { relays, identifier } = data as AddressPointer let { relays, identifier } = data
if (!relays || relays.length === 0) return null if (!relays || relays.length === 0) return null
let host = relays![0] let host = relays![0]

214
nip46.ts
View File

@@ -5,7 +5,7 @@ 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'
var _fetch: any var _fetch: any
@@ -77,12 +77,120 @@ 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
} }
export class BunkerSigner { export class BunkerSigner implements Signer {
private params: BunkerSignerParams private params: BunkerSignerParams
private pool: AbstractSimplePool private pool: AbstractSimplePool
private subCloser: SubCloser | undefined private subCloser: SubCloser | undefined
@@ -97,8 +205,9 @@ export class BunkerSigner {
} }
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 {
* @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) {
@@ -237,13 +418,6 @@ export class BunkerSigner {
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.
@@ -297,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

55
nipb7.test.ts Normal file
View File

@@ -0,0 +1,55 @@
import { test, expect } from 'bun:test'
import { BlossomClient } from './nipb7.ts'
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from './utils.ts'
import { PlainKeySigner } from './signer.ts'
import { generateSecretKey } from './pure.ts'
test('blossom', async () => {
const BLOSSOM_SERVER = 'blossom.primal.net'
const TEST_CONTENT = 'hello world'
const TEST_BLOB = new Blob([TEST_CONTENT], { type: 'text/plain' })
const expectedHash = bytesToHex(sha256(new TextEncoder().encode(TEST_CONTENT)))
const signer = new PlainKeySigner(generateSecretKey())
const client = new BlossomClient(BLOSSOM_SERVER, signer)
expect(client).toBeDefined()
// check for non-existent file should throw
const invalidHash = expectedHash.slice(0, 62) + 'ba'
let hasThrown = false
try {
await client.check(invalidHash)
} catch (err) {
hasThrown = true
}
expect(hasThrown).toBeTrue()
// upload hello world blob
const descriptor = await client.uploadBlob(TEST_BLOB, 'text/plain')
expect(descriptor).toBeDefined()
expect(descriptor.sha256).toBe(expectedHash)
expect(descriptor.size).toBe(TEST_CONTENT.length)
expect(descriptor.type).toBe('text/plain')
expect(descriptor.url).toContain(expectedHash)
expect(descriptor.uploaded).toBeGreaterThan(0)
await client.check(expectedHash)
// download and verify
const downloadedBuffer = await client.download(expectedHash)
const downloadedContent = new TextDecoder().decode(downloadedBuffer)
expect(downloadedContent).toBe(TEST_CONTENT)
// list blobs should include our uploaded file
const blobs = await client.list()
expect(Array.isArray(blobs)).toBe(true)
const ourBlob = blobs.find(blob => blob.sha256 === expectedHash)
expect(ourBlob).toBeDefined()
expect(ourBlob?.type).toBe('text/plain')
expect(ourBlob?.size).toBe(TEST_CONTENT.length)
// delete
await client.delete(expectedHash)
})

203
nipb7.ts Normal file
View File

@@ -0,0 +1,203 @@
import { sha256 } from '@noble/hashes/sha256'
import { EventTemplate } from './core.ts'
import { Signer } from './signer.ts'
import { bytesToHex } from './utils.ts'
export type BlobDescriptor = {
url: string
sha256: string
size: number
type: string
uploaded: number
}
export class BlossomClient {
private mediaserver: string
private signer: Signer
constructor(mediaserver: string, signer: Signer) {
if (!mediaserver.startsWith('http')) {
mediaserver = 'https://' + mediaserver
}
this.mediaserver = mediaserver.replace(/\/$/, '') + '/'
this.signer = signer
}
private async httpCall(
method: string,
url: string,
contentType?: string,
addAuthorization?: () => Promise<string>,
body?: File | Blob,
result?: any,
): Promise<any> {
const headers: { [_: string]: string } = {}
if (contentType) {
headers['Content-Type'] = contentType
}
if (addAuthorization) {
const auth = await addAuthorization()
if (auth) {
headers['Authorization'] = auth
}
}
const response = await fetch(this.mediaserver + url, {
method,
headers,
body,
})
if (response.status >= 300) {
const reason = response.headers.get('X-Reason') || response.statusText
throw new Error(`${url} returned an error (${response.status}): ${reason}`)
}
if (result !== null && response.headers.get('content-type')?.includes('application/json')) {
return await response.json()
}
return response
}
private async authorizationHeader(modify?: (event: EventTemplate) => void): Promise<string> {
const now = Math.floor(Date.now() / 1000)
const event: EventTemplate = {
created_at: now,
kind: 24242,
content: 'blossom stuff',
tags: [['expiration', String(now + 60)]],
}
if (modify) {
modify(event)
}
try {
const signedEvent = await this.signer.signEvent(event)
const eventJson = JSON.stringify(signedEvent)
return 'Nostr ' + btoa(eventJson)
} catch (error) {
return ''
}
}
private isValid32ByteHex(hash: string): boolean {
return /^[a-f0-9]{64}$/i.test(hash)
}
async check(hash: string): Promise<void> {
if (!this.isValid32ByteHex(hash)) {
throw new Error(`${hash} is not a valid 32-byte hex string`)
}
try {
await this.httpCall('HEAD', hash)
} catch (error) {
throw new Error(`failed to check for ${hash}: ${error}`)
}
}
async uploadBlob(file: File | Blob, contentType?: string): Promise<BlobDescriptor> {
const hash = bytesToHex(sha256(new Uint8Array(await file.arrayBuffer())))
const actualContentType = contentType || file.type || 'application/octet-stream'
const bd = await this.httpCall(
'PUT',
'upload',
actualContentType,
() =>
this.authorizationHeader(evt => {
evt.tags.push(['t', 'upload'])
evt.tags.push(['x', hash])
}),
file,
{},
)
return bd
}
async uploadFile(file: File): Promise<BlobDescriptor> {
return this.uploadBlob(file, file.type)
}
async download(hash: string): Promise<ArrayBuffer> {
if (!this.isValid32ByteHex(hash)) {
throw new Error(`${hash} is not a valid 32-byte hex string`)
}
const authHeader = await this.authorizationHeader(evt => {
evt.tags.push(['t', 'get'])
evt.tags.push(['x', hash])
})
const response = await fetch(this.mediaserver + hash, {
method: 'GET',
headers: {
Authorization: authHeader,
},
})
if (response.status >= 300) {
throw new Error(`${hash} is not present in ${this.mediaserver}: ${response.status}`)
}
return await response.arrayBuffer()
}
async downloadAsBlob(hash: string): Promise<Blob> {
const arrayBuffer = await this.download(hash)
return new Blob([arrayBuffer])
}
async list(): Promise<BlobDescriptor[]> {
const pubkey = await this.signer.getPublicKey()
if (!this.isValid32ByteHex(pubkey)) {
throw new Error(`pubkey ${pubkey} is not valid`)
}
try {
const bds = await this.httpCall(
'GET',
`list/${pubkey}`,
undefined,
() =>
this.authorizationHeader(evt => {
evt.tags.push(['t', 'list'])
}),
undefined,
[],
)
return bds
} catch (error) {
throw new Error(`failed to list blobs: ${error}`)
}
}
async delete(hash: string): Promise<void> {
if (!this.isValid32ByteHex(hash)) {
throw new Error(`${hash} is not a valid 32-byte hex string`)
}
try {
await this.httpCall(
'DELETE',
hash,
undefined,
() =>
this.authorizationHeader(evt => {
evt.tags.push(['t', 'delete'])
evt.tags.push(['x', hash])
}),
undefined,
null,
)
} catch (error) {
throw new Error(`failed to delete ${hash}: ${error}`)
}
}
}

View File

@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "nostr-tools", "name": "nostr-tools",
"version": "2.13.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",
@@ -213,11 +213,21 @@
"require": "./lib/cjs/nip99.js", "require": "./lib/cjs/nip99.js",
"types": "./lib/types/nip99.d.ts" "types": "./lib/types/nip99.d.ts"
}, },
"./nipb7": {
"import": "./lib/esm/nipb7.js",
"require": "./lib/cjs/nipb7.js",
"types": "./lib/types/nipb7.d.ts"
},
"./fakejson": { "./fakejson": {
"import": "./lib/esm/fakejson.js", "import": "./lib/esm/fakejson.js",
"require": "./lib/cjs/fakejson.js", "require": "./lib/cjs/fakejson.js",
"types": "./lib/types/fakejson.d.ts" "types": "./lib/types/fakejson.d.ts"
}, },
"./signer": {
"import": "./lib/esm/signer.js",
"require": "./lib/cjs/signer.js",
"types": "./lib/types/signer.d.ts"
},
"./utils": { "./utils": {
"import": "./lib/esm/utils.js", "import": "./lib/esm/utils.js",
"require": "./lib/cjs/utils.js", "require": "./lib/cjs/utils.js",

View File

@@ -35,14 +35,18 @@ test('removing duplicates when subscribing', async () => {
priv, priv,
) )
pool.subscribeMany(relayURLs, [{ authors: [pub] }], { pool.subscribeMany(
relayURLs,
{ authors: [pub] },
{
onevent(event: Event) { onevent(event: Event) {
// this should be called only once even though we're listening // this should be called only once even though we're listening
// to multiple relays because the events will be caught and // to multiple relays because the events will be caught and
// deduplicated efficiently (without even being parsed) // deduplicated efficiently (without even being parsed)
received.push(event) 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 })
} }
} }

View File

@@ -3,13 +3,6 @@
import { verifyEvent } from './pure.ts' import { verifyEvent } from './pure.ts'
import { AbstractRelay } from './abstract-relay.ts' import { AbstractRelay } from './abstract-relay.ts'
/**
* @deprecated use Relay.connect() instead.
*/
export function relayConnect(url: string): Promise<Relay> {
return Relay.connect(url)
}
var _WebSocket: typeof WebSocket var _WebSocket: typeof WebSocket
try { try {

23
signer.ts Normal file
View File

@@ -0,0 +1,23 @@
import { EventTemplate, VerifiedEvent } from './core.ts'
import { finalizeEvent, getPublicKey } from './pure.ts'
export interface Signer {
getPublicKey(): Promise<string>
signEvent(event: EventTemplate): Promise<VerifiedEvent>
}
export class PlainKeySigner implements Signer {
private secretKey: Uint8Array
constructor(secretKey: Uint8Array) {
this.secretKey = secretKey
}
async getPublicKey(): Promise<string> {
return getPublicKey(this.secretKey)
}
async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
return finalizeEvent(event, this.secretKey)
}
}