Compare commits

...

108 Commits

Author SHA1 Message Date
fiatjaf
de7d459f6f only transform filters with enableReconnect() when we're actually reconnecting. 2025-12-05 09:22:33 -03:00
fiatjaf
21ec5bb2dc allow automatically performing AUTH against all relays. 2025-12-05 09:12:59 -03:00
fiatjaf
e959409c14 fix classifyKind() test. 2025-11-25 22:21:46 -03:00
fiatjaf
8a76c4e329 fix normalizeUrl to make websocket urls out of http urls. 2025-11-25 22:20:38 -03:00
fiatjaf
34a1d8db47 kinds: more reliable regular/replaceable kind figuring. 2025-11-24 20:08:15 -03:00
fiatjaf
d3ddd490c2 nip27: test emoji behavior when no tags. 2025-11-22 22:23:03 -03:00
fiatjaf
7730e321a5 nip27: support more image, audio and video extensions. 2025-11-22 20:36:04 -03:00
fiatjaf
400d132612 nip77: negentropy tests and small fixes. 2025-11-21 19:51:55 -03:00
fiatjaf
01880b6fb5 nip27: parse emoji shortcodes and hashtags too. 2025-11-21 00:37:40 -03:00
fiatjaf
e87ffc433c build "core" although we shouldn't. 2025-11-21 00:37:40 -03:00
fiatjaf
c45e861493 fire subscriptions.
this was broken during the negentropy stuff.

fixes https://github.com/nbd-wtf/nostr-tools/issues/517
2025-11-19 14:53:24 -03:00
fiatjaf
66cc55c7f0 nip77: negentropy implementation and nip77 interface.
supersedes https://github.com/nbd-wtf/nostr-tools/pull/516
2025-11-18 09:33:11 -03:00
fiatjaf
5841b0936b throw when subscription is created without filters.
fixes https://github.com/nbd-wtf/nostr-tools/pull/497
2025-11-18 08:09:45 -03:00
max-gy
f5d0c0eb0f fix prettier checks on nip77 related *ts files 2025-11-18 08:06:26 -03:00
max-gy
e19db61bec nip77: adds wrapper for negentropy and fallback for yieldThread MessageChannel 2025-11-18 08:06:26 -03:00
Chris McCormick
1e0f393268 Fix subscribeMap EOSE grouping. Fixes #514 2025-10-29 08:18:49 -03:00
Chris McCormick
1bec9fa365 Ping pong memory leak fix for #511 (#512)
* New test for pingpong memory leak (failing).

* Shim once in relay ping mem test.

* Fix pong memory leak with .once.

Fixes #511.

* Fix missing global WebSocket on Node.

* Lint fix.

