mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e0f393268 | ||
|
|
1bec9fa365 | ||
|
|
e8927d78e6 | ||
|
|
bc1294e4e6 | ||
|
|
226d7d07e2 | ||
|
|
c9ff51e278 | ||
|
|
23aebbd341 | ||
|
|
a3fcd79545 | ||
|
|
0e6e7af934 | ||
|
|
8866042edf | ||
|
|
ebe7df7b9e | ||
|
|
86235314c4 | ||
|
|
b39dac3551 | ||
|
|
929d62bbbb | ||
|
|
b575e47844 | ||
|
|
b076c34a2f | ||
|
|
4bb3eb2d40 | ||
|
|
87f2c74bb3 | ||
|
|
4b6cc19b9c | ||
|
|
b2f3a01439 | ||
|
|
6ec19b618c | ||
|
|
b3cc9f50e5 | ||
|
|
de1cf0ed60 | ||
|
|
d706ef961f | ||
|
|
2f529b3f8a | ||
|
|
f0357805c3 | ||
|
|
ffa7fb926e | ||
|
|
12acb900ab | ||
|
|
d773012658 | ||
|
|
b8f91c37fa | ||
|
|
2da3528362 | ||
|
|
315e9a472c | ||
|
|
a2b1bf0338 | ||
|
|
861a77e2b3 | ||
|
|
9132b722f3 | ||
|
|
ae2f97655b | ||
|
|
5b78a829c7 | ||
|
|
de26ee98c5 | ||
|
|
1437bbdb0f | ||
|
|
57354b9fb4 | ||
|
|
924075b803 | ||
|
|
666a02027e | ||
|
|
eff9ea9579 | ||
|
|
ca174e6cd8 | ||
|
|
4ba9c8886b | ||
|
|
7dbd86eb5c | ||
|
|
3e839db6f2 | ||
|
|
cb370fbf4f | ||
|
|
c015b6e794 | ||
|
|
52079f6e75 | ||
|
|
ef28b2eb73 | ||
|
|
2a422774fb | ||
|
|
b80f8a0bcc | ||
|
|
dd603e47d8 | ||
|
|
ba26b92973 | ||
|
|
aec8ff5946 | ||
|
|
e498c9144d | ||
|
|
42d47abba1 | ||
|
|
303c35120c | ||
|
|
4a738c93d0 | ||
|
|
2a11c9ec91 | ||
|
|
cbe3a9d683 | ||
|
|
2944a932b8 | ||
|
|
6b39de04d7 | ||
|
|
9a612e59a2 | ||
|
|
266dbdf766 | ||
|
|
19ae9837a7 | ||
|
|
4188f2c596 | ||
|
|
97bded8f5b | ||
|
|
174d36a440 | ||
|
|
0177b130c3 | ||
|
|
05eb62da5b | ||
|
|
3c4019a154 | ||
|
|
e7e8db1dbd | ||
|
|
44a679e642 | ||
|
|
c1172caf1d | ||
|
|
86f37d6003 | ||
|
|
3daade322c | ||
|
|
fcf10541c8 | ||
|
|
548abb5d4a | ||
|
|
1e5bfe856b | ||
|
|
3266b4d4c2 | ||
|
|
a0b950ab12 | ||
|
|
be741159d7 | ||
|
|
9c50b2c655 | ||
|
|
bbb09420fe | ||
|
|
2e85f7a5fe | ||
|
|
b22e2465cc | ||
|
|
43ce7f9377 | ||
|
|
5a55c670fb | ||
|
|
bf0c4d4988 | ||
|
|
50fe7c2a8b |
@@ -3,7 +3,7 @@
|
||||
"extends": ["prettier"],
|
||||
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "babel"],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
|
||||
308
README.md
308
README.md
@@ -4,7 +4,7 @@ Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||
|
||||
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
|
||||
|
||||
@@ -57,43 +57,57 @@ let event = finalizeEvent({
|
||||
let isGood = verifyEvent(event)
|
||||
```
|
||||
|
||||
### Interacting with a relay
|
||||
### Interacting with one or multiple relays
|
||||
|
||||
Doesn't matter what you do, you always should be using a `SimplePool`:
|
||||
|
||||
```js
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
|
||||
const relay = await Relay.connect('wss://relay.example.com')
|
||||
console.log(`connected to ${relay.url}`)
|
||||
const pool = new SimplePool()
|
||||
|
||||
// let's query for an event that exists
|
||||
const sub = relay.subscribe([
|
||||
const relays = ['wss://relay.example.com', 'wss://relay.example2.com']
|
||||
|
||||
// let's query for one event that exists
|
||||
const event = pool.get(
|
||||
relays,
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
},
|
||||
], {
|
||||
onevent(event) {
|
||||
console.log('we got the event we wanted:', event)
|
||||
)
|
||||
if (event) {
|
||||
console.log('it exists indeed on this relay:', event)
|
||||
}
|
||||
|
||||
// let's query for more than one event that exists
|
||||
const events = pool.querySync(
|
||||
relays,
|
||||
{
|
||||
kinds: [1],
|
||||
limit: 10
|
||||
},
|
||||
oneose() {
|
||||
sub.close()
|
||||
}
|
||||
})
|
||||
)
|
||||
if (events) {
|
||||
console.log('it exists indeed on this relay:', events)
|
||||
}
|
||||
|
||||
// let's publish a new event while simultaneously monitoring the relay for it
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
relay.subscribe([
|
||||
pool.subscribe(
|
||||
['wss://a.com', 'wss://b.com', 'wss://c.com'],
|
||||
{
|
||||
kinds: [1],
|
||||
authors: [pk],
|
||||
},
|
||||
], {
|
||||
onevent(event) {
|
||||
console.log('got event:', event)
|
||||
{
|
||||
onevent(event) {
|
||||
console.log('got event:', event)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
let eventTemplate = {
|
||||
kind: 1,
|
||||
@@ -104,7 +118,7 @@ let eventTemplate = {
|
||||
|
||||
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||
await relay.publish(signedEvent)
|
||||
await Promise.any(pool.publish(['wss://a.com', 'wss://b.com'], signedEvent))
|
||||
|
||||
relay.close()
|
||||
```
|
||||
@@ -119,59 +133,207 @@ import WebSocket from 'ws'
|
||||
useWebSocketImplementation(WebSocket)
|
||||
```
|
||||
|
||||
### Interacting with multiple relays
|
||||
#### enablePing
|
||||
|
||||
You can enable regular pings of connected relays with the `enablePing` option. This will set up a heartbeat that closes the websocket if it doesn't receive a response in time. Some platforms, like Node.js, don't report websocket disconnections due to network issues, and enabling this can increase the reliability of the `onclose` event.
|
||||
|
||||
```js
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
|
||||
const pool = new SimplePool()
|
||||
const pool = new SimplePool({ enablePing: true })
|
||||
```
|
||||
|
||||
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
|
||||
#### enableReconnect
|
||||
|
||||
let h = pool.subscribeMany(
|
||||
[...relays, 'wss://relay.example3.com'],
|
||||
[
|
||||
{
|
||||
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
// this will only be called once the first time the event is received
|
||||
// ...
|
||||
},
|
||||
oneose() {
|
||||
h.close()
|
||||
}
|
||||
You can also enable automatic reconnection with the `enableReconnect` option. This will make the pool try to reconnect to relays with an exponential backoff delay if the connection is lost unexpectedly.
|
||||
|
||||
```js
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
|
||||
const pool = new SimplePool({ enableReconnect: true })
|
||||
```
|
||||
|
||||
Using both `enablePing: true` and `enableReconnect: true` is recommended as it will improve the reliability and timeliness of the reconnection (at the expense of slighly higher bandwidth due to the ping messages).
|
||||
|
||||
```js
|
||||
// on Node.js
|
||||
const pool = new SimplePool({ enablePing: true, enableReconnect: true })
|
||||
```
|
||||
|
||||
The `enableReconnect` option can also be a callback function which will receive the current subscription filters and should return a new set of filters. This is useful if you want to modify the subscription on reconnect, for example, to update the `since` parameter to fetch only new events.
|
||||
|
||||
```js
|
||||
const pool = new SimplePool({
|
||||
enableReconnect: (filters) => {
|
||||
const newSince = Math.floor(Date.now() / 1000)
|
||||
return filters.map(filter => ({ ...filter, since: newSince }))
|
||||
}
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, newEvent))
|
||||
console.log('published to at least one relay!')
|
||||
|
||||
let events = await pool.querySync(relays, { kinds: [0, 1] })
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
})
|
||||
```
|
||||
|
||||
### Parsing references (mentions) from a content using NIP-10 and NIP-27
|
||||
### Parsing references (mentions) from a content based on NIP-27
|
||||
|
||||
```js
|
||||
import { parseReferences } from 'nostr-tools/references'
|
||||
import * as nip27 from '@nostr/tools/nip27'
|
||||
|
||||
let references = parseReferences(event)
|
||||
let simpleAugmentedContent = event.content
|
||||
for (let i = 0; i < references.length; i++) {
|
||||
let { text, profile, event, address } = references[i]
|
||||
let augmentedReference = profile
|
||||
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
|
||||
: event
|
||||
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>`
|
||||
: address
|
||||
? `<a href="${text}">[link]</a>`
|
||||
: text
|
||||
simpleAugmentedContent.replaceAll(text, augmentedReference)
|
||||
for (let block of nip27.parse(evt.content)) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
console.log(block.text)
|
||||
break
|
||||
case 'reference': {
|
||||
if ('id' in block.pointer) {
|
||||
console.log("it's a nevent1 uri", block.pointer)
|
||||
} else if ('identifier' in block.pointer) {
|
||||
console.log("it's a naddr1 uri", block.pointer)
|
||||
} else {
|
||||
console.log("it's an npub1 or nprofile1 uri", block.pointer)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'url': {
|
||||
console.log("it's a normal url:", block.url)
|
||||
break
|
||||
}
|
||||
case 'image':
|
||||
case 'video':
|
||||
case 'audio':
|
||||
console.log("it's a media url:", block.url)
|
||||
break
|
||||
case 'relay':
|
||||
console.log("it's a websocket url, probably a relay address:", block.url)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Connecting to a bunker using NIP-46
|
||||
|
||||
`BunkerSigner` allows your application to request signatures and other actions from a remote NIP-46 signer, often called a "bunker". There are two primary ways to establish a connection, depending on whether the client or the bunker initiates the connection.
|
||||
|
||||
A local secret key is required for the client to communicate securely with the bunker. This key should generally be persisted for the user's session.
|
||||
|
||||
```js
|
||||
import { generateSecretKey } from '@nostr/tools/pure'
|
||||
|
||||
const localSecretKey = generateSecretKey()
|
||||
```
|
||||
|
||||
### Method 1: Using a Bunker URI (`bunker://`)
|
||||
|
||||
This is the bunker-initiated flow. Your client receives a `bunker://` string or a NIP-05 identifier from the user. You use `BunkerSigner.fromBunker()` to create an instance, which returns immediately. For the **initial connection** with a new bunker, you must explicitly call `await bunker.connect()` to establish the connection and receive authorization.
|
||||
|
||||
```js
|
||||
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
|
||||
import { SimplePool } from '@nostr/tools/pool'
|
||||
|
||||
// parse a bunker URI
|
||||
const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com')
|
||||
if (!bunkerPointer) {
|
||||
throw new Error('Invalid bunker input')
|
||||
}
|
||||
|
||||
// create the bunker instance
|
||||
const pool = new SimplePool()
|
||||
const bunker = BunkerSigner.fromBunker(localSecretKey, bunkerPointer, { pool })
|
||||
await bunker.connect()
|
||||
|
||||
// and use it
|
||||
const pubkey = await bunker.getPublicKey()
|
||||
const event = await bunker.signEvent({
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'Hello from bunker!'
|
||||
})
|
||||
|
||||
// cleanup
|
||||
await signer.close()
|
||||
pool.close([])
|
||||
```
|
||||
> **Note on Reconnecting:** Once a connection has been successfully established and the `BunkerPointer` is stored, you do **not** need to call `await bunker.connect()` on subsequent sessions.
|
||||
|
||||
### Method 2: Using a Client-generated URI (`nostrconnect://`)
|
||||
|
||||
This is the client-initiated flow, which generally provides a better user experience for first-time connections (e.g., via QR code). Your client generates a `nostrconnect://` URI and waits for the bunker to connect to it.
|
||||
|
||||
`BunkerSigner.fromURI()` is an **asynchronous** method. It returns a `Promise` that resolves only after the bunker has successfully connected. Therefore, the returned signer instance is already fully connected and ready to use, so you **do not** need to call `.connect()` on it.
|
||||
|
||||
```js
|
||||
import { getPublicKey } from '@nostr/tools/pure'
|
||||
import { BunkerSigner, createNostrConnectURI } from '@nostr/tools/nip46'
|
||||
import { SimplePool } from '@nostr/tools/pool'
|
||||
|
||||
const clientPubkey = getPublicKey(localSecretKey)
|
||||
|
||||
// generate a connection URI for the bunker to scan
|
||||
const connectionUri = createNostrConnectURI({
|
||||
clientPubkey,
|
||||
relays: ['wss://relay.damus.io', 'wss://relay.primal.net'],
|
||||
secret: 'a-random-secret-string', // A secret to verify the bunker's response
|
||||
name: 'My Awesome App'
|
||||
})
|
||||
|
||||
// wait for the bunker to connect
|
||||
const pool = new SimplePool()
|
||||
const signer = await BunkerSigner.fromURI(localSecretKey, connectionUri, { pool })
|
||||
|
||||
// and use it
|
||||
const pubkey = await signer.getPublicKey()
|
||||
const event = await signer.signEvent({
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'Hello from a client-initiated connection!'
|
||||
})
|
||||
|
||||
// cleanup
|
||||
await signer.close()
|
||||
pool.close([])
|
||||
```
|
||||
> **Note on Persistence:** This method is ideal for the initial sign-in. To allow users to stay logged in across sessions, you should store the connection details and use `Method 1` for subsequent reconnections.
|
||||
|
||||
### Parsing thread from any note based on NIP-10
|
||||
|
||||
```js
|
||||
import * as nip10 from '@nostr/tools/nip10'
|
||||
|
||||
// event is a nostr event with tags
|
||||
const refs = nip10.parse(event)
|
||||
|
||||
// get the root event of the thread
|
||||
if (refs.root) {
|
||||
console.log('root event:', refs.root.id)
|
||||
console.log('root event relay hints:', refs.root.relays)
|
||||
console.log('root event author:', refs.root.author)
|
||||
}
|
||||
|
||||
// get the immediate parent being replied to
|
||||
if (refs.reply) {
|
||||
console.log('reply to:', refs.reply.id)
|
||||
console.log('reply relay hints:', refs.reply.relays)
|
||||
console.log('reply author:', refs.reply.author)
|
||||
}
|
||||
|
||||
// get any mentioned events
|
||||
for (let mention of refs.mentions) {
|
||||
console.log('mentioned event:', mention.id)
|
||||
console.log('mention relay hints:', mention.relays)
|
||||
console.log('mention author:', mention.author)
|
||||
}
|
||||
|
||||
// get any quoted events
|
||||
for (let quote of refs.quotes) {
|
||||
console.log('quoted event:', quote.id)
|
||||
console.log('quote relay hints:', quote.relays)
|
||||
}
|
||||
|
||||
// get any referenced profiles
|
||||
for (let profile of refs.profiles) {
|
||||
console.log('referenced profile:', profile.pubkey)
|
||||
console.log('profile relay hints:', profile.relays)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -205,32 +367,6 @@ declare global {
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Generating NIP-06 keys
|
||||
```js
|
||||
import {
|
||||
privateKeyFromSeedWords,
|
||||
accountFromSeedWords,
|
||||
extendedKeysFromSeedWords,
|
||||
accountFromExtendedKey
|
||||
} from 'nostr-tools/nip06'
|
||||
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123' // optional
|
||||
const accountIndex = 0
|
||||
const sk0 = privateKeyFromSeedWords(mnemonic, passphrase, accountIndex)
|
||||
|
||||
const { privateKey: sk1, publicKey: pk1 } = accountFromSeedWords(mnemonic, passphrase, accountIndex)
|
||||
|
||||
const extendedAccountIndex = 0
|
||||
|
||||
const { privateExtendedKey, publicExtendedKey } = extendedKeysFromSeedWords(mnemonic, passphrase, extendedAccountIndex)
|
||||
|
||||
const { privateKey: sk2, publicKey: pk2 } = accountFromExtendedKey(privateExtendedKey)
|
||||
|
||||
const { publicKey: pk3 } = accountFromExtendedKey(publicExtendedKey)
|
||||
```
|
||||
|
||||
### Encoding and decoding NIP-19 codes
|
||||
|
||||
```js
|
||||
|
||||
178
abstract-pool.ts
178
abstract-pool.ts
@@ -8,18 +8,22 @@ import {
|
||||
} from './abstract-relay.ts'
|
||||
import { normalizeURL } from './utils.ts'
|
||||
|
||||
import type { Event, Nostr } from './core.ts'
|
||||
import type { Event, EventTemplate, Nostr, VerifiedEvent } from './core.ts'
|
||||
import { type Filter } from './filter.ts'
|
||||
import { alwaysTrue } from './helpers.ts'
|
||||
|
||||
export type SubCloser = { close: () => void }
|
||||
export type SubCloser = { close: (reason?: string) => void }
|
||||
|
||||
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {}
|
||||
|
||||
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
|
||||
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
|
||||
maxWait?: number
|
||||
onclose?: (reasons: string[]) => void
|
||||
onauth?: (event: EventTemplate) => Promise<VerifiedEvent>
|
||||
// Deprecated: use onauth instead
|
||||
doauth?: (event: EventTemplate) => Promise<VerifiedEvent>
|
||||
id?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export class AbstractSimplePool {
|
||||
@@ -28,6 +32,8 @@ export class AbstractSimplePool {
|
||||
public trackRelays: boolean = false
|
||||
|
||||
public verifyEvent: Nostr['verifyEvent']
|
||||
public enablePing: boolean | undefined
|
||||
public enableReconnect: boolean | ((filters: Filter[]) => Filter[]) | undefined
|
||||
public trustedRelayURLs: Set<string> = new Set()
|
||||
|
||||
private _WebSocket?: typeof WebSocket
|
||||
@@ -35,6 +41,8 @@ export class AbstractSimplePool {
|
||||
constructor(opts: AbstractPoolConstructorOptions) {
|
||||
this.verifyEvent = opts.verifyEvent
|
||||
this._WebSocket = opts.websocketImplementation
|
||||
this.enablePing = opts.enablePing
|
||||
this.enableReconnect = opts.enableReconnect
|
||||
}
|
||||
|
||||
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
|
||||
@@ -45,7 +53,14 @@ export class AbstractSimplePool {
|
||||
relay = new AbstractRelay(url, {
|
||||
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
|
||||
websocketImplementation: this._WebSocket,
|
||||
enablePing: this.enablePing,
|
||||
enableReconnect: this.enableReconnect,
|
||||
})
|
||||
relay.onclose = () => {
|
||||
if (relay && !relay.enableReconnect) {
|
||||
this.relays.delete(url)
|
||||
}
|
||||
}
|
||||
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
|
||||
this.relays.set(url, relay)
|
||||
}
|
||||
@@ -57,14 +72,51 @@ export class AbstractSimplePool {
|
||||
close(relays: string[]) {
|
||||
relays.map(normalizeURL).forEach(url => {
|
||||
this.relays.get(url)?.close()
|
||||
this.relays.delete(url)
|
||||
})
|
||||
}
|
||||
|
||||
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
|
||||
return this.subscribeManyMap(Object.fromEntries(relays.map(url => [url, filters])), params)
|
||||
subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
|
||||
params.onauth = params.onauth || params.doauth
|
||||
|
||||
const request: { url: string; filter: Filter }[] = []
|
||||
for (let i = 0; i < relays.length; i++) {
|
||||
const url = normalizeURL(relays[i])
|
||||
if (!request.find(r => r.url === url)) {
|
||||
request.push({ url, filter: filter })
|
||||
}
|
||||
}
|
||||
|
||||
return this.subscribeMap(request, params)
|
||||
}
|
||||
|
||||
subscribeManyMap(requests: { [relay: string]: Filter[] }, params: SubscribeManyParams): SubCloser {
|
||||
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 {
|
||||
params.onauth = params.onauth || params.doauth
|
||||
|
||||
const grouped = new Map<string, Filter[]>()
|
||||
for (const req of requests) {
|
||||
const { url, filter } = req
|
||||
if (!grouped.has(url)) grouped.set(url, [])
|
||||
grouped.get(url)!.push(filter)
|
||||
}
|
||||
const groupedRequests = Array.from(grouped.entries()).map(([url, filters]) => ({ url, filters }))
|
||||
|
||||
if (this.trackRelays) {
|
||||
params.receivedEvent = (relay: AbstractRelay, id: string) => {
|
||||
let set = this.seenOn.get(id)
|
||||
@@ -78,13 +130,13 @@ export class AbstractSimplePool {
|
||||
|
||||
const _knownIds = new Set<string>()
|
||||
const subs: Subscription[] = []
|
||||
const relaysLength = Object.keys(requests).length
|
||||
|
||||
// batch all EOSEs into a single
|
||||
const eosesReceived: boolean[] = []
|
||||
let handleEose = (i: number) => {
|
||||
if (eosesReceived[i]) return // do not act twice for the same relay
|
||||
eosesReceived[i] = true
|
||||
if (eosesReceived.filter(a => a).length === relaysLength) {
|
||||
if (eosesReceived.filter(a => a).length === groupedRequests.length) {
|
||||
params.oneose?.()
|
||||
handleEose = () => {}
|
||||
}
|
||||
@@ -92,9 +144,10 @@ export class AbstractSimplePool {
|
||||
// batch all closes into a single
|
||||
const closesReceived: string[] = []
|
||||
let handleClose = (i: number, reason: string) => {
|
||||
if (closesReceived[i]) return // do not act twice for the same relay
|
||||
handleEose(i)
|
||||
closesReceived[i] = reason
|
||||
if (closesReceived.filter(a => a).length === relaysLength) {
|
||||
if (closesReceived.filter(a => a).length === groupedRequests.length) {
|
||||
params.onclose?.(closesReceived)
|
||||
handleClose = () => {}
|
||||
}
|
||||
@@ -111,16 +164,7 @@ export class AbstractSimplePool {
|
||||
|
||||
// open a subscription in all given relays
|
||||
const allOpened = Promise.all(
|
||||
Object.entries(requests).map(async (req, i, arr) => {
|
||||
if (arr.indexOf(req) !== i) {
|
||||
// duplicate
|
||||
handleClose(i, 'duplicate url')
|
||||
return
|
||||
}
|
||||
|
||||
let [url, filters] = req
|
||||
url = normalizeURL(url)
|
||||
|
||||
groupedRequests.map(async ({ url, filters }, i) => {
|
||||
let relay: AbstractRelay
|
||||
try {
|
||||
relay = await this.ensureRelay(url, {
|
||||
@@ -134,7 +178,28 @@ export class AbstractSimplePool {
|
||||
let subscription = relay.subscribe(filters, {
|
||||
...params,
|
||||
oneose: () => handleEose(i),
|
||||
onclose: reason => handleClose(i, reason),
|
||||
onclose: reason => {
|
||||
if (reason.startsWith('auth-required: ') && params.onauth) {
|
||||
relay
|
||||
.auth(params.onauth)
|
||||
.then(() => {
|
||||
relay.subscribe(filters, {
|
||||
...params,
|
||||
oneose: () => handleEose(i),
|
||||
onclose: reason => {
|
||||
handleClose(i, reason) // the second time we won't try to auth anymore
|
||||
},
|
||||
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||
eoseTimeout: params.maxWait,
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
handleClose(i, `auth was required and attempted, but failed with: ${err}`)
|
||||
})
|
||||
} else {
|
||||
handleClose(i, reason)
|
||||
}
|
||||
},
|
||||
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||
eoseTimeout: params.maxWait,
|
||||
})
|
||||
@@ -144,24 +209,42 @@ export class AbstractSimplePool {
|
||||
)
|
||||
|
||||
return {
|
||||
async close() {
|
||||
async close(reason?: string) {
|
||||
await allOpened
|
||||
subs.forEach(sub => {
|
||||
sub.close()
|
||||
sub.close(reason)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
subscribeManyEose(
|
||||
subscribeEose(
|
||||
relays: string[],
|
||||
filters: Filter[],
|
||||
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>,
|
||||
filter: Filter,
|
||||
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
|
||||
): SubCloser {
|
||||
const subcloser = this.subscribeMany(relays, filters, {
|
||||
params.onauth = params.onauth || params.doauth
|
||||
|
||||
const subcloser = this.subscribe(relays, filter, {
|
||||
...params,
|
||||
oneose() {
|
||||
subcloser.close()
|
||||
subcloser.close('closed automatically on eose')
|
||||
},
|
||||
})
|
||||
return subcloser
|
||||
}
|
||||
|
||||
subscribeManyEose(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
|
||||
): SubCloser {
|
||||
params.onauth = params.onauth || params.doauth
|
||||
|
||||
const subcloser = this.subscribeMany(relays, filter, {
|
||||
...params,
|
||||
oneose() {
|
||||
subcloser.close('closed automatically on eose')
|
||||
},
|
||||
})
|
||||
return subcloser
|
||||
@@ -170,11 +253,11 @@ export class AbstractSimplePool {
|
||||
async querySync(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
|
||||
): Promise<Event[]> {
|
||||
return new Promise(async resolve => {
|
||||
const events: Event[] = []
|
||||
this.subscribeManyEose(relays, [filter], {
|
||||
this.subscribeEose(relays, filter, {
|
||||
...params,
|
||||
onevent(event: Event) {
|
||||
events.push(event)
|
||||
@@ -189,7 +272,7 @@ export class AbstractSimplePool {
|
||||
async get(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
|
||||
): Promise<Event | null> {
|
||||
filter.limit = 1
|
||||
const events = await this.querySync(relays, filter, params)
|
||||
@@ -197,7 +280,11 @@ export class AbstractSimplePool {
|
||||
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) => {
|
||||
if (arr.indexOf(url) !== i) {
|
||||
// duplicate
|
||||
@@ -205,17 +292,26 @@ export class AbstractSimplePool {
|
||||
}
|
||||
|
||||
let r = await this.ensureRelay(url)
|
||||
return r.publish(event).then(reason => {
|
||||
if (this.trackRelays) {
|
||||
let set = this.seenOn.get(event.id)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.seenOn.set(event.id, set)
|
||||
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
|
||||
}
|
||||
set.add(r)
|
||||
}
|
||||
return reason
|
||||
})
|
||||
throw err
|
||||
})
|
||||
.then(reason => {
|
||||
if (this.trackRelays) {
|
||||
let set = this.seenOn.get(event.id)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.seenOn.set(event.id, set)
|
||||
}
|
||||
set.add(r)
|
||||
}
|
||||
return reason
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
/* 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 { getHex64, getSubscriptionId } from './fakejson.ts'
|
||||
import { Queue, normalizeURL } from './utils.ts'
|
||||
import { makeAuthEvent } from './nip42.ts'
|
||||
import { yieldThread } from './helpers.ts'
|
||||
|
||||
type RelayWebSocket = WebSocket & {
|
||||
ping?(): void
|
||||
on?(event: 'pong', listener: () => void): any
|
||||
}
|
||||
|
||||
export type AbstractRelayConstructorOptions = {
|
||||
verifyEvent: Nostr['verifyEvent']
|
||||
websocketImplementation?: typeof WebSocket
|
||||
enablePing?: boolean
|
||||
enableReconnect?: boolean | ((filters: Filter[]) => Filter[])
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -19,22 +33,29 @@ export class AbstractRelay {
|
||||
public onclose: (() => void) | null = null
|
||||
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 connectionTimeout: number = 4400
|
||||
public publishTimeout: number = 4400
|
||||
public pingFrequency: number = 20000
|
||||
public pingTimeout: number = 20000
|
||||
public resubscribeBackoff: number[] = [10000, 10000, 10000, 20000, 20000, 30000, 60000]
|
||||
public openSubs: Map<string, Subscription> = new Map()
|
||||
public enablePing: boolean | undefined
|
||||
public enableReconnect: boolean | ((filters: Filter[]) => Filter[])
|
||||
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
private reconnectTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
private pingTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
private reconnectAttempts: number = 0
|
||||
private closedIntentionally: boolean = false
|
||||
|
||||
private connectionPromise: Promise<void> | undefined
|
||||
private openCountRequests = new Map<string, CountResolver>()
|
||||
private openEventPublishes = new Map<string, EventPublishResolver>()
|
||||
private ws: WebSocket | undefined
|
||||
private ws: RelayWebSocket | undefined
|
||||
private incomingMessageQueue = new Queue<string>()
|
||||
private queueRunning = false
|
||||
private challenge: string | undefined
|
||||
private authPromise: Promise<string> | undefined
|
||||
private serial: number = 0
|
||||
private verifyEvent: Nostr['verifyEvent']
|
||||
|
||||
@@ -44,6 +65,8 @@ export class AbstractRelay {
|
||||
this.url = normalizeURL(url)
|
||||
this.verifyEvent = opts.verifyEvent
|
||||
this._WebSocket = opts.websocketImplementation || WebSocket
|
||||
this.enablePing = opts.enablePing
|
||||
this.enableReconnect = opts.enableReconnect || false
|
||||
}
|
||||
|
||||
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
|
||||
@@ -73,10 +96,45 @@ export class AbstractRelay {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
private async reconnect(): Promise<void> {
|
||||
const backoff = this.resubscribeBackoff[Math.min(this.reconnectAttempts, this.resubscribeBackoff.length - 1)]
|
||||
this.reconnectAttempts++
|
||||
|
||||
this.reconnectTimeoutHandle = setTimeout(async () => {
|
||||
try {
|
||||
await this.connect()
|
||||
} catch (err) {
|
||||
// this will be called again through onclose/onerror
|
||||
}
|
||||
}, backoff)
|
||||
}
|
||||
|
||||
private handleHardClose(reason: string) {
|
||||
if (this.pingTimeoutHandle) {
|
||||
clearTimeout(this.pingTimeoutHandle)
|
||||
this.pingTimeoutHandle = undefined
|
||||
}
|
||||
|
||||
this._connected = false
|
||||
this.connectionPromise = undefined
|
||||
|
||||
const wasIntentional = this.closedIntentionally
|
||||
this.closedIntentionally = false // reset for next time
|
||||
|
||||
this.onclose?.()
|
||||
|
||||
if (this.enableReconnect && !wasIntentional) {
|
||||
this.reconnect()
|
||||
} else {
|
||||
this.closeAllSubscriptions(reason)
|
||||
}
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connectionPromise) return this.connectionPromise
|
||||
|
||||
this.challenge = undefined
|
||||
this.authPromise = undefined
|
||||
this.connectionPromise = new Promise((resolve, reject) => {
|
||||
this.connectionTimeoutHandle = setTimeout(() => {
|
||||
reject('connection timed out')
|
||||
@@ -88,33 +146,45 @@ export class AbstractRelay {
|
||||
try {
|
||||
this.ws = new this._WebSocket(this.url)
|
||||
} catch (err) {
|
||||
clearTimeout(this.connectionTimeoutHandle)
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
if (this.reconnectTimeoutHandle) {
|
||||
clearTimeout(this.reconnectTimeoutHandle)
|
||||
this.reconnectTimeoutHandle = undefined
|
||||
}
|
||||
clearTimeout(this.connectionTimeoutHandle)
|
||||
this._connected = true
|
||||
this.reconnectAttempts = 0
|
||||
|
||||
// resubscribe to all open subscriptions
|
||||
for (const sub of this.openSubs.values()) {
|
||||
sub.eosed = false
|
||||
if (typeof this.enableReconnect === 'function') {
|
||||
sub.filters = this.enableReconnect(sub.filters)
|
||||
}
|
||||
sub.fire()
|
||||
}
|
||||
|
||||
if (this.enablePing) {
|
||||
this.pingpong()
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.ws.onerror = ev => {
|
||||
clearTimeout(this.connectionTimeoutHandle)
|
||||
reject((ev as any).message || 'websocket error')
|
||||
if (this._connected) {
|
||||
this._connected = false
|
||||
this.connectionPromise = undefined
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection errored')
|
||||
}
|
||||
this.handleHardClose('relay connection errored')
|
||||
}
|
||||
|
||||
this.ws.onclose = async () => {
|
||||
if (this._connected) {
|
||||
this._connected = false
|
||||
this.connectionPromise = undefined
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection closed')
|
||||
}
|
||||
this.ws.onclose = ev => {
|
||||
clearTimeout(this.connectionTimeoutHandle)
|
||||
reject((ev as any).message || 'websocket closed')
|
||||
this.handleHardClose('relay connection closed')
|
||||
}
|
||||
|
||||
this.ws.onmessage = this._onmessage.bind(this)
|
||||
@@ -123,6 +193,52 @@ export class AbstractRelay {
|
||||
return this.connectionPromise
|
||||
}
|
||||
|
||||
private waitForPingPong() {
|
||||
return new Promise(resolve => {
|
||||
// listen for pong
|
||||
;(this.ws as any).once('pong', () => resolve(true))
|
||||
// send a 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 as any).once ? this.waitForPingPong() : this.waitForDummyReq(),
|
||||
new Promise(res => setTimeout(() => res(false), this.pingTimeout)),
|
||||
])
|
||||
if (result) {
|
||||
// schedule another pingpong
|
||||
this.pingTimeoutHandle = setTimeout(() => this.pingpong(), this.pingFrequency)
|
||||
} else {
|
||||
// pingpong closing socket
|
||||
if (this.ws?.readyState === this._WebSocket.OPEN) {
|
||||
this.ws?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runQueue() {
|
||||
this.queueRunning = true
|
||||
while (true) {
|
||||
@@ -172,7 +288,7 @@ export class AbstractRelay {
|
||||
switch (data[0]) {
|
||||
case 'EVENT': {
|
||||
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)) {
|
||||
so.onevent(event)
|
||||
}
|
||||
@@ -200,6 +316,7 @@ export class AbstractRelay {
|
||||
const reason: string = data[3]
|
||||
const ep = this.openEventPublishes.get(id) as EventPublishResolver
|
||||
if (ep) {
|
||||
clearTimeout(ep.timeout)
|
||||
if (ok) ep.resolve(reason)
|
||||
else ep.reject(new Error(reason))
|
||||
this.openEventPublishes.delete(id)
|
||||
@@ -219,7 +336,6 @@ export class AbstractRelay {
|
||||
return
|
||||
case 'AUTH': {
|
||||
this.challenge = data[1] as string
|
||||
this._onauth?.(data[1] as string)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -229,7 +345,7 @@ export class AbstractRelay {
|
||||
}
|
||||
|
||||
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.ws?.send(message)
|
||||
@@ -237,27 +353,41 @@ export class AbstractRelay {
|
||||
}
|
||||
|
||||
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> {
|
||||
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
||||
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
|
||||
const ret = new Promise<string>((resolve, reject) => {
|
||||
this.openEventPublishes.set(evt.id, { resolve, reject })
|
||||
const challenge = this.challenge
|
||||
if (!challenge) throw new Error("can't perform auth, no challenge was received")
|
||||
if (this.authPromise) return this.authPromise
|
||||
|
||||
this.authPromise = new Promise<string>(async (resolve, reject) => {
|
||||
try {
|
||||
let evt = await signAuthEvent(makeAuthEvent(this.url, challenge))
|
||||
let timeout = setTimeout(() => {
|
||||
let ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
|
||||
if (ep) {
|
||||
ep.reject(new Error('auth timed out'))
|
||||
this.openEventPublishes.delete(evt.id)
|
||||
}
|
||||
}, this.publishTimeout)
|
||||
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
|
||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||
} catch (err) {
|
||||
console.warn('subscribe auth function failed:', err)
|
||||
}
|
||||
})
|
||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||
return ret
|
||||
return this.authPromise
|
||||
}
|
||||
|
||||
public async publish(event: Event): Promise<string> {
|
||||
const ret = new Promise<string>((resolve, reject) => {
|
||||
this.openEventPublishes.set(event.id, { resolve, reject })
|
||||
const timeout = setTimeout(() => {
|
||||
const ep = this.openEventPublishes.get(event.id) as EventPublishResolver
|
||||
if (ep) {
|
||||
ep.reject(new Error('publish timed out'))
|
||||
this.openEventPublishes.delete(event.id)
|
||||
}
|
||||
}, this.publishTimeout)
|
||||
this.openEventPublishes.set(event.id, { resolve, reject, timeout })
|
||||
})
|
||||
this.send('["EVENT",' + JSON.stringify(event) + ']')
|
||||
setTimeout(() => {
|
||||
const ep = this.openEventPublishes.get(event.id) as EventPublishResolver
|
||||
if (ep) {
|
||||
ep.reject(new Error('publish timed out'))
|
||||
this.openEventPublishes.delete(event.id)
|
||||
}
|
||||
}, this.publishTimeout)
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -271,24 +401,42 @@ export class AbstractRelay {
|
||||
return ret
|
||||
}
|
||||
|
||||
public subscribe(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
|
||||
public subscribe(
|
||||
filters: Filter[],
|
||||
params: Partial<SubscriptionParams> & { label?: string; id?: string },
|
||||
): Subscription {
|
||||
const subscription = this.prepareSubscription(filters, params)
|
||||
subscription.fire()
|
||||
return subscription
|
||||
}
|
||||
|
||||
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
|
||||
public prepareSubscription(
|
||||
filters: Filter[],
|
||||
params: Partial<SubscriptionParams> & { label?: string; id?: string },
|
||||
): Subscription {
|
||||
this.serial++
|
||||
const id = params.id || 'sub:' + this.serial
|
||||
const id = params.id || (params.label ? params.label + ':' : 'sub:') + this.serial
|
||||
const subscription = new Subscription(this, id, filters, params)
|
||||
this.openSubs.set(id, subscription)
|
||||
return subscription
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.closedIntentionally = true
|
||||
if (this.reconnectTimeoutHandle) {
|
||||
clearTimeout(this.reconnectTimeoutHandle)
|
||||
this.reconnectTimeoutHandle = undefined
|
||||
}
|
||||
if (this.pingTimeoutHandle) {
|
||||
clearTimeout(this.pingTimeoutHandle)
|
||||
this.pingTimeoutHandle = undefined
|
||||
}
|
||||
this.closeAllSubscriptions('relay connection closed by us')
|
||||
this._connected = false
|
||||
this.ws?.close()
|
||||
this.onclose?.()
|
||||
if (this.ws?.readyState === this._WebSocket.OPEN) {
|
||||
this.ws?.close()
|
||||
}
|
||||
}
|
||||
|
||||
// this is the function assigned to this.ws.onmessage
|
||||
@@ -356,7 +504,15 @@ export class Subscription {
|
||||
if (!this.closed && this.relay.connected) {
|
||||
// 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
|
||||
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
|
||||
try {
|
||||
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.relay.openSubs.delete(this.id)
|
||||
@@ -381,4 +537,5 @@ export type CountResolver = {
|
||||
export type EventPublishResolver = {
|
||||
resolve: (reason: string) => void
|
||||
reject: (err: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
2
core.ts
2
core.ts
@@ -43,7 +43,7 @@ export function validateEvent<T>(event: T): event is T & UnsignedEvent {
|
||||
let tag = event.tags[i]
|
||||
if (!Array.isArray(tag)) return false
|
||||
for (let j = 0; j < tag.length; j++) {
|
||||
if (typeof tag[j] === 'object') return false
|
||||
if (typeof tag[j] !== 'string') return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Event } from './core.ts'
|
||||
import { isParameterizedReplaceableKind, isReplaceableKind } from './kinds.ts'
|
||||
import { isAddressableKind, isReplaceableKind } from './kinds.ts'
|
||||
|
||||
export type Filter = {
|
||||
ids?: string[]
|
||||
@@ -98,7 +98,7 @@ export function getFilterLimit(filter: Filter): number {
|
||||
: Infinity,
|
||||
|
||||
// Parameterized replaceable events are limited by the number of authors, kinds, and "d" tags.
|
||||
filter.authors?.length && filter.kinds?.every(kind => isParameterizedReplaceableKind(kind)) && filter['#d']?.length
|
||||
filter.authors?.length && filter.kinds?.every(kind => isAddressableKind(kind)) && filter['#d']?.length
|
||||
? filter.authors.length * filter.kinds.length * filter['#d'].length
|
||||
: Infinity,
|
||||
)
|
||||
|
||||
2
index.ts
2
index.ts
@@ -9,6 +9,7 @@ export * as nip05 from './nip05.ts'
|
||||
export * as nip10 from './nip10.ts'
|
||||
export * as nip11 from './nip11.ts'
|
||||
export * as nip13 from './nip13.ts'
|
||||
export * as nip17 from './nip17.ts'
|
||||
export * as nip18 from './nip18.ts'
|
||||
export * as nip19 from './nip19.ts'
|
||||
export * as nip21 from './nip21.ts'
|
||||
@@ -20,6 +21,7 @@ export * as nip39 from './nip39.ts'
|
||||
export * as nip42 from './nip42.ts'
|
||||
export * as nip44 from './nip44.ts'
|
||||
export * as nip47 from './nip47.ts'
|
||||
export * as nip54 from './nip54.ts'
|
||||
export * as nip57 from './nip57.ts'
|
||||
export * as nip59 from './nip59.ts'
|
||||
export * as nip98 from './nip98.ts'
|
||||
|
||||
8
jsr.json
8
jsr.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nostr/tools",
|
||||
"version": "2.10.1",
|
||||
"version": "2.17.2",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./core": "./core.ts",
|
||||
@@ -34,15 +34,17 @@
|
||||
"./nip44": "./nip44.ts",
|
||||
"./nip46": "./nip46.ts",
|
||||
"./nip49": "./nip49.ts",
|
||||
"./nip54": "./nip54.ts",
|
||||
"./nip57": "./nip57.ts",
|
||||
"./nip58": "./nip58.ts",
|
||||
"./nip59": "./nip59.ts",
|
||||
"./nip75": "./nip75.ts",
|
||||
"./nip94": "./nip94.ts",
|
||||
"./nip96": "./nip96.ts",
|
||||
"./nip98": "./nip98.ts",
|
||||
"./nip99": "./nip99.ts",
|
||||
"./nipb7": "./nipb7.ts",
|
||||
"./fakejson": "./fakejson.ts",
|
||||
"./utils": "./utils.ts"
|
||||
"./utils": "./utils.ts",
|
||||
"./signer": "./signer.ts"
|
||||
}
|
||||
}
|
||||
|
||||
6
justfile
6
justfile
@@ -12,6 +12,12 @@ test-only file:
|
||||
bun test {{file}}
|
||||
|
||||
publish: build
|
||||
# publish to jsr first because it is more strict and will catch some errors
|
||||
perl -i -0pe "s/},\n \"optionalDependencies\": {\n/,/" package.json
|
||||
jsr publish --allow-dirty
|
||||
git checkout -- package.json
|
||||
|
||||
# then to npm
|
||||
npm publish
|
||||
|
||||
format:
|
||||
|
||||
6
kinds.ts
6
kinds.ts
@@ -15,8 +15,8 @@ export function isEphemeralKind(kind: number): boolean {
|
||||
return 20000 <= kind && kind < 30000
|
||||
}
|
||||
|
||||
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
|
||||
export function isParameterizedReplaceableKind(kind: number): boolean {
|
||||
/** Events are **addressable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
|
||||
export function isAddressableKind(kind: number): boolean {
|
||||
return 30000 <= kind && kind < 40000
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function classifyKind(kind: number): KindClassification {
|
||||
if (isRegularKind(kind)) return 'regular'
|
||||
if (isReplaceableKind(kind)) return 'replaceable'
|
||||
if (isEphemeralKind(kind)) return 'ephemeral'
|
||||
if (isParameterizedReplaceableKind(kind)) return 'parameterized'
|
||||
if (isAddressableKind(kind)) return 'parameterized'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
|
||||
4
nip04.ts
4
nip04.ts
@@ -5,7 +5,7 @@ import { base64 } from '@scure/base'
|
||||
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
export async function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): Promise<string> {
|
||||
export function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): string {
|
||||
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
const normalizedKey = getNormalizedX(key)
|
||||
@@ -21,7 +21,7 @@ export async function encrypt(secretKey: string | Uint8Array, pubkey: string, te
|
||||
return `${ctb64}?iv=${ivb64}`
|
||||
}
|
||||
|
||||
export async function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): Promise<string> {
|
||||
export function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): string {
|
||||
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||
let [ctb64, ivb64] = data.split('?iv=')
|
||||
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
|
||||
6
nip07.ts
6
nip07.ts
@@ -1,10 +1,8 @@
|
||||
import { EventTemplate, NostrEvent } from './core.ts'
|
||||
import { RelayRecord } from './relay.ts'
|
||||
import { EventTemplate, VerifiedEvent } from './core.ts'
|
||||
|
||||
export interface WindowNostr {
|
||||
getPublicKey(): Promise<string>
|
||||
signEvent(event: EventTemplate): Promise<NostrEvent>
|
||||
getRelays(): Promise<RelayRecord>
|
||||
signEvent(event: EventTemplate): Promise<VerifiedEvent>
|
||||
nip04?: {
|
||||
encrypt(pubkey: string, plaintext: string): Promise<string>
|
||||
decrypt(pubkey: string, ciphertext: string): Promise<string>
|
||||
|
||||
187
nip10.test.ts
187
nip10.test.ts
@@ -5,20 +5,21 @@ describe('parse NIP10-referenced events', () => {
|
||||
test('legacy + a lot of events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
|
||||
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
|
||||
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
|
||||
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
|
||||
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
|
||||
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
|
||||
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
@@ -55,33 +56,80 @@ describe('parse NIP10-referenced events', () => {
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
root: {
|
||||
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
reply: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 3 events', () => {
|
||||
test('modern', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['e', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', '', 'root'],
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', '', 'reply'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
id: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
root: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
reply: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('modern, inverted, author hint', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', 'wss://goiaba.com'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', '', 'reply'],
|
||||
[
|
||||
'e',
|
||||
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
'wss://banana.com',
|
||||
'root',
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
],
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [
|
||||
{
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
@@ -96,98 +144,80 @@ describe('parse NIP10-referenced events', () => {
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
relays: ['wss://banana.com', 'wss://goiaba.com'],
|
||||
},
|
||||
],
|
||||
root: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: ['wss://banana.com', 'wss://goiaba.com'],
|
||||
author: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
},
|
||||
reply: {
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 2 events', () => {
|
||||
test('1 event, relay hint from author', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', 'wss://banana.com'],
|
||||
[
|
||||
'e',
|
||||
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
'',
|
||||
'root',
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
relays: ['wss://banana.com'],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: undefined,
|
||||
root: {
|
||||
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
relays: [],
|
||||
relays: ['wss://banana.com'],
|
||||
},
|
||||
root: {
|
||||
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
relays: ['wss://banana.com'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('recommended + 1 event', () => {
|
||||
test('many p 1 reply', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
|
||||
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
|
||||
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
|
||||
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
|
||||
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
|
||||
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
|
||||
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
|
||||
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'],
|
||||
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
|
||||
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
|
||||
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
|
||||
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
|
||||
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
|
||||
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
|
||||
[
|
||||
'e',
|
||||
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
'wss://relay.mostr.pub',
|
||||
'reply',
|
||||
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
],
|
||||
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
@@ -222,8 +252,13 @@ describe('parse NIP10-referenced events', () => {
|
||||
reply: {
|
||||
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
},
|
||||
root: {
|
||||
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
},
|
||||
root: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
152
nip10.ts
152
nip10.ts
@@ -1,7 +1,7 @@
|
||||
import type { Event } from './core.ts'
|
||||
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
||||
|
||||
export type NIP10Result = {
|
||||
export function parse(event: Pick<Event, 'tags'>): {
|
||||
/**
|
||||
* Pointer to the root of the thread.
|
||||
*/
|
||||
@@ -13,29 +13,80 @@ export type NIP10Result = {
|
||||
reply: EventPointer | undefined
|
||||
|
||||
/**
|
||||
* Pointers to events which may or may not be in the reply chain.
|
||||
* Pointers to events that may or may not be in the reply chain.
|
||||
*/
|
||||
mentions: EventPointer[]
|
||||
|
||||
/**
|
||||
* Pointers to events that were directly quoted.
|
||||
*/
|
||||
quotes: EventPointer[]
|
||||
|
||||
/**
|
||||
* List of pubkeys that are involved in the thread in no particular order.
|
||||
*/
|
||||
profiles: ProfilePointer[]
|
||||
}
|
||||
|
||||
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||
const result: NIP10Result = {
|
||||
} {
|
||||
const result: ReturnType<typeof parse> = {
|
||||
reply: undefined,
|
||||
root: undefined,
|
||||
mentions: [],
|
||||
profiles: [],
|
||||
quotes: [],
|
||||
}
|
||||
|
||||
const eTags: string[][] = []
|
||||
let maybeParent: EventPointer | undefined
|
||||
let maybeRoot: EventPointer | undefined
|
||||
|
||||
for (let i = event.tags.length - 1; i >= 0; i--) {
|
||||
const tag = event.tags[i]
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'e' && tag[1]) {
|
||||
eTags.push(tag)
|
||||
const [_, eTagEventId, eTagRelayUrl, eTagMarker, eTagAuthor] = tag as [
|
||||
string,
|
||||
string,
|
||||
undefined | string,
|
||||
undefined | string,
|
||||
undefined | string,
|
||||
]
|
||||
|
||||
const eventPointer: EventPointer = {
|
||||
id: eTagEventId,
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||
author: eTagAuthor,
|
||||
}
|
||||
|
||||
if (eTagMarker === 'root') {
|
||||
result.root = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (eTagMarker === 'reply') {
|
||||
result.reply = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (eTagMarker === 'mention') {
|
||||
result.mentions.push(eventPointer)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!maybeParent) {
|
||||
maybeParent = eventPointer
|
||||
} else {
|
||||
maybeRoot = eventPointer
|
||||
}
|
||||
|
||||
result.mentions.push(eventPointer)
|
||||
continue
|
||||
}
|
||||
|
||||
if (tag[0] === 'q' && tag[1]) {
|
||||
const [_, eTagEventId, eTagRelayUrl] = tag as [string, string, undefined | string]
|
||||
result.quotes.push({
|
||||
id: eTagEventId,
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||
})
|
||||
}
|
||||
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
@@ -43,49 +94,54 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||
pubkey: tag[1],
|
||||
relays: tag[2] ? [tag[2]] : [],
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
|
||||
const eTag = eTags[eTagIndex]
|
||||
|
||||
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [string, string, undefined | string, undefined | string]
|
||||
|
||||
const eventPointer: EventPointer = {
|
||||
id: eTagEventId,
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||
}
|
||||
|
||||
const isFirstETag = eTagIndex === 0
|
||||
const isLastETag = eTagIndex === eTags.length - 1
|
||||
|
||||
if (eTagMarker === 'root') {
|
||||
result.root = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (eTagMarker === 'reply') {
|
||||
result.reply = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (eTagMarker === 'mention') {
|
||||
result.mentions.push(eventPointer)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isFirstETag) {
|
||||
result.root = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLastETag) {
|
||||
result.reply = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
result.mentions.push(eventPointer)
|
||||
// get legacy (positional) markers, set reply to root and vice-versa if one of them is missing
|
||||
if (!result.root) {
|
||||
result.root = maybeRoot || maybeParent || result.reply
|
||||
}
|
||||
if (!result.reply) {
|
||||
result.reply = maybeParent || result.root
|
||||
}
|
||||
|
||||
// remove root and reply from mentions, inherit relay hints from authors if any
|
||||
;[result.reply, result.root].forEach(ref => {
|
||||
if (!ref) return
|
||||
|
||||
let idx = result.mentions.indexOf(ref)
|
||||
if (idx !== -1) {
|
||||
result.mentions.splice(idx, 1)
|
||||
}
|
||||
if (ref.author) {
|
||||
let author = result.profiles.find(p => p.pubkey === ref.author)
|
||||
if (author && author.relays) {
|
||||
if (!ref.relays) {
|
||||
ref.relays = []
|
||||
}
|
||||
author.relays.forEach(url => {
|
||||
if (ref.relays!?.indexOf(url) === -1) ref.relays!.push(url)
|
||||
})
|
||||
author.relays = ref.relays
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
result.mentions.forEach(ref => {
|
||||
if (ref!.author) {
|
||||
let author = result.profiles.find(p => p.pubkey === ref.author)
|
||||
if (author && author.relays) {
|
||||
if (!ref.relays) {
|
||||
ref.relays = []
|
||||
}
|
||||
author.relays.forEach(url => {
|
||||
if (ref.relays!.indexOf(url) === -1) ref.relays!.push(url)
|
||||
})
|
||||
author.relays = ref.relays
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ describe('requesting relay as for NIP11', () => {
|
||||
const info = await fetchRelayInformation('wss://nos.lol')
|
||||
expect(info.name).toEqual('nos.lol')
|
||||
expect(info.description).toContain('Generally accepts notes, except spammy ones.')
|
||||
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40])
|
||||
expect(info.supported_nips).toContain(1)
|
||||
expect(info.supported_nips).toContain(11)
|
||||
expect(info.supported_nips).toContain(70)
|
||||
expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git')
|
||||
})
|
||||
})
|
||||
|
||||
2
nip11.ts
2
nip11.ts
@@ -126,7 +126,7 @@ export interface Limitations {
|
||||
restricted_writes: boolean
|
||||
}
|
||||
|
||||
interface RetentionDetails {
|
||||
export interface RetentionDetails {
|
||||
kinds: (number | number[])[]
|
||||
time?: number | null
|
||||
count?: number | null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { Repost, ShortTextNote } from './kinds.ts'
|
||||
import { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } from './kinds.ts'
|
||||
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
@@ -86,6 +86,51 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
|
||||
})
|
||||
})
|
||||
|
||||
describe('GenericRepost', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: 1617932114,
|
||||
kind: BadgeDefinitionKind,
|
||||
tags: [
|
||||
['d', 'badge-id'],
|
||||
['name', 'Badge Name'],
|
||||
['description', 'Badge Description'],
|
||||
['image', 'https://example.com/badge.png', '1024x1024'],
|
||||
['thumb', 'https://example.com/thumb.png', '100x100'],
|
||||
['thumb', 'https://example.com/thumb2.png', '200x200'],
|
||||
],
|
||||
}
|
||||
|
||||
const repostedEvent = finalizeEvent(eventTemplate, privateKey)
|
||||
test('should create a generic reposted event', () => {
|
||||
const template = { created_at: 1617932115 }
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(GenericRepost)
|
||||
expect(event.tags).toEqual([
|
||||
['e', repostedEvent.id, relayUrl],
|
||||
['p', repostedEvent.pubkey],
|
||||
['k', '30009'],
|
||||
])
|
||||
expect(event.content).toEqual(JSON.stringify(repostedEvent))
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
|
||||
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
|
||||
const repostedEventFromContent = getRepostedEvent(event)
|
||||
|
||||
expect(repostedEventFromContent).toEqual(repostedEvent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRepostedEventPointer', () => {
|
||||
test('should parse an event with only an `e` tag', () => {
|
||||
const event = buildEvent({
|
||||
@@ -100,3 +145,26 @@ describe('getRepostedEventPointer', () => {
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
})
|
||||
})
|
||||
|
||||
describe('finishRepostEvent', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
|
||||
test('should create an event with empty content if the reposted event is protected', () => {
|
||||
const repostedEvent = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [['-']],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
const template = {
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.content).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
21
nip18.ts
21
nip18.ts
@@ -1,6 +1,6 @@
|
||||
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||
import { Repost } from './kinds.ts'
|
||||
import { GenericRepost, Repost, ShortTextNote } from './kinds.ts'
|
||||
import { EventPointer } from './nip19.ts'
|
||||
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||
|
||||
export type RepostEventTemplate = {
|
||||
/**
|
||||
@@ -25,11 +25,20 @@ export function finishRepostEvent(
|
||||
relayUrl: string,
|
||||
privateKey: Uint8Array,
|
||||
): Event {
|
||||
let kind: Repost | GenericRepost
|
||||
const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
|
||||
if (reposted.kind === ShortTextNote) {
|
||||
kind = Repost
|
||||
} else {
|
||||
kind = GenericRepost
|
||||
tags.push(['k', String(reposted.kind)])
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Repost,
|
||||
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
|
||||
content: t.content === '' ? '' : JSON.stringify(reposted),
|
||||
kind,
|
||||
tags,
|
||||
content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
@@ -37,7 +46,7 @@ export function finishRepostEvent(
|
||||
}
|
||||
|
||||
export function getRepostedEventPointer(event: Event): undefined | EventPointer {
|
||||
if (event.kind !== Repost) {
|
||||
if (![Repost, GenericRepost].includes(event.kind)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { test, expect, describe } from 'bun:test'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
// prettier-ignore
|
||||
import {
|
||||
decode,
|
||||
naddrEncode,
|
||||
neventEncode,
|
||||
NostrTypeGuard,
|
||||
nprofileEncode,
|
||||
npubEncode,
|
||||
nsecEncode,
|
||||
neventEncode,
|
||||
type AddressPointer,
|
||||
type ProfilePointer,
|
||||
EventPointer,
|
||||
NostrTypeGuard,
|
||||
nsecEncode
|
||||
} from './nip19.ts'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
|
||||
test('encode and decode nsec', () => {
|
||||
let sk = generateSecretKey()
|
||||
@@ -38,7 +36,7 @@ test('encode and decode nprofile', () => {
|
||||
expect(nprofile).toMatch(/nprofile1\w+/)
|
||||
let { type, data } = decode(nprofile)
|
||||
expect(type).toEqual('nprofile')
|
||||
const pointer = data as ProfilePointer
|
||||
const pointer = data
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.relays).toContain(relays[1])
|
||||
@@ -67,7 +65,7 @@ test('encode and decode naddr', () => {
|
||||
expect(naddr).toMatch(/naddr1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
const pointer = data
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.relays).toContain(relays[1])
|
||||
@@ -86,7 +84,7 @@ test('encode and decode nevent', () => {
|
||||
expect(nevent).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(nevent)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
const pointer = data
|
||||
expect(pointer.id).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
@@ -103,7 +101,7 @@ test('encode and decode nevent with kind 0', () => {
|
||||
expect(nevent).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(nevent)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
const pointer = data
|
||||
expect(pointer.id).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(0)
|
||||
@@ -121,7 +119,7 @@ test('encode and decode naddr with empty "d"', () => {
|
||||
expect(naddr).toMatch(/naddr\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
const pointer = data
|
||||
expect(pointer.identifier).toEqual('')
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(3)
|
||||
@@ -133,7 +131,7 @@ test('decode naddr from habla.news', () => {
|
||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
||||
)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
const pointer = data
|
||||
expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('references')
|
||||
@@ -145,7 +143,7 @@ test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||
)
|
||||
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
const pointer = data
|
||||
expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||
expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
|
||||
expect(pointer.relays).toContain('wss://nostr.banana.com')
|
||||
|
||||
62
nip19.ts
62
nip19.ts
@@ -61,28 +61,56 @@ export type AddressPointer = {
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
type Prefixes = {
|
||||
nprofile: ProfilePointer
|
||||
nevent: EventPointer
|
||||
naddr: AddressPointer
|
||||
nsec: Uint8Array
|
||||
npub: string
|
||||
note: string
|
||||
export function decodeNostrURI(nip19code: string): ReturnType<typeof decode> | { type: 'invalid'; data: null } {
|
||||
try {
|
||||
if (nip19code.startsWith('nostr:')) nip19code = nip19code.substring(6)
|
||||
return decode(nip19code)
|
||||
} catch (_err) {
|
||||
return { type: 'invalid', data: null }
|
||||
}
|
||||
}
|
||||
|
||||
type DecodeValue<Prefix extends keyof Prefixes> = {
|
||||
type: Prefix
|
||||
data: Prefixes[Prefix]
|
||||
export type DecodedNevent = {
|
||||
type: 'nevent'
|
||||
data: EventPointer
|
||||
}
|
||||
|
||||
export type DecodeResult = {
|
||||
[P in keyof Prefixes]: DecodeValue<P>
|
||||
}[keyof Prefixes]
|
||||
export type DecodedNprofile = {
|
||||
type: 'nprofile'
|
||||
data: ProfilePointer
|
||||
}
|
||||
|
||||
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
|
||||
export function decode(nip19: string): DecodeResult
|
||||
export function decode(nip19: string): DecodeResult {
|
||||
let { prefix, words } = bech32.decode(nip19, Bech32MaxSize)
|
||||
export type DecodedNaddr = {
|
||||
type: 'naddr'
|
||||
data: AddressPointer
|
||||
}
|
||||
|
||||
export type DecodedNsec = {
|
||||
type: 'nsec'
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
export type DecodedNpub = {
|
||||
type: 'npub'
|
||||
data: string
|
||||
}
|
||||
|
||||
export type DecodedNote = {
|
||||
type: 'note'
|
||||
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 data = new Uint8Array(bech32.fromWords(words))
|
||||
|
||||
switch (prefix) {
|
||||
|
||||
28
nip21.ts
28
nip21.ts
@@ -1,4 +1,4 @@
|
||||
import { BECH32_REGEX, decode, type DecodeResult } from './nip19.ts'
|
||||
import { AddressPointer, BECH32_REGEX, decode, EventPointer, ProfilePointer } from './nip19.ts'
|
||||
|
||||
/** Nostr URI regex, eg `nostr:npub1...` */
|
||||
export const NOSTR_URI_REGEX: RegExp = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||
@@ -15,7 +15,31 @@ export interface NostrURI {
|
||||
/** The bech32-encoded data (eg `npub1...`). */
|
||||
value: string
|
||||
/** Decoded bech32 string, according to NIP-19. */
|
||||
decoded: DecodeResult
|
||||
decoded:
|
||||
| {
|
||||
type: 'nevent'
|
||||
data: EventPointer
|
||||
}
|
||||
| {
|
||||
type: 'nprofile'
|
||||
data: ProfilePointer
|
||||
}
|
||||
| {
|
||||
type: 'naddr'
|
||||
data: AddressPointer
|
||||
}
|
||||
| {
|
||||
type: 'npub'
|
||||
data: string
|
||||
}
|
||||
| {
|
||||
type: 'nsec'
|
||||
data: Uint8Array
|
||||
}
|
||||
| {
|
||||
type: 'note'
|
||||
data: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse and decode a Nostr URI. */
|
||||
|
||||
117
nip27.test.ts
117
nip27.test.ts
@@ -1,68 +1,77 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchAll, replaceAll } from './nip27.ts'
|
||||
import { parse } from './nip27.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
test('first: parse simple content with 1 url and 1 nostr uri', () => {
|
||||
const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg`
|
||||
const blocks = Array.from(parse(content))
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
decoded: {
|
||||
type: 'npub',
|
||||
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||
},
|
||||
start: 6,
|
||||
end: 75,
|
||||
},
|
||||
{
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
decoded: {
|
||||
type: 'note',
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
},
|
||||
start: 78,
|
||||
end: 147,
|
||||
},
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
|
||||
{ type: 'text', text: ' check out my profile:' },
|
||||
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||
{ type: 'text', text: '; and this cool image ' },
|
||||
{ type: 'image', url: 'https://images.com/image.jpg' },
|
||||
])
|
||||
})
|
||||
|
||||
test('matchAll with an invalid nip19', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
test('second: parse content with 3 urls of different types', () => {
|
||||
const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music:
|
||||
http://music.com/song.mp3
|
||||
and a regular link: https://regular.com/page?ok=true. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally https://ok.com!`
|
||||
const blocks = Array.from(parse(content))
|
||||
|
||||
expect([...result]).toEqual([
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'text', text: ':' },
|
||||
{ type: 'relay', url: 'wss://oa.ao/' },
|
||||
{ type: 'text', text: "; this was a relay and now here's a video -> " },
|
||||
{ type: 'video', url: 'https://videos.com/video.mp4' },
|
||||
{ type: 'text', text: '! and some music:\n' },
|
||||
{ type: 'audio', url: 'http://music.com/song.mp3' },
|
||||
{ type: 'text', text: '\nand a regular link: ' },
|
||||
{ type: 'url', url: 'https://regular.com/page?ok=true' },
|
||||
{
|
||||
decoded: {
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
type: 'note',
|
||||
},
|
||||
end: 193,
|
||||
start: 124,
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
type: 'text',
|
||||
text: '. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally ',
|
||||
},
|
||||
{ type: 'url', url: 'https://ok.com/' },
|
||||
{ type: 'text', text: '!' },
|
||||
])
|
||||
})
|
||||
|
||||
test('replaceAll', () => {
|
||||
const content =
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
test('third: parse complex content with 4 nostr uris and 3 urls', () => {
|
||||
const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl
|
||||
here's an image https://example.com/pic.png and another profile nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s
|
||||
with a video https://example.com/vid.webm and finally https://example.com/docs`
|
||||
const blocks = Array.from(parse(content))
|
||||
|
||||
const result = replaceAll(content, ({ decoded, value }) => {
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
return '@alex'
|
||||
case 'note':
|
||||
return '!1234'
|
||||
default:
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual('Hello @alex!\n\n!1234')
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'text', text: 'Look at these profiles ' },
|
||||
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||
{ type: 'text', text: ' ' },
|
||||
{
|
||||
type: 'reference',
|
||||
pointer: {
|
||||
pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
|
||||
relays: ['wss://qwieu.com'],
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: ' check this event ' },
|
||||
{
|
||||
type: 'reference',
|
||||
pointer: {
|
||||
id: '37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba',
|
||||
relays: ['wss://zjbdksa.aswjdkn'],
|
||||
author: undefined,
|
||||
kind: undefined,
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: "\n here's an image " },
|
||||
{ type: 'image', url: 'https://example.com/pic.png' },
|
||||
{ type: 'text', text: ' and another profile ' },
|
||||
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||
{ type: 'text', text: '\n with a video ' },
|
||||
{ type: 'video', url: 'https://example.com/vid.webm' },
|
||||
{ type: 'text', text: ' and finally ' },
|
||||
{ type: 'url', url: 'https://example.com/docs' },
|
||||
])
|
||||
})
|
||||
|
||||
196
nip27.ts
196
nip27.ts
@@ -1,63 +1,153 @@
|
||||
import { decode } from './nip19.ts'
|
||||
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
|
||||
import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts'
|
||||
|
||||
/** Regex to find NIP-21 URIs inside event content. */
|
||||
export const regex = (): RegExp => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
||||
export type Block =
|
||||
| {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
type: 'reference'
|
||||
pointer: ProfilePointer | AddressPointer | EventPointer
|
||||
}
|
||||
| {
|
||||
type: 'url'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'relay'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'image'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'video'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'audio'
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Match result for a Nostr URI in event content. */
|
||||
export interface NostrURIMatch extends NostrURI {
|
||||
/** Index where the URI begins in the event content. */
|
||||
start: number
|
||||
/** Index where the URI ends in the event content. */
|
||||
end: number
|
||||
}
|
||||
const noCharacter = /\W/m
|
||||
const noURLCharacter = /\W |\W$|$|,| /m
|
||||
|
||||
/** Find and decode all NIP-21 URIs. */
|
||||
export function* matchAll(content: string): Iterable<NostrURIMatch> {
|
||||
const matches = content.matchAll(regex())
|
||||
export function* parse(content: string): Iterable<Block> {
|
||||
const max = content.length
|
||||
let prevIndex = 0
|
||||
let index = 0
|
||||
while (index < max) {
|
||||
let u = content.indexOf(':', index)
|
||||
if (u === -1) {
|
||||
// reached end
|
||||
break
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const [uri, value] = match
|
||||
if (content.substring(u - 5, u) === 'nostr') {
|
||||
const m = content.substring(u + 60).match(noCharacter)
|
||||
const end = m ? u + 60 + m.index! : max
|
||||
try {
|
||||
let pointer: ProfilePointer | AddressPointer | EventPointer
|
||||
let { data, type } = decode(content.substring(u + 1, end))
|
||||
|
||||
yield {
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value),
|
||||
start: match.index!,
|
||||
end: match.index! + uri.length,
|
||||
switch (type) {
|
||||
case 'npub':
|
||||
pointer = { pubkey: data } as ProfilePointer
|
||||
break
|
||||
case 'nsec':
|
||||
case 'note':
|
||||
// ignore this, treat it as not a valid uri
|
||||
index = end + 1
|
||||
continue
|
||||
default:
|
||||
pointer = data as any
|
||||
}
|
||||
|
||||
if (prevIndex !== u - 5) {
|
||||
yield { type: 'text', text: content.substring(prevIndex, u - 5) }
|
||||
}
|
||||
yield { type: 'reference', pointer }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
} catch (_err) {
|
||||
// ignore this, not a valid nostr uri
|
||||
index = u + 1
|
||||
continue
|
||||
}
|
||||
} catch (_e) {
|
||||
// do nothing
|
||||
} else if (content.substring(u - 5, u) === 'https' || content.substring(u - 4, u) === 'http') {
|
||||
const m = content.substring(u + 4).match(noURLCharacter)
|
||||
const end = m ? u + 4 + m.index! : max
|
||||
const prefixLen = content[u - 1] === 's' ? 5 : 4
|
||||
try {
|
||||
let url = new URL(content.substring(u - prefixLen, end))
|
||||
if (url.hostname.indexOf('.') === -1) {
|
||||
throw new Error('invalid url')
|
||||
}
|
||||
|
||||
if (prevIndex !== u - prefixLen) {
|
||||
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
|
||||
}
|
||||
|
||||
if (/\.(png|jpe?g|gif|webp)$/i.test(url.pathname)) {
|
||||
yield { type: 'image', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
}
|
||||
if (/\.(mp4|avi|webm|mkv)$/i.test(url.pathname)) {
|
||||
yield { type: 'video', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
}
|
||||
if (/\.(mp3|aac|ogg|opus)$/i.test(url.pathname)) {
|
||||
yield { type: 'audio', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
}
|
||||
|
||||
yield { type: 'url', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
} catch (_err) {
|
||||
// ignore this, not a valid url
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
} else if (content.substring(u - 3, u) === 'wss' || content.substring(u - 2, u) === 'ws') {
|
||||
const m = content.substring(u + 4).match(noURLCharacter)
|
||||
const end = m ? u + 4 + m.index! : max
|
||||
const prefixLen = content[u - 1] === 's' ? 3 : 2
|
||||
try {
|
||||
let url = new URL(content.substring(u - prefixLen, end))
|
||||
if (url.hostname.indexOf('.') === -1) {
|
||||
throw new Error('invalid ws url')
|
||||
}
|
||||
|
||||
if (prevIndex !== u - prefixLen) {
|
||||
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
|
||||
}
|
||||
yield { type: 'relay', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
} catch (_err) {
|
||||
// ignore this, not a valid url
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// ignore this, it is nothing
|
||||
index = u + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all occurrences of Nostr URIs in the text.
|
||||
*
|
||||
* WARNING: using this on an HTML string is potentially unsafe!
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* nip27.replaceAll(event.content, ({ decoded, value }) => {
|
||||
* switch(decoded.type) {
|
||||
* case 'npub':
|
||||
* return renderMention(decoded)
|
||||
* case 'note':
|
||||
* return renderNote(decoded)
|
||||
* default:
|
||||
* return value
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function replaceAll(content: string, replacer: (match: NostrURI) => string): string {
|
||||
return content.replaceAll(regex(), (uri, value: string) => {
|
||||
return replacer({
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value),
|
||||
})
|
||||
})
|
||||
if (prevIndex !== max) {
|
||||
yield { type: 'text', text: content.substring(prevIndex) }
|
||||
}
|
||||
}
|
||||
|
||||
14
nip29.ts
14
nip29.ts
@@ -2,7 +2,7 @@ import { AbstractSimplePool } from './abstract-pool.ts'
|
||||
import { Subscription } from './abstract-relay.ts'
|
||||
import type { Event, EventTemplate } from './core.ts'
|
||||
import { fetchRelayInformation, RelayInformation } from './nip11.ts'
|
||||
import { AddressPointer, decode } from './nip19.ts'
|
||||
import { decode, NostrTypeGuard } from './nip19.ts'
|
||||
import { normalizeURL } from './utils.ts'
|
||||
|
||||
/**
|
||||
@@ -58,13 +58,21 @@ export type GroupAdmin = {
|
||||
* Represents the permissions that a NIP29 group admin can have.
|
||||
*/
|
||||
export enum GroupAdminPermission {
|
||||
/** @deprecated use PutUser instead */
|
||||
AddUser = 'add-user',
|
||||
EditMetadata = 'edit-metadata',
|
||||
DeleteEvent = 'delete-event',
|
||||
RemoveUser = 'remove-user',
|
||||
/** @deprecated removed from NIP */
|
||||
AddPermission = 'add-permission',
|
||||
/** @deprecated removed from NIP */
|
||||
RemovePermission = 'remove-permission',
|
||||
/** @deprecated removed from NIP */
|
||||
EditGroupStatus = 'edit-group-status',
|
||||
PutUser = 'put-user',
|
||||
CreateGroup = 'create-group',
|
||||
DeleteGroup = 'delete-group',
|
||||
CreateInvite = 'create-invite',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -510,11 +518,11 @@ export async function loadGroupFromCode(pool: AbstractSimplePool, code: string):
|
||||
* @returns A GroupReference object if the code is valid, otherwise null.
|
||||
*/
|
||||
export function parseGroupCode(code: string): null | GroupReference {
|
||||
if (code.startsWith('naddr1')) {
|
||||
if (NostrTypeGuard.isNAddr(code)) {
|
||||
try {
|
||||
let { data } = decode(code)
|
||||
|
||||
let { relays, identifier } = data as AddressPointer
|
||||
let { relays, identifier } = data
|
||||
if (!relays || relays.length === 0) return null
|
||||
|
||||
let host = relays![0]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { v2 } from './nip44.js'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import { default as vec } from './nip44.vectors.json' assert { type: 'json' }
|
||||
import { default as vec } from './nip44.vectors.json' with { type: 'json' }
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
const v2vec = vec.v2
|
||||
|
||||
|
||||
259
nip46.ts
259
nip46.ts
@@ -1,12 +1,11 @@
|
||||
import { NostrEvent, UnsignedEvent, VerifiedEvent } from './core.ts'
|
||||
import { EventTemplate, NostrEvent, VerifiedEvent } from './core.ts'
|
||||
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
|
||||
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
|
||||
import { decrypt as legacyDecrypt } from './nip04.ts'
|
||||
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
|
||||
import { NIP05_REGEX } from './nip05.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
import { Handlerinformation, NostrConnect } from './kinds.ts'
|
||||
import type { RelayRecord } from './relay.ts'
|
||||
import { Signer } from './signer.ts'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
@@ -27,6 +26,17 @@ export type BunkerPointer = {
|
||||
secret: null | string
|
||||
}
|
||||
|
||||
export function toBunkerURL(bunkerPointer: BunkerPointer): string {
|
||||
let bunkerURL = new URL(`bunker://${bunkerPointer.pubkey}`)
|
||||
bunkerPointer.relays.forEach(relay => {
|
||||
bunkerURL.searchParams.append('relay', relay)
|
||||
})
|
||||
if (bunkerPointer.secret) {
|
||||
bunkerURL.searchParams.set('secret', bunkerPointer.secret)
|
||||
}
|
||||
return bunkerURL.toString()
|
||||
}
|
||||
|
||||
/** This takes either a bunker:// URL or a name@domain.com NIP-05 identifier
|
||||
and returns a BunkerPointer -- or null in case of error */
|
||||
export async function parseBunkerInput(input: string): Promise<BunkerPointer | null> {
|
||||
@@ -67,14 +77,123 @@ export async function queryBunkerProfile(nip05: string): Promise<BunkerPointer |
|
||||
}
|
||||
}
|
||||
|
||||
export type NostrConnectParams = {
|
||||
clientPubkey: string
|
||||
relays: string[]
|
||||
secret: string
|
||||
perms?: string[]
|
||||
name?: string
|
||||
url?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
export type ParsedNostrConnectURI = {
|
||||
protocol: 'nostrconnect'
|
||||
clientPubkey: string
|
||||
params: {
|
||||
relays: string[]
|
||||
secret: string
|
||||
perms?: string[]
|
||||
name?: string
|
||||
url?: string
|
||||
image?: string
|
||||
}
|
||||
originalString: string
|
||||
}
|
||||
|
||||
export function createNostrConnectURI(params: NostrConnectParams): string {
|
||||
if (!params.clientPubkey) {
|
||||
throw new Error('clientPubkey is required.')
|
||||
}
|
||||
if (!params.relays || params.relays.length === 0) {
|
||||
throw new Error('At least one relay is required.')
|
||||
}
|
||||
if (!params.secret) {
|
||||
throw new Error('secret is required.')
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
params.relays.forEach(relay => {
|
||||
queryParams.append('relay', relay)
|
||||
})
|
||||
|
||||
queryParams.append('secret', params.secret)
|
||||
|
||||
if (params.perms && params.perms.length > 0) {
|
||||
queryParams.append('perms', params.perms.join(','))
|
||||
}
|
||||
if (params.name) {
|
||||
queryParams.append('name', params.name)
|
||||
}
|
||||
if (params.url) {
|
||||
queryParams.append('url', params.url)
|
||||
}
|
||||
if (params.image) {
|
||||
queryParams.append('image', params.image)
|
||||
}
|
||||
|
||||
return `nostrconnect://${params.clientPubkey}?${queryParams.toString()}`
|
||||
}
|
||||
|
||||
export function parseNostrConnectURI(uri: string): ParsedNostrConnectURI {
|
||||
if (!uri.startsWith('nostrconnect://')) {
|
||||
throw new Error('Invalid nostrconnect URI: Must start with "nostrconnect://".')
|
||||
}
|
||||
|
||||
const [protocolAndPubkey, queryString] = uri.split('?')
|
||||
if (!protocolAndPubkey || !queryString) {
|
||||
throw new Error('Invalid nostrconnect URI: Missing query string.')
|
||||
}
|
||||
|
||||
const clientPubkey = protocolAndPubkey.substring('nostrconnect://'.length)
|
||||
if (!clientPubkey) {
|
||||
throw new Error('Invalid nostrconnect URI: Missing client-pubkey.')
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams(queryString)
|
||||
|
||||
const relays = queryParams.getAll('relay')
|
||||
if (relays.length === 0) {
|
||||
throw new Error('Invalid nostrconnect URI: Missing "relay" parameter.')
|
||||
}
|
||||
|
||||
const secret = queryParams.get('secret')
|
||||
if (!secret) {
|
||||
throw new Error('Invalid nostrconnect URI: Missing "secret" parameter.')
|
||||
}
|
||||
|
||||
const permsString = queryParams.get('perms')
|
||||
const perms = permsString ? permsString.split(',') : undefined
|
||||
|
||||
const name = queryParams.get('name') || undefined
|
||||
const url = queryParams.get('url') || undefined
|
||||
const image = queryParams.get('image') || undefined
|
||||
|
||||
return {
|
||||
protocol: 'nostrconnect',
|
||||
clientPubkey,
|
||||
params: {
|
||||
relays,
|
||||
secret,
|
||||
perms,
|
||||
name,
|
||||
url,
|
||||
image,
|
||||
},
|
||||
originalString: uri,
|
||||
}
|
||||
}
|
||||
|
||||
export type BunkerSignerParams = {
|
||||
pool?: AbstractSimplePool
|
||||
onauth?: (url: string) => void
|
||||
}
|
||||
|
||||
export class BunkerSigner {
|
||||
export class BunkerSigner implements Signer {
|
||||
private params: BunkerSignerParams
|
||||
private pool: AbstractSimplePool
|
||||
private subCloser: SubCloser
|
||||
private subCloser: SubCloser | undefined
|
||||
private isOpen: boolean
|
||||
private serial: number
|
||||
private idPrefix: string
|
||||
@@ -86,8 +205,9 @@ export class BunkerSigner {
|
||||
}
|
||||
private waitingForAuth: { [id: string]: boolean }
|
||||
private secretKey: Uint8Array
|
||||
private conversationKey: Uint8Array
|
||||
public bp: BunkerPointer
|
||||
// If the client initiates the connection, the two variables below can be filled in later.
|
||||
private conversationKey!: Uint8Array
|
||||
public bp!: BunkerPointer
|
||||
|
||||
private cachedPubKey: string | undefined
|
||||
|
||||
@@ -97,37 +217,108 @@ export class BunkerSigner {
|
||||
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
|
||||
* @param secretKey - An optional key pair.
|
||||
*/
|
||||
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) {
|
||||
if (bp.relays.length === 0) {
|
||||
throw new Error('no relays are specified for this bunker')
|
||||
}
|
||||
|
||||
private constructor(clientSecretKey: Uint8Array, params: BunkerSignerParams) {
|
||||
this.params = params
|
||||
this.pool = params.pool || new SimplePool()
|
||||
this.secretKey = clientSecretKey
|
||||
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
|
||||
this.bp = bp
|
||||
this.isOpen = false
|
||||
this.idPrefix = Math.random().toString(36).substring(7)
|
||||
this.serial = 0
|
||||
this.listeners = {}
|
||||
this.waitingForAuth = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* [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) {
|
||||
const listeners = this.listeners
|
||||
const waitingForAuth = this.waitingForAuth
|
||||
const convKey = this.conversationKey
|
||||
|
||||
this.subCloser = this.pool.subscribeMany(
|
||||
this.subCloser = this.pool.subscribe(
|
||||
this.bp.relays,
|
||||
[{ kinds: [NostrConnect], authors: [bp.pubkey], '#p': [getPublicKey(this.secretKey)] }],
|
||||
{ kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] },
|
||||
{
|
||||
async onevent(event: NostrEvent) {
|
||||
let o
|
||||
try {
|
||||
o = JSON.parse(decrypt(event.content, convKey))
|
||||
} catch (err) {
|
||||
o = JSON.parse(await legacyDecrypt(clientSecretKey, event.pubkey, event.content))
|
||||
}
|
||||
|
||||
onevent: async (event: NostrEvent) => {
|
||||
const o = JSON.parse(decrypt(event.content, convKey))
|
||||
const { id, result, error } = o
|
||||
|
||||
if (result === 'auth_url' && waitingForAuth[id]) {
|
||||
@@ -137,7 +328,7 @@ export class BunkerSigner {
|
||||
params.onauth(error)
|
||||
} else {
|
||||
console.warn(
|
||||
`nostr-tools/nip46: remote signer ${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.`,
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -150,6 +341,9 @@ export class BunkerSigner {
|
||||
delete listeners[id]
|
||||
}
|
||||
},
|
||||
onclose: () => {
|
||||
this.subCloser = undefined
|
||||
},
|
||||
},
|
||||
)
|
||||
this.isOpen = true
|
||||
@@ -158,13 +352,15 @@ export class BunkerSigner {
|
||||
// closes the subscription -- this object can't be used anymore after this
|
||||
async close() {
|
||||
this.isOpen = false
|
||||
this.subCloser.close()
|
||||
this.subCloser!.close()
|
||||
}
|
||||
|
||||
async sendRequest(method: string, params: string[]): Promise<string> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one')
|
||||
if (!this.subCloser) this.setupSubscription(this.params)
|
||||
|
||||
this.serial++
|
||||
const id = `${this.idPrefix}-${this.serial}`
|
||||
|
||||
@@ -222,19 +418,12 @@ export class BunkerSigner {
|
||||
return this.cachedPubKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the "get_relays" method on the bunker.
|
||||
*/
|
||||
async getRelays(): Promise<RelayRecord> {
|
||||
return JSON.parse(await this.sendRequest('get_relays', []))
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs an event using the remote private key.
|
||||
* @param event - The event to sign.
|
||||
* @returns A Promise that resolves to the signed event.
|
||||
*/
|
||||
async signEvent(event: UnsignedEvent): Promise<VerifiedEvent> {
|
||||
async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
|
||||
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
|
||||
let signed: NostrEvent = JSON.parse(resp)
|
||||
if (verifyEvent(signed)) {
|
||||
@@ -282,7 +471,7 @@ export async function createAccount(
|
||||
): Promise<BunkerSigner> {
|
||||
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
|
||||
|
||||
let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params)
|
||||
let rpc = BunkerSigner.fromBunker(localSecretKey, bunker.bunkerPointer, params)
|
||||
|
||||
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])
|
||||
|
||||
|
||||
@@ -5,6 +5,16 @@ import { decrypt } from './nip04.ts'
|
||||
import { NWCWalletRequest } from './kinds.ts'
|
||||
|
||||
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', () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
|
||||
6
nip47.ts
6
nip47.ts
@@ -9,8 +9,8 @@ interface NWCConnection {
|
||||
}
|
||||
|
||||
export function parseConnectionString(connectionString: string): NWCConnection {
|
||||
const { pathname, searchParams } = new URL(connectionString)
|
||||
const pubkey = pathname
|
||||
const { host, pathname, searchParams } = new URL(connectionString)
|
||||
const pubkey = pathname || host
|
||||
const relay = searchParams.get('relay')
|
||||
const secret = searchParams.get('secret')
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function makeNwcRequestEvent(
|
||||
invoice,
|
||||
},
|
||||
}
|
||||
const encryptedContent = await encrypt(secretKey, pubkey, JSON.stringify(content))
|
||||
const encryptedContent = encrypt(secretKey, pubkey, JSON.stringify(content))
|
||||
const eventTemplate = {
|
||||
kind: NWCWalletRequest,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
|
||||
42
nip54.test.ts
Normal file
42
nip54.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { normalizeIdentifier } from './nip54.ts'
|
||||
|
||||
describe('normalizeIdentifier', () => {
|
||||
test('converts to lowercase', () => {
|
||||
expect(normalizeIdentifier('HELLO')).toBe('hello')
|
||||
expect(normalizeIdentifier('MixedCase')).toBe('mixedcase')
|
||||
})
|
||||
|
||||
test('trims whitespace', () => {
|
||||
expect(normalizeIdentifier(' hello ')).toBe('hello')
|
||||
expect(normalizeIdentifier('\thello\n')).toBe('hello')
|
||||
})
|
||||
|
||||
test('normalizes Unicode to NFKC form', () => {
|
||||
// é can be represented as single char é (U+00E9) or e + ´ (U+0065 U+0301)
|
||||
expect(normalizeIdentifier('café')).toBe('café')
|
||||
expect(normalizeIdentifier('cafe\u0301')).toBe('café')
|
||||
})
|
||||
|
||||
test('replaces non-alphanumeric characters with hyphens', () => {
|
||||
expect(normalizeIdentifier('hello world')).toBe('hello-world')
|
||||
expect(normalizeIdentifier('user@example.com')).toBe('user-example-com')
|
||||
expect(normalizeIdentifier('$special#chars!')).toBe('-special-chars-')
|
||||
})
|
||||
|
||||
test('preserves numbers', () => {
|
||||
expect(normalizeIdentifier('user123')).toBe('user123')
|
||||
expect(normalizeIdentifier('2fast4you')).toBe('2fast4you')
|
||||
})
|
||||
|
||||
test('handles multiple consecutive special characters', () => {
|
||||
expect(normalizeIdentifier('hello!!!world')).toBe('hello---world')
|
||||
expect(normalizeIdentifier('multiple spaces')).toBe('multiple---spaces')
|
||||
})
|
||||
|
||||
test('handles Unicode letters from different scripts', () => {
|
||||
expect(normalizeIdentifier('привет')).toBe('привет')
|
||||
expect(normalizeIdentifier('こんにちは')).toBe('こんにちは')
|
||||
expect(normalizeIdentifier('مرحبا')).toBe('مرحبا')
|
||||
})
|
||||
})
|
||||
19
nip54.ts
Normal file
19
nip54.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function normalizeIdentifier(name: string): string {
|
||||
// Trim and lowercase
|
||||
name = name.trim().toLowerCase()
|
||||
|
||||
// Normalize Unicode to NFKC form
|
||||
name = name.normalize('NFKC')
|
||||
|
||||
// Convert to array of characters and map each one
|
||||
return Array.from(name)
|
||||
.map(char => {
|
||||
// Check if character is letter or number using Unicode ranges
|
||||
if (/\p{Letter}/u.test(char) || /\p{Number}/u.test(char)) {
|
||||
return char
|
||||
}
|
||||
|
||||
return '-'
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
166
nip55.test.ts
Normal file
166
nip55.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import * as nip55 from './nip55.js'
|
||||
|
||||
// Function to parse the NostrSigner URI
|
||||
function parseNostrSignerUri(uri: string) {
|
||||
const [base, query] = uri.split('?')
|
||||
const basePart = base.replace('nostrsigner:', '')
|
||||
|
||||
let jsonObject = null
|
||||
if (basePart) {
|
||||
try {
|
||||
jsonObject = JSON.parse(decodeURIComponent(basePart))
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse base JSON:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const urlSearchParams = new URLSearchParams(query)
|
||||
const queryParams = Object.fromEntries(urlSearchParams.entries())
|
||||
if (queryParams.permissions) {
|
||||
queryParams.permissions = JSON.parse(decodeURIComponent(queryParams.permissions))
|
||||
}
|
||||
|
||||
return {
|
||||
base: jsonObject,
|
||||
...queryParams,
|
||||
}
|
||||
}
|
||||
|
||||
// Test cases
|
||||
test('Get Public Key URI', () => {
|
||||
const permissions = [{ type: 'sign_event', kind: 22242 }, { type: 'nip44_decrypt' }]
|
||||
const callbackUrl = 'https://example.com/?event='
|
||||
|
||||
const uri = nip55.getPublicKeyUri({
|
||||
permissions,
|
||||
callbackUrl,
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'get_public_key')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('callbackUrl', 'https://example.com/?event=')
|
||||
expect(jsonObject).toHaveProperty('permissions[0].type', 'sign_event')
|
||||
expect(jsonObject).toHaveProperty('permissions[0].kind', 22242)
|
||||
expect(jsonObject).toHaveProperty('permissions[1].type', 'nip44_decrypt')
|
||||
})
|
||||
|
||||
test('Sign Event URI', () => {
|
||||
const eventJson = { kind: 1, content: 'test' }
|
||||
|
||||
const uri = nip55.signEventUri({
|
||||
eventJson,
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('base.kind', 1)
|
||||
expect(jsonObject).toHaveProperty('base.content', 'test')
|
||||
expect(jsonObject).toHaveProperty('type', 'sign_event')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
})
|
||||
|
||||
test('Encrypt NIP-04 URI', () => {
|
||||
const callbackUrl = 'https://example.com/?event='
|
||||
|
||||
const uri = nip55.encryptNip04Uri({
|
||||
callbackUrl,
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'plainText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip04_encrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('callbackUrl', callbackUrl)
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('plainText', 'plainText')
|
||||
})
|
||||
|
||||
test('Decrypt NIP-04 URI', () => {
|
||||
const uri = nip55.decryptNip04Uri({
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'encryptedText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip04_decrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
|
||||
})
|
||||
|
||||
test('Encrypt NIP-44 URI', () => {
|
||||
const uri = nip55.encryptNip44Uri({
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'plainText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip44_encrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('plainText', 'plainText')
|
||||
})
|
||||
|
||||
test('Decrypt NIP-44 URI', () => {
|
||||
const uri = nip55.decryptNip44Uri({
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'encryptedText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip44_decrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
|
||||
})
|
||||
|
||||
test('Decrypt Zap Event URI', () => {
|
||||
const eventJson = { kind: 1, content: 'test' }
|
||||
|
||||
const uri = nip55.decryptZapEventUri({
|
||||
eventJson,
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
returnType: 'event',
|
||||
compressionType: 'gzip',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'decrypt_zap_event')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'gzip')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'event')
|
||||
expect(jsonObject).toHaveProperty('base.kind', 1)
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
})
|
||||
123
nip55.ts
Normal file
123
nip55.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
type BaseParams = {
|
||||
callbackUrl?: string
|
||||
returnType?: 'signature' | 'event'
|
||||
compressionType?: 'none' | 'gzip'
|
||||
}
|
||||
|
||||
type PermissionsParams = BaseParams & {
|
||||
permissions?: { type: string; kind?: number }[]
|
||||
}
|
||||
|
||||
type EventUriParams = BaseParams & {
|
||||
eventJson: Record<string, unknown>
|
||||
id?: string
|
||||
currentUser?: string
|
||||
}
|
||||
|
||||
type EncryptDecryptParams = BaseParams & {
|
||||
pubKey: string
|
||||
content: string
|
||||
id?: string
|
||||
currentUser?: string
|
||||
}
|
||||
|
||||
type UriParams = BaseParams & {
|
||||
base: string
|
||||
type: string
|
||||
id?: string
|
||||
currentUser?: string
|
||||
permissions?: { type: string; kind?: number }[]
|
||||
pubKey?: string
|
||||
plainText?: string
|
||||
encryptedText?: string
|
||||
appName?: string
|
||||
}
|
||||
|
||||
function encodeParams(params: Record<string, unknown>): string {
|
||||
return new URLSearchParams(params as Record<string, string>).toString()
|
||||
}
|
||||
|
||||
function filterUndefined<T extends Record<string, unknown>>(obj: T): T {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T
|
||||
}
|
||||
|
||||
function buildUri({
|
||||
base,
|
||||
type,
|
||||
callbackUrl,
|
||||
returnType = 'signature',
|
||||
compressionType = 'none',
|
||||
...params
|
||||
}: UriParams): string {
|
||||
const baseParams = {
|
||||
type,
|
||||
compressionType,
|
||||
returnType,
|
||||
callbackUrl,
|
||||
id: params.id,
|
||||
current_user: params.currentUser,
|
||||
permissions:
|
||||
params.permissions && params.permissions.length > 0
|
||||
? encodeURIComponent(JSON.stringify(params.permissions))
|
||||
: undefined,
|
||||
pubKey: params.pubKey,
|
||||
plainText: params.plainText,
|
||||
encryptedText: params.encryptedText,
|
||||
appName: params.appName,
|
||||
}
|
||||
|
||||
const filteredParams = filterUndefined(baseParams)
|
||||
return `${base}?${encodeParams(filteredParams)}`
|
||||
}
|
||||
|
||||
function buildDefaultUri(type: string, params: Partial<UriParams>): string {
|
||||
return buildUri({
|
||||
base: 'nostrsigner:',
|
||||
type,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getPublicKeyUri({ permissions = [], ...params }: PermissionsParams): string {
|
||||
return buildDefaultUri('get_public_key', { permissions, ...params })
|
||||
}
|
||||
|
||||
export function signEventUri({ eventJson, ...params }: EventUriParams): string {
|
||||
return buildUri({
|
||||
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
|
||||
type: 'sign_event',
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
function encryptUri(type: 'nip44_encrypt' | 'nip04_encrypt', params: EncryptDecryptParams): string {
|
||||
return buildDefaultUri(type, { ...params, plainText: params.content })
|
||||
}
|
||||
|
||||
function decryptUri(type: 'nip44_decrypt' | 'nip04_decrypt', params: EncryptDecryptParams): string {
|
||||
return buildDefaultUri(type, { ...params, encryptedText: params.content })
|
||||
}
|
||||
|
||||
export function encryptNip04Uri(params: EncryptDecryptParams): string {
|
||||
return encryptUri('nip04_encrypt', params)
|
||||
}
|
||||
|
||||
export function decryptNip04Uri(params: EncryptDecryptParams): string {
|
||||
return decryptUri('nip04_decrypt', params)
|
||||
}
|
||||
|
||||
export function encryptNip44Uri(params: EncryptDecryptParams): string {
|
||||
return encryptUri('nip44_encrypt', params)
|
||||
}
|
||||
|
||||
export function decryptNip44Uri(params: EncryptDecryptParams): string {
|
||||
return decryptUri('nip44_decrypt', params)
|
||||
}
|
||||
|
||||
export function decryptZapEventUri({ eventJson, ...params }: EventUriParams): string {
|
||||
return buildUri({
|
||||
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
|
||||
type: 'decrypt_zap_event',
|
||||
...params,
|
||||
})
|
||||
}
|
||||
125
nip57.test.ts
125
nip57.test.ts
@@ -1,105 +1,7 @@
|
||||
import { describe, test, expect, mock } from 'bun:test'
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { finalizeEvent } from './pure.ts'
|
||||
import { getPublicKey, generateSecretKey } from './pure.ts'
|
||||
import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('getZapEndpoint', () => {
|
||||
test('returns null if neither lud06 nor lud16 is present', async () => {
|
||||
const metadata = buildEvent({ kind: 0, content: '{}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null if fetch fails', async () => {
|
||||
const fetchImplementation = mock(() => Promise.reject(new Error()))
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
|
||||
test('returns null if the response does not allow Nostr payments', async () => {
|
||||
const fetchImplementation = mock(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
|
||||
test('returns the callback URL if the response allows Nostr payments', async () => {
|
||||
const fetchImplementation = mock(() =>
|
||||
Promise.resolve({
|
||||
json: () => ({
|
||||
allowsNostr: true,
|
||||
nostrPubkey: 'pubkey',
|
||||
callback: 'callback',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBe('callback')
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeZapRequest', () => {
|
||||
test('throws an error if amount is not given', () => {
|
||||
expect(() =>
|
||||
// @ts-expect-error
|
||||
makeZapRequest({
|
||||
profile: 'profile',
|
||||
event: null,
|
||||
relays: [],
|
||||
comment: '',
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test('throws an error if profile is not given', () => {
|
||||
expect(() =>
|
||||
// @ts-expect-error
|
||||
makeZapRequest({
|
||||
event: null,
|
||||
amount: 100,
|
||||
relays: [],
|
||||
comment: '',
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test('returns a valid Zap request', () => {
|
||||
const result = makeZapRequest({
|
||||
profile: 'profile',
|
||||
event: 'event',
|
||||
amount: 100,
|
||||
relays: ['relay1', 'relay2'],
|
||||
comment: 'comment',
|
||||
})
|
||||
expect(result.kind).toBe(9734)
|
||||
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
|
||||
expect(result.content).toBe('comment')
|
||||
expect(result.tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
['p', 'profile'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
['e', 'event'],
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
import { getSatoshisAmountFromBolt11, makeZapReceipt, validateZapRequest } from './nip57.ts'
|
||||
|
||||
describe('validateZapRequest', () => {
|
||||
test('returns an error message for invalid JSON', () => {
|
||||
@@ -317,3 +219,26 @@ describe('makeZapReceipt', () => {
|
||||
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
||||
})
|
||||
})
|
||||
|
||||
test('parses the amount from bolt11 invoices', () => {
|
||||
expect(
|
||||
getSatoshisAmountFromBolt11(
|
||||
'lnbc4u1p5zcarnpp5djng98r73nxu66nxp6gndjkw24q7rdzgp7p80lt0gk4z3h3krkssdq9tfpygcqzzsxqzjcsp58hz3v5qefdm70g5fnm2cn6q9thzpu6m4f5wjqurhur5xzmf9vl3s9qxpqysgq9v6qv86xaruzeak9jjyz54fygrkn526z7xhm0llh8wl44gcgh0rznhjqdswd4cjurzdgh0pgzrfj4sd7f3mf89jd6kadse008ex7kxgqqa5xrk',
|
||||
),
|
||||
).toEqual(400)
|
||||
expect(
|
||||
getSatoshisAmountFromBolt11(
|
||||
'lnbc8400u1p5zcaz5pp5ltvyhtg4ed7sd8jurj28ugmavezkmqsadpe3t9npufpcrd0uet0scqzyssp5l3hz4ayt5ee0p83ma4a96l2rruhx33eyycewldu2ffa5pk2qx7jq9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdq8w3jhxaqmqz9gxqyjw5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glclll8qkt3np4rqyqqqqlgqqqqqeqqjqhuhjk5u9r850ncxngne7cfp9s08s2nm6c2rkz7jhl8gjmlx0fga5tlncgeuh4avlsrkq6ljyyhgq8rrxprga03esqhd0gf5455x6tdcqahhw9q',
|
||||
),
|
||||
).toEqual(840000)
|
||||
expect(
|
||||
getSatoshisAmountFromBolt11(
|
||||
'lnbc210n1p5zcuaxpp52nn778cfk46md4ld0hdj2juuzvfrsrdaf4ek2k0yeensae07x2cqdq9tfpygcqzzsxqzjcsp5768c4k79jtnq92pgppan8rjnujcpcqhnqwqwk3lm5dfr7e0k2a7s9qxpqysgqt8lnh9l7ple27t73x7gty570ltas2s33uahc7egke5tdmhxr3ezn590wf2utxyt7d3afnk2lxc2u0enc6n53ck4mxwpmzpxa7ws05aqp0c5x3r',
|
||||
),
|
||||
).toEqual(21)
|
||||
expect(
|
||||
getSatoshisAmountFromBolt11(
|
||||
'lnbc899640n1p5zcuavpp5w72fqrf09286lq33vw364qryrq5nw60z4dhdx56f8w05xkx4massdq9tfpygcqzzsxqzjcsp5qrqn4kpvem5jwpl63kj5pfdlqxg2plaffz0prz7vaqjy29uc66us9qxpqysgqlhzzqmn2jxd2476404krm8nvrarymwq7nj2zecl92xug54ek0mfntdxvxwslf756m8kq0r7jtpantm52fmewc72r5lfmd85505jnemgqw5j0pc',
|
||||
),
|
||||
).toEqual(89964)
|
||||
})
|
||||
|
||||
107
nip57.ts
107
nip57.ts
@@ -1,7 +1,8 @@
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
|
||||
import { NostrEvent, validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
|
||||
import { utf8Decoder } from './utils.ts'
|
||||
import { isReplaceableKind, isAddressableKind } from './kinds.ts'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
@@ -17,13 +18,13 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||
try {
|
||||
let lnurl: string = ''
|
||||
let { lud06, lud16 } = JSON.parse(metadata.content)
|
||||
if (lud06) {
|
||||
if (lud16) {
|
||||
let [name, domain] = lud16.split('@')
|
||||
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||
} else if (lud06) {
|
||||
let { words } = bech32.decode(lud06, 1000)
|
||||
let data = bech32.fromWords(words)
|
||||
lnurl = utf8Decoder.decode(data)
|
||||
} else if (lud16) {
|
||||
let [name, domain] = lud16.split('@')
|
||||
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
@@ -41,35 +42,44 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||
return null
|
||||
}
|
||||
|
||||
export function makeZapRequest({
|
||||
profile,
|
||||
event,
|
||||
amount,
|
||||
relays,
|
||||
comment = '',
|
||||
}: {
|
||||
profile: string
|
||||
event: string | null
|
||||
type ProfileZap = {
|
||||
pubkey: string
|
||||
amount: number
|
||||
comment: string
|
||||
comment?: string
|
||||
relays: string[]
|
||||
}): EventTemplate {
|
||||
if (!amount) throw new Error('amount not given')
|
||||
if (!profile) throw new Error('profile not given')
|
||||
}
|
||||
|
||||
type EventZap = {
|
||||
event: NostrEvent
|
||||
amount: number
|
||||
comment?: string
|
||||
relays: string[]
|
||||
}
|
||||
|
||||
export function makeZapRequest(params: ProfileZap | EventZap): EventTemplate {
|
||||
let zr: EventTemplate = {
|
||||
kind: 9734,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: comment,
|
||||
content: params.comment || '',
|
||||
tags: [
|
||||
['p', profile],
|
||||
['amount', amount.toString()],
|
||||
['relays', ...relays],
|
||||
['p', 'pubkey' in params ? params.pubkey : params.event.pubkey],
|
||||
['amount', params.amount.toString()],
|
||||
['relays', ...params.relays],
|
||||
],
|
||||
}
|
||||
|
||||
if (event) {
|
||||
zr.tags.push(['e', event])
|
||||
if ('event' in params) {
|
||||
zr.tags.push(['e', params.event.id])
|
||||
if (isReplaceableKind(params.event.kind)) {
|
||||
const a = ['a', `${params.event.kind}:${params.event.pubkey}:`]
|
||||
zr.tags.push(a)
|
||||
} else if (isAddressableKind(params.event.kind)) {
|
||||
let d = params.event.tags.find(([t, v]) => t === 'd' && v)
|
||||
if (!d) throw new Error('d tag not found or is empty')
|
||||
const a = ['a', `${params.event.kind}:${params.event.pubkey}:${d[1]}`]
|
||||
zr.tags.push(a)
|
||||
}
|
||||
zr.tags.push(['k', params.event.kind.toString()])
|
||||
}
|
||||
|
||||
return zr
|
||||
@@ -128,3 +138,52 @@ export function makeZapReceipt({
|
||||
|
||||
return zap
|
||||
}
|
||||
|
||||
export function getSatoshisAmountFromBolt11(bolt11: string): number {
|
||||
if (bolt11.length < 50) {
|
||||
return 0
|
||||
}
|
||||
bolt11 = bolt11.substring(0, 50)
|
||||
const idx = bolt11.lastIndexOf('1')
|
||||
if (idx === -1) {
|
||||
return 0
|
||||
}
|
||||
const hrp = bolt11.substring(0, idx)
|
||||
if (!hrp.startsWith('lnbc')) {
|
||||
return 0
|
||||
}
|
||||
const amount = hrp.substring(4) // equivalent to strings.CutPrefix
|
||||
|
||||
if (amount.length < 1) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// if last character is a digit, then the amount can just be interpreted as BTC
|
||||
const char = amount[amount.length - 1]
|
||||
const digit = char.charCodeAt(0) - '0'.charCodeAt(0)
|
||||
const isDigit = digit >= 0 && digit <= 9
|
||||
|
||||
let cutPoint = amount.length - 1
|
||||
if (isDigit) {
|
||||
cutPoint++
|
||||
}
|
||||
|
||||
if (cutPoint < 1) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const num = parseInt(amount.substring(0, cutPoint))
|
||||
|
||||
switch (char) {
|
||||
case 'm':
|
||||
return num * 100000
|
||||
case 'u':
|
||||
return num * 100
|
||||
case 'n':
|
||||
return num / 10
|
||||
case 'p':
|
||||
return num / 10000
|
||||
default:
|
||||
return num * 100000000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { SimplePool } from './pool.ts'
|
||||
import { GiftWrap } from './kinds.ts'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
|
||||
const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data
|
||||
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data as Uint8Array
|
||||
const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data as Uint8Array
|
||||
const recipientPublicKey = getPublicKey(recipientPrivateKey)
|
||||
const event = {
|
||||
kind: 1,
|
||||
|
||||
654
nip96.test.ts
654
nip96.test.ts
@@ -1,654 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
|
||||
import { FileServerPreference } from './kinds.ts'
|
||||
import {
|
||||
calculateFileHash,
|
||||
checkFileProcessingStatus,
|
||||
deleteFile,
|
||||
generateDownloadUrl,
|
||||
generateFSPEventTemplate,
|
||||
readServerConfig,
|
||||
uploadFile,
|
||||
validateDelayedProcessingResponse,
|
||||
validateFileUploadResponse,
|
||||
validateServerConfiguration,
|
||||
type DelayedProcessingResponse,
|
||||
type FileUploadResponse,
|
||||
type ServerConfiguration,
|
||||
} from './nip96.ts'
|
||||
|
||||
describe('validateServerConfiguration', () => {
|
||||
it("should return true if 'api_url' is valid URL", () => {
|
||||
const config: ServerConfiguration = {
|
||||
api_url: 'http://example.com',
|
||||
}
|
||||
|
||||
expect(validateServerConfiguration(config)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false if 'api_url' is empty", () => {
|
||||
const config: ServerConfiguration = {
|
||||
api_url: '',
|
||||
}
|
||||
|
||||
expect(validateServerConfiguration(config)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false if both 'api_url' and 'delegated_to_url' are provided", () => {
|
||||
const config: ServerConfiguration = {
|
||||
api_url: 'http://example.com',
|
||||
delegated_to_url: 'http://example.com',
|
||||
}
|
||||
|
||||
expect(validateServerConfiguration(config)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readServerConfig', () => {
|
||||
it('should return a valid ServerConfiguration object', async () => {
|
||||
// setup mock server
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
const validConfig: ServerConfiguration = {
|
||||
api_url: 'http://example.com',
|
||||
}
|
||||
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||
return HttpResponse.json(validConfig)
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const result = await readServerConfig('http://example.com/')
|
||||
|
||||
expect(result).toEqual(validConfig)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response is not valid', async () => {
|
||||
// setup mock server
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
const invalidConfig = {
|
||||
// missing api_url
|
||||
}
|
||||
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||
return HttpResponse.json(invalidConfig)
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
expect(readServerConfig('http://example.com/')).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response is not proper json', async () => {
|
||||
// setup mock server
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||
return HttpResponse.json(null)
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
expect(readServerConfig('http://example.com/')).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response status is not 200', async () => {
|
||||
// setup mock server
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
expect(readServerConfig('http://example.com/')).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if input url is not valid', async () => {
|
||||
expect(readServerConfig('invalid-url')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateFileUploadResponse', () => {
|
||||
it('should return true if response is valid', () => {
|
||||
const mockResponse: FileUploadResponse = {
|
||||
status: 'error',
|
||||
message: 'File uploaded failed',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false if status is undefined', () => {
|
||||
const mockResponse: Omit<FileUploadResponse, 'status'> = {
|
||||
// status: 'error',
|
||||
message: 'File upload failed',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if message is undefined', () => {
|
||||
const mockResponse: Omit<FileUploadResponse, 'message'> = {
|
||||
status: 'error',
|
||||
// message: 'message',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if status is not valid', () => {
|
||||
const mockResponse = {
|
||||
status: 'something else',
|
||||
message: 'message',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if "message" is not a string', () => {
|
||||
const mockResponse = {
|
||||
status: 'error',
|
||||
message: 123,
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if status is "processing" and "processing_url" is undefined', () => {
|
||||
const mockResponse = {
|
||||
status: 'processing',
|
||||
message: 'message',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if status is "processing" and "processing_url" is not a string', () => {
|
||||
const mockResponse = {
|
||||
status: 'processing',
|
||||
message: 'message',
|
||||
processing_url: 123,
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if status is "success" and "nip94_event" is undefined', () => {
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if "nip94_event" tags are invalid', () => {
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
tags: [
|
||||
// missing url
|
||||
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if "nip94_event" tags are empty', () => {
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
tags: [],
|
||||
},
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true if "nip94_event" tags are valid', () => {
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
tags: [
|
||||
['url', 'http://example.com'],
|
||||
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should return a valid FileUploadResponse object', async () => {
|
||||
// setup mock server
|
||||
const validFileUploadResponse: FileUploadResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
content: '',
|
||||
tags: [
|
||||
['url', 'http://example.com'],
|
||||
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||
],
|
||||
},
|
||||
}
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return HttpResponse.json(validFileUploadResponse, { status: 200 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
const result = await uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)
|
||||
|
||||
expect(result).toEqual(validFileUploadResponse)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw a proper error if response status is 413', async () => {
|
||||
// setup mock server
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return new HttpResponse(null, { status: 413 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow('File too large!')
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw a proper error if response status is 400', async () => {
|
||||
// setup mock server
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow(
|
||||
'Bad request! Some fields are missing or invalid!',
|
||||
)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw a proper error if response status is 403', async () => {
|
||||
// setup mock server
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return new HttpResponse(null, { status: 403 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow(
|
||||
'Forbidden! Payload tag does not match the requested file!',
|
||||
)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw a proper error if response status is 402', async () => {
|
||||
// setup mock server
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return new HttpResponse(null, { status: 402 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow('Payment required!')
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw a proper error if response status is not 200, 400, 402, 403, 413', async () => {
|
||||
// setup mock server
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return new HttpResponse(null, { status: 500 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow(
|
||||
'Unknown error in uploading file!',
|
||||
)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateDownloadUrl', () => {
|
||||
it('should generate a download URL without file extension', () => {
|
||||
const fileHash = 'abc123'
|
||||
const serverDownloadUrl = 'http://example.com/download'
|
||||
const expectedUrl = 'http://example.com/download/abc123'
|
||||
|
||||
const result = generateDownloadUrl(fileHash, serverDownloadUrl)
|
||||
|
||||
expect(result).toBe(expectedUrl)
|
||||
})
|
||||
|
||||
it('should generate a download URL with file extension', () => {
|
||||
const fileHash = 'abc123'
|
||||
const serverDownloadUrl = 'http://example.com/download'
|
||||
const fileExtension = '.jpg'
|
||||
const expectedUrl = 'http://example.com/download/abc123.jpg'
|
||||
|
||||
const result = generateDownloadUrl(fileHash, serverDownloadUrl, fileExtension)
|
||||
|
||||
expect(result).toBe(expectedUrl)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteFile', () => {
|
||||
it('should return a basic json response for successful delete', async () => {
|
||||
// setup mock server
|
||||
const handler = http.delete('http://example.com/delete/abc123', () => {
|
||||
return HttpResponse.json({ status: 'success', message: 'File deleted.' }, { status: 200 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const fileHash = 'abc123'
|
||||
const serverDeleteUrl = 'http://example.com/delete'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
const result = await deleteFile(fileHash, serverDeleteUrl, nip98AuthorizationHeader)
|
||||
|
||||
expect(result).toEqual({ status: 'success', message: 'File deleted.' })
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error for unsuccessful delete', async () => {
|
||||
// setup mock server
|
||||
const handler = http.delete('http://example.com/delete/abc123', () => {
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const fileHash = 'abc123'
|
||||
const serverDeleteUrl = 'http://example.com/delete'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(deleteFile(fileHash, serverDeleteUrl, nip98AuthorizationHeader)).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateDelayedProcessingResponse', () => {
|
||||
it('should return false for non-object input', () => {
|
||||
expect(validateDelayedProcessingResponse('not an object')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for null input', () => {
|
||||
expect(validateDelayedProcessingResponse(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for object missing required properties', () => {
|
||||
const missingStatus: Omit<DelayedProcessingResponse, 'status'> = {
|
||||
// missing status
|
||||
message: 'test',
|
||||
percentage: 50,
|
||||
}
|
||||
const missingMessage: Omit<DelayedProcessingResponse, 'message'> = {
|
||||
status: 'processing',
|
||||
// missing message
|
||||
percentage: 50,
|
||||
}
|
||||
const missingPercentage: Omit<DelayedProcessingResponse, 'percentage'> = {
|
||||
status: 'processing',
|
||||
message: 'test',
|
||||
// missing percentage
|
||||
}
|
||||
|
||||
expect(validateDelayedProcessingResponse(missingStatus)).toBe(false)
|
||||
expect(validateDelayedProcessingResponse(missingMessage)).toBe(false)
|
||||
expect(validateDelayedProcessingResponse(missingPercentage)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for invalid status', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'invalid', message: 'test', percentage: 50 })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-string message', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'processing', message: 123, percentage: 50 })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-number percentage', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: '50' })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for percentage out of range', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: 150 })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for valid input', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: 50 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkFileProcessingStatus', () => {
|
||||
it('should throw an error if response is not ok', async () => {
|
||||
// setup mock server
|
||||
const handler = http.get('http://example.com/status/abc123', () => {
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const processingUrl = 'http://example.com/status/abc123'
|
||||
|
||||
expect(checkFileProcessingStatus(processingUrl)).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response is not a valid json', async () => {
|
||||
// setup mock server
|
||||
const handler = http.get('http://example.com/status/abc123', () => {
|
||||
return HttpResponse.text('not a json', { status: 200 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const processingUrl = 'http://example.com/status/abc123'
|
||||
|
||||
expect(checkFileProcessingStatus(processingUrl)).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should return a valid DelayedProcessingResponse object if response status is 200', async () => {
|
||||
// setup mock server
|
||||
const validDelayedProcessingResponse: DelayedProcessingResponse = {
|
||||
status: 'processing',
|
||||
message: 'test',
|
||||
percentage: 50,
|
||||
}
|
||||
const handler = http.get('http://example.com/status/abc123', () => {
|
||||
return HttpResponse.json(validDelayedProcessingResponse, { status: 200 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const processingUrl = 'http://example.com/status/abc123'
|
||||
|
||||
const result = await checkFileProcessingStatus(processingUrl)
|
||||
|
||||
expect(result).toEqual(validDelayedProcessingResponse)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should return a valid FileUploadResponse object if response status is 201', async () => {
|
||||
// setup mock server
|
||||
const validFileUploadResponse: FileUploadResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
content: '',
|
||||
tags: [
|
||||
['url', 'http://example.com'],
|
||||
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||
],
|
||||
},
|
||||
}
|
||||
const handler = http.get('http://example.com/status/abc123', () => {
|
||||
return HttpResponse.json(validFileUploadResponse, { status: 201 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const processingUrl = 'http://example.com/status/abc123'
|
||||
|
||||
const result = await checkFileProcessingStatus(processingUrl)
|
||||
|
||||
expect(result).toEqual(validFileUploadResponse)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateFSPEventTemplate', () => {
|
||||
it('should generate FSP event template', () => {
|
||||
const serverUrls = ['http://example.com', 'https://example.org']
|
||||
const eventTemplate = generateFSPEventTemplate(serverUrls)
|
||||
|
||||
expect(eventTemplate.kind).toBe(FileServerPreference)
|
||||
expect(eventTemplate.content).toBe('')
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['server', 'http://example.com'],
|
||||
['server', 'https://example.org'],
|
||||
])
|
||||
expect(typeof eventTemplate.created_at).toBe('number')
|
||||
})
|
||||
|
||||
it('should filter invalid server URLs', () => {
|
||||
const serverUrls = ['http://example.com', 'invalid-url', 'https://example.org']
|
||||
const eventTemplate = generateFSPEventTemplate(serverUrls)
|
||||
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['server', 'http://example.com'],
|
||||
['server', 'https://example.org'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle empty server URLs', () => {
|
||||
const serverUrls: string[] = []
|
||||
const eventTemplate = generateFSPEventTemplate(serverUrls)
|
||||
|
||||
expect(eventTemplate.tags).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateFileHash', () => {
|
||||
it('should calculate file hash', async () => {
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const hash = await calculateFileHash(file)
|
||||
|
||||
expect(hash).toBe('b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9')
|
||||
})
|
||||
|
||||
it('should calculate file hash with empty file', async () => {
|
||||
const file = new File([], 'empty.txt')
|
||||
const hash = await calculateFileHash(file)
|
||||
|
||||
expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
||||
})
|
||||
})
|
||||
578
nip96.ts
578
nip96.ts
@@ -1,578 +0,0 @@
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { EventTemplate } from './core.ts'
|
||||
import { FileServerPreference } from './kinds.ts'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
|
||||
/**
|
||||
* Represents the configuration for a server compliant with NIP-96.
|
||||
*/
|
||||
export type ServerConfiguration = {
|
||||
/**
|
||||
* The base URL from which file upload and deletion operations are served.
|
||||
* Also used for downloads if "download_url" is not specified.
|
||||
*/
|
||||
api_url: string
|
||||
|
||||
/**
|
||||
* Optional. The base URL from which files are downloaded.
|
||||
* Used if different from the "api_url".
|
||||
*/
|
||||
download_url?: string
|
||||
|
||||
/**
|
||||
* Optional. URL of another HTTP file storage server's configuration.
|
||||
* Used by nostr relays to delegate to another server.
|
||||
* In this case, "api_url" must be an empty string.
|
||||
*/
|
||||
delegated_to_url?: string
|
||||
|
||||
/**
|
||||
* Optional. An array of NIP numbers that this server supports.
|
||||
*/
|
||||
supported_nips?: number[]
|
||||
|
||||
/**
|
||||
* Optional. URL to the server's Terms of Service.
|
||||
*/
|
||||
tos_url?: string
|
||||
|
||||
/**
|
||||
* Optional. An array of MIME types supported by the server.
|
||||
*/
|
||||
content_types?: string[]
|
||||
|
||||
/**
|
||||
* Optional. Defines various storage plans offered by the server.
|
||||
*/
|
||||
plans?: {
|
||||
[planKey: string]: {
|
||||
/**
|
||||
* The name of the storage plan.
|
||||
*/
|
||||
name: string
|
||||
|
||||
/**
|
||||
* Optional. Indicates whether NIP-98 is required for uploads in this plan.
|
||||
*/
|
||||
is_nip98_required?: boolean
|
||||
|
||||
/**
|
||||
* Optional. URL to a landing page providing more information about the plan.
|
||||
*/
|
||||
url?: string
|
||||
|
||||
/**
|
||||
* Optional. The maximum file size allowed under this plan, in bytes.
|
||||
*/
|
||||
max_byte_size?: number
|
||||
|
||||
/**
|
||||
* Optional. Defines the range of file expiration in days.
|
||||
* The first value indicates the minimum expiration time, and the second value indicates the maximum.
|
||||
* A value of 0 indicates no expiration.
|
||||
*/
|
||||
file_expiration?: [number, number]
|
||||
|
||||
/**
|
||||
* Optional. Specifies the types of media transformations supported under this plan.
|
||||
* Currently, only image transformations are considered.
|
||||
*/
|
||||
media_transformations?: {
|
||||
/**
|
||||
* Optional. An array of supported image transformation types.
|
||||
*/
|
||||
image?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the optional form data fields for file upload in accordance with NIP-96.
|
||||
*/
|
||||
export type OptionalFormDataFields = {
|
||||
/**
|
||||
* Specifies the desired expiration time of the file on the server.
|
||||
* It should be a string representing a UNIX timestamp in seconds.
|
||||
* An empty string indicates that the file should be stored indefinitely.
|
||||
*/
|
||||
expiration?: string
|
||||
|
||||
/**
|
||||
* Indicates the size of the file in bytes.
|
||||
* This field can be used by the server to pre-validate the file size before processing the upload.
|
||||
*/
|
||||
size?: string
|
||||
|
||||
/**
|
||||
* Provides a strict description of the file for accessibility purposes,
|
||||
* particularly useful for visibility-impaired users.
|
||||
*/
|
||||
alt?: string
|
||||
|
||||
/**
|
||||
* A loose, more descriptive caption for the file.
|
||||
* This can be used for additional context or commentary about the file.
|
||||
*/
|
||||
caption?: string
|
||||
|
||||
/**
|
||||
* Specifies the intended use of the file.
|
||||
* Can be either 'avatar' or 'banner', indicating if the file is to be used as an avatar or a banner.
|
||||
* Absence of this field suggests standard file upload without special treatment.
|
||||
*/
|
||||
media_type?: 'avatar' | 'banner'
|
||||
|
||||
/**
|
||||
* The MIME type of the file being uploaded.
|
||||
* This can be used for early rejection by the server if the file type isn't supported.
|
||||
*/
|
||||
content_type?: string
|
||||
|
||||
/**
|
||||
* Other custom form data fields.
|
||||
*/
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representing the response from a NIP-96 compliant server after a file upload request.
|
||||
*/
|
||||
export type FileUploadResponse = {
|
||||
/**
|
||||
* The status of the upload request.
|
||||
* - 'success': Indicates the file was successfully uploaded.
|
||||
* - 'error': Indicates there was an error in the upload process.
|
||||
* - 'processing': Indicates the file is still being processed (used in cases of delayed processing).
|
||||
*/
|
||||
status: 'success' | 'error' | 'processing'
|
||||
|
||||
/**
|
||||
* A message provided by the server, which could be a success message, error description, or processing status.
|
||||
*/
|
||||
message: string
|
||||
|
||||
/**
|
||||
* Optional. A URL provided by the server where the upload processing status can be checked.
|
||||
* This is relevant in cases where the file upload involves delayed processing.
|
||||
*/
|
||||
processing_url?: string
|
||||
|
||||
/**
|
||||
* Optional. An event object conforming to NIP-94, which includes details about the uploaded file.
|
||||
* This object is typically provided in the response for a successful upload and contains
|
||||
* essential information such as the download URL and file metadata.
|
||||
*/
|
||||
nip94_event?: {
|
||||
/**
|
||||
* A collection of key-value pairs (tags) providing metadata about the uploaded file.
|
||||
* Standard tags include:
|
||||
* - 'url': The URL where the file can be accessed.
|
||||
* - 'ox': The SHA-256 hash of the original file before any server-side transformations.
|
||||
* Additional optional tags might include file dimensions, MIME type, etc.
|
||||
*/
|
||||
tags: Array<[string, string]>
|
||||
|
||||
/**
|
||||
* A content field, which is typically empty for file upload events but included for consistency with the NIP-94 structure.
|
||||
*/
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representing the response from a NIP-96 compliant server after a delayed processing request.
|
||||
*/
|
||||
export type DelayedProcessingResponse = {
|
||||
/**
|
||||
* The status of the delayed processing request.
|
||||
* - 'processing': Indicates the file is still being processed.
|
||||
* - 'error': Indicates there was an error in the processing.
|
||||
*/
|
||||
status: 'processing' | 'error'
|
||||
|
||||
/**
|
||||
* A message provided by the server, which could be a success message or error description.
|
||||
*/
|
||||
message: string
|
||||
|
||||
/**
|
||||
* The percentage of the file that has been processed. This is a number between 0 and 100.
|
||||
*/
|
||||
percentage: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the server configuration.
|
||||
*
|
||||
* @param config - The server configuration object.
|
||||
* @returns True if the configuration is valid, false otherwise.
|
||||
*/
|
||||
export function validateServerConfiguration(config: ServerConfiguration): boolean {
|
||||
if (Boolean(config.api_url) == false) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Boolean(config.delegated_to_url) && Boolean(config.api_url)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches, parses, and validates the server configuration from the given URL.
|
||||
*
|
||||
* @param serverUrl The URL of the server.
|
||||
* @returns The server configuration, or an error if the configuration could not be fetched or parsed.
|
||||
*/
|
||||
export async function readServerConfig(serverUrl: string): Promise<ServerConfiguration> {
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
let fetchUrl = ''
|
||||
|
||||
try {
|
||||
const { origin } = new URL(serverUrl)
|
||||
fetchUrl = origin + HTTPROUTE
|
||||
} catch (error) {
|
||||
throw new Error('Invalid URL')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(fetchUrl)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching ${fetchUrl}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data: any = await response.json()
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No data')
|
||||
}
|
||||
|
||||
if (!validateServerConfiguration(data)) {
|
||||
throw new Error('Invalid configuration data')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (_) {
|
||||
throw new Error(`Error fetching.`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the given object is a valid FileUploadResponse.
|
||||
*
|
||||
* @param response - The object to validate.
|
||||
* @returns true if the object is a valid FileUploadResponse, otherwise false.
|
||||
*/
|
||||
export function validateFileUploadResponse(response: any): response is FileUploadResponse {
|
||||
if (typeof response !== 'object' || response === null) return false
|
||||
|
||||
if (!response.status || !response.message) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status !== 'success' && response.status !== 'error' && response.status !== 'processing') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof response.message !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status === 'processing' && !response.processing_url) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.processing_url) {
|
||||
if (typeof response.processing_url !== 'string') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 'success' && !response.nip94_event) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.nip94_event) {
|
||||
if (
|
||||
!response.nip94_event.tags ||
|
||||
!Array.isArray(response.nip94_event.tags) ||
|
||||
response.nip94_event.tags.length === 0
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const tag of response.nip94_event.tags) {
|
||||
if (!Array.isArray(tag) || tag.length !== 2) return false
|
||||
|
||||
if (typeof tag[0] !== 'string' || typeof tag[1] !== 'string') return false
|
||||
}
|
||||
|
||||
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'url')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'ox')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to a NIP-96 compliant server.
|
||||
*
|
||||
* @param file - The file to be uploaded.
|
||||
* @param serverApiUrl - The API URL of the server, retrieved from the server's configuration.
|
||||
* @param nip98AuthorizationHeader - The authorization header from NIP-98.
|
||||
* @param optionalFormDataFields - Optional form data fields.
|
||||
* @returns A promise that resolves to the server's response.
|
||||
*/
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
serverApiUrl: string,
|
||||
nip98AuthorizationHeader: string,
|
||||
optionalFormDataFields?: OptionalFormDataFields,
|
||||
): Promise<FileUploadResponse> {
|
||||
// Create FormData object
|
||||
const formData = new FormData()
|
||||
|
||||
// Append optional fields to FormData
|
||||
optionalFormDataFields &&
|
||||
Object.entries(optionalFormDataFields).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
formData.append(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
// Append the file to FormData as the last field
|
||||
formData.append('file', file)
|
||||
|
||||
// Make the POST request to the server
|
||||
const response = await fetch(serverApiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: nip98AuthorizationHeader,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.ok === false) {
|
||||
// 413 Payload Too Large
|
||||
if (response.status === 413) {
|
||||
throw new Error('File too large!')
|
||||
}
|
||||
|
||||
// 400 Bad Request
|
||||
if (response.status === 400) {
|
||||
throw new Error('Bad request! Some fields are missing or invalid!')
|
||||
}
|
||||
|
||||
// 403 Forbidden
|
||||
if (response.status === 403) {
|
||||
throw new Error('Forbidden! Payload tag does not match the requested file!')
|
||||
}
|
||||
|
||||
// 402 Payment Required
|
||||
if (response.status === 402) {
|
||||
throw new Error('Payment required!')
|
||||
}
|
||||
|
||||
// unknown error
|
||||
throw new Error('Unknown error in uploading file!')
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedResponse = await response.json()
|
||||
|
||||
if (!validateFileUploadResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
} catch (error) {
|
||||
throw new Error('Error parsing JSON response!')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the URL for downloading a file from a NIP-96 compliant server.
|
||||
*
|
||||
* @param fileHash - The SHA-256 hash of the original file.
|
||||
* @param serverDownloadUrl - The base URL provided by the server, retrieved from the server's configuration.
|
||||
* @param fileExtension - An optional parameter that specifies the file extension (e.g., '.jpg', '.png').
|
||||
* @returns A string representing the complete URL to download the file.
|
||||
*
|
||||
*/
|
||||
export function generateDownloadUrl(fileHash: string, serverDownloadUrl: string, fileExtension?: string): string {
|
||||
// Construct the base download URL using the file hash
|
||||
let downloadUrl = `${serverDownloadUrl}/${fileHash}`
|
||||
|
||||
// Append the file extension if provided
|
||||
if (fileExtension) {
|
||||
downloadUrl += fileExtension
|
||||
}
|
||||
|
||||
return downloadUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to delete a file from a NIP-96 compliant server.
|
||||
*
|
||||
* @param fileHash - The SHA-256 hash of the original file.
|
||||
* @param serverApiUrl - The base API URL of the server, retrieved from the server's configuration.
|
||||
* @param nip98AuthorizationHeader - The authorization header from NIP-98.
|
||||
* @returns A promise that resolves to the server's response to the deletion request.
|
||||
*
|
||||
*/
|
||||
export async function deleteFile(
|
||||
fileHash: string,
|
||||
serverApiUrl: string,
|
||||
nip98AuthorizationHeader: string,
|
||||
): Promise<any> {
|
||||
// make sure the serverApiUrl ends with a slash
|
||||
if (!serverApiUrl.endsWith('/')) {
|
||||
serverApiUrl += '/'
|
||||
}
|
||||
|
||||
// Construct the URL for the delete request
|
||||
const deleteUrl = `${serverApiUrl}${fileHash}`
|
||||
|
||||
// Send the DELETE request
|
||||
const response = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: nip98AuthorizationHeader,
|
||||
},
|
||||
})
|
||||
|
||||
// Handle the response
|
||||
if (!response.ok) {
|
||||
throw new Error('Error deleting file!')
|
||||
}
|
||||
|
||||
// Return the response from the server
|
||||
try {
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
throw new Error('Error parsing JSON response!')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the server's response to a delayed processing request.
|
||||
*
|
||||
* @param response - The server's response to a delayed processing request.
|
||||
* @returns A boolean indicating whether the response is valid.
|
||||
*/
|
||||
export function validateDelayedProcessingResponse(response: any): response is DelayedProcessingResponse {
|
||||
if (typeof response !== 'object' || response === null) return false
|
||||
|
||||
if (!response.status || !response.message || !response.percentage) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status !== 'processing' && response.status !== 'error') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof response.message !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof response.percentage !== 'number') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Number(response.percentage) < 0 || Number(response.percentage) > 100) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the processing status of a file when delayed processing is used.
|
||||
*
|
||||
* @param processingUrl - The URL provided by the server where the processing status can be checked.
|
||||
* @returns A promise that resolves to an object containing the processing status and other relevant information.
|
||||
*/
|
||||
export async function checkFileProcessingStatus(
|
||||
processingUrl: string,
|
||||
): Promise<FileUploadResponse | DelayedProcessingResponse> {
|
||||
// Make the GET request to the processing URL
|
||||
const response = await fetch(processingUrl)
|
||||
|
||||
// Handle the response
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to retrieve processing status. Server responded with status: ${response.status}`)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
try {
|
||||
const parsedResponse = await response.json()
|
||||
|
||||
// 201 Created: Indicates the processing is over.
|
||||
if (response.status === 201) {
|
||||
// Validate the response
|
||||
if (!validateFileUploadResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
}
|
||||
|
||||
// 200 OK: Indicates the processing is still ongoing.
|
||||
if (response.status === 200) {
|
||||
// Validate the response
|
||||
if (!validateDelayedProcessingResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
}
|
||||
|
||||
throw new Error('Invalid response from the server!')
|
||||
} catch (error) {
|
||||
throw new Error('Error parsing JSON response!')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template to indicate a user's File Server Preferences.
|
||||
* This event is of kind 10096 and is used to specify one or more preferred servers for file uploads.
|
||||
*
|
||||
* @param serverUrls - An array of URLs representing the user's preferred file storage servers.
|
||||
* @returns An object representing a Nostr event template for setting file server preferences.
|
||||
*/
|
||||
export function generateFSPEventTemplate(serverUrls: string[]): EventTemplate {
|
||||
serverUrls = serverUrls.filter(serverUrl => {
|
||||
try {
|
||||
new URL(serverUrl)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
kind: FileServerPreference,
|
||||
content: '',
|
||||
tags: serverUrls.map(serverUrl => ['server', serverUrl]),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the SHA-256 hash of a given file. This hash is used in various NIP-96 operations,
|
||||
* such as file upload, download, and deletion, to uniquely identify files.
|
||||
*
|
||||
* @param file - The file for which the SHA-256 hash needs to be calculated.
|
||||
* @returns A promise that resolves to the SHA-256 hash of the file.
|
||||
*/
|
||||
export async function calculateFileHash(file: Blob): Promise<string> {
|
||||
return bytesToHex(sha256(new Uint8Array(await file.arrayBuffer())))
|
||||
}
|
||||
55
nipb7.test.ts
Normal file
55
nipb7.test.ts
Normal 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
203
nipb7.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
package.json
34
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "nostr-tools",
|
||||
"version": "2.10.1",
|
||||
"version": "2.17.2",
|
||||
"description": "Tools for making a Nostr client.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -173,6 +173,11 @@
|
||||
"require": "./lib/cjs/nip49.js",
|
||||
"types": "./lib/types/nip49.d.ts"
|
||||
},
|
||||
"./nip54": {
|
||||
"import": "./lib/esm/nip54.js",
|
||||
"require": "./lib/cjs/nip54.js",
|
||||
"types": "./lib/types/nip54.d.ts"
|
||||
},
|
||||
"./nip57": {
|
||||
"import": "./lib/esm/nip57.js",
|
||||
"require": "./lib/cjs/nip57.js",
|
||||
@@ -198,11 +203,6 @@
|
||||
"require": "./lib/cjs/nip94.js",
|
||||
"types": "./lib/types/nip94.d.ts"
|
||||
},
|
||||
"./nip96": {
|
||||
"import": "./lib/esm/nip96.js",
|
||||
"require": "./lib/cjs/nip96.js",
|
||||
"types": "./lib/types/nip96.d.ts"
|
||||
},
|
||||
"./nip98": {
|
||||
"import": "./lib/esm/nip98.js",
|
||||
"require": "./lib/cjs/nip98.js",
|
||||
@@ -213,11 +213,21 @@
|
||||
"require": "./lib/cjs/nip99.js",
|
||||
"types": "./lib/types/nip99.d.ts"
|
||||
},
|
||||
"./nipb7": {
|
||||
"import": "./lib/esm/nipb7.js",
|
||||
"require": "./lib/cjs/nipb7.js",
|
||||
"types": "./lib/types/nipb7.d.ts"
|
||||
},
|
||||
"./fakejson": {
|
||||
"import": "./lib/esm/fakejson.js",
|
||||
"require": "./lib/cjs/fakejson.js",
|
||||
"types": "./lib/types/fakejson.d.ts"
|
||||
},
|
||||
"./signer": {
|
||||
"import": "./lib/esm/signer.js",
|
||||
"require": "./lib/cjs/signer.js",
|
||||
"types": "./lib/types/signer.d.ts"
|
||||
},
|
||||
"./utils": {
|
||||
"import": "./lib/esm/utils.js",
|
||||
"require": "./lib/cjs/utils.js",
|
||||
@@ -231,10 +241,8 @@
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"nostr-wasm": "v0.1.0"
|
||||
"@scure/bip39": "1.2.1",
|
||||
"nostr-wasm": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
@@ -258,18 +266,14 @@
|
||||
"@typescript-eslint/parser": "^6.5.0",
|
||||
"bun-types": "^1.0.18",
|
||||
"esbuild": "0.16.9",
|
||||
"esbuild-plugin-alias": "^0.2.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"esm-loader-typescript": "^1.0.3",
|
||||
"events": "^3.3.0",
|
||||
"mitata": "^0.1.6",
|
||||
"mock-socket": "^9.3.1",
|
||||
"msw": "^2.1.4",
|
||||
"node-fetch": "^2.6.9",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^5.0.4"
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublish": "just build"
|
||||
|
||||
213
pool.test.ts
213
pool.test.ts
@@ -35,14 +35,18 @@ test('removing duplicates when subscribing', async () => {
|
||||
priv,
|
||||
)
|
||||
|
||||
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||
onevent(event: Event) {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be caught and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
pool.subscribeMany(
|
||||
relayURLs,
|
||||
{ authors: [pub] },
|
||||
{
|
||||
onevent(event: Event) {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be caught and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relayURLs, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
|
||||
@@ -55,16 +59,24 @@ test('same with double subs', async () => {
|
||||
let priv = generateSecretKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
pool.subscribeMany(
|
||||
relayURLs,
|
||||
{ authors: [pub] },
|
||||
{
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
},
|
||||
})
|
||||
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
)
|
||||
pool.subscribeMany(
|
||||
relayURLs,
|
||||
{ authors: [pub] },
|
||||
{
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
let received: Event[] = []
|
||||
|
||||
@@ -119,12 +131,12 @@ test('subscribe many map', async () => {
|
||||
|
||||
const [relayA, relayB, relayC] = relayURLs
|
||||
|
||||
pool.subscribeManyMap(
|
||||
{
|
||||
[relayA]: [{ authors: [pub], kinds: [20001] }],
|
||||
[relayB]: [{ authors: [pub], kinds: [20002] }],
|
||||
[relayC]: [{ kinds: [20003], '#t': ['biloba'] }],
|
||||
},
|
||||
pool.subscribeMap(
|
||||
[
|
||||
{ url: relayA, filter: { authors: [pub], kinds: [20001] } },
|
||||
{ url: relayB, filter: { authors: [pub], kinds: [20002] } },
|
||||
{ url: relayC, filter: { kinds: [20003], '#t': ['biloba'] } },
|
||||
],
|
||||
{
|
||||
onevent(event: Event) {
|
||||
received.push(event)
|
||||
@@ -168,12 +180,16 @@ test('query a bunch of events and cancel on eose', async () => {
|
||||
let events = new Set<string>()
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], {
|
||||
onevent(event) {
|
||||
events.add(event.id)
|
||||
pool.subscribeManyEose(
|
||||
relayURLs,
|
||||
{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 },
|
||||
{
|
||||
onevent(event) {
|
||||
events.add(event.id)
|
||||
},
|
||||
onclose: resolve as any,
|
||||
},
|
||||
onclose: resolve as any,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(events.size).toBeGreaterThan(50)
|
||||
@@ -206,6 +222,151 @@ test('get()', async () => {
|
||||
expect(event).toHaveProperty('id', ids[0])
|
||||
})
|
||||
|
||||
test('ping-pong timeout in pool', async () => {
|
||||
const mockRelay = mockRelays[0]
|
||||
pool = new SimplePool({ enablePing: true })
|
||||
const relay = await pool.ensureRelay(mockRelay.url)
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
|
||||
let closed = false
|
||||
const closedPromise = new Promise<void>(resolve => {
|
||||
relay.onclose = () => {
|
||||
closed = true
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(closed).toBeFalse()
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail
|
||||
await closedPromise
|
||||
|
||||
expect(relay.connected).toBeFalse()
|
||||
expect(closed).toBeTrue()
|
||||
})
|
||||
|
||||
test('reconnect on disconnect in pool', async () => {
|
||||
const mockRelay = mockRelays[0]
|
||||
pool = new SimplePool({ enablePing: true, enableReconnect: true })
|
||||
const relay = await pool.ensureRelay(mockRelay.url)
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
relay.resubscribeBackoff = [50, 100]
|
||||
|
||||
let closes = 0
|
||||
relay.onclose = () => {
|
||||
closes++
|
||||
}
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(closes).toBe(0)
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail, which will trigger a close
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (closes > 0) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
expect(closes).toBe(1)
|
||||
expect(relay.connected).toBeFalse()
|
||||
|
||||
// now make it responsive again
|
||||
mockRelay.unresponsive = false
|
||||
|
||||
// wait for reconnect
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (relay.connected) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
expect(closes).toBe(1)
|
||||
})
|
||||
|
||||
test('reconnect with filter update in pool', async () => {
|
||||
const mockRelay = mockRelays[0]
|
||||
const newSince = Math.floor(Date.now() / 1000)
|
||||
pool = new SimplePool({
|
||||
enablePing: true,
|
||||
enableReconnect: filters => {
|
||||
return filters.map(f => ({ ...f, since: newSince }))
|
||||
},
|
||||
})
|
||||
const relay = await pool.ensureRelay(mockRelay.url)
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
relay.resubscribeBackoff = [50, 100]
|
||||
|
||||
let closes = 0
|
||||
relay.onclose = () => {
|
||||
closes++
|
||||
}
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
const sub = relay.subscribe([{ kinds: [1], since: 0 }], { onevent: () => {} })
|
||||
expect(sub.filters[0].since).toBe(0)
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(closes).toBe(0)
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail, which will trigger a close
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (closes > 0) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
expect(closes).toBe(1)
|
||||
expect(relay.connected).toBeFalse()
|
||||
|
||||
// now make it responsive again
|
||||
mockRelay.unresponsive = false
|
||||
|
||||
// wait for reconnect
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (relay.connected) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
expect(closes).toBe(1)
|
||||
|
||||
// check if filter was updated
|
||||
expect(sub.filters[0].since).toBe(newSince)
|
||||
})
|
||||
|
||||
test('track relays when publishing', async () => {
|
||||
let event1 = finalizeEvent(
|
||||
{
|
||||
|
||||
6
pool.ts
6
pool.ts
@@ -1,7 +1,7 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import { verifyEvent } from './pure.ts'
|
||||
import { AbstractSimplePool } from './abstract-pool.ts'
|
||||
import { AbstractSimplePool, type AbstractPoolConstructorOptions } from './abstract-pool.ts'
|
||||
|
||||
var _WebSocket: typeof WebSocket
|
||||
|
||||
@@ -14,8 +14,8 @@ export function useWebSocketImplementation(websocketImplementation: any) {
|
||||
}
|
||||
|
||||
export class SimplePool extends AbstractSimplePool {
|
||||
constructor() {
|
||||
super({ verifyEvent, websocketImplementation: _WebSocket })
|
||||
constructor(options?: Pick<AbstractPoolConstructorOptions, 'enablePing' | 'enableReconnect'>) {
|
||||
super({ verifyEvent, websocketImplementation: _WebSocket, ...options })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
282
relay.test.ts
282
relay.test.ts
@@ -117,3 +117,285 @@ test('publish timeout', async () => {
|
||||
),
|
||||
).rejects.toThrow('publish timed out')
|
||||
})
|
||||
|
||||
test('ping-pong timeout (with native ping)', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
let pingCalled = false
|
||||
|
||||
// mock a native ping/pong mechanism
|
||||
;(MockWebSocketClient.prototype as any).ping = function (this: any) {
|
||||
pingCalled = true
|
||||
if (!mockRelay.unresponsive) {
|
||||
this.dispatchEvent(new Event('pong'))
|
||||
}
|
||||
}
|
||||
;(MockWebSocketClient.prototype as any).once = function (
|
||||
this: any,
|
||||
event: string,
|
||||
listener: (...args: any[]) => void,
|
||||
) {
|
||||
if (event === 'pong') {
|
||||
const onceListener = (...args: any[]) => {
|
||||
this.removeEventListener(event, onceListener)
|
||||
listener.apply(this, args)
|
||||
}
|
||||
this.addEventListener('pong', onceListener)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const relay = new Relay(mockRelay.url, { enablePing: true })
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
|
||||
let closed = false
|
||||
const closedPromise = new Promise<void>(resolve => {
|
||||
relay.onclose = () => {
|
||||
closed = true
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
await relay.connect()
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(pingCalled).toBeTrue()
|
||||
expect(closed).toBeFalse()
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail
|
||||
await closedPromise
|
||||
|
||||
expect(relay.connected).toBeFalse()
|
||||
expect(closed).toBeTrue()
|
||||
} finally {
|
||||
delete (MockWebSocketClient.prototype as any).ping
|
||||
delete (MockWebSocketClient.prototype as any).once
|
||||
}
|
||||
})
|
||||
|
||||
test('ping-pong timeout (no-ping browser environment)', async () => {
|
||||
// spy on send to ensure the fallback dummy REQ is used, since MockWebSocketClient has no ping
|
||||
const originalSend = MockWebSocketClient.prototype.send
|
||||
let dummyReqSent = false
|
||||
|
||||
try {
|
||||
MockWebSocketClient.prototype.send = function (message: string) {
|
||||
if (message.includes('REQ') && message.includes('a'.repeat(64))) {
|
||||
dummyReqSent = true
|
||||
}
|
||||
originalSend.call(this, message)
|
||||
}
|
||||
|
||||
const mockRelay = new MockRelay()
|
||||
const relay = new Relay(mockRelay.url, { enablePing: true })
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
|
||||
let closed = false
|
||||
const closedPromise = new Promise<void>(resolve => {
|
||||
relay.onclose = () => {
|
||||
closed = true
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
await relay.connect()
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(dummyReqSent).toBeTrue()
|
||||
expect(closed).toBeFalse()
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail
|
||||
await closedPromise
|
||||
|
||||
expect(relay.connected).toBeFalse()
|
||||
expect(closed).toBeTrue()
|
||||
} finally {
|
||||
MockWebSocketClient.prototype.send = originalSend
|
||||
}
|
||||
})
|
||||
|
||||
test('ping-pong listeners are cleaned up', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
let listenerCount = 0
|
||||
|
||||
// mock a native ping/pong mechanism
|
||||
;(MockWebSocketClient.prototype as any).ping = function (this: any) {
|
||||
if (!mockRelay.unresponsive) {
|
||||
this.dispatchEvent(new Event('pong'))
|
||||
}
|
||||
}
|
||||
|
||||
const originalAddEventListener = MockWebSocketClient.prototype.addEventListener
|
||||
MockWebSocketClient.prototype.addEventListener = function (event, listener, options) {
|
||||
if (event === 'pong') {
|
||||
listenerCount++
|
||||
}
|
||||
// @ts-ignore
|
||||
return originalAddEventListener.call(this, event, listener, options)
|
||||
}
|
||||
|
||||
const originalRemoveEventListener = MockWebSocketClient.prototype.removeEventListener
|
||||
MockWebSocketClient.prototype.removeEventListener = function (event, listener) {
|
||||
if (event === 'pong') {
|
||||
listenerCount--
|
||||
}
|
||||
// @ts-ignore
|
||||
return originalRemoveEventListener.call(this, event, listener)
|
||||
}
|
||||
|
||||
// the check in pingpong() is for .once() so we must mock it
|
||||
;(MockWebSocketClient.prototype as any).once = function (
|
||||
this: any,
|
||||
event: string,
|
||||
listener: (...args: any[]) => void,
|
||||
) {
|
||||
const onceListener = (...args: any[]) => {
|
||||
this.removeEventListener(event, onceListener)
|
||||
listener.apply(this, args)
|
||||
}
|
||||
this.addEventListener(event, onceListener)
|
||||
}
|
||||
|
||||
try {
|
||||
const relay = new Relay(mockRelay.url, { enablePing: true })
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
|
||||
await relay.connect()
|
||||
await new Promise(resolve => setTimeout(resolve, 175))
|
||||
|
||||
expect(listenerCount).toBeLessThan(2)
|
||||
|
||||
relay.close()
|
||||
} finally {
|
||||
delete (MockWebSocketClient.prototype as any).ping
|
||||
delete (MockWebSocketClient.prototype as any).once
|
||||
MockWebSocketClient.prototype.addEventListener = originalAddEventListener
|
||||
MockWebSocketClient.prototype.removeEventListener = originalRemoveEventListener
|
||||
}
|
||||
})
|
||||
|
||||
test('reconnect on disconnect', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
const relay = new Relay(mockRelay.url, { enablePing: true, enableReconnect: true })
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
relay.resubscribeBackoff = [50, 100] // short backoff for testing
|
||||
|
||||
let closes = 0
|
||||
relay.onclose = () => {
|
||||
closes++
|
||||
}
|
||||
|
||||
await relay.connect()
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(closes).toBe(0)
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail, which will trigger a close
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (closes > 0) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
expect(closes).toBe(1)
|
||||
expect(relay.connected).toBeFalse()
|
||||
|
||||
// now make it responsive again
|
||||
mockRelay.unresponsive = false
|
||||
|
||||
// wait for reconnect
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (relay.connected) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
expect(closes).toBe(1) // should not have closed again
|
||||
})
|
||||
|
||||
test('reconnect with filter update', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
const newSince = Math.floor(Date.now() / 1000)
|
||||
const relay = new Relay(mockRelay.url, {
|
||||
enablePing: true,
|
||||
enableReconnect: filters => {
|
||||
return filters.map(f => ({ ...f, since: newSince }))
|
||||
},
|
||||
})
|
||||
relay.pingTimeout = 50
|
||||
relay.pingFrequency = 50
|
||||
relay.resubscribeBackoff = [50, 100]
|
||||
|
||||
let closes = 0
|
||||
relay.onclose = () => {
|
||||
closes++
|
||||
}
|
||||
|
||||
await relay.connect()
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
const sub = relay.subscribe([{ kinds: [1], since: 0 }], { onevent: () => {} })
|
||||
expect(sub.filters[0].since).toBe(0)
|
||||
|
||||
// wait for the first ping to succeed
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(closes).toBe(0)
|
||||
|
||||
// now make it unresponsive
|
||||
mockRelay.unresponsive = true
|
||||
|
||||
// wait for the second ping to fail, which will trigger a close
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (closes > 0) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
expect(closes).toBe(1)
|
||||
expect(relay.connected).toBeFalse()
|
||||
|
||||
// now make it responsive again
|
||||
mockRelay.unresponsive = false
|
||||
|
||||
// wait for reconnect
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
if (relay.connected) {
|
||||
clearInterval(interval)
|
||||
resolve(null)
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
expect(closes).toBe(1)
|
||||
|
||||
// check if filter was updated
|
||||
expect(sub.filters[0].since).toBe(newSince)
|
||||
})
|
||||
|
||||
20
relay.ts
20
relay.ts
@@ -1,14 +1,7 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import { verifyEvent } from './pure.ts'
|
||||
import { AbstractRelay } from './abstract-relay.ts'
|
||||
|
||||
/**
|
||||
* @deprecated use Relay.connect() instead.
|
||||
*/
|
||||
export function relayConnect(url: string): Promise<Relay> {
|
||||
return Relay.connect(url)
|
||||
}
|
||||
import { AbstractRelay, type AbstractRelayConstructorOptions } from './abstract-relay.ts'
|
||||
|
||||
var _WebSocket: typeof WebSocket
|
||||
|
||||
@@ -21,12 +14,15 @@ export function useWebSocketImplementation(websocketImplementation: any) {
|
||||
}
|
||||
|
||||
export class Relay extends AbstractRelay {
|
||||
constructor(url: string) {
|
||||
super(url, { verifyEvent, websocketImplementation: _WebSocket })
|
||||
constructor(url: string, options?: Pick<AbstractRelayConstructorOptions, 'enablePing' | 'enableReconnect'>) {
|
||||
super(url, { verifyEvent, websocketImplementation: _WebSocket, ...options })
|
||||
}
|
||||
|
||||
static async connect(url: string): Promise<Relay> {
|
||||
const relay = new Relay(url)
|
||||
static async connect(
|
||||
url: string,
|
||||
options?: Pick<AbstractRelayConstructorOptions, 'enablePing' | 'enableReconnect'>,
|
||||
): Promise<Relay> {
|
||||
const relay = new Relay(url, options)
|
||||
await relay.connect()
|
||||
return relay
|
||||
}
|
||||
|
||||
23
signer.ts
Normal file
23
signer.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export class MockRelay {
|
||||
public url: string
|
||||
public secretKeys: Uint8Array[]
|
||||
public preloadedEvents: Event[]
|
||||
public unresponsive: boolean = false
|
||||
|
||||
constructor(url?: string | undefined) {
|
||||
serial++
|
||||
@@ -48,6 +49,7 @@ export class MockRelay {
|
||||
let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {}
|
||||
|
||||
conn.on('message', (message: string) => {
|
||||
if (this.unresponsive) return
|
||||
const data = JSON.parse(message)
|
||||
|
||||
switch (data[0]) {
|
||||
|
||||
25
utils.ts
25
utils.ts
@@ -3,15 +3,21 @@ import type { Event } from './core.ts'
|
||||
export const utf8Decoder: TextDecoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder: TextEncoder = new TextEncoder()
|
||||
|
||||
export { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
export function normalizeURL(url: string): string {
|
||||
if (url.indexOf('://') === -1) url = 'wss://' + url
|
||||
let p = new URL(url)
|
||||
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = ''
|
||||
p.searchParams.sort()
|
||||
p.hash = ''
|
||||
return p.toString()
|
||||
try {
|
||||
if (url.indexOf('://') === -1) url = 'wss://' + url
|
||||
let p = new URL(url)
|
||||
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = ''
|
||||
p.searchParams.sort()
|
||||
p.hash = ''
|
||||
return p.toString()
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid URL: ${url}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function insertEventIntoDescendingList(sortedArray: Event[], event: Event): Event[] {
|
||||
@@ -111,6 +117,9 @@ export class Queue<V> {
|
||||
|
||||
const target = this.first
|
||||
this.first = target.next
|
||||
if (this.first) {
|
||||
this.first.prev = null // fix: clean up prev pointer
|
||||
}
|
||||
|
||||
return target.value
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user