* Remove overkill WebSocket impl check.
2025-10-26 23:33:38 -03:00
雪猫
e8927d78e6 nip57: lud16 must take precedence over lud06 2025-10-12 11:01:25 -03:00
Chris McCormick
bc1294e4e6 Reconnect with exponential backoff flag: enableReconnect (#507)
https://github.com/nbd-wtf/nostr-tools/pull/507
2025-09-30 10:01:07 -03:00
Chris McCormick
226d7d07e2 Improvements to enablePing() & tests (#506)
https://github.com/nbd-wtf/nostr-tools/pull/506
2025-09-29 10:41:40 -03:00
fiatjaf
c9ff51e278 subscribeMap() now sends multiple filters to the same relay in the same REQ.
because the initiative to get rid of multiple filters went down.
2025-09-20 16:54:12 -03:00
Anderson Juhasc
23aebbd341 update NIP-27 example in README 2025-08-27 10:32:45 -03:00
Anderson Juhasc
a3fcd79545 ensures consistency for .jpg/.JPG, .mp4/.MP4, etc 2025-08-27 10:32:45 -03:00
tajava2006
0e6e7af934 chore: Bump version and document NIP-46 usage 2025-08-25 11:00:06 -03:00
codytseng
8866042edf relay: ensure onclose callback is triggered 2025-08-24 22:22:38 -03:00
hoppe
ebe7df7b9e feat(nip46): Add support for client-initiated connections in BunkerSigner (#502)
* add: nostrconnect

* fix: typo
2025-08-24 15:53:01 -03:00
fiatjaf
86235314c4 deduplicate relay URLs in pool.subscribe() and pool.subscribeMany() 2025-08-06 10:37:36 -03:00
Don
b39dac3551 nip57: include "e" tag. 2025-08-04 15:23:29 -03:00
fiatjaf
929d62bbbb nip57: cleanup useless tests. 2025-08-01 20:28:49 -03:00
fiatjaf
b575e47844 nip57: include "k" tag. 2025-08-01 19:38:03 -03:00
fiatjaf
b076c34a2f tag new minor because of the pingpong stuff. 2025-08-01 14:12:53 -03:00
fiatjaf
4bb3eb2d40 remove unnecessary normalizeURL() call that can throw sometimes. 2025-08-01 14:11:44 -03:00
Chris McCormick
87f2c74bb3 Get pingpong working in the browser with dummy REQ (#499) 2025-07-24 11:22:15 -03:00
fiatjaf
4b6cc19b9c cleanup. 2025-07-23 16:22:25 -03:00
fiatjaf
b2f3a01439 nip46: remove deprecated getRelays() 2025-07-23 16:22:16 -03:00
Chris McCormick
6ec19b618c WIP: pingpong with logging. 2025-07-23 16:16:12 -03:00
Chris McCormick
b3cc9f50e5 WIP: hack in pingpong #495
TypeScript does not like the duck typing of .on and .ping (only valid on Node ws).
2025-07-23 16:16:12 -03:00
vornis101
de1cf0ed60 Fix JSON syntax of jsr.json 2025-07-19 08:58:05 -03:00
fiatjaf
d706ef961f pool: closed relays must be eliminated. 2025-07-17 23:39:16 -03:00
SondreB
2f529b3f8a enhance parseConnectionString to support double slash URL format 2025-07-13 11:59:04 -03:00
fiatjaf
f0357805c3 catch errors on function passed to auth() and log them. 2025-06-10 10:20:20 -03:00
fiatjaf
ffa7fb926e remove deprecated unused _onauth hook. 2025-06-10 10:16:11 -03:00
fiatjaf
12acb900ab SubCloser.close() can take a reason string optionally. 2025-06-10 10:15:58 -03:00
fiatjaf
d773012658 proper auth support on pool.publish(). 2025-06-06 22:36:07 -03:00
fiatjaf
b8f91c37fa and there was an error in jsr.json 2025-06-05 14:37:56 -03:00
fiatjaf
2da3528362 forgot to expose blossom, as usual. 2025-06-05 01:29:54 -03:00
fiatjaf
315e9a472c expose signer module. 2025-06-04 21:47:17 -03:00
fiatjaf
a2b1bf0338 blossom test. 2025-06-04 21:45:43 -03:00
fiatjaf
861a77e2b3 nipB7 (blossom) and a generic signer interface. 2025-06-04 21:28:33 -03:00
António Conselheiro
9132b722f3 improve signature for decode function (#489) 2025-06-01 11:08:57 -03:00
fiatjaf
ae2f97655b remove two deprecated things. 2025-05-31 20:04:46 -03:00
fiatjaf
5b78a829c7 ignore error when sending on a CLOSE to a closed connection. 2025-05-31 12:29:24 -03:00
fiatjaf
de26ee98c5 failed to connect to a websocket should reject the promise. 2025-05-31 11:16:22 -03:00
fiatjaf
1437bbdb0f update removed function in test. 2025-05-28 14:52:36 -03:00
fiatjaf
57354b9fb4 expose hexToBytes and bytesToHex helpers. 2025-05-28 14:50:25 -03:00
fiatjaf
924075b803 nip57: get sats amount from bolt11 helper. 2025-05-20 09:25:31 -03:00
Anderson Juhasc
666a02027e readme updated 2025-05-19 17:13:10 -03:00
fiatjaf
eff9ea9579 remove deprecated subscribeManyMap() 2025-05-17 18:52:01 -03:00
fiatjaf
ca174e6cd8 publish to jsr before npm. 2025-05-12 05:27:16 -03:00
fiatjaf
4ba9c8886b forgot to remove nip96 from export lists. 2025-05-12 05:24:57 -03:00
fiatjaf
7dbd86eb5c fix types from latest nip19 type change. 2025-05-12 05:23:54 -03:00
fiatjaf
3e839db6f2 tag v2.13.0 (breaking because stuff is removed). 2025-05-12 05:20:45 -03:00
codytseng
cb370fbf4f nip46: fix crash caused by endless resubscribe 2025-05-11 15:22:26 -03:00
codytseng
c015b6e794 fix bug where concurrent auth calls returned only one response 2025-05-09 08:43:53 -03:00
fiatjaf
52079f6e75 saner nip19 types.
@staab should be happy now.
2025-04-26 09:00:28 -03:00
fiatjaf
ef28b2eb73 nip46: toBunkerURL() function. 2025-04-21 23:40:15 -03:00
fiatjaf
2a422774fb fix pool.publish() example in README.
following https://github.com/nbd-wtf/nostr-tools/issues/482
2025-04-17 17:17:36 -03:00
fiatjaf
b80f8a0bcc nip07: return a VerifiedEvent 2025-04-11 17:31:57 -03:00
fiatjaf
dd603e47d8 some small bugs codebuff found. 2025-04-03 23:31:34 -03:00
fiatjaf
ba26b92973 get rid of nip96 and unnecessary dependencies. 2025-04-02 11:51:02 -03:00
fiatjaf
aec8ff5946 fix for updated typescript. 2025-04-02 11:44:41 -03:00
fiatjaf
e498c9144d nip46: auto-reconnect. 2025-04-02 10:58:26 -03:00
fiatjaf
42d47abba1 update readme and add more examples. 2025-04-02 10:53:33 -03:00
fiatjaf
303c35120c pool: deprecate subscribeManyMap and introduce subscribe/subscribeEose methods that take a single filter. 2025-04-02 10:37:10 -03:00
fiatjaf
4a738c93d0 nip46: stop supporting nip04-encrypted messages. 2025-04-02 10:25:19 -03:00
fiatjaf
2a11c9ec91 nip04: functions shouldn't be async. 2025-04-02 10:19:27 -03:00
fiatjaf
cbe3a9d683 pool subscribe methods accept an onauth param. 2025-04-01 19:16:42 -03:00
fiatjaf
2944a932b8 nip46: mark connection as closed when relays disconnect. 2025-03-29 18:03:39 -03:00
codytseng
6b39de04d7 Fix auth() not returning on consecutive calls 2025-03-17 13:31:24 -03:00
fiatjaf
9a612e59a2 update nip11 test. 2025-03-14 09:30:35 -03:00
fiatjaf
266dbdf766 nip27: rewrite to support urls and references in a simpler API for rich UIs. 2025-03-14 09:26:40 -03:00
fiatjaf
19ae9837a7 nip19: decodeNostrURI() function that doesn't throw. 2025-03-14 09:26:40 -03:00
António Conselheiro
4188f2c596 Generic repost 2025-03-10 01:58:00 -03:00
fiatjaf
97bded8f5b prevent a relay from eoseing then closing and causing pool handlers to fire twice. 2025-03-02 01:25:39 -03:00
fiatjaf
174d36a440 nip07: remove getRelays() 2025-03-02 01:25:39 -03:00
fiatjaf
0177b130c3 nip55: remove getRelays() 2025-03-02 01:25:39 -03:00
fiatjaf
05eb62da5b support subscription label, not only an absolute id. 2025-03-02 01:25:39 -03:00
Baris Aydek
3c4019a154 nip54 normalizeIdentifier function 2025-02-25 13:52:40 -03:00
fiatjaf
e7e8db1dbd nip46: take EventTemplate instead of UnsignedEvent. 2025-02-24 14:48:47 -03:00
bitcoinpirate
44a679e642 added support for zapping replaceable events (#424)
* added support for zapping replaceable events

* Update nip57.ts

* Update nip57.ts

Co-authored-by: 雪猫 <SnowCait@users.noreply.github.com>

* apply @SnowCait's suggestions.

* fix lint error.

---------

Co-authored-by: AsaiToshiya <to.asai.60@gmail.com>
Co-authored-by: 雪猫 <SnowCait@users.noreply.github.com>
2025-02-24 00:46:51 +09:00
Asai Toshiya
c1172caf1d mark getRelays and get_relays as deprecated. 2025-02-21 15:08:55 -03:00
Jon Staab
86f37d6003 Clean up nip96 upload validation and make it less strict 2025-02-11 15:58:20 -03:00
Sandwich
3daade322c export retention details
pain to use without being available as an export.
2025-02-10 09:25:33 -03:00
Asai Toshiya
fcf10541c8 rename "parameterized replaceable" to "addressable". 2025-01-23 14:08:22 -03:00
Asai Toshiya
548abb5d4a nip18: tweak test data. 2025-01-23 20:03:52 +09:00
Asai Toshiya
1e5bfe856b nip18: don't stringify protected event. 2025-01-17 21:30:09 -03:00
Anderson Juhasc
3266b4d4c2 added NIP-55 2025-01-04 14:15:11 -03:00
Asai Toshiya
a0b950ab12 remove unnecessary id from Omit keys. 2025-01-02 15:57:39 -03:00
Asai Toshiya
be741159d7 nip29: update GroupAdminPermission. 2024-12-17 13:33:00 -03:00
im-adithya
9c50b2c655 fix: clear timeout in publish and auth 2024-12-03 11:09:11 -03:00
Egge
bbb09420fe export nip17 2024-11-26 11:59:58 -03:00
Asai Toshiya
2e85f7a5fe Revert "nip19: remove note1."
This reverts commit a8a805fb71.
2024-11-26 11:59:58 -03:00
Asai Toshiya
b22e2465cc nip19: remove note1. 2024-11-26 11:59:58 -03:00
fiatjaf
43ce7f9377 fix reference to nostr-wasm dependency so it can be installed on deno.
fixes https://github.com/nbd-wtf/nostr-tools/issues/459
2024-11-25 21:33:25 -03:00
fiatjaf
5a55c670fb nip10: fix. 2024-11-13 01:21:54 -03:00
fiatjaf
bf0c4d4988 nip10: improve, support quotes, author hints, change the way legacy refs are discovered. 2024-11-04 15:37:39 -03:00
fiatjaf
50fe7c2a8b streamline jsr publishes. 2024-11-02 08:35:52 -03:00
fiatjaf
29270c8c9d nip46: fix legacyDecrypt argument. 2024-11-02 08:13:33 -03:00
53 changed files with 3613 additions and 1945 deletions

View File

@@ -3,7 +3,7 @@
"extends": ["prettier"], "extends": ["prettier"],
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "babel"], "plugins": ["@typescript-eslint"],
"parserOptions": { "parserOptions": {
"ecmaVersion": 9, "ecmaVersion": 9,
@@ -138,6 +138,7 @@
"valid-typeof": 2, "valid-typeof": 2,
"wrap-iife": [2, "any"], "wrap-iife": [2, "any"],
"yield-star-spacing": [2, "both"], "yield-star-spacing": [2, "both"],
"yoda": [0] "yoda": [0],
"no-labels": [0]
} }
} }

308
README.md
View File

@@ -4,7 +4,7 @@ Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
Only depends on _@scure_ and _@noble_ packages. Only depends on _@scure_ and _@noble_ packages.
This package is only providing lower-level functionality. If you want more higher-level features, take a look at [Nostrify](https://nostrify.dev), or if you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system). This package is only providing lower-level functionality. If you want higher-level features, take a look at [@nostr/gadgets](https://jsr.io/@nostr/gadgets) which is based on this library and expands upon it and has other goodies (it's only available on jsr).
## Installation ## Installation
@@ -57,43 +57,57 @@ let event = finalizeEvent({
let isGood = verifyEvent(event) 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 ```js
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure' 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') const pool = new SimplePool()
console.log(`connected to ${relay.url}`)
// let's query for an event that exists const relays = ['wss://relay.example.com', 'wss://relay.example2.com']
const sub = relay.subscribe([
// let's query for one event that exists
const event = pool.get(
relays,
{ {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'], ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
}, },
], { )
onevent(event) { if (event) {
console.log('we got the event we wanted:', 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's publish a new event while simultaneously monitoring the relay for it
let sk = generateSecretKey() let sk = generateSecretKey()
let pk = getPublicKey(sk) let pk = getPublicKey(sk)
relay.subscribe([ pool.subscribe(
['wss://a.com', 'wss://b.com', 'wss://c.com'],
{ {
kinds: [1], kinds: [1],
authors: [pk], authors: [pk],
}, },
], { {
onevent(event) { onevent(event) {
console.log('got event:', event) console.log('got event:', event)
}
} }
}) )
let eventTemplate = { let eventTemplate = {
kind: 1, kind: 1,
@@ -104,7 +118,7 @@ let eventTemplate = {
// this assigns the pubkey, calculates the event id and signs the event in a single step // this assigns the pubkey, calculates the event id and signs the event in a single step
const signedEvent = finalizeEvent(eventTemplate, sk) const signedEvent = finalizeEvent(eventTemplate, sk)
await relay.publish(signedEvent) await Promise.any(pool.publish(['wss://a.com', 'wss://b.com'], signedEvent))
relay.close() relay.close()
``` ```
@@ -119,59 +133,207 @@ import WebSocket from 'ws'
useWebSocketImplementation(WebSocket) 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 ```js
import { SimplePool } from 'nostr-tools/pool' 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( 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.
[...relays, 'wss://relay.example3.com'],
[ ```js
{ import { SimplePool } from 'nostr-tools/pool'
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
}, const pool = new SimplePool({ enableReconnect: true })
], ```
{
onevent(event) { 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).
// this will only be called once the first time the event is received
// ... ```js
}, // on Node.js
oneose() { const pool = new SimplePool({ enablePing: true, enableReconnect: true })
h.close() ```
}
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 ```js
import { parseReferences } from 'nostr-tools/references' import * as nip27 from '@nostr/tools/nip27'
let references = parseReferences(event) for (let block of nip27.parse(evt.content)) {
let simpleAugmentedContent = event.content switch (block.type) {
for (let i = 0; i < references.length; i++) { case 'text':
let { text, profile, event, address } = references[i] console.log(block.text)
let augmentedReference = profile break
? `<strong>@${profilesCache[profile.pubkey].name}</strong>` case 'reference': {
: event if ('id' in block.pointer) {
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>` console.log("it's a nevent1 uri", block.pointer)
: address } else if ('identifier' in block.pointer) {
? `<a href="${text}">[link]</a>` console.log("it's a naddr1 uri", block.pointer)
: text } else {
simpleAugmentedContent.replaceAll(text, augmentedReference) 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 ### Encoding and decoding NIP-19 codes
```js ```js

View File

@@ -8,18 +8,25 @@ import {
} from './abstract-relay.ts' } from './abstract-relay.ts'
import { normalizeURL } from './utils.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 { type Filter } from './filter.ts'
import { alwaysTrue } from './helpers.ts' import { alwaysTrue } from './helpers.ts'
export type SubCloser = { close: () => void } export type SubCloser = { close: (reason?: string) => void }
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {} export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {
// automaticallyAuth takes a relay URL and should return null
// in case that relay shouldn't be authenticated against
// or a function to sign the AUTH event template otherwise (that function may still throw in case of failure)
automaticallyAuth?: (relayURL: string) => null | ((event: EventTemplate) => Promise<VerifiedEvent>)
}
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & { export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
maxWait?: number maxWait?: number
onclose?: (reasons: string[]) => void onclose?: (reasons: string[]) => void
onauth?: (event: EventTemplate) => Promise<VerifiedEvent>
id?: string id?: string
label?: string
} }
export class AbstractSimplePool { export class AbstractSimplePool {
@@ -28,6 +35,9 @@ export class AbstractSimplePool {
public trackRelays: boolean = false public trackRelays: boolean = false
public verifyEvent: Nostr['verifyEvent'] public verifyEvent: Nostr['verifyEvent']
public enablePing: boolean | undefined
public enableReconnect: boolean | ((filters: Filter[]) => Filter[]) | undefined
public automaticallyAuth?: (relayURL: string) => null | ((event: EventTemplate) => Promise<VerifiedEvent>)
public trustedRelayURLs: Set<string> = new Set() public trustedRelayURLs: Set<string> = new Set()
private _WebSocket?: typeof WebSocket private _WebSocket?: typeof WebSocket
@@ -35,6 +45,9 @@ export class AbstractSimplePool {
constructor(opts: AbstractPoolConstructorOptions) { constructor(opts: AbstractPoolConstructorOptions) {
this.verifyEvent = opts.verifyEvent this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation this._WebSocket = opts.websocketImplementation
this.enablePing = opts.enablePing
this.enableReconnect = opts.enableReconnect
this.automaticallyAuth = opts.automaticallyAuth
} }
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> { async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
@@ -45,10 +58,25 @@ export class AbstractSimplePool {
relay = new AbstractRelay(url, { relay = new AbstractRelay(url, {
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent, verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
websocketImplementation: this._WebSocket, websocketImplementation: this._WebSocket,
enablePing: this.enablePing,
enableReconnect: this.enableReconnect,
}) })
relay.onclose = () => {
if (relay && !relay.enableReconnect) {
this.relays.delete(url)
}
}
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
this.relays.set(url, relay) this.relays.set(url, relay)
} }
if (this.automaticallyAuth) {
const authSignerFn = this.automaticallyAuth(url)
if (authSignerFn) {
relay.onauth = authSignerFn
}
}
await relay.connect() await relay.connect()
return relay return relay
@@ -57,14 +85,45 @@ export class AbstractSimplePool {
close(relays: string[]) { close(relays: string[]) {
relays.map(normalizeURL).forEach(url => { relays.map(normalizeURL).forEach(url => {
this.relays.get(url)?.close() this.relays.get(url)?.close()
this.relays.delete(url)
}) })
} }
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser { subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
return this.subscribeManyMap(Object.fromEntries(relays.map(url => [url, filters])), params) const request: { url: string; filter: Filter }[] = []
for (let i = 0; i < relays.length; i++) {
const url = normalizeURL(relays[i])
if (!request.find(r => r.url === url)) {
request.push({ url, filter: filter })
}
}
return this.subscribeMap(request, params)
} }
subscribeManyMap(requests: { [relay: string]: Filter[] }, params: SubscribeManyParams): SubCloser { subscribeMany(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
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 {
const grouped = new Map<string, Filter[]>()
for (const req of requests) {
const { url, filter } = req
if (!grouped.has(url)) grouped.set(url, [])
grouped.get(url)!.push(filter)
}
const groupedRequests = Array.from(grouped.entries()).map(([url, filters]) => ({ url, filters }))
if (this.trackRelays) { if (this.trackRelays) {
params.receivedEvent = (relay: AbstractRelay, id: string) => { params.receivedEvent = (relay: AbstractRelay, id: string) => {
let set = this.seenOn.get(id) let set = this.seenOn.get(id)
@@ -78,13 +137,13 @@ export class AbstractSimplePool {
const _knownIds = new Set<string>() const _knownIds = new Set<string>()
const subs: Subscription[] = [] const subs: Subscription[] = []
const relaysLength = Object.keys(requests).length
// batch all EOSEs into a single // batch all EOSEs into a single
const eosesReceived: boolean[] = [] const eosesReceived: boolean[] = []
let handleEose = (i: number) => { let handleEose = (i: number) => {
if (eosesReceived[i]) return // do not act twice for the same relay
eosesReceived[i] = true eosesReceived[i] = true
if (eosesReceived.filter(a => a).length === relaysLength) { if (eosesReceived.filter(a => a).length === groupedRequests.length) {
params.oneose?.() params.oneose?.()
handleEose = () => {} handleEose = () => {}
} }
@@ -92,9 +151,10 @@ export class AbstractSimplePool {
// batch all closes into a single // batch all closes into a single
const closesReceived: string[] = [] const closesReceived: string[] = []
let handleClose = (i: number, reason: string) => { let handleClose = (i: number, reason: string) => {
if (closesReceived[i]) return // do not act twice for the same relay
handleEose(i) handleEose(i)
closesReceived[i] = reason closesReceived[i] = reason
if (closesReceived.filter(a => a).length === relaysLength) { if (closesReceived.filter(a => a).length === groupedRequests.length) {
params.onclose?.(closesReceived) params.onclose?.(closesReceived)
handleClose = () => {} handleClose = () => {}
} }
@@ -111,16 +171,7 @@ export class AbstractSimplePool {
// open a subscription in all given relays // open a subscription in all given relays
const allOpened = Promise.all( const allOpened = Promise.all(
Object.entries(requests).map(async (req, i, arr) => { groupedRequests.map(async ({ url, filters }, i) => {
if (arr.indexOf(req) !== i) {
// duplicate
handleClose(i, 'duplicate url')
return
}
let [url, filters] = req
url = normalizeURL(url)
let relay: AbstractRelay let relay: AbstractRelay
try { try {
relay = await this.ensureRelay(url, { relay = await this.ensureRelay(url, {
@@ -134,7 +185,28 @@ export class AbstractSimplePool {
let subscription = relay.subscribe(filters, { let subscription = relay.subscribe(filters, {
...params, ...params,
oneose: () => handleEose(i), 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, alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait, eoseTimeout: params.maxWait,
}) })
@@ -144,24 +216,38 @@ export class AbstractSimplePool {
) )
return { return {
async close() { async close(reason?: string) {
await allOpened await allOpened
subs.forEach(sub => { subs.forEach(sub => {
sub.close() sub.close(reason)
}) })
}, },
} }
} }
subscribeManyEose( subscribeEose(
relays: string[], relays: string[],
filters: Filter[], filter: Filter,
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>, params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth'>,
): SubCloser { ): SubCloser {
const subcloser = this.subscribeMany(relays, filters, { const subcloser = this.subscribe(relays, filter, {
...params, ...params,
oneose() { 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'>,
): SubCloser {
const subcloser = this.subscribeMany(relays, filter, {
...params,
oneose() {
subcloser.close('closed automatically on eose')
}, },
}) })
return subcloser return subcloser
@@ -170,11 +256,11 @@ export class AbstractSimplePool {
async querySync( async querySync(
relays: string[], relays: string[],
filter: Filter, filter: Filter,
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>, params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event[]> { ): Promise<Event[]> {
return new Promise(async resolve => { return new Promise(async resolve => {
const events: Event[] = [] const events: Event[] = []
this.subscribeManyEose(relays, [filter], { this.subscribeEose(relays, filter, {
...params, ...params,
onevent(event: Event) { onevent(event: Event) {
events.push(event) events.push(event)
@@ -189,7 +275,7 @@ export class AbstractSimplePool {
async get( async get(
relays: string[], relays: string[],
filter: Filter, filter: Filter,
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>, params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event | null> { ): Promise<Event | null> {
filter.limit = 1 filter.limit = 1
const events = await this.querySync(relays, filter, params) const events = await this.querySync(relays, filter, params)
@@ -197,7 +283,11 @@ export class AbstractSimplePool {
return events[0] || null return events[0] || null
} }
publish(relays: string[], event: Event): Promise<string>[] { publish(
relays: string[],
event: Event,
options?: { onauth?: (evt: EventTemplate) => Promise<VerifiedEvent> },
): Promise<string>[] {
return relays.map(normalizeURL).map(async (url, i, arr) => { return relays.map(normalizeURL).map(async (url, i, arr) => {
if (arr.indexOf(url) !== i) { if (arr.indexOf(url) !== i) {
// duplicate // duplicate
@@ -205,17 +295,26 @@ export class AbstractSimplePool {
} }
let r = await this.ensureRelay(url) let r = await this.ensureRelay(url)
return r.publish(event).then(reason => { return r
if (this.trackRelays) { .publish(event)
let set = this.seenOn.get(event.id) .catch(async err => {
if (!set) { if (err instanceof Error && err.message.startsWith('auth-required: ') && options?.onauth) {
set = new Set() await r.auth(options.onauth)
this.seenOn.set(event.id, set) return r.publish(event) // retry
} }
set.add(r) throw err
} })
return reason .then(reason => {
}) if (this.trackRelays) {
let set = this.seenOn.get(event.id)
if (!set) {
set = new Set()
this.seenOn.set(event.id, set)
}
set.add(r)
}
return reason
})
}) })
} }

View File

@@ -1,15 +1,29 @@
/* global WebSocket */ /* global WebSocket */
import type { Event, EventTemplate, VerifiedEvent, Nostr } from './core.ts' import type { Event, EventTemplate, VerifiedEvent, Nostr, NostrEvent } from './core.ts'
import { matchFilters, type Filter } from './filter.ts' import { matchFilters, type Filter } from './filter.ts'
import { getHex64, getSubscriptionId } from './fakejson.ts' import { getHex64, getSubscriptionId } from './fakejson.ts'
import { Queue, normalizeURL } from './utils.ts' import { Queue, normalizeURL } from './utils.ts'
import { makeAuthEvent } from './nip42.ts' import { makeAuthEvent } from './nip42.ts'
import { yieldThread } from './helpers.ts' import { yieldThread } from './helpers.ts'
type RelayWebSocket = WebSocket & {
ping?(): void
on?(event: 'pong', listener: () => void): any
}
export type AbstractRelayConstructorOptions = { export type AbstractRelayConstructorOptions = {
verifyEvent: Nostr['verifyEvent'] verifyEvent: Nostr['verifyEvent']
websocketImplementation?: typeof WebSocket websocketImplementation?: typeof WebSocket
enablePing?: boolean
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 { export class AbstractRelay {
@@ -18,23 +32,31 @@ export class AbstractRelay {
public onclose: (() => void) | null = null public onclose: (() => void) | null = null
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`) public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
public onauth: undefined | ((evt: EventTemplate) => Promise<VerifiedEvent>)
// this is exposed just to help in ndk migration, shouldn't be relied upon
public _onauth: ((challenge: string) => void) | null = null
public baseEoseTimeout: number = 4400 public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400 public connectionTimeout: number = 4400
public publishTimeout: number = 4400 public publishTimeout: number = 4400
public pingFrequency: number = 20000
public pingTimeout: number = 20000
public resubscribeBackoff: number[] = [10000, 10000, 10000, 20000, 20000, 30000, 60000]
public openSubs: Map<string, Subscription> = new Map() public openSubs: Map<string, Subscription> = new Map()
public enablePing: boolean | undefined
public enableReconnect: boolean | ((filters: Filter[]) => Filter[])
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined 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 connectionPromise: Promise<void> | undefined
private openCountRequests = new Map<string, CountResolver>() private openCountRequests = new Map<string, CountResolver>()
private openEventPublishes = new Map<string, EventPublishResolver>() private openEventPublishes = new Map<string, EventPublishResolver>()
private ws: WebSocket | undefined private ws: RelayWebSocket | undefined
private incomingMessageQueue = new Queue<string>() private incomingMessageQueue = new Queue<string>()
private queueRunning = false private queueRunning = false
private challenge: string | undefined private challenge: string | undefined
private authPromise: Promise<string> | undefined
private serial: number = 0 private serial: number = 0
private verifyEvent: Nostr['verifyEvent'] private verifyEvent: Nostr['verifyEvent']
@@ -44,6 +66,8 @@ export class AbstractRelay {
this.url = normalizeURL(url) this.url = normalizeURL(url)
this.verifyEvent = opts.verifyEvent this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation || WebSocket this._WebSocket = opts.websocketImplementation || WebSocket
this.enablePing = opts.enablePing
this.enableReconnect = opts.enableReconnect || false
} }
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> { static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
@@ -73,10 +97,45 @@ export class AbstractRelay {
return this._connected 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> { public async connect(): Promise<void> {
if (this.connectionPromise) return this.connectionPromise if (this.connectionPromise) return this.connectionPromise
this.challenge = undefined this.challenge = undefined
this.authPromise = undefined
this.connectionPromise = new Promise((resolve, reject) => { this.connectionPromise = new Promise((resolve, reject) => {
this.connectionTimeoutHandle = setTimeout(() => { this.connectionTimeoutHandle = setTimeout(() => {
reject('connection timed out') reject('connection timed out')
@@ -88,33 +147,47 @@ export class AbstractRelay {
try { try {
this.ws = new this._WebSocket(this.url) this.ws = new this._WebSocket(this.url)
} catch (err) { } catch (err) {
clearTimeout(this.connectionTimeoutHandle)
reject(err) reject(err)
return return
} }
this.ws.onopen = () => { this.ws.onopen = () => {
if (this.reconnectTimeoutHandle) {
clearTimeout(this.reconnectTimeoutHandle)
this.reconnectTimeoutHandle = undefined
}
clearTimeout(this.connectionTimeoutHandle) clearTimeout(this.connectionTimeoutHandle)
this._connected = true this._connected = true
const isReconnection = this.reconnectAttempts > 0
this.reconnectAttempts = 0
// resubscribe to all open subscriptions
for (const sub of this.openSubs.values()) {
sub.eosed = false
if (isReconnection && typeof this.enableReconnect === 'function') {
sub.filters = this.enableReconnect(sub.filters)
}
sub.fire()
}
if (this.enablePing) {
this.pingpong()
}
resolve() resolve()
} }
this.ws.onerror = ev => { this.ws.onerror = ev => {
clearTimeout(this.connectionTimeoutHandle)
reject((ev as any).message || 'websocket error') reject((ev as any).message || 'websocket error')
if (this._connected) { this.handleHardClose('relay connection errored')
this._connected = false
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection errored')
}
} }
this.ws.onclose = async () => { this.ws.onclose = ev => {
if (this._connected) { clearTimeout(this.connectionTimeoutHandle)
this._connected = false reject((ev as any).message || 'websocket closed')
this.connectionPromise = undefined this.handleHardClose('relay connection closed')
this.onclose?.()
this.closeAllSubscriptions('relay connection closed')
}
} }
this.ws.onmessage = this._onmessage.bind(this) this.ws.onmessage = this._onmessage.bind(this)
@@ -123,6 +196,55 @@ export class AbstractRelay {
return this.connectionPromise 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"], "limit": 0}]
const sub = this.subscribe(
[{ ids: ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], limit: 0 }],
{
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() { private async runQueue() {
this.queueRunning = true this.queueRunning = true
while (true) { while (true) {
@@ -140,6 +262,7 @@ export class AbstractRelay {
return false return false
} }
// shortcut EVENT sub
const subid = getSubscriptionId(json) const subid = getSubscriptionId(json)
if (subid) { if (subid) {
const so = this.openSubs.get(subid as string) const so = this.openSubs.get(subid as string)
@@ -172,7 +295,7 @@ export class AbstractRelay {
switch (data[0]) { switch (data[0]) {
case 'EVENT': { case 'EVENT': {
const so = this.openSubs.get(data[1] as string) as Subscription const so = this.openSubs.get(data[1] as string) as Subscription
const event = data[2] as Event const event = data[2] as NostrEvent
if (this.verifyEvent(event) && matchFilters(so.filters, event)) { if (this.verifyEvent(event) && matchFilters(so.filters, event)) {
so.onevent(event) so.onevent(event)
} }
@@ -200,6 +323,7 @@ export class AbstractRelay {
const reason: string = data[3] const reason: string = data[3]
const ep = this.openEventPublishes.get(id) as EventPublishResolver const ep = this.openEventPublishes.get(id) as EventPublishResolver
if (ep) { if (ep) {
clearTimeout(ep.timeout)
if (ok) ep.resolve(reason) if (ok) ep.resolve(reason)
else ep.reject(new Error(reason)) else ep.reject(new Error(reason))
this.openEventPublishes.delete(id) this.openEventPublishes.delete(id)
@@ -214,12 +338,20 @@ export class AbstractRelay {
so.close(data[2] as string) so.close(data[2] as string)
return return
} }
case 'NOTICE': case 'NOTICE': {
this.onnotice(data[1] as string) this.onnotice(data[1] as string)
return return
}
case 'AUTH': { case 'AUTH': {
this.challenge = data[1] as string this.challenge = data[1] as string
this._onauth?.(data[1] as string) if (this.onauth) {
this.auth(this.onauth)
}
return
}
default: {
const so = this.openSubs.get(data[1])
so?.oncustom?.(data)
return return
} }
} }
@@ -229,7 +361,7 @@ export class AbstractRelay {
} }
public async send(message: string) { public async send(message: string) {
if (!this.connectionPromise) throw new Error('sending on closed connection') if (!this.connectionPromise) throw new SendingOnClosedConnection(message, this.url)
this.connectionPromise.then(() => { this.connectionPromise.then(() => {
this.ws?.send(message) this.ws?.send(message)
@@ -237,27 +369,41 @@ export class AbstractRelay {
} }
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> { 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 challenge = this.challenge
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge)) if (!challenge) throw new Error("can't perform auth, no challenge was received")
const ret = new Promise<string>((resolve, reject) => { if (this.authPromise) return this.authPromise
this.openEventPublishes.set(evt.id, { resolve, reject })
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 this.authPromise
return ret
} }
public async publish(event: Event): Promise<string> { public async publish(event: Event): Promise<string> {
const ret = new Promise<string>((resolve, reject) => { 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) + ']') 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 return ret
} }
@@ -271,24 +417,42 @@ export class AbstractRelay {
return ret return ret
} }
public subscribe(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription { public subscribe(
const subscription = this.prepareSubscription(filters, params) filters: Filter[],
subscription.fire() params: Partial<SubscriptionParams> & { label?: string; id?: string },
return subscription ): Subscription {
const sub = this.prepareSubscription(filters, params)
sub.fire()
return sub
} }
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription { public prepareSubscription(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
this.serial++ 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) const subscription = new Subscription(this, id, filters, params)
this.openSubs.set(id, subscription) this.openSubs.set(id, subscription)
return subscription return subscription
} }
public close() { 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.closeAllSubscriptions('relay connection closed by us')
this._connected = false 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 // this is the function assigned to this.ws.onmessage
@@ -315,10 +479,15 @@ export class Subscription {
public oneose: (() => void) | undefined public oneose: (() => void) | undefined
public onclose: ((reason: string) => void) | undefined public onclose: ((reason: string) => void) | undefined
// will get any messages that have this subscription id as their second item and are not default standard
public oncustom: ((msg: string[]) => void) | undefined
public eoseTimeout: number public eoseTimeout: number
private eoseTimeoutHandle: ReturnType<typeof setTimeout> | undefined private eoseTimeoutHandle: ReturnType<typeof setTimeout> | undefined
constructor(relay: AbstractRelay, id: string, filters: Filter[], params: SubscriptionParams) { constructor(relay: AbstractRelay, id: string, filters: Filter[], params: SubscriptionParams) {
if (filters.length === 0) throw new Error("subscription can't be created with zero filters")
this.relay = relay this.relay = relay
this.filters = filters this.filters = filters
this.id = id this.id = id
@@ -356,7 +525,15 @@ export class Subscription {
if (!this.closed && this.relay.connected) { if (!this.closed && this.relay.connected) {
// if the connection was closed by the user calling .close() we will send a CLOSE message // if the connection was closed by the user calling .close() we will send a CLOSE message
// otherwise this._open will be already set to false so we will skip this // otherwise this._open will be already set to false so we will skip this
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.closed = true
} }
this.relay.openSubs.delete(this.id) this.relay.openSubs.delete(this.id)
@@ -381,4 +558,5 @@ export type CountResolver = {
export type EventPublishResolver = { export type EventPublishResolver = {
resolve: (reason: string) => void resolve: (reason: string) => void
reject: (err: Error) => void reject: (err: Error) => void
timeout: ReturnType<typeof setTimeout>
} }

View File

@@ -7,7 +7,6 @@ const entryPoints = fs
.filter( .filter(
file => file =>
file.endsWith('.ts') && file.endsWith('.ts') &&
file !== 'core.ts' &&
file !== 'test-helpers.ts' && file !== 'test-helpers.ts' &&
file !== 'helpers.ts' && file !== 'helpers.ts' &&
file !== 'benchmarks.ts' && file !== 'benchmarks.ts' &&

View File

@@ -43,7 +43,7 @@ export function validateEvent<T>(event: T): event is T & UnsignedEvent {
let tag = event.tags[i] let tag = event.tags[i]
if (!Array.isArray(tag)) return false if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) { for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === 'object') return false if (typeof tag[j] !== 'string') return false
} }
} }

View File

@@ -1,5 +1,5 @@
import { Event } from './core.ts' import { Event } from './core.ts'
import { isParameterizedReplaceableKind, isReplaceableKind } from './kinds.ts' import { isAddressableKind, isReplaceableKind } from './kinds.ts'
export type Filter = { export type Filter = {
ids?: string[] ids?: string[]
@@ -98,7 +98,7 @@ export function getFilterLimit(filter: Filter): number {
: Infinity, : Infinity,
// Parameterized replaceable events are limited by the number of authors, kinds, and "d" tags. // 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 ? filter.authors.length * filter.kinds.length * filter['#d'].length
: Infinity, : Infinity,
) )

View File

@@ -1,17 +1,34 @@
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts' import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
export async function yieldThread() { export async function yieldThread() {
return new Promise<void>(resolve => { return new Promise<void>((resolve, reject) => {
const ch = new MessageChannel() try {
const handler = () => { // Check if MessageChannel is available
// @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`) if (typeof MessageChannel !== 'undefined') {
ch.port1.removeEventListener('message', handler) const ch = new MessageChannel()
resolve() const handler = () => {
// @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`)
ch.port1.removeEventListener('message', handler)
resolve()
}
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
ch.port1.addEventListener('message', handler)
ch.port2.postMessage(0)
ch.port1.start()
} else {
if (typeof setImmediate !== 'undefined') {
setImmediate(resolve)
} else if (typeof setTimeout !== 'undefined') {
setTimeout(resolve, 0)
} else {
// Last resort - resolve immediately
resolve()
}
}
} catch (e) {
console.error('during yield: ', e)
reject(e)
} }
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
ch.port1.addEventListener('message', handler)
ch.port2.postMessage(0)
ch.port1.start()
}) })
} }

View File

@@ -9,6 +9,7 @@ export * as nip05 from './nip05.ts'
export * as nip10 from './nip10.ts' export * as nip10 from './nip10.ts'
export * as nip11 from './nip11.ts' export * as nip11 from './nip11.ts'
export * as nip13 from './nip13.ts' export * as nip13 from './nip13.ts'
export * as nip17 from './nip17.ts'
export * as nip18 from './nip18.ts' export * as nip18 from './nip18.ts'
export * as nip19 from './nip19.ts' export * as nip19 from './nip19.ts'
export * as nip21 from './nip21.ts' export * as nip21 from './nip21.ts'
@@ -20,8 +21,10 @@ export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts' export * as nip42 from './nip42.ts'
export * as nip44 from './nip44.ts' export * as nip44 from './nip44.ts'
export * as nip47 from './nip47.ts' export * as nip47 from './nip47.ts'
export * as nip54 from './nip54.ts'
export * as nip57 from './nip57.ts' export * as nip57 from './nip57.ts'
export * as nip59 from './nip59.ts' export * as nip59 from './nip59.ts'
export * as nip77 from './nip77.ts'
export * as nip98 from './nip98.ts' export * as nip98 from './nip98.ts'
export * as kinds from './kinds.ts' export * as kinds from './kinds.ts'

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nostr/tools", "name": "@nostr/tools",
"version": "2.10.0", "version": "2.19.0",
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./core": "./core.ts", "./core": "./core.ts",
@@ -34,15 +34,17 @@
"./nip44": "./nip44.ts", "./nip44": "./nip44.ts",
"./nip46": "./nip46.ts", "./nip46": "./nip46.ts",
"./nip49": "./nip49.ts", "./nip49": "./nip49.ts",
"./nip54": "./nip54.ts",
"./nip57": "./nip57.ts", "./nip57": "./nip57.ts",
"./nip58": "./nip58.ts", "./nip58": "./nip58.ts",
"./nip59": "./nip59.ts", "./nip59": "./nip59.ts",
"./nip75": "./nip75.ts", "./nip75": "./nip75.ts",
"./nip94": "./nip94.ts", "./nip94": "./nip94.ts",
"./nip96": "./nip96.ts",
"./nip98": "./nip98.ts", "./nip98": "./nip98.ts",
"./nip99": "./nip99.ts", "./nip99": "./nip99.ts",
"./nipb7": "./nipb7.ts",
"./fakejson": "./fakejson.ts", "./fakejson": "./fakejson.ts",
"./utils": "./utils.ts" "./utils": "./utils.ts",
"./signer": "./signer.ts"
} }
} }

View File

@@ -12,6 +12,12 @@ test-only file:
bun test {{file}} bun test {{file}}
publish: build 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 npm publish
format: format:

View File

@@ -18,7 +18,7 @@ test('kind classification', () => {
expect(classifyKind(30000)).toBe('parameterized') expect(classifyKind(30000)).toBe('parameterized')
expect(classifyKind(39999)).toBe('parameterized') expect(classifyKind(39999)).toBe('parameterized')
expect(classifyKind(40000)).toBe('unknown') expect(classifyKind(40000)).toBe('unknown')
expect(classifyKind(255)).toBe('unknown') expect(classifyKind(255)).toBe('regular')
}) })
test('kind type guard', () => { test('kind type guard', () => {

View File

@@ -2,12 +2,12 @@ import { NostrEvent, validateEvent } from './pure.ts'
/** Events are **regular**, which means they're all expected to be stored by relays. */ /** Events are **regular**, which means they're all expected to be stored by relays. */
export function isRegularKind(kind: number): boolean { export function isRegularKind(kind: number): boolean {
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind) return kind < 10000 && kind !== 0 && kind !== 3
} }
/** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */ /** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */
export function isReplaceableKind(kind: number): boolean { export function isReplaceableKind(kind: number): boolean {
return [0, 3].includes(kind) || (10000 <= kind && kind < 20000) return kind === 0 || kind === 3 || (10000 <= kind && kind < 20000)
} }
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */ /** Events are **ephemeral**, which means they are not expected to be stored by relays. */
@@ -15,8 +15,8 @@ export function isEphemeralKind(kind: number): boolean {
return 20000 <= kind && kind < 30000 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. */ /** 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 isParameterizedReplaceableKind(kind: number): boolean { export function isAddressableKind(kind: number): boolean {
return 30000 <= kind && kind < 40000 return 30000 <= kind && kind < 40000
} }
@@ -28,7 +28,7 @@ export function classifyKind(kind: number): KindClassification {
if (isRegularKind(kind)) return 'regular' if (isRegularKind(kind)) return 'regular'
if (isReplaceableKind(kind)) return 'replaceable' if (isReplaceableKind(kind)) return 'replaceable'
if (isEphemeralKind(kind)) return 'ephemeral' if (isEphemeralKind(kind)) return 'ephemeral'
if (isParameterizedReplaceableKind(kind)) return 'parameterized' if (isAddressableKind(kind)) return 'parameterized'
return 'unknown' return 'unknown'
} }

View File

@@ -5,7 +5,7 @@ import { base64 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts' 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 privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey) const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key) const normalizedKey = getNormalizedX(key)
@@ -21,7 +21,7 @@ export async function encrypt(secretKey: string | Uint8Array, pubkey: string, te
return `${ctb64}?iv=${ivb64}` 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 const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
let [ctb64, ivb64] = data.split('?iv=') let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey) let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)

View File

@@ -1,10 +1,8 @@
import { EventTemplate, NostrEvent } from './core.ts' import { EventTemplate, VerifiedEvent } from './core.ts'
import { RelayRecord } from './relay.ts'
export interface WindowNostr { export interface WindowNostr {
getPublicKey(): Promise<string> getPublicKey(): Promise<string>
signEvent(event: EventTemplate): Promise<NostrEvent> signEvent(event: EventTemplate): Promise<VerifiedEvent>
getRelays(): Promise<RelayRecord>
nip04?: { nip04?: {
encrypt(pubkey: string, plaintext: string): Promise<string> encrypt(pubkey: string, plaintext: string): Promise<string>
decrypt(pubkey: string, ciphertext: string): Promise<string> decrypt(pubkey: string, ciphertext: string): Promise<string>

View File

@@ -5,20 +5,21 @@ describe('parse NIP10-referenced events', () => {
test('legacy + a lot of events', () => { test('legacy + a lot of events', () => {
let event = { let event = {
tags: [ tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'], ['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
], ],
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
quotes: [],
mentions: [ mentions: [
{ {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
@@ -55,33 +56,80 @@ describe('parse NIP10-referenced events', () => {
relays: [], relays: [],
}, },
], ],
reply: { root: {
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d', id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
relays: [], relays: [],
}, },
root: { reply: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [], relays: [],
}, },
}) })
}) })
test('legacy + 3 events', () => { test('modern', () => {
let event = { let event = {
tags: [ tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'], ['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['e', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', '', 'root'],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', '', 'reply'],
], ],
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
quotes: [],
mentions: [ 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: [], relays: [],
}, },
], ],
@@ -96,98 +144,80 @@ describe('parse NIP10-referenced events', () => {
}, },
{ {
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [], relays: ['wss://banana.com', 'wss://goiaba.com'],
}, },
], ],
root: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: ['wss://banana.com', 'wss://goiaba.com'],
author: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
},
reply: { reply: {
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: [], relays: [],
}, },
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
}) })
}) })
test('legacy + 2 events', () => { test('1 event, relay hint from author', () => {
let event = { let event = {
tags: [ tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'], ['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', 'wss://banana.com'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'], [
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'], 'e',
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'], '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'], '',
'root',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
],
], ],
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
quotes: [],
mentions: [], mentions: [],
profiles: [ profiles: [
{
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
{ {
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [], relays: ['wss://banana.com'],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
}, },
], ],
reply: { reply: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
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: {
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590', 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 = { let event = {
tags: [ 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'], ['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'], ['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
], ],
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
quotes: [],
mentions: [], mentions: [],
profiles: [ profiles: [
{ {
@@ -222,8 +252,13 @@ describe('parse NIP10-referenced events', () => {
reply: { reply: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'], relays: ['wss://relay.mostr.pub'],
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
},
root: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'],
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
}, },
root: undefined,
}) })
}) })
}) })

152
nip10.ts
View File

@@ -1,7 +1,7 @@
import type { Event } from './core.ts' import type { Event } from './core.ts'
import type { EventPointer, ProfilePointer } from './nip19.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. * Pointer to the root of the thread.
*/ */
@@ -13,29 +13,80 @@ export type NIP10Result = {
reply: EventPointer | undefined 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[] mentions: EventPointer[]
/**
* Pointers to events that were directly quoted.
*/
quotes: EventPointer[]
/** /**
* List of pubkeys that are involved in the thread in no particular order. * List of pubkeys that are involved in the thread in no particular order.
*/ */
profiles: ProfilePointer[] profiles: ProfilePointer[]
} } {
const result: ReturnType<typeof parse> = {
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
const result: NIP10Result = {
reply: undefined, reply: undefined,
root: undefined, root: undefined,
mentions: [], mentions: [],
profiles: [], 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]) { 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]) { if (tag[0] === 'p' && tag[1]) {
@@ -43,49 +94,54 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
pubkey: tag[1], pubkey: tag[1],
relays: tag[2] ? [tag[2]] : [], relays: tag[2] ? [tag[2]] : [],
}) })
continue
} }
} }
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) { // get legacy (positional) markers, set reply to root and vice-versa if one of them is missing
const eTag = eTags[eTagIndex] if (!result.root) {
result.root = maybeRoot || maybeParent || result.reply
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)
} }
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 return result
} }

View File

@@ -10,7 +10,9 @@ describe('requesting relay as for NIP11', () => {
const info = await fetchRelayInformation('wss://nos.lol') const info = await fetchRelayInformation('wss://nos.lol')
expect(info.name).toEqual('nos.lol') expect(info.name).toEqual('nos.lol')
expect(info.description).toContain('Generally accepts notes, except spammy ones.') 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') expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git')
}) })
}) })

View File

@@ -126,7 +126,7 @@ export interface Limitations {
restricted_writes: boolean restricted_writes: boolean
} }
interface RetentionDetails { export interface RetentionDetails {
kinds: (number | number[])[] kinds: (number | number[])[]
time?: number | null time?: number | null
count?: number | null count?: number | null

View File

@@ -1,7 +1,7 @@
import { describe, test, expect } from 'bun:test' import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils' import { hexToBytes } from '@noble/hashes/utils'
import { finalizeEvent, getPublicKey } from './pure.ts' import { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
import { Repost, ShortTextNote } from './kinds.ts' import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } from './kinds.ts'
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts' import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
import { buildEvent } from './test-helpers.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', () => { describe('getRepostedEventPointer', () => {
test('should parse an event with only an `e` tag', () => { test('should parse an event with only an `e` tag', () => {
const event = buildEvent({ const event = buildEvent({
@@ -100,3 +145,26 @@ describe('getRepostedEventPointer', () => {
expect(repostedEventPointer!.relays).toEqual([relayUrl]) 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('')
})
})

View File

@@ -1,6 +1,6 @@
import { Event, finalizeEvent, verifyEvent } from './pure.ts' import { GenericRepost, Repost, ShortTextNote } from './kinds.ts'
import { Repost } from './kinds.ts'
import { EventPointer } from './nip19.ts' import { EventPointer } from './nip19.ts'
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
export type RepostEventTemplate = { export type RepostEventTemplate = {
/** /**
@@ -25,11 +25,20 @@ export function finishRepostEvent(
relayUrl: string, relayUrl: string,
privateKey: Uint8Array, privateKey: Uint8Array,
): Event { ): 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( return finalizeEvent(
{ {
kind: Repost, kind,
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]], tags,
content: t.content === '' ? '' : JSON.stringify(reposted), content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
created_at: t.created_at, created_at: t.created_at,
}, },
privateKey, privateKey,
@@ -37,7 +46,7 @@ export function finishRepostEvent(
} }
export function getRepostedEventPointer(event: Event): undefined | EventPointer { export function getRepostedEventPointer(event: Event): undefined | EventPointer {
if (event.kind !== Repost) { if (![Repost, GenericRepost].includes(event.kind)) {
return undefined return undefined
} }

View File

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

View File

@@ -61,28 +61,56 @@ export type AddressPointer = {
relays?: string[] relays?: string[]
} }
type Prefixes = { export function decodeNostrURI(nip19code: string): ReturnType<typeof decode> | { type: 'invalid'; data: null } {
nprofile: ProfilePointer try {
nevent: EventPointer if (nip19code.startsWith('nostr:')) nip19code = nip19code.substring(6)
naddr: AddressPointer return decode(nip19code)
nsec: Uint8Array } catch (_err) {
npub: string return { type: 'invalid', data: null }
note: string }
} }
type DecodeValue<Prefix extends keyof Prefixes> = { export type DecodedNevent = {
type: Prefix type: 'nevent'
data: Prefixes[Prefix] data: EventPointer
} }
export type DecodeResult = { export type DecodedNprofile = {
[P in keyof Prefixes]: DecodeValue<P> type: 'nprofile'
}[keyof Prefixes] data: ProfilePointer
}
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix> export type DecodedNaddr = {
export function decode(nip19: string): DecodeResult type: 'naddr'
export function decode(nip19: string): DecodeResult { data: AddressPointer
let { prefix, words } = bech32.decode(nip19, Bech32MaxSize) }
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)) let data = new Uint8Array(bech32.fromWords(words))
switch (prefix) { switch (prefix) {

View File

@@ -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...` */ /** Nostr URI regex, eg `nostr:npub1...` */
export const NOSTR_URI_REGEX: RegExp = new RegExp(`nostr:(${BECH32_REGEX.source})`) 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...`). */ /** The bech32-encoded data (eg `npub1...`). */
value: string value: string
/** Decoded bech32 string, according to NIP-19. */ /** 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. */ /** Parse and decode a Nostr URI. */

View File

@@ -1,68 +1,115 @@
import { test, expect } from 'bun:test' import { test, expect } from 'bun:test'
import { matchAll, replaceAll } from './nip27.ts' import { parse } from './nip27.ts'
import { NostrEvent } from './core.ts'
test('matchAll', () => { test('first: parse simple content with 1 url and 1 nostr uri', () => {
const result = matchAll( const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg`
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', const blocks = Array.from(parse(content))
)
expect([...result]).toEqual([ expect(blocks).toEqual([
{ { type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', { type: 'text', text: ' check out my profile:' },
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', { type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
decoded: { { type: 'text', text: '; and this cool image ' },
type: 'npub', { type: 'image', url: 'https://images.com/image.jpg' },
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
},
start: 6,
end: 75,
},
{
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
},
start: 78,
end: 147,
},
]) ])
}) })
test('matchAll with an invalid nip19', () => { test('second: parse content with 3 urls of different types', () => {
const result = matchAll( const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music:
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', 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: { type: 'text',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b', 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: 'note',
},
end: 193,
start: 124,
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
}, },
{ type: 'url', url: 'https://ok.com/' },
{ type: 'text', text: '!' },
]) ])
}) })
test('replaceAll', () => { test('third: parse complex content with 4 nostr uris and 3 urls', () => {
const content = const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' 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 }) => { expect(blocks).toEqual([
switch (decoded.type) { { type: 'text', text: 'Look at these profiles ' },
case 'npub': { type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
return '@alex' { type: 'text', text: ' ' },
case 'note': {
return '!1234' type: 'reference',
default: pointer: {
return value pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
} relays: ['wss://qwieu.com'],
}) },
},
expect(result).toEqual('Hello @alex!\n\n!1234') { 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' },
])
})
test('parse content with hashtags and emoji shortcodes', () => {
const event: NostrEvent = {
kind: 1,
tags: [
['emoji', 'star', 'https://example.com/star.png'],
['emoji', 'alpaca', 'https://example.com/alpaca.png'],
],
content:
'hey nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out :alpaca::alpaca: #alpaca at wss://alpaca.com! :star:',
created_at: 1234567890,
pubkey: 'dummy',
id: 'dummy',
sig: 'dummy',
}
const blocks = Array.from(parse(event))
expect(blocks).toEqual([
{ type: 'text', text: 'hey ' },
{ type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
{ type: 'text', text: ' check out ' },
{ type: 'emoji', shortcode: 'alpaca', url: 'https://example.com/alpaca.png' },
{ type: 'emoji', shortcode: 'alpaca', url: 'https://example.com/alpaca.png' },
{ type: 'text', text: ' ' },
{ type: 'hashtag', value: 'alpaca' },
{ type: 'text', text: ' at ' },
{ type: 'relay', url: 'wss://alpaca.com/' },
{ type: 'text', text: '! ' },
{ type: 'emoji', shortcode: 'star', url: 'https://example.com/star.png' },
])
})
test('emoji shortcodes are treated as text if no event tags', () => {
const blocks = Array.from(parse('hello :alpaca:'))
expect(blocks).toEqual([{ type: 'text', text: 'hello :alpaca:' }])
}) })

259
nip27.ts
View File

@@ -1,63 +1,212 @@
import { decode } from './nip19.ts' import { NostrEvent } from './core.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 type Block =
export const regex = (): RegExp => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g') | {
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
}
| {
type: 'emoji'
shortcode: string
url: string
}
| {
type: 'hashtag'
value: string
}
/** Match result for a Nostr URI in event content. */ const noCharacter = /\W/m
export interface NostrURIMatch extends NostrURI { const noURLCharacter = /\W |\W$|$|,| /m
/** Index where the URI begins in the event content. */ const MAX_HASHTAG_LENGTH = 42
start: number
/** Index where the URI ends in the event content. */
end: number
}
/** Find and decode all NIP-21 URIs. */ export function* parse(content: string | NostrEvent): Iterable<Block> {
export function* matchAll(content: string): Iterable<NostrURIMatch> { let emojis: { type: 'emoji'; shortcode: string; url: string }[] = []
const matches = content.matchAll(regex()) if (typeof content !== 'string') {
for (let i = 0; i < content.tags.length; i++) {
for (const match of matches) { const tag = content.tags[i]
try { if (tag[0] === 'emoji' && tag.length >= 3) {
const [uri, value] = match emojis.push({ type: 'emoji', shortcode: tag[1], url: tag[2] })
yield {
uri: uri as `nostr:${string}`,
value,
decoded: decode(value),
start: match.index!,
end: match.index! + uri.length,
} }
} catch (_e) { }
// do nothing content = content.content
}
const max = content.length
let prevIndex = 0
let index = 0
mainloop: while (index < max) {
const u = content.indexOf(':', index)
const h = content.indexOf('#', index)
if (u === -1 && h === -1) {
// reached end
break mainloop
}
if (u === -1 || (h >= 0 && h < u)) {
// parse hashtag
if (h === 0 || content[h - 1] === ' ') {
const m = content.slice(h + 1, h + MAX_HASHTAG_LENGTH).match(noCharacter)
const end = m ? h + 1 + m.index! : max
yield { type: 'text', text: content.slice(prevIndex, h) }
yield { type: 'hashtag', value: content.slice(h + 1, end) }
index = end
prevIndex = index
continue mainloop
}
// ignore this, it is nothing
index = h + 1
continue mainloop
}
// otherwise parse things that have an ":"
if (content.slice(u - 5, u) === 'nostr') {
const m = content.slice(u + 60).match(noCharacter)
const end = m ? u + 60 + m.index! : max
try {
let pointer: ProfilePointer | AddressPointer | EventPointer
let { data, type } = decode(content.slice(u + 1, end))
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.slice(prevIndex, u - 5) }
}
yield { type: 'reference', pointer }
index = end
prevIndex = index
continue mainloop
} catch (_err) {
// ignore this, not a valid nostr uri
index = u + 1
continue mainloop
}
} else if (content.slice(u - 5, u) === 'https' || content.slice(u - 4, u) === 'http') {
const m = content.slice(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.slice(u - prefixLen, end))
if (url.hostname.indexOf('.') === -1) {
throw new Error('invalid url')
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.slice(prevIndex, u - prefixLen) }
}
if (/\.(png|jpe?g|gif|webp|heic|svg)$/i.test(url.pathname)) {
yield { type: 'image', url: url.toString() }
index = end
prevIndex = index
continue mainloop
}
if (/\.(mp4|avi|webm|mkv|mov)$/i.test(url.pathname)) {
yield { type: 'video', url: url.toString() }
index = end
prevIndex = index
continue mainloop
}
if (/\.(mp3|aac|ogg|opus|wav|flac)$/i.test(url.pathname)) {
yield { type: 'audio', url: url.toString() }
index = end
prevIndex = index
continue mainloop
}
yield { type: 'url', url: url.toString() }
index = end
prevIndex = index
continue mainloop
} catch (_err) {
// ignore this, not a valid url
index = end + 1
continue mainloop
}
} else if (content.slice(u - 3, u) === 'wss' || content.slice(u - 2, u) === 'ws') {
const m = content.slice(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.slice(u - prefixLen, end))
if (url.hostname.indexOf('.') === -1) {
throw new Error('invalid ws url')
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.slice(prevIndex, u - prefixLen) }
}
yield { type: 'relay', url: url.toString() }
index = end
prevIndex = index
continue mainloop
} catch (_err) {
// ignore this, not a valid url
index = end + 1
continue mainloop
}
} else {
// try to parse an emoji shortcode
for (let e = 0; e < emojis.length; e++) {
const emoji = emojis[e]
if (
content[u + emoji.shortcode.length + 1] === ':' &&
content.slice(u + 1, u + emoji.shortcode.length + 1) === emoji.shortcode
) {
// found an emoji
if (prevIndex !== u) {
yield { type: 'text', text: content.slice(prevIndex, u) }
}
yield emoji
index = u + emoji.shortcode.length + 2
prevIndex = index
continue mainloop
}
}
// ignore this, it is nothing
index = u + 1
continue mainloop
} }
} }
}
/** if (prevIndex !== max) {
* Replace all occurrences of Nostr URIs in the text. yield { type: 'text', text: content.slice(prevIndex) }
* }
* 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),
})
})
} }

View File

@@ -2,7 +2,7 @@ import { AbstractSimplePool } from './abstract-pool.ts'
import { Subscription } from './abstract-relay.ts' import { Subscription } from './abstract-relay.ts'
import type { Event, EventTemplate } from './core.ts' import type { Event, EventTemplate } from './core.ts'
import { fetchRelayInformation, RelayInformation } from './nip11.ts' import { fetchRelayInformation, RelayInformation } from './nip11.ts'
import { AddressPointer, decode } from './nip19.ts' import { decode, NostrTypeGuard } from './nip19.ts'
import { normalizeURL } from './utils.ts' import { normalizeURL } from './utils.ts'
/** /**
@@ -58,13 +58,21 @@ export type GroupAdmin = {
* Represents the permissions that a NIP29 group admin can have. * Represents the permissions that a NIP29 group admin can have.
*/ */
export enum GroupAdminPermission { export enum GroupAdminPermission {
/** @deprecated use PutUser instead */
AddUser = 'add-user', AddUser = 'add-user',
EditMetadata = 'edit-metadata', EditMetadata = 'edit-metadata',
DeleteEvent = 'delete-event', DeleteEvent = 'delete-event',
RemoveUser = 'remove-user', RemoveUser = 'remove-user',
/** @deprecated removed from NIP */
AddPermission = 'add-permission', AddPermission = 'add-permission',
/** @deprecated removed from NIP */
RemovePermission = 'remove-permission', RemovePermission = 'remove-permission',
/** @deprecated removed from NIP */
EditGroupStatus = 'edit-group-status', 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. * @returns A GroupReference object if the code is valid, otherwise null.
*/ */
export function parseGroupCode(code: string): null | GroupReference { export function parseGroupCode(code: string): null | GroupReference {
if (code.startsWith('naddr1')) { if (NostrTypeGuard.isNAddr(code)) {
try { try {
let { data } = decode(code) let { data } = decode(code)
let { relays, identifier } = data as AddressPointer let { relays, identifier } = data
if (!relays || relays.length === 0) return null if (!relays || relays.length === 0) return null
let host = relays![0] let host = relays![0]

View File

@@ -1,7 +1,7 @@
import { test, expect } from 'bun:test' import { test, expect } from 'bun:test'
import { v2 } from './nip44.js' import { v2 } from './nip44.js'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' 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' import { schnorr } from '@noble/curves/secp256k1'
const v2vec = vec.v2 const v2vec = vec.v2

259
nip46.ts
View File

@@ -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 { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts' import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
import { decrypt as legacyDecrypt } from './nip04.ts'
import { getConversationKey, decrypt, encrypt } from './nip44.ts' import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { NIP05_REGEX } from './nip05.ts' import { NIP05_REGEX } from './nip05.ts'
import { SimplePool } from './pool.ts' import { SimplePool } from './pool.ts'
import { Handlerinformation, NostrConnect } from './kinds.ts' import { Handlerinformation, NostrConnect } from './kinds.ts'
import type { RelayRecord } from './relay.ts' import { Signer } from './signer.ts'
var _fetch: any var _fetch: any
@@ -27,6 +26,17 @@ export type BunkerPointer = {
secret: null | string 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 /** This takes either a bunker:// URL or a name@domain.com NIP-05 identifier
and returns a BunkerPointer -- or null in case of error */ and returns a BunkerPointer -- or null in case of error */
export async function parseBunkerInput(input: string): Promise<BunkerPointer | null> { 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 = { export type BunkerSignerParams = {
pool?: AbstractSimplePool pool?: AbstractSimplePool
onauth?: (url: string) => void onauth?: (url: string) => void
} }
export class BunkerSigner { export class BunkerSigner implements Signer {
private params: BunkerSignerParams
private pool: AbstractSimplePool private pool: AbstractSimplePool
private subCloser: SubCloser private subCloser: SubCloser | undefined
private isOpen: boolean private isOpen: boolean
private serial: number private serial: number
private idPrefix: string private idPrefix: string
@@ -86,8 +205,9 @@ export class BunkerSigner {
} }
private waitingForAuth: { [id: string]: boolean } private waitingForAuth: { [id: string]: boolean }
private secretKey: Uint8Array private secretKey: Uint8Array
private conversationKey: Uint8Array // If the client initiates the connection, the two variables below can be filled in later.
public bp: BunkerPointer private conversationKey!: Uint8Array
public bp!: BunkerPointer
private cachedPubKey: string | undefined private cachedPubKey: string | undefined
@@ -97,37 +217,108 @@ export class BunkerSigner {
* @param remotePubkey - An optional remote public key. This is the key you want to sign as. * @param remotePubkey - An optional remote public key. This is the key you want to sign as.
* @param secretKey - An optional key pair. * @param secretKey - An optional key pair.
*/ */
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) { private constructor(clientSecretKey: Uint8Array, params: BunkerSignerParams) {
if (bp.relays.length === 0) { this.params = params
throw new Error('no relays are specified for this bunker')
}
this.pool = params.pool || new SimplePool() this.pool = params.pool || new SimplePool()
this.secretKey = clientSecretKey this.secretKey = clientSecretKey
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
this.bp = bp
this.isOpen = false this.isOpen = false
this.idPrefix = Math.random().toString(36).substring(7) this.idPrefix = Math.random().toString(36).substring(7)
this.serial = 0 this.serial = 0
this.listeners = {} this.listeners = {}
this.waitingForAuth = {} this.waitingForAuth = {}
}
/**
* [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 listeners = this.listeners
const waitingForAuth = this.waitingForAuth const waitingForAuth = this.waitingForAuth
const convKey = this.conversationKey const convKey = this.conversationKey
this.subCloser = this.pool.subscribeMany( this.subCloser = this.pool.subscribe(
this.bp.relays, 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) { onevent: async (event: NostrEvent) => {
let o const o = JSON.parse(decrypt(event.content, convKey))
try {
o = JSON.parse(decrypt(event.content, convKey))
} catch (err) {
o = JSON.parse(await legacyDecrypt(event.content, event.pubkey, event.content))
}
const { id, result, error } = o const { id, result, error } = o
if (result === 'auth_url' && waitingForAuth[id]) { if (result === 'auth_url' && waitingForAuth[id]) {
@@ -137,7 +328,7 @@ export class BunkerSigner {
params.onauth(error) params.onauth(error)
} else { } else {
console.warn( 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 return
@@ -150,6 +341,9 @@ export class BunkerSigner {
delete listeners[id] delete listeners[id]
} }
}, },
onclose: () => {
this.subCloser = undefined
},
}, },
) )
this.isOpen = true this.isOpen = true
@@ -158,13 +352,15 @@ export class BunkerSigner {
// closes the subscription -- this object can't be used anymore after this // closes the subscription -- this object can't be used anymore after this
async close() { async close() {
this.isOpen = false this.isOpen = false
this.subCloser.close() this.subCloser!.close()
} }
async sendRequest(method: string, params: string[]): Promise<string> { async sendRequest(method: string, params: string[]): Promise<string> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
try { try {
if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one') if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one')
if (!this.subCloser) this.setupSubscription(this.params)
this.serial++ this.serial++
const id = `${this.idPrefix}-${this.serial}` const id = `${this.idPrefix}-${this.serial}`
@@ -222,19 +418,12 @@ export class BunkerSigner {
return this.cachedPubKey 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. * Signs an event using the remote private key.
* @param event - The event to sign. * @param event - The event to sign.
* @returns A Promise that resolves to the signed event. * @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 resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
let signed: NostrEvent = JSON.parse(resp) let signed: NostrEvent = JSON.parse(resp)
if (verifyEvent(signed)) { if (verifyEvent(signed)) {
@@ -282,7 +471,7 @@ export async function createAccount(
): Promise<BunkerSigner> { ): Promise<BunkerSigner> {
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email') if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params) let rpc = BunkerSigner.fromBunker(localSecretKey, bunker.bunkerPointer, params)
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || '']) let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])

View File

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

View File

@@ -9,8 +9,8 @@ interface NWCConnection {
} }
export function parseConnectionString(connectionString: string): NWCConnection { export function parseConnectionString(connectionString: string): NWCConnection {
const { pathname, searchParams } = new URL(connectionString) const { host, pathname, searchParams } = new URL(connectionString)
const pubkey = pathname const pubkey = pathname || host
const relay = searchParams.get('relay') const relay = searchParams.get('relay')
const secret = searchParams.get('secret') const secret = searchParams.get('secret')
@@ -32,7 +32,7 @@ export async function makeNwcRequestEvent(
invoice, invoice,
}, },
} }
const encryptedContent = await encrypt(secretKey, pubkey, JSON.stringify(content)) const encryptedContent = encrypt(secretKey, pubkey, JSON.stringify(content))
const eventTemplate = { const eventTemplate = {
kind: NWCWalletRequest, kind: NWCWalletRequest,
created_at: Math.round(Date.now() / 1000), created_at: Math.round(Date.now() / 1000),

42
nip54.test.ts Normal file
View 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
View 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
View 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
View 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,
})
}

View File

@@ -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 { finalizeEvent } from './pure.ts'
import { getPublicKey, generateSecretKey } from './pure.ts' import { getPublicKey, generateSecretKey } from './pure.ts'
import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts' import { getSatoshisAmountFromBolt11, makeZapReceipt, validateZapRequest } from './nip57.ts'
import { buildEvent } from './test-helpers.ts'
describe('getZapEndpoint', () => {
test('returns null if neither lud06 nor lud16 is present', async () => {
const metadata = buildEvent({ kind: 0, content: '{}' })
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
})
test('returns null if fetch fails', async () => {
const fetchImplementation = mock(() => Promise.reject(new Error()))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
})
test('returns null if the response does not allow Nostr payments', async () => {
const fetchImplementation = mock(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
})
test('returns the callback URL if the response allows Nostr payments', async () => {
const fetchImplementation = mock(() =>
Promise.resolve({
json: () => ({
allowsNostr: true,
nostrPubkey: 'pubkey',
callback: 'callback',
}),
}),
)
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
expect(result).toBe('callback')
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
})
})
describe('makeZapRequest', () => {
test('throws an error if amount is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
profile: 'profile',
event: null,
relays: [],
comment: '',
}),
).toThrow()
})
test('throws an error if profile is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
event: null,
amount: 100,
relays: [],
comment: '',
}),
).toThrow()
})
test('returns a valid Zap request', () => {
const result = makeZapRequest({
profile: 'profile',
event: 'event',
amount: 100,
relays: ['relay1', 'relay2'],
comment: 'comment',
})
expect(result.kind).toBe(9734)
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
expect(result.content).toBe('comment')
expect(result.tags).toEqual(
expect.arrayContaining([
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
['e', 'event'],
]),
)
})
})
describe('validateZapRequest', () => { describe('validateZapRequest', () => {
test('returns an error message for invalid JSON', () => { test('returns an error message for invalid JSON', () => {
@@ -317,3 +219,26 @@ describe('makeZapReceipt', () => {
expect(JSON.stringify(result.tags)).not.toContain('preimage') 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
View File

@@ -1,7 +1,8 @@
import { bech32 } from '@scure/base' import { bech32 } from '@scure/base'
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts' import { NostrEvent, validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
import { utf8Decoder } from './utils.ts' import { utf8Decoder } from './utils.ts'
import { isReplaceableKind, isAddressableKind } from './kinds.ts'
var _fetch: any var _fetch: any
@@ -17,13 +18,13 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
try { try {
let lnurl: string = '' let lnurl: string = ''
let { lud06, lud16 } = JSON.parse(metadata.content) 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 { words } = bech32.decode(lud06, 1000)
let data = bech32.fromWords(words) let data = bech32.fromWords(words)
lnurl = utf8Decoder.decode(data) lnurl = utf8Decoder.decode(data)
} else if (lud16) {
let [name, domain] = lud16.split('@')
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
} else { } else {
return null return null
} }
@@ -41,35 +42,44 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
return null return null
} }
export function makeZapRequest({ type ProfileZap = {
profile, pubkey: string
event,
amount,
relays,
comment = '',
}: {
profile: string
event: string | null
amount: number amount: number
comment: string comment?: string
relays: string[] relays: string[]
}): EventTemplate { }
if (!amount) throw new Error('amount not given')
if (!profile) throw new Error('profile not given')
type EventZap = {
event: NostrEvent
amount: number
comment?: string
relays: string[]
}
export function makeZapRequest(params: ProfileZap | EventZap): EventTemplate {
let zr: EventTemplate = { let zr: EventTemplate = {
kind: 9734, kind: 9734,
created_at: Math.round(Date.now() / 1000), created_at: Math.round(Date.now() / 1000),
content: comment, content: params.comment || '',
tags: [ tags: [
['p', profile], ['p', 'pubkey' in params ? params.pubkey : params.event.pubkey],
['amount', amount.toString()], ['amount', params.amount.toString()],
['relays', ...relays], ['relays', ...params.relays],
], ],
} }
if (event) { if ('event' in params) {
zr.tags.push(['e', event]) 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 return zr
@@ -128,3 +138,52 @@ export function makeZapReceipt({
return zap 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
}
}

View File

@@ -6,8 +6,8 @@ import { SimplePool } from './pool.ts'
import { GiftWrap } from './kinds.ts' import { GiftWrap } from './kinds.ts'
import { hexToBytes } from '@noble/hashes/utils' import { hexToBytes } from '@noble/hashes/utils'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data as Uint8Array
const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data as Uint8Array
const recipientPublicKey = getPublicKey(recipientPrivateKey) const recipientPublicKey = getPublicKey(recipientPrivateKey)
const event = { const event = {
kind: 1, kind: 1,

114
nip77.test.ts Normal file
View File

@@ -0,0 +1,114 @@
import { describe, test, expect } from 'bun:test'
import { NegentropySync, NegentropyStorageVector } from './nip77.ts'
import { Relay } from './relay.ts'
import { NostrEvent } from './core.ts'
// const RELAY = 'ws://127.0.0.1:10547'
const RELAY = 'wss://relay.damus.io'
describe('NegentropySync', () => {
test('syncs events from ' + RELAY, async () => {
const relay = await Relay.connect(RELAY)
const storage = new NegentropyStorageVector()
storage.seal()
const filter = {
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
kinds: [30617, 30618],
}
let ids1: string[] = []
const done1 = Promise.withResolvers<void>()
const sync1 = new NegentropySync(relay, storage, filter, {
onneed: (id: string) => {
ids1.push(id)
},
onclose: err => {
expect(err).toBeUndefined()
done1.resolve()
},
})
await sync1.start()
await done1.promise
expect(ids1.length).toBeGreaterThan(10)
sync1.close()
// fetch events
const events1: NostrEvent[] = []
const fetched = Promise.withResolvers()
const sub = relay.subscribe([{ ids: ids1 }], {
onevent(evt) {
events1.push(evt)
},
oneose() {
sub.close()
fetched.resolve()
},
})
await fetched.promise
expect(events1.map(evt => evt.id).sort()).toEqual(ids1.sort())
// Second sync with local events
await relay.connect()
const storage2 = new NegentropyStorageVector()
for (const evt of events1) {
storage2.insert(evt.created_at, evt.id)
}
storage2.seal()
let ids2: string[] = []
let done2 = Promise.withResolvers()
const sync2 = new NegentropySync(relay, storage2, filter, {
onneed: (id: string) => {
ids2.push(id)
},
onclose: err => {
expect(err).toBeUndefined()
done2.resolve()
},
})
await sync2.start()
await done2.promise
expect(ids2.length).toBe(0)
sync2.close()
// third sync with 4 events removed
const storage3 = new NegentropyStorageVector()
// shuffle
ids1.sort(() => Math.random() - 0.5)
const removedEvents = ids1.slice(0, 1 + Math.floor(Math.random() * ids1.length - 1))
for (const evt of events1) {
if (!removedEvents.includes(evt.id)) {
storage3.insert(evt.created_at, evt.id)
}
}
storage3.seal()
let ids3: string[] = []
const done3 = Promise.withResolvers()
const sync3 = new NegentropySync(relay, storage3, filter, {
onneed: (id: string) => {
ids3.push(id)
},
onclose: err => {
expect(err).toBeUndefined()
done3.resolve()
},
})
await sync3.start()
await done3.promise
expect(ids3.sort()).toEqual(removedEvents.sort())
sync3.close()
})
})

607
nip77.ts Normal file
View File

@@ -0,0 +1,607 @@
import { bytesToHex, hexToBytes } from '@noble/ciphers/utils'
import { Filter } from './filter.ts'
import { AbstractRelay, Subscription } from './relay.ts'
import { sha256 } from '@noble/hashes/sha256'
// Negentropy implementation by Doug Hoyte
const PROTOCOL_VERSION = 0x61 // Version 1
const ID_SIZE = 32
const FINGERPRINT_SIZE = 16
const Mode = {
Skip: 0,
Fingerprint: 1,
IdList: 2,
}
class WrappedBuffer {
_raw: Uint8Array
length: number
constructor(buffer?: Uint8Array | number) {
if (typeof buffer === 'number') {
this._raw = new Uint8Array(buffer)
this.length = 0
} else if (buffer instanceof Uint8Array) {
this._raw = new Uint8Array(buffer)
this.length = buffer.length
} else {
this._raw = new Uint8Array(512)
this.length = 0
}
}
unwrap(): Uint8Array {
return this._raw.subarray(0, this.length)
}
get capacity(): number {
return this._raw.byteLength
}
extend(buf: Uint8Array | WrappedBuffer): void {
if (buf instanceof WrappedBuffer) buf = buf.unwrap()
if (typeof buf.length !== 'number') throw Error('bad length')
const targetSize = buf.length + this.length
if (this.capacity < targetSize) {
const oldRaw = this._raw
const newCapacity = Math.max(this.capacity * 2, targetSize)
this._raw = new Uint8Array(newCapacity)
this._raw.set(oldRaw)
}
this._raw.set(buf, this.length)
this.length += buf.length
}
shift(): number {
const first = this._raw[0]
this._raw = this._raw.subarray(1)
this.length--
return first
}
shiftN(n: number = 1): Uint8Array {
const firstSubarray = this._raw.subarray(0, n)
this._raw = this._raw.subarray(n)
this.length -= n
return firstSubarray
}
}
function decodeVarInt(buf: WrappedBuffer): number {
let res = 0
while (1) {
if (buf.length === 0) throw Error('parse ends prematurely')
let byte = buf.shift()
res = (res << 7) | (byte & 127)
if ((byte & 128) === 0) break
}
return res
}
function encodeVarInt(n: number): WrappedBuffer {
if (n === 0) return new WrappedBuffer(new Uint8Array([0]))
let o: number[] = []
while (n !== 0) {
o.push(n & 127)
n >>>= 7
}
o.reverse()
for (let i = 0; i < o.length - 1; i++) o[i] |= 128
return new WrappedBuffer(new Uint8Array(o))
}
function getByte(buf: WrappedBuffer): number {
return getBytes(buf, 1)[0]
}
function getBytes(buf: WrappedBuffer, n: number): Uint8Array {
if (buf.length < n) throw Error('parse ends prematurely')
return buf.shiftN(n)
}
class Accumulator {
buf!: Uint8Array
constructor() {
this.setToZero()
}
setToZero(): void {
this.buf = new Uint8Array(ID_SIZE)
}
add(otherBuf: Uint8Array): void {
let currCarry = 0,
nextCarry = 0
let p = new DataView(this.buf.buffer)
let po = new DataView(otherBuf.buffer)
for (let i = 0; i < 8; i++) {
let offset = i * 4
let orig = p.getUint32(offset, true)
let otherV = po.getUint32(offset, true)
let next = orig
next += currCarry
next += otherV
if (next > 0xffffffff) nextCarry = 1
p.setUint32(offset, next & 0xffffffff, true)
currCarry = nextCarry
nextCarry = 0
}
}
negate(): void {
let p = new DataView(this.buf.buffer)
for (let i = 0; i < 8; i++) {
let offset = i * 4
p.setUint32(offset, ~p.getUint32(offset, true))
}
let one = new Uint8Array(ID_SIZE)
one[0] = 1
this.add(one)
}
getFingerprint(n: number): Uint8Array {
let input = new WrappedBuffer()
input.extend(this.buf)
input.extend(encodeVarInt(n))
let hash = sha256(input.unwrap())
return hash.subarray(0, FINGERPRINT_SIZE)
}
}
export class NegentropyStorageVector {
items: { timestamp: number; id: Uint8Array }[]
sealed: boolean
constructor() {
this.items = []
this.sealed = false
}
insert(timestamp: number, id: string): void {
if (this.sealed) throw Error('already sealed')
const idb = hexToBytes(id)
if (idb.byteLength !== ID_SIZE) throw Error('bad id size for added item')
this.items.push({ timestamp, id: idb })
}
seal(): void {
if (this.sealed) throw Error('already sealed')
this.sealed = true
this.items.sort(itemCompare)
for (let i = 1; i < this.items.length; i++) {
if (itemCompare(this.items[i - 1], this.items[i]) === 0) throw Error('duplicate item inserted')
}
}
unseal(): void {
this.sealed = false
}
size(): number {
this._checkSealed()
return this.items.length
}
getItem(i: number): { timestamp: number; id: Uint8Array } {
this._checkSealed()
if (i >= this.items.length) throw Error('out of range')
return this.items[i]
}
iterate(begin: number, end: number, cb: (item: { timestamp: number; id: Uint8Array }, i: number) => boolean): void {
this._checkSealed()
this._checkBounds(begin, end)
for (let i = begin; i < end; ++i) {
if (!cb(this.items[i], i)) break
}
}
findLowerBound(begin: number, end: number, bound: { timestamp: number; id: Uint8Array }): number {
this._checkSealed()
this._checkBounds(begin, end)
return this._binarySearch(this.items, begin, end, a => itemCompare(a, bound) < 0)
}
fingerprint(begin: number, end: number): Uint8Array {
let out = new Accumulator()
out.setToZero()
this.iterate(begin, end, item => {
out.add(item.id)
return true
})
return out.getFingerprint(end - begin)
}
_checkSealed(): void {
if (!this.sealed) throw Error('not sealed')
}
_checkBounds(begin: number, end: number): void {
if (begin > end || end > this.items.length) throw Error('bad range')
}
_binarySearch(
arr: { timestamp: number; id: Uint8Array }[],
first: number,
last: number,
cmp: (a: { timestamp: number; id: Uint8Array }) => boolean,
): number {
let count = last - first
while (count > 0) {
let it = first
let step = Math.floor(count / 2)
it += step
if (cmp(arr[it])) {
first = ++it
count -= step + 1
} else {
count = step
}
}
return first
}
}
export class Negentropy {
storage: NegentropyStorageVector
frameSizeLimit: number
lastTimestampIn: number
lastTimestampOut: number
constructor(storage: NegentropyStorageVector, frameSizeLimit: number = 60_000) {
if (frameSizeLimit < 4096) throw Error('frameSizeLimit too small')
this.storage = storage
this.frameSizeLimit = frameSizeLimit
this.lastTimestampIn = 0
this.lastTimestampOut = 0
}
_bound(timestamp: number, id?: Uint8Array): { timestamp: number; id: Uint8Array } {
return { timestamp, id: id || new Uint8Array(0) }
}
initiate(): string {
let output = new WrappedBuffer()
output.extend(new Uint8Array([PROTOCOL_VERSION]))
this.splitRange(0, this.storage.size(), this._bound(Number.MAX_VALUE), output)
return bytesToHex(output.unwrap())
}
reconcile(queryMsg: string, onhave?: (id: string) => void, onneed?: (id: string) => void): string | null {
const query = new WrappedBuffer(hexToBytes(queryMsg))
this.lastTimestampIn = this.lastTimestampOut = 0 // reset for each message
let fullOutput = new WrappedBuffer()
fullOutput.extend(new Uint8Array([PROTOCOL_VERSION]))
let protocolVersion = getByte(query)
if (protocolVersion < 0x60 || protocolVersion > 0x6f) throw Error('invalid negentropy protocol version byte')
if (protocolVersion !== PROTOCOL_VERSION) {
throw Error('unsupported negentropy protocol version requested: ' + (protocolVersion - 0x60))
}
let storageSize = this.storage.size()
let prevBound = this._bound(0)
let prevIndex = 0
let skip = false
while (query.length !== 0) {
let o = new WrappedBuffer()
let doSkip = () => {
if (skip) {
skip = false
o.extend(this.encodeBound(prevBound))
o.extend(encodeVarInt(Mode.Skip))
}
}
let currBound = this.decodeBound(query)
let mode = decodeVarInt(query)
let lower = prevIndex
let upper = this.storage.findLowerBound(prevIndex, storageSize, currBound)
if (mode === Mode.Skip) {
skip = true
} else if (mode === Mode.Fingerprint) {
let theirFingerprint = getBytes(query, FINGERPRINT_SIZE)
let ourFingerprint = this.storage.fingerprint(lower, upper)
if (compareUint8Array(theirFingerprint, ourFingerprint) !== 0) {
doSkip()
this.splitRange(lower, upper, currBound, o)
} else {
skip = true
}
} else if (mode === Mode.IdList) {
let numIds = decodeVarInt(query)
let theirElems: { [key: string]: Uint8Array } = {} // stringified Uint8Array -> original Uint8Array (or hex)
for (let i = 0; i < numIds; i++) {
let e = getBytes(query, ID_SIZE)
theirElems[bytesToHex(e)] = e
}
skip = true
this.storage.iterate(lower, upper, item => {
let k = item.id
const id = bytesToHex(k)
if (!theirElems[id]) {
// ID exists on our side, but not their side
onhave?.(id)
} else {
// ID exists on both sides
delete theirElems[bytesToHex(k)]
}
return true
})
if (onneed) {
for (let v of Object.values(theirElems)) {
// ID exists on their side, but not our side
onneed(bytesToHex(v))
}
}
} else {
throw Error('unexpected mode')
}
if (this.exceededFrameSizeLimit(fullOutput.length + o.length)) {
// frameSizeLimit exceeded: stop range processing and return a fingerprint for the remaining range
let remainingFingerprint = this.storage.fingerprint(upper, storageSize)
fullOutput.extend(this.encodeBound(this._bound(Number.MAX_VALUE)))
fullOutput.extend(encodeVarInt(Mode.Fingerprint))
fullOutput.extend(remainingFingerprint)
break
} else {
fullOutput.extend(o)
}
prevIndex = upper
prevBound = currBound
}
return fullOutput.length === 1 ? null : bytesToHex(fullOutput.unwrap())
}
splitRange(lower: number, upper: number, upperBound: { timestamp: number; id: Uint8Array }, o: WrappedBuffer) {
let numElems = upper - lower
let buckets = 16
if (numElems < buckets * 2) {
o.extend(this.encodeBound(upperBound))
o.extend(encodeVarInt(Mode.IdList))
o.extend(encodeVarInt(numElems))
this.storage.iterate(lower, upper, item => {
o.extend(item.id)
return true
})
} else {
let itemsPerBucket = Math.floor(numElems / buckets)
let bucketsWithExtra = numElems % buckets
let curr = lower
for (let i = 0; i < buckets; i++) {
let bucketSize = itemsPerBucket + (i < bucketsWithExtra ? 1 : 0)
let ourFingerprint = this.storage.fingerprint(curr, curr + bucketSize)
curr += bucketSize
let nextBound: { timestamp: number; id: Uint8Array }
if (curr === upper) {
nextBound = upperBound
} else {
let prevItem: { timestamp: number; id: Uint8Array } | undefined
let currItem: { timestamp: number; id: Uint8Array } | undefined
this.storage.iterate(curr - 1, curr + 1, (item, index) => {
if (index === curr - 1) prevItem = item
else currItem = item
return true
})
nextBound = this.getMinimalBound(prevItem!, currItem!)
}
o.extend(this.encodeBound(nextBound))
o.extend(encodeVarInt(Mode.Fingerprint))
o.extend(ourFingerprint)
}
}
}
exceededFrameSizeLimit(n: number): boolean {
return n > this.frameSizeLimit - 200
}
// Decoding
decodeTimestampIn(encoded: WrappedBuffer): number {
let timestamp = decodeVarInt(encoded)
timestamp = timestamp === 0 ? Number.MAX_VALUE : timestamp - 1
if (this.lastTimestampIn === Number.MAX_VALUE || timestamp === Number.MAX_VALUE) {
this.lastTimestampIn = Number.MAX_VALUE
return Number.MAX_VALUE
}
timestamp += this.lastTimestampIn
this.lastTimestampIn = timestamp
return timestamp
}
decodeBound(encoded: WrappedBuffer): { timestamp: number; id: Uint8Array } {
let timestamp = this.decodeTimestampIn(encoded)
let len = decodeVarInt(encoded)
if (len > ID_SIZE) throw Error('bound key too long')
let id = getBytes(encoded, len)
return { timestamp, id }
}
// Encoding
encodeTimestampOut(timestamp: number): WrappedBuffer {
if (timestamp === Number.MAX_VALUE) {
this.lastTimestampOut = Number.MAX_VALUE
return encodeVarInt(0)
}
let temp = timestamp
timestamp -= this.lastTimestampOut
this.lastTimestampOut = temp
return encodeVarInt(timestamp + 1)
}
encodeBound(key: { timestamp: number; id: Uint8Array }): WrappedBuffer {
let output = new WrappedBuffer()
output.extend(this.encodeTimestampOut(key.timestamp))
output.extend(encodeVarInt(key.id.length))
output.extend(key.id)
return output
}
getMinimalBound(
prev: { timestamp: number; id: Uint8Array },
curr: { timestamp: number; id: Uint8Array },
): { timestamp: number; id: Uint8Array } {
if (curr.timestamp !== prev.timestamp) {
return this._bound(curr.timestamp)
} else {
let sharedPrefixBytes = 0
let currKey = curr.id
let prevKey = prev.id
for (let i = 0; i < ID_SIZE; i++) {
if (currKey[i] !== prevKey[i]) break
sharedPrefixBytes++
}
return this._bound(curr.timestamp, curr.id.subarray(0, sharedPrefixBytes + 1))
}
}
}
function compareUint8Array(a: Uint8Array, b: Uint8Array): number {
for (let i = 0; i < a.byteLength; i++) {
if (a[i] < b[i]) return -1
if (a[i] > b[i]) return 1
}
if (a.byteLength > b.byteLength) return 1
if (a.byteLength < b.byteLength) return -1
return 0
}
function itemCompare(a: { timestamp: number; id: Uint8Array }, b: { timestamp: number; id: Uint8Array }): number {
if (a.timestamp === b.timestamp) {
return compareUint8Array(a.id, b.id)
}
return a.timestamp - b.timestamp
}
export class NegentropySync {
relay: AbstractRelay
storage: NegentropyStorageVector
private neg: Negentropy
private filter: Filter
private subscription: Subscription
private onhave?: (id: string) => void
private onneed?: (id: string) => void
constructor(
relay: AbstractRelay,
storage: NegentropyStorageVector,
filter: Filter,
params: {
label?: string
onhave?: (id: string) => void
onneed?: (id: string) => void
onclose?: (errReason?: string) => void
} = {},
) {
this.relay = relay
this.storage = storage
this.neg = new Negentropy(storage)
this.onhave = params.onhave
this.onneed = params.onneed
this.filter = filter
// we prepare a subscription with an empty filter, but it will not be used
this.subscription = this.relay.prepareSubscription([{}], { label: params.label || 'negentropy' })
this.subscription.oncustom = (data: string[]) => {
switch (data[0]) {
case 'NEG-MSG': {
if (data.length < 3) {
console.warn(`got invalid NEG-MSG from ${this.relay.url}: ${data}`)
}
try {
const response = this.neg.reconcile(data[2], this.onhave, this.onneed)
if (response) {
this.relay.send(`["NEG-MSG", "${this.subscription.id}", "${response}"]`)
} else {
this.close()
params.onclose?.()
}
} catch (error) {
console.error('negentropy reconcile error:', error)
params?.onclose?.(`reconcile error: ${error}`)
}
break
}
case 'NEG-CLOSE': {
const reason = data[2]
console.warn('negentropy error:', reason)
params.onclose?.(reason)
break
}
case 'NEG-ERR': {
params.onclose?.()
}
}
}
}
async start(): Promise<void> {
const initMsg = this.neg.initiate()
this.relay.send(`["NEG-OPEN","${this.subscription.id}",${JSON.stringify(this.filter)},"${initMsg}"]`)
}
close(): void {
this.relay.send(`["NEG-CLOSE","${this.subscription.id}"]`)
this.subscription.close()
}
}

View File

@@ -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
View File

@@ -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
View File

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

203
nipb7.ts Normal file
View File

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

View File

@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "nostr-tools", "name": "nostr-tools",
"version": "2.10.0", "version": "2.19.0",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -173,6 +173,11 @@
"require": "./lib/cjs/nip49.js", "require": "./lib/cjs/nip49.js",
"types": "./lib/types/nip49.d.ts" "types": "./lib/types/nip49.d.ts"
}, },
"./nip54": {
"import": "./lib/esm/nip54.js",
"require": "./lib/cjs/nip54.js",
"types": "./lib/types/nip54.d.ts"
},
"./nip57": { "./nip57": {
"import": "./lib/esm/nip57.js", "import": "./lib/esm/nip57.js",
"require": "./lib/cjs/nip57.js", "require": "./lib/cjs/nip57.js",
@@ -198,11 +203,6 @@
"require": "./lib/cjs/nip94.js", "require": "./lib/cjs/nip94.js",
"types": "./lib/types/nip94.d.ts" "types": "./lib/types/nip94.d.ts"
}, },
"./nip96": {
"import": "./lib/esm/nip96.js",
"require": "./lib/cjs/nip96.js",
"types": "./lib/types/nip96.d.ts"
},
"./nip98": { "./nip98": {
"import": "./lib/esm/nip98.js", "import": "./lib/esm/nip98.js",
"require": "./lib/cjs/nip98.js", "require": "./lib/cjs/nip98.js",
@@ -213,11 +213,21 @@
"require": "./lib/cjs/nip99.js", "require": "./lib/cjs/nip99.js",
"types": "./lib/types/nip99.d.ts" "types": "./lib/types/nip99.d.ts"
}, },
"./nipb7": {
"import": "./lib/esm/nipb7.js",
"require": "./lib/cjs/nipb7.js",
"types": "./lib/types/nipb7.d.ts"
},
"./fakejson": { "./fakejson": {
"import": "./lib/esm/fakejson.js", "import": "./lib/esm/fakejson.js",
"require": "./lib/cjs/fakejson.js", "require": "./lib/cjs/fakejson.js",
"types": "./lib/types/fakejson.d.ts" "types": "./lib/types/fakejson.d.ts"
}, },
"./signer": {
"import": "./lib/esm/signer.js",
"require": "./lib/cjs/signer.js",
"types": "./lib/types/signer.d.ts"
},
"./utils": { "./utils": {
"import": "./lib/esm/utils.js", "import": "./lib/esm/utils.js",
"require": "./lib/cjs/utils.js", "require": "./lib/cjs/utils.js",
@@ -231,10 +241,8 @@
"@noble/hashes": "1.3.1", "@noble/hashes": "1.3.1",
"@scure/base": "1.1.1", "@scure/base": "1.1.1",
"@scure/bip32": "1.3.1", "@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1" "@scure/bip39": "1.2.1",
}, "nostr-wasm": "0.1.0"
"optionalDependencies": {
"nostr-wasm": "v0.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
@@ -258,18 +266,14 @@
"@typescript-eslint/parser": "^6.5.0", "@typescript-eslint/parser": "^6.5.0",
"bun-types": "^1.0.18", "bun-types": "^1.0.18",
"esbuild": "0.16.9", "esbuild": "0.16.9",
"esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-babel": "^5.3.1",
"esm-loader-typescript": "^1.0.3",
"events": "^3.3.0", "events": "^3.3.0",
"mitata": "^0.1.6", "mitata": "^0.1.6",
"mock-socket": "^9.3.1", "mock-socket": "^9.3.1",
"msw": "^2.1.4",
"node-fetch": "^2.6.9", "node-fetch": "^2.6.9",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"typescript": "^5.0.4" "typescript": "^5.8.2"
}, },
"scripts": { "scripts": {
"prepublish": "just build" "prepublish": "just build"

View File

@@ -35,14 +35,18 @@ test('removing duplicates when subscribing', async () => {
priv, priv,
) )
pool.subscribeMany(relayURLs, [{ authors: [pub] }], { pool.subscribeMany(
onevent(event: Event) { relayURLs,
// this should be called only once even though we're listening { authors: [pub] },
// to multiple relays because the events will be caught and {
// deduplicated efficiently (without even being parsed) onevent(event: Event) {
received.push(event) // this should be called only once even though we're listening
// to multiple relays because the events will be caught and
// deduplicated efficiently (without even being parsed)
received.push(event)
},
}, },
}) )
await Promise.any(pool.publish(relayURLs, event)) await Promise.any(pool.publish(relayURLs, event))
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
@@ -55,16 +59,24 @@ test('same with double subs', async () => {
let priv = generateSecretKey() let priv = generateSecretKey()
let pub = getPublicKey(priv) let pub = getPublicKey(priv)
pool.subscribeMany(relayURLs, [{ authors: [pub] }], { pool.subscribeMany(
onevent(event) { relayURLs,
received.push(event) { authors: [pub] },
{
onevent(event) {
received.push(event)
},
}, },
}) )
pool.subscribeMany(relayURLs, [{ authors: [pub] }], { pool.subscribeMany(
onevent(event) { relayURLs,
received.push(event) { authors: [pub] },
{
onevent(event) {
received.push(event)
},
}, },
}) )
let received: Event[] = [] let received: Event[] = []
@@ -119,12 +131,12 @@ test('subscribe many map', async () => {
const [relayA, relayB, relayC] = relayURLs const [relayA, relayB, relayC] = relayURLs
pool.subscribeManyMap( pool.subscribeMap(
{ [
[relayA]: [{ authors: [pub], kinds: [20001] }], { url: relayA, filter: { authors: [pub], kinds: [20001] } },
[relayB]: [{ authors: [pub], kinds: [20002] }], { url: relayB, filter: { authors: [pub], kinds: [20002] } },
[relayC]: [{ kinds: [20003], '#t': ['biloba'] }], { url: relayC, filter: { kinds: [20003], '#t': ['biloba'] } },
}, ],
{ {
onevent(event: Event) { onevent(event: Event) {
received.push(event) received.push(event)
@@ -168,12 +180,16 @@ test('query a bunch of events and cancel on eose', async () => {
let events = new Set<string>() let events = new Set<string>()
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], { pool.subscribeManyEose(
onevent(event) { relayURLs,
events.add(event.id) { 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) expect(events.size).toBeGreaterThan(50)
@@ -206,6 +222,151 @@ test('get()', async () => {
expect(event).toHaveProperty('id', ids[0]) 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 () => { test('track relays when publishing', async () => {
let event1 = finalizeEvent( let event1 = finalizeEvent(
{ {

View File

@@ -1,7 +1,7 @@
/* global WebSocket */ /* global WebSocket */
import { verifyEvent } from './pure.ts' import { verifyEvent } from './pure.ts'
import { AbstractSimplePool } from './abstract-pool.ts' import { AbstractSimplePool, type AbstractPoolConstructorOptions } from './abstract-pool.ts'
var _WebSocket: typeof WebSocket var _WebSocket: typeof WebSocket
@@ -14,8 +14,8 @@ export function useWebSocketImplementation(websocketImplementation: any) {
} }
export class SimplePool extends AbstractSimplePool { export class SimplePool extends AbstractSimplePool {
constructor() { constructor(options?: Pick<AbstractPoolConstructorOptions, 'enablePing' | 'enableReconnect'>) {
super({ verifyEvent, websocketImplementation: _WebSocket }) super({ verifyEvent, websocketImplementation: _WebSocket, ...options })
} }
} }

View File

@@ -117,3 +117,285 @@ test('publish timeout', async () => {
), ),
).rejects.toThrow('publish timed out') ).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)
})

View File

@@ -1,14 +1,7 @@
/* global WebSocket */ /* global WebSocket */
import { verifyEvent } from './pure.ts' import { verifyEvent } from './pure.ts'
import { AbstractRelay } from './abstract-relay.ts' import { AbstractRelay, type AbstractRelayConstructorOptions } from './abstract-relay.ts'
/**
* @deprecated use Relay.connect() instead.
*/
export function relayConnect(url: string): Promise<Relay> {
return Relay.connect(url)
}
var _WebSocket: typeof WebSocket var _WebSocket: typeof WebSocket
@@ -21,12 +14,15 @@ export function useWebSocketImplementation(websocketImplementation: any) {
} }
export class Relay extends AbstractRelay { export class Relay extends AbstractRelay {
constructor(url: string) { constructor(url: string, options?: Pick<AbstractRelayConstructorOptions, 'enablePing' | 'enableReconnect'>) {
super(url, { verifyEvent, websocketImplementation: _WebSocket }) super(url, { verifyEvent, websocketImplementation: _WebSocket, ...options })
} }
static async connect(url: string): Promise<Relay> { static async connect(
const relay = new Relay(url) url: string,
options?: Pick<AbstractRelayConstructorOptions, 'enablePing' | 'enableReconnect'>,
): Promise<Relay> {
const relay = new Relay(url, options)
await relay.connect() await relay.connect()
return relay return relay
} }

23
signer.ts Normal file
View File

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

View File

@@ -26,6 +26,7 @@ export class MockRelay {
public url: string public url: string
public secretKeys: Uint8Array[] public secretKeys: Uint8Array[]
public preloadedEvents: Event[] public preloadedEvents: Event[]
public unresponsive: boolean = false
constructor(url?: string | undefined) { constructor(url?: string | undefined) {
serial++ serial++
@@ -48,6 +49,7 @@ export class MockRelay {
let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {} let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {}
conn.on('message', (message: string) => { conn.on('message', (message: string) => {
if (this.unresponsive) return
const data = JSON.parse(message) const data = JSON.parse(message)
switch (data[0]) { switch (data[0]) {

View File

@@ -1,6 +1,12 @@
import { describe, test, expect } from 'bun:test' import { describe, test, expect } from 'bun:test'
import { buildEvent } from './test-helpers.ts' import { buildEvent } from './test-helpers.ts'
import { Queue, insertEventIntoAscendingList, insertEventIntoDescendingList, binarySearch } from './utils.ts' import {
Queue,
insertEventIntoAscendingList,
insertEventIntoDescendingList,
binarySearch,
normalizeURL,
} from './utils.ts'
import type { Event } from './core.ts' import type { Event } from './core.ts'
@@ -263,3 +269,43 @@ test('binary search', () => {
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('a' < b ? -1 : 'a' === b ? 0 : 1))).toEqual([0, true]) expect(binarySearch(['a', 'b', 'd', 'e'], b => ('a' < b ? -1 : 'a' === b ? 0 : 1))).toEqual([0, true])
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('[' < b ? -1 : '[' === b ? 0 : 1))).toEqual([0, false]) expect(binarySearch(['a', 'b', 'd', 'e'], b => ('[' < b ? -1 : '[' === b ? 0 : 1))).toEqual([0, false])
}) })
describe('normalizeURL', () => {
test('normalizes wss:// URLs', () => {
expect(normalizeURL('wss://example.com')).toBe('wss://example.com/')
expect(normalizeURL('wss://example.com/')).toBe('wss://example.com/')
expect(normalizeURL('wss://example.com//path')).toBe('wss://example.com/path')
expect(normalizeURL('wss://example.com:443')).toBe('wss://example.com/')
})
test('normalizes https:// URLs', () => {
expect(normalizeURL('https://example.com')).toBe('wss://example.com/')
expect(normalizeURL('https://example.com/')).toBe('wss://example.com/')
expect(normalizeURL('http://example.com//path')).toBe('ws://example.com/path')
})
test('normalizes ws:// URLs', () => {
expect(normalizeURL('ws://example.com')).toBe('ws://example.com/')
expect(normalizeURL('ws://example.com/')).toBe('ws://example.com/')
expect(normalizeURL('ws://example.com//path')).toBe('ws://example.com/path')
expect(normalizeURL('ws://example.com:80')).toBe('ws://example.com/')
})
test('adds wss:// to URLs without scheme', () => {
expect(normalizeURL('example.com')).toBe('wss://example.com/')
expect(normalizeURL('example.com/')).toBe('wss://example.com/')
expect(normalizeURL('example.com//path')).toBe('wss://example.com/path')
})
test('handles query parameters', () => {
expect(normalizeURL('wss://example.com?z=1&a=2')).toBe('wss://example.com/?a=2&z=1')
})
test('removes hash', () => {
expect(normalizeURL('wss://example.com#hash')).toBe('wss://example.com/')
})
test('throws on invalid URL', () => {
expect(() => normalizeURL('http://')).toThrow('Invalid URL: http://')
})
})

View File

@@ -3,15 +3,23 @@ import type { Event } from './core.ts'
export const utf8Decoder: TextDecoder = new TextDecoder('utf-8') export const utf8Decoder: TextDecoder = new TextDecoder('utf-8')
export const utf8Encoder: TextEncoder = new TextEncoder() export const utf8Encoder: TextEncoder = new TextEncoder()
export { bytesToHex, hexToBytes } from '@noble/hashes/utils'
export function normalizeURL(url: string): string { export function normalizeURL(url: string): string {
if (url.indexOf('://') === -1) url = 'wss://' + url try {
let p = new URL(url) if (url.indexOf('://') === -1) url = 'wss://' + url
p.pathname = p.pathname.replace(/\/+/g, '/') let p = new URL(url)
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1) if (p.protocol === 'http:') p.protocol = 'ws:'
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = '' else if (p.protocol === 'https:') p.protocol = 'wss:'
p.searchParams.sort() p.pathname = p.pathname.replace(/\/+/g, '/')
p.hash = '' if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
return p.toString() 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[] { export function insertEventIntoDescendingList(sortedArray: Event[], event: Event): Event[] {
@@ -111,6 +119,9 @@ export class Queue<V> {
const target = this.first const target = this.first
this.first = target.next this.first = target.next
if (this.first) {
this.first.prev = null // fix: clean up prev pointer
}
return target.value return target.value
} }