Compare commits

...

23 Commits

Author SHA1 Message Date
umk0m1qk
354b80a929 fix(relay): move _connected = false above closeAllSubscriptions() in close()
close() was setting _connected = false after closeAllSubscriptions(), which meant each sub still saw the relay as connected and tried to send CLOSE frames. Those sends get queued as microtasks, but by the time they run the socket is already closing, so you get a bunch of "WebSocket is already in CLOSING or CLOSED state" warnings.

handleHardClose() already gets this order right — this just makes close() consistent with that.
2026-02-11 12:24:58 -03:00
fiatjaf
0c2c2cd4d8 nip13: improve mining by skipping hex. 2026-02-08 01:06:01 -03:00
fiatjaf
28f7553187 fix a type so jsr is happy. 2026-02-02 18:49:17 -03:00
fiatjaf
ca29d9b515 ok, we need the prepareSubscription method. 2026-02-02 18:46:50 -03:00
fiatjaf
ab802c8dbe automatic prune broken relay objects and keep track of relay idleness so they can be pruned. 2026-02-02 18:44:52 -03:00
fiatjaf
9db705d86c delete queue test since we don't have queues anymore. 2026-02-02 17:01:59 -03:00
fiatjaf
be9b91318f relay: get rid of the message queue, because js is single-threaded. 2026-02-02 09:06:56 -03:00
fiatjaf
c2423f7f31 nip27: fix hashtag parsing after newline or other characters. 2026-02-02 08:56:18 -03:00
fiatjaf
05b1fba511 export source files so they can be imported by other ts apps and libraries better. 2026-02-02 00:38:15 -03:00
fiatjaf
2d1345096b subscribeMany and subscribeManyEose are the same as subscribe/subscribeEose. 2026-02-01 17:19:58 -03:00
fiatjaf
6fc7788a4f utils: merging two (reverse) sorted lists of events. 2026-02-01 08:44:49 -03:00
fiatjaf
2180c7a1fe add onRelayConnectionSuccess to pair with onRelayConnectionFailure. 2026-01-31 19:27:45 -03:00
fiatjaf
b4bec2097d finally stop reconnecting when the first connection fails once and for all. 2026-01-31 19:27:45 -03:00
fiatjaf
fb7de7f1aa prevent reconnections when initial connection fails. 2026-01-31 13:57:33 -03:00
fiatjaf
ccb9641fb9 pool: maxWaitForConnection parameter.
this was so obvious.
2026-01-31 00:27:55 -03:00
fiatjaf
b624ad4059 pool: hooks to notify when a relay fails to connect, then ask whether a connection should be attempted. 2026-01-30 17:35:46 -03:00
fiatjaf
b3d314643a make everything dependent on npm again. 2026-01-30 07:16:04 -03:00
fiatjaf
30ac8a02c2 readme to point only to jsr because npm is awful. 2026-01-28 17:28:27 -03:00
fiatjaf
42c9c7554d migrate to jsr dependencies for @noble and @scure. 2026-01-27 23:54:50 -03:00
fiatjaf
3588d30044 do the same for @noble/ciphers and @scure packages. 2026-01-27 22:43:54 -03:00
lemonknowsall
b40f59af74 Upgrade to @noble/curves ^2.0.1 and @noble/hashes ^2.0.1
This commit upgrades the noble cryptography dependencies to v2.0.1, which includes:

Breaking changes addressed:
- Updated all @noble imports to include .js extensions (required by v2 ESM-only API)
- Changed @noble/hashes/sha256 to @noble/hashes/sha2.js across 8 files
- Fixed secp256k1 API changes: methods now require Uint8Array instead of hex strings
- Updated schnorr.utils.randomPrivateKey() to schnorr.utils.randomSecretKey()

Files modified (27 total):
- package.json: Bump dependency versions
- Source files (12): pure.ts, nip04.ts, nip06.ts, nip13.ts, nip19.ts, nip44.ts,
  nip49.ts, nip77.ts, nip98.ts, nipb7.ts, utils.ts, wasm.ts
- Test files (14): All corresponding test files updated

Benefits:
- Latest security updates from audited noble libraries
- Smaller bundle sizes from v2 optimizations
- Future-proof ESM-only compatibility
- All tests passing

Co-authored-by: OpenCode <opencode@anomalyco.com>
2026-01-24 09:41:15 -03:00
fiatjaf
bfa40da316 nip46: improve fromURI() and implement "switch_relays". 2026-01-22 21:50:30 -03:00
fiatjaf
9078f45a64 optionally take an AbortSignal on subscriptions. 2026-01-22 21:49:39 -03:00
38 changed files with 617 additions and 504 deletions

View File

@@ -1,4 +1,4 @@
# ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) [![JSR](https://jsr.io/badges/@nostr/tools)](https://jsr.io/@nostr/tools) nostr-tools
# [![JSR](https://jsr.io/badges/@nostr/tools)](https://jsr.io/@nostr/tools) @nostr/tools
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
@@ -9,9 +9,6 @@ This package is only providing lower-level functionality. If you want higher-lev
## Installation
```bash
# npm
npm install --save nostr-tools
# jsr
npx jsr add @nostr/tools
```
@@ -27,7 +24,7 @@ https://jsr.io/@nostr/tools/doc
### Generating a private key and a public key
```js
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import { generateSecretKey, getPublicKey } from '@nostr/tools/pure'
let sk = generateSecretKey() // `sk` is a Uint8Array
let pk = getPublicKey(sk) // `pk` is a hex string
@@ -36,7 +33,7 @@ let pk = getPublicKey(sk) // `pk` is a hex string
To get the secret key in hex format, use
```js
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js' // already an installed dependency
let skHex = bytesToHex(sk)
let backToBytes = hexToBytes(skHex)
@@ -45,7 +42,7 @@ let backToBytes = hexToBytes(skHex)
### Creating, signing and verifying events
```js
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'
import { finalizeEvent, verifyEvent } from '@nostr/tools/pure'
let event = finalizeEvent({
kind: 1,
@@ -62,8 +59,8 @@ let isGood = verifyEvent(event)
Doesn't matter what you do, you always should be using a `SimplePool`:
```js
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import { SimplePool } from 'nostr-tools/pool'
import { finalizeEvent, generateSecretKey, getPublicKey } from '@nostr/tools/pure'
import { SimplePool } from '@nostr/tools/pool'
const pool = new SimplePool()
@@ -126,8 +123,8 @@ relay.close()
To use this on Node.js you first must install `ws` and call something like this:
```js
import { useWebSocketImplementation } from 'nostr-tools/pool'
// or import { useWebSocketImplementation } from 'nostr-tools/relay' if you're using the Relay directly
import { useWebSocketImplementation } from '@nostr/tools/pool'
// or import { useWebSocketImplementation } from '@nostr/tools/relay' if you're using the Relay directly
import WebSocket from 'ws'
useWebSocketImplementation(WebSocket)
@@ -138,7 +135,7 @@ useWebSocketImplementation(WebSocket)
You can enable regular pings of connected relays with the `enablePing` option. This will set up a heartbeat that closes the websocket if it doesn't receive a response in time. Some platforms, like Node.js, don't report websocket disconnections due to network issues, and enabling this can increase the reliability of the `onclose` event.
```js
import { SimplePool } from 'nostr-tools/pool'
import { SimplePool } from '@nostr/tools/pool'
const pool = new SimplePool({ enablePing: true })
```
@@ -148,7 +145,7 @@ const pool = new SimplePool({ enablePing: true })
You can also enable automatic reconnection with the `enableReconnect` option. This will make the pool try to reconnect to relays with an exponential backoff delay if the connection is lost unexpectedly.
```js
import { SimplePool } from 'nostr-tools/pool'
import { SimplePool } from '@nostr/tools/pool'
const pool = new SimplePool({ enableReconnect: true })
```
@@ -331,7 +328,7 @@ for (let profile of refs.profiles) {
### Querying profile data from a NIP-05 address
```js
import { queryProfile } from 'nostr-tools/nip05'
import { queryProfile } from '@nostr/tools/nip05'
let profile = await queryProfile('jb55.com')
console.log(profile.pubkey)
@@ -343,13 +340,13 @@ console.log(profile.relays)
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
```js
import { useFetchImplementation } from 'nostr-tools/nip05'
import { useFetchImplementation } from '@nostr/tools/nip05'
useFetchImplementation(require('node-fetch'))
```
### Including NIP-07 types
```js
import type { WindowNostr } from 'nostr-tools/nip07'
import type { WindowNostr } from '@nostr/tools/nip07'
declare global {
interface Window {
@@ -361,8 +358,8 @@ declare global {
### Encoding and decoding NIP-19 codes
```js
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import * as nip19 from 'nostr-tools/nip19'
import { generateSecretKey, getPublicKey } from '@nostr/tools/pure'
import * as nip19 from '@nostr/tools/nip19'
let sk = generateSecretKey()
let nsec = nip19.nsecEncode(sk)
@@ -390,7 +387,7 @@ assert(data.relays.length === 2)
[`nostr-wasm`](https://github.com/fiatjaf/nostr-wasm) is a thin wrapper over [libsecp256k1](https://github.com/bitcoin-core/secp256k1) compiled to WASM just for hashing, signing and verifying Nostr events.
```js
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm'
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from '@nostr/tools/wasm'
import { initNostrWasm } from 'nostr-wasm'
// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent
@@ -403,9 +400,9 @@ initNostrWasm().then(setNostrWasm)
If you're going to use `Relay` and `SimplePool` you must also import `nostr-tools/abstract-relay` and/or `nostr-tools/abstract-pool` instead of the defaults and then instantiate them by passing the `verifyEvent`:
```js
import { setNostrWasm, verifyEvent } from 'nostr-tools/wasm'
import { AbstractRelay } from 'nostr-tools/abstract-relay'
import { AbstractSimplePool } from 'nostr-tools/abstract-pool'
import { setNostrWasm, verifyEvent } from '@nostr/tools/wasm'
import { AbstractRelay } from '@nostr/tools/abstract-relay'
import { AbstractSimplePool } from '@nostr/tools/abstract-pool'
import { initNostrWasm } from 'nostr-wasm'
initNostrWasm().then(setNostrWasm)
@@ -442,7 +439,7 @@ summary for relay read message and verify event
## Plumbing
To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
To develop `@nostr/tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
## License

View File

@@ -11,6 +11,7 @@ import { normalizeURL } from './utils.ts'
import type { Event, EventTemplate, Nostr, VerifiedEvent } from './core.ts'
import { type Filter } from './filter.ts'
import { alwaysTrue } from './helpers.ts'
import { Relay } from './relay.ts'
export type SubCloser = { close: (reason?: string) => void }
@@ -19,10 +20,21 @@ export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {
// 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>)
// onRelayConnectionFailure is called with the URL of a relay that failed the initial connection
onRelayConnectionFailure?: (url: string) => void
// onRelayConnectionSuccess is called with the URL of a relay that succeeds the initial connection
onRelayConnectionSuccess?: (url: string) => void
// allowConnectingToRelay takes a relay URL and the operation being performed
// return false to skip connecting to that relay
allowConnectingToRelay?: (url: string, operation: ['read', Filter[]] | ['write', Event]) => boolean
// maxWaitForConnection takes a number in milliseconds that will be given to ensureRelay such that we
// don't get stuck forever when attempting to connect to a relay, it is 3000 (3 seconds) by default
maxWaitForConnection: number
}
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
maxWait?: number
abort?: AbortSignal
onclose?: (reasons: string[]) => void
onauth?: (event: EventTemplate) => Promise<VerifiedEvent>
id?: string
@@ -39,6 +51,10 @@ export class AbstractSimplePool {
public enableReconnect: boolean
public automaticallyAuth?: (relayURL: string) => null | ((event: EventTemplate) => Promise<VerifiedEvent>)
public trustedRelayURLs: Set<string> = new Set()
public onRelayConnectionFailure?: (url: string) => void
public onRelayConnectionSuccess?: (url: string) => void
public allowConnectingToRelay?: (url: string, operation: ['read', Filter[]] | ['write', Event]) => boolean
public maxWaitForConnection: number
private _WebSocket?: typeof WebSocket
@@ -48,9 +64,19 @@ export class AbstractSimplePool {
this.enablePing = opts.enablePing
this.enableReconnect = opts.enableReconnect || false
this.automaticallyAuth = opts.automaticallyAuth
this.onRelayConnectionFailure = opts.onRelayConnectionFailure
this.onRelayConnectionSuccess = opts.onRelayConnectionSuccess
this.allowConnectingToRelay = opts.allowConnectingToRelay
this.maxWaitForConnection = opts.maxWaitForConnection || 3000
}
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
async ensureRelay(
url: string,
params?: {
connectionTimeout?: number
abort?: AbortSignal
},
): Promise<AbstractRelay> {
url = normalizeURL(url)
let relay = this.relays.get(url)
@@ -62,11 +88,8 @@ export class AbstractSimplePool {
enableReconnect: this.enableReconnect,
})
relay.onclose = () => {
if (relay && !relay.enableReconnect) {
this.relays.delete(url)
}
}
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
this.relays.set(url, relay)
}
@@ -77,7 +100,15 @@ export class AbstractSimplePool {
}
}
await relay.connect()
try {
await relay.connect({
timeout: params?.connectionTimeout,
abort: params?.abort,
})
} catch (err) {
this.relays.delete(url)
throw err
}
return relay
}
@@ -91,28 +122,22 @@ export class AbstractSimplePool {
subscribe(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 (!request.find(r => r.url === url)) {
if (uniqUrls.indexOf(url) === -1) {
uniqUrls.push(url)
request.push({ url, filter: filter })
}
}
}
return this.subscribeMap(request, params)
}
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)
return this.subscribe(relays, filter, params)
}
subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser {
@@ -172,16 +197,28 @@ export class AbstractSimplePool {
// open a subscription in all given relays
const allOpened = Promise.all(
groupedRequests.map(async ({ url, filters }, i) => {
if (this.allowConnectingToRelay?.(url, ['read', filters]) === false) {
handleClose(i, 'connection skipped by allowConnectingToRelay')
return
}
let relay: AbstractRelay
try {
relay = await this.ensureRelay(url, {
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
connectionTimeout:
this.maxWaitForConnection < (params.maxWait || 0)
? Math.max(params.maxWait! * 0.8, params.maxWait! - 1000)
: this.maxWaitForConnection,
abort: params.abort,
})
} catch (err) {
this.onRelayConnectionFailure?.(url)
handleClose(i, (err as any)?.message || String(err))
return
}
this.onRelayConnectionSuccess?.(url)
let subscription = relay.subscribe(filters, {
...params,
oneose: () => handleEose(i),
@@ -198,6 +235,7 @@ export class AbstractSimplePool {
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
abort: params.abort,
})
})
.catch(err => {
@@ -209,6 +247,7 @@ export class AbstractSimplePool {
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
abort: params.abort,
})
subs.push(subscription)
@@ -244,13 +283,7 @@ export class AbstractSimplePool {
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 this.subscribeEose(relays, filter, params)
}
async querySync(
@@ -286,7 +319,11 @@ export class AbstractSimplePool {
publish(
relays: string[],
event: Event,
options?: { onauth?: (evt: EventTemplate) => Promise<VerifiedEvent> },
params?: {
onauth?: (evt: EventTemplate) => Promise<VerifiedEvent>
maxWait?: number
abort?: AbortSignal
},
): Promise<string>[] {
return relays.map(normalizeURL).map(async (url, i, arr) => {
if (arr.indexOf(url) !== i) {
@@ -294,12 +331,29 @@ export class AbstractSimplePool {
return Promise.reject('duplicate url')
}
let r = await this.ensureRelay(url)
if (this.allowConnectingToRelay?.(url, ['write', event]) === false) {
return Promise.reject('connection skipped by allowConnectingToRelay')
}
let r: Relay
try {
r = await this.ensureRelay(url, {
connectionTimeout:
this.maxWaitForConnection < (params?.maxWait || 0)
? Math.max(params!.maxWait! * 0.8, params!.maxWait! - 1000)
: this.maxWaitForConnection,
abort: params?.abort,
})
} catch (err) {
this.onRelayConnectionFailure?.(url)
return String('connection failure: ' + String(err))
}
return r
.publish(event)
.catch(async err => {
if (err instanceof Error && err.message.startsWith('auth-required: ') && options?.onauth) {
await r.auth(options.onauth)
if (err instanceof Error && err.message.startsWith('auth-required: ') && params?.onauth) {
await r.auth(params.onauth)
return r.publish(event) // retry
}
throw err
@@ -329,4 +383,19 @@ export class AbstractSimplePool {
this.relays.forEach(conn => conn.close())
this.relays = new Map()
}
pruneIdleRelays(idleThresholdMs: number = 10000): string[] {
const prunedUrls: string[] = []
// check each relay's idle status and prune if over threshold
for (const [url, relay] of this.relays) {
if (relay.idleSince && Date.now() - relay.idleSince >= idleThresholdMs) {
this.relays.delete(url)
prunedUrls.push(url)
relay.close()
}
}
return prunedUrls
}
}

View File

@@ -3,9 +3,8 @@
import type { Event, EventTemplate, VerifiedEvent, Nostr, NostrEvent } from './core.ts'
import { matchFilters, type Filter } from './filter.ts'
import { getHex64, getSubscriptionId } from './fakejson.ts'
import { Queue, normalizeURL } from './utils.ts'
import { normalizeURL } from './utils.ts'
import { makeAuthEvent } from './nip42.ts'
import { yieldThread } from './helpers.ts'
type RelayWebSocket = WebSocket & {
ping?(): void
@@ -35,7 +34,6 @@ export class AbstractRelay {
public onauth: undefined | ((evt: EventTemplate) => Promise<VerifiedEvent>)
public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400
public publishTimeout: number = 4400
public pingFrequency: number = 29000
public pingTimeout: number = 20000
@@ -43,18 +41,17 @@ export class AbstractRelay {
public openSubs: Map<string, Subscription> = new Map()
public enablePing: boolean | undefined
public enableReconnect: boolean
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
public idleSince: number | undefined = Date.now() // when undefined that means it isn't idle
public ongoingOperations: number = 0 // used to compute idleness
private reconnectTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private pingIntervalHandle: ReturnType<typeof setInterval> | undefined
private reconnectAttempts: number = 0
private closedIntentionally: boolean = false
private skipReconnection: boolean = false
private connectionPromise: Promise<void> | undefined
private openCountRequests = new Map<string, CountResolver>()
private openEventPublishes = new Map<string, EventPublishResolver>()
private ws: RelayWebSocket | undefined
private incomingMessageQueue = new Queue<string>()
private queueRunning = false
private challenge: string | undefined
private authPromise: Promise<string> | undefined
private serial: number = 0
@@ -70,9 +67,12 @@ export class AbstractRelay {
this.enableReconnect = opts.enableReconnect || false
}
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
static async connect(
url: string,
opts: AbstractRelayConstructorOptions & Parameters<AbstractRelay['connect']>[0],
): Promise<AbstractRelay> {
const relay = new AbstractRelay(url, opts)
await relay.connect()
await relay.connect(opts)
return relay
}
@@ -118,36 +118,43 @@ export class AbstractRelay {
this._connected = false
this.connectionPromise = undefined
this.idleSince = undefined
const wasIntentional = this.closedIntentionally
this.closedIntentionally = false // reset for next time
this.onclose?.()
if (this.enableReconnect && !wasIntentional) {
if (this.enableReconnect && !this.skipReconnection) {
this.reconnect()
} else {
this.onclose?.()
this.closeAllSubscriptions(reason)
}
}
public async connect(): Promise<void> {
public async connect(opts?: { timeout?: number; abort?: AbortSignal }): Promise<void> {
let connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
if (this.connectionPromise) return this.connectionPromise
this.challenge = undefined
this.authPromise = undefined
this.skipReconnection = false
this.connectionPromise = new Promise((resolve, reject) => {
this.connectionTimeoutHandle = setTimeout(() => {
if (opts?.timeout) {
connectionTimeoutHandle = setTimeout(() => {
reject('connection timed out')
this.connectionPromise = undefined
this.skipReconnection = true
this.onclose?.()
this.closeAllSubscriptions('relay connection timed out')
}, this.connectionTimeout)
this.handleHardClose('relay connection timed out')
}, opts.timeout)
}
if (opts?.abort) {
opts.abort.onabort = reject
}
try {
this.ws = new this._WebSocket(this.url)
} catch (err) {
clearTimeout(this.connectionTimeoutHandle)
clearTimeout(connectionTimeoutHandle)
reject(err)
return
}
@@ -157,7 +164,7 @@ export class AbstractRelay {
clearTimeout(this.reconnectTimeoutHandle)
this.reconnectTimeoutHandle = undefined
}
clearTimeout(this.connectionTimeoutHandle)
clearTimeout(connectionTimeoutHandle)
this._connected = true
const isReconnection = this.reconnectAttempts > 0
@@ -182,14 +189,17 @@ export class AbstractRelay {
resolve()
}
this.ws.onerror = ev => {
clearTimeout(this.connectionTimeoutHandle)
reject((ev as any).message || 'websocket error')
this.handleHardClose('relay connection errored')
this.ws.onerror = () => {
clearTimeout(connectionTimeoutHandle)
reject('connection failed')
this.connectionPromise = undefined
this.skipReconnection = true
this.onclose?.()
this.handleHardClose('relay connection failed')
}
this.ws.onclose = ev => {
clearTimeout(this.connectionTimeoutHandle)
clearTimeout(connectionTimeoutHandle)
reject((ev as any).message || 'websocket closed')
this.handleHardClose('relay connection closed')
}
@@ -219,7 +229,7 @@ export class AbstractRelay {
const sub = this.subscribe(
[{ ids: ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], limit: 0 }],
{
label: 'forced-ping',
label: '<forced-ping>',
oneose: () => {
resolve(true)
sub.close()
@@ -258,21 +268,126 @@ export class AbstractRelay {
}
}
private async runQueue() {
this.queueRunning = true
while (true) {
if (false === this.handleNext()) {
break
}
await yieldThread()
}
this.queueRunning = false
public async send(message: string) {
if (!this.connectionPromise) throw new SendingOnClosedConnection(message, this.url)
this.connectionPromise.then(() => {
this.ws?.send(message)
})
}
private handleNext(): undefined | false {
const json = this.incomingMessageQueue.dequeue()
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> {
const challenge = this.challenge
if (!challenge) throw new Error("can't perform auth, no challenge was received")
if (this.authPromise) return this.authPromise
this.authPromise = new Promise<string>(async (resolve, reject) => {
try {
let evt = await signAuthEvent(makeAuthEvent(this.url, challenge))
let timeout = setTimeout(() => {
let ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('auth timed out'))
this.openEventPublishes.delete(evt.id)
}
}, this.publishTimeout)
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
this.send('["AUTH",' + JSON.stringify(evt) + ']')
} catch (err) {
console.warn('subscribe auth function failed:', err)
}
})
return this.authPromise
}
public async publish(event: Event): Promise<string> {
this.idleSince = undefined
this.ongoingOperations++
const ret = new Promise<string>((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) + ']')
// compute idleness state
this.ongoingOperations--
if (this.ongoingOperations === 0) this.idleSince = Date.now()
return ret
}
public async count(filters: Filter[], params: { id?: string | null }): Promise<number> {
this.serial++
const id = params?.id || 'count:' + this.serial
const ret = new Promise<number>((resolve, reject) => {
this.openCountRequests.set(id, { resolve, reject })
})
this.send('["COUNT","' + id + '",' + JSON.stringify(filters).substring(1))
return ret
}
public subscribe(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
if (params.label !== '<forced-ping>') {
this.idleSince = undefined
this.ongoingOperations++
}
const sub = this.prepareSubscription(filters, params)
sub.fire()
if (params.abort) {
params.abort.onabort = () => sub.close(String(params.abort!.reason || '<aborted>'))
}
return sub
}
public prepareSubscription(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
this.serial++
const id = params.id || (params.label ? params.label + ':' : 'sub:') + this.serial
const sub = new Subscription(this, id, filters, params)
this.openSubs.set(id, sub)
return sub
}
public close() {
this.skipReconnection = true
if (this.reconnectTimeoutHandle) {
clearTimeout(this.reconnectTimeoutHandle)
this.reconnectTimeoutHandle = undefined
}
if (this.pingIntervalHandle) {
clearInterval(this.pingIntervalHandle)
this.pingIntervalHandle = undefined
}
this._connected = false
this.closeAllSubscriptions('relay connection closed by us')
this.idleSince = undefined
this.onclose?.()
if (this.ws?.readyState === this._WebSocket.OPEN) {
this.ws?.close()
}
}
// this is the function assigned to this.ws.onmessage
// it's exposed for testing and debugging purposes
public _onmessage(ev: MessageEvent<any>): void {
const json = ev.data
if (!json) {
return false
return
}
// shortcut EVENT sub
@@ -370,113 +485,11 @@ export class AbstractRelay {
}
}
} catch (err) {
const [_, __, event] = JSON.parse(json)
;(window as any).printer.maybe(event.pubkey, ':: caught err', event, this.url, err)
return
}
}
public async send(message: string) {
if (!this.connectionPromise) throw new SendingOnClosedConnection(message, this.url)
this.connectionPromise.then(() => {
this.ws?.send(message)
})
}
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> {
const challenge = this.challenge
if (!challenge) throw new Error("can't perform auth, no challenge was received")
if (this.authPromise) return this.authPromise
this.authPromise = new Promise<string>(async (resolve, reject) => {
try {
let evt = await signAuthEvent(makeAuthEvent(this.url, challenge))
let timeout = setTimeout(() => {
let ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('auth timed out'))
this.openEventPublishes.delete(evt.id)
}
}, this.publishTimeout)
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
this.send('["AUTH",' + JSON.stringify(evt) + ']')
} catch (err) {
console.warn('subscribe auth function failed:', err)
}
})
return this.authPromise
}
public async publish(event: Event): Promise<string> {
const ret = new Promise<string>((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) + ']')
return ret
}
public async count(filters: Filter[], params: { id?: string | null }): Promise<number> {
this.serial++
const id = params?.id || 'count:' + this.serial
const ret = new Promise<number>((resolve, reject) => {
this.openCountRequests.set(id, { resolve, reject })
})
this.send('["COUNT","' + id + '",' + JSON.stringify(filters).substring(1))
return ret
}
public subscribe(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
const sub = this.prepareSubscription(filters, params)
sub.fire()
return sub
}
public prepareSubscription(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
this.serial++
const id = params.id || (params.label ? params.label + ':' : 'sub:') + this.serial
const subscription = new Subscription(this, id, filters, params)
this.openSubs.set(id, subscription)
return subscription
}
public close() {
this.closedIntentionally = true
if (this.reconnectTimeoutHandle) {
clearTimeout(this.reconnectTimeoutHandle)
this.reconnectTimeoutHandle = undefined
}
if (this.pingIntervalHandle) {
clearInterval(this.pingIntervalHandle)
this.pingIntervalHandle = undefined
}
this.closeAllSubscriptions('relay connection closed by us')
this._connected = false
this.onclose?.()
if (this.ws?.readyState === this._WebSocket.OPEN) {
this.ws?.close()
}
}
// this is the function assigned to this.ws.onmessage
// it's exposed for testing and debugging purposes
public _onmessage(ev: MessageEvent<any>) {
this.incomingMessageQueue.enqueue(ev.data as string)
if (!this.queueRunning) {
this.runQueue()
}
}
}
export class Subscription {
@@ -552,6 +565,11 @@ export class Subscription {
this.closed = true
}
this.relay.openSubs.delete(this.id)
// compute idleness state
this.relay.ongoingOperations--
if (this.relay.ongoingOperations === 0) this.relay.idleSince = Date.now()
this.onclose?.(reason)
}
}
@@ -563,6 +581,7 @@ export type SubscriptionParams = {
alreadyHaveEvent?: (id: string) => boolean
receivedEvent?: (relay: AbstractRelay, id: string) => void
eoseTimeout?: number
abort?: AbortSignal
}
export type CountResolver = {

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,37 +1,5 @@
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
export async function yieldThread() {
return new Promise<void>((resolve, reject) => {
try {
// Check if MessageChannel is available
if (typeof MessageChannel !== 'undefined') {
const ch = new MessageChannel()
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)
}
})
}
export const alwaysTrue: Nostr['verifyEvent'] = (t: Event): t is VerifiedEvent => {
t[verifiedSymbol] = true
return true

View File

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

View File

@@ -2,7 +2,7 @@ import { test, expect } from 'bun:test'
import { encrypt, decrypt } from './nip04.ts'
import { getPublicKey, generateSecretKey } from './pure.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
test('encrypt and decrypt message', async () => {
let sk1 = generateSecretKey()

View File

@@ -1,13 +1,13 @@
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { cbc } from '@noble/ciphers/aes'
import { hexToBytes, randomBytes } from '@noble/hashes/utils.js'
import { secp256k1 } from '@noble/curves/secp256k1.js'
import { cbc } from '@noble/ciphers/aes.js'
import { base64 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
export function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): string {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const privkey: Uint8Array = secretKey instanceof Uint8Array ? secretKey : hexToBytes(secretKey)
const key = secp256k1.getSharedSecret(privkey, hexToBytes('02' + pubkey))
const normalizedKey = getNormalizedX(key)
let iv = Uint8Array.from(randomBytes(16))
@@ -22,9 +22,9 @@ export function encrypt(secretKey: string | Uint8Array, pubkey: string, text: st
}
export function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): string {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
const privkey: Uint8Array = secretKey instanceof Uint8Array ? secretKey : hexToBytes(secretKey)
let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let key = secp256k1.getSharedSecret(privkey, hexToBytes('02' + pubkey))
let normalizedKey = getNormalizedX(key)
let iv = base64.decode(ivb64)

View File

@@ -5,7 +5,7 @@ import {
extendedKeysFromSeedWords,
accountFromExtendedKey,
} from './nip06.ts'
import { hexToBytes } from '@noble/hashes/utils'
import { hexToBytes } from '@noble/hashes/utils.js'
test('generate private key from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'

View File

@@ -1,5 +1,5 @@
import { bytesToHex } from '@noble/hashes/utils'
import { wordlist } from '@scure/bip39/wordlists/english'
import { bytesToHex } from '@noble/hashes/utils.js'
import { wordlist } from '@scure/bip39/wordlists/english.js'
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
import { HDKey } from '@scure/bip32'

View File

@@ -1,6 +1,6 @@
import { bytesToHex } from '@noble/hashes/utils'
import { bytesToHex } from '@noble/hashes/utils.js'
import { type UnsignedEvent, type Event } from './pure.ts'
import { sha256 } from '@noble/hashes/sha256'
import { sha256 } from '@noble/hashes/sha2.js'
import { utf8Encoder } from './utils.ts'
@@ -21,6 +21,23 @@ export function getPow(hex: string): number {
return count
}
/** Get POW difficulty directly from a Uint8Array hash. */
function getPowFromBytes(hash: Uint8Array): number {
let count = 0
for (let i = 0; i < hash.length; i++) {
const byte = hash[i]
if (byte === 0) {
count += 8
} else {
count += Math.clz32(byte) - 24
break
}
}
return count
}
/**
* Mine an event with the desired POW. This function mutates the event.
* Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread.
@@ -43,18 +60,15 @@ export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event
tag[1] = (++count).toString()
event.id = fastEventHash(event)
const hash = sha256(
utf8Encoder.encode(JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content])),
)
if (getPow(event.id) >= difficulty) {
if (getPowFromBytes(hash) >= difficulty) {
event.id = bytesToHex(hash)
break
}
}
return event
}
export function fastEventHash(evt: UnsignedEvent): string {
return bytesToHex(
sha256(utf8Encoder.encode(JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content]))),
)
}

View File

@@ -2,7 +2,7 @@ import { test, expect } from 'bun:test'
import { getPublicKey } from './pure.ts'
import { decode } from './nip19.ts'
import { wrapEvent, wrapManyEvents, unwrapEvent } from './nip17.ts'
import { hexToBytes } from '@noble/hashes/utils'
import { hexToBytes } from '@noble/hashes/utils.js'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { hexToBytes } from '@noble/hashes/utils.js'
import { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } from './kinds.ts'
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'

View File

@@ -1,4 +1,4 @@
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils.js'
import { bech32 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
@@ -110,7 +110,7 @@ export function decode(nip19: NPub): DecodedNpub
export function decode(nip19: Note): DecodedNote
export function decode(code: string): DecodedResult
export function decode(code: string): DecodedResult {
let { prefix, words } = bech32.decode(code, Bech32MaxSize)
let { prefix, words } = bech32.decode(code as `${string}1${string}`, Bech32MaxSize)
let data = new Uint8Array(bech32.fromWords(words))
switch (prefix) {

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { hexToBytes } from '@noble/hashes/utils.js'
import { finalizeEvent, getPublicKey } from './pure.ts'
import { Reaction, ShortTextNote } from './kinds.ts'
import { finishReactionEvent, getReactedEventPointer } from './nip25.ts'

View File

@@ -85,7 +85,7 @@ test('parse content with hashtags and emoji shortcodes', () => {
['emoji', 'alpaca', 'https://example.com/alpaca.png'],
],
content:
'hey nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out :alpaca::alpaca: #alpaca at wss://alpaca.com! :star:',
'hey nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out :alpaca::alpaca: #alpaca at wss://alpaca.com! :star:\n\n#WORDS #486 5/6',
created_at: 1234567890,
pubkey: 'dummy',
id: 'dummy',
@@ -105,6 +105,11 @@ test('parse content with hashtags and emoji shortcodes', () => {
{ type: 'relay', url: 'wss://alpaca.com/' },
{ type: 'text', text: '! ' },
{ type: 'emoji', shortcode: 'star', url: 'https://example.com/star.png' },
{ type: 'text', text: '\n\n' },
{ type: 'hashtag', value: 'WORDS' },
{ type: 'text', text: ' ' },
{ type: 'hashtag', value: '486' },
{ type: 'text', text: ' 5/6' },
])
})

View File

@@ -69,7 +69,7 @@ export function* parse(content: string | NostrEvent): Iterable<Block> {
if (u === -1 || (h >= 0 && h < u)) {
// parse hashtag
if (h === 0 || content[h - 1] === ' ') {
if (h === 0 || content[h - 1].match(noCharacter)) {
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) }

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { hexToBytes } from '@noble/hashes/utils.js'
import { getPublicKey } from './pure.ts'
import * as Kind from './kinds.ts'
import {

View File

@@ -1,8 +1,8 @@
import { test, expect } from 'bun:test'
import { v2 } from './nip44.js'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
import { default as vec } from './nip44.vectors.json' with { type: 'json' }
import { schnorr } from '@noble/curves/secp256k1'
import { schnorr } from '@noble/curves/secp256k1.js'
const v2vec = vec.v2
test('get_conversation_key', () => {
@@ -14,7 +14,7 @@ test('get_conversation_key', () => {
test('encrypt_decrypt', () => {
for (const v of v2vec.valid.encrypt_decrypt) {
const pub2 = bytesToHex(schnorr.getPublicKey(v.sec2))
const pub2 = bytesToHex(schnorr.getPublicKey(hexToBytes(v.sec2)))
const key = v2.utils.getConversationKey(hexToBytes(v.sec1), pub2)
expect(bytesToHex(key)).toEqual(v.conversation_key)
const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce))
@@ -40,7 +40,7 @@ test('decrypt', async () => {
test('get_conversation_key', async () => {
for (const v of v2vec.invalid.get_conversation_key) {
expect(() => v2.utils.getConversationKey(hexToBytes(v.sec1), v.pub2)).toThrow(
/(Point is not on curve|Cannot find square root)/,
/(Point is not on curve|Cannot find square root|invalid field element)/,
)
}
})

View File

@@ -1,10 +1,10 @@
import { chacha20 } from '@noble/ciphers/chacha'
import { equalBytes } from '@noble/ciphers/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
import { hmac } from '@noble/hashes/hmac'
import { sha256 } from '@noble/hashes/sha256'
import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { chacha20 } from '@noble/ciphers/chacha.js'
import { equalBytes } from '@noble/ciphers/utils.js'
import { secp256k1 } from '@noble/curves/secp256k1.js'
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf.js'
import { hmac } from '@noble/hashes/hmac.js'
import { sha256 } from '@noble/hashes/sha2.js'
import { concatBytes, hexToBytes, randomBytes } from '@noble/hashes/utils.js'
import { base64 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
@@ -13,8 +13,8 @@ const minPlaintextSize = 0x0001 // 1b msg => padded to 32b
const maxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb
export function getConversationKey(privkeyA: Uint8Array, pubkeyB: string): Uint8Array {
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
return hkdf_extract(sha256, sharedX, 'nip44-v2')
const sharedX = secp256k1.getSharedSecret(privkeyA, hexToBytes('02' + pubkeyB)).subarray(1, 33)
return hkdf_extract(sha256, sharedX, utf8Encoder.encode('nip44-v2'))
}
function getMessageKeys(

157
nip46.ts
View File

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

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { hexToBytes } from '@noble/hashes/utils.js'
import { makeNwcRequestEvent, parseConnectionString } from './nip47.ts'
import { decrypt } from './nip04.ts'
import { NWCWalletRequest } from './kinds.ts'

View File

@@ -1,6 +1,6 @@
import { test, expect } from 'bun:test'
import { decrypt, encrypt } from './nip49.ts'
import { hexToBytes } from '@noble/hashes/utils'
import { hexToBytes } from '@noble/hashes/utils.js'
test('encrypt and decrypt', () => {
for (let i = 0; i < vectors.length; i++) {

View File

@@ -1,8 +1,8 @@
import { scrypt } from '@noble/hashes/scrypt'
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { Bech32MaxSize, Ncryptsec, encodeBytes } from './nip19.ts'
import { bech32 } from '@scure/base'
import { scrypt } from '@noble/hashes/scrypt.js'
import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'
import { concatBytes, randomBytes } from '@noble/hashes/utils.js'
import { Bech32MaxSize, Ncryptsec, encodeBytes } from './nip19.ts'
export function encrypt(
sec: Uint8Array,
@@ -22,7 +22,7 @@ export function encrypt(
}
export function decrypt(ncryptsec: string, password: string): Uint8Array {
let { prefix, words } = bech32.decode(ncryptsec, Bech32MaxSize)
let { prefix, words } = bech32.decode(ncryptsec as `${string}1${string}`, Bech32MaxSize)
if (prefix !== 'ncryptsec') {
throw new Error(`invalid prefix ${prefix}, expected 'ncryptsec'`)
}

View File

@@ -4,7 +4,7 @@ import { decode } from './nip19.ts'
import { NostrEvent, getPublicKey } from './pure.ts'
import { SimplePool } from './pool.ts'
import { GiftWrap } from './kinds.ts'
import { hexToBytes } from '@noble/hashes/utils'
import { hexToBytes } from '@noble/hashes/utils.js'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data as Uint8Array
const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data as Uint8Array

View File

@@ -1,7 +1,7 @@
import { bytesToHex, hexToBytes } from '@noble/ciphers/utils'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
import { Filter } from './filter.ts'
import { AbstractRelay, Subscription } from './relay.ts'
import { sha256 } from '@noble/hashes/sha256'
import { sha256 } from '@noble/hashes/sha2.js'
// Negentropy implementation by Doug Hoyte
const PROTOCOL_VERSION = 0x61 // Version 1

View File

@@ -1,5 +1,5 @@
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
import { sha256 } from '@noble/hashes/sha2.js'
import { bytesToHex } from '@noble/hashes/utils.js'
import { describe, expect, test } from 'bun:test'
import { HTTPAuth } from './kinds.ts'

View File

@@ -1,5 +1,5 @@
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
import { sha256 } from '@noble/hashes/sha2.js'
import { bytesToHex } from '@noble/hashes/utils.js'
import { base64 } from '@scure/base'
import { HTTPAuth } from './kinds.ts'

View File

@@ -1,6 +1,6 @@
import { test, expect } from 'bun:test'
import { BlossomClient } from './nipb7.ts'
import { sha256 } from '@noble/hashes/sha256'
import { sha256 } from '@noble/hashes/sha2.js'
import { bytesToHex } from './utils.ts'
import { PlainKeySigner } from './signer.ts'
import { generateSecretKey } from './pure.ts'

View File

@@ -1,4 +1,4 @@
import { sha256 } from '@noble/hashes/sha256'
import { sha256 } from '@noble/hashes/sha2.js'
import { EventTemplate } from './core.ts'
import { Signer } from './signer.ts'
import { bytesToHex } from './utils.ts'

View File

@@ -1,7 +1,7 @@
{
"type": "module",
"name": "nostr-tools",
"version": "2.19.4",
"version": "2.23.0",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
@@ -16,71 +16,85 @@
"types": "./lib/types/index.d.ts",
"exports": {
".": {
"source": "./index.ts",
"import": "./lib/esm/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./core": {
"source": "./core.ts",
"import": "./lib/esm/core.js",
"require": "./lib/cjs/core.js",
"types": "./lib/types/core.d.ts"
},
"./pure": {
"source": "./pure.ts",
"import": "./lib/esm/pure.js",
"require": "./lib/cjs/pure.js",
"types": "./lib/types/pure.d.ts"
},
"./wasm": {
"source": "./wasm.ts",
"import": "./lib/esm/wasm.js",
"require": "./lib/cjs/wasm.js",
"types": "./lib/types/wasm.d.ts"
},
"./kinds": {
"source": "./kinds.ts",
"import": "./lib/esm/kinds.js",
"require": "./lib/cjs/kinds.js",
"types": "./lib/types/kinds.d.ts"
},
"./filter": {
"source": "./filter.ts",
"import": "./lib/esm/filter.js",
"require": "./lib/cjs/filter.js",
"types": "./lib/types/filter.d.ts"
},
"./abstract-relay": {
"source": "./abstract-relay.ts",
"import": "./lib/esm/abstract-relay.js",
"require": "./lib/cjs/abstract-relay.js",
"types": "./lib/types/abstract-relay.d.ts"
},
"./relay": {
"source": "./relay.ts",
"import": "./lib/esm/relay.js",
"require": "./lib/cjs/relay.js",
"types": "./lib/types/relay.d.ts"
},
"./abstract-pool": {
"source": "./abstract-pool.ts",
"import": "./lib/esm/abstract-pool.js",
"require": "./lib/cjs/abstract-pool.js",
"types": "./lib/types/abstract-pool.d.ts"
},
"./pool": {
"source": "./pool.ts",
"import": "./lib/esm/pool.js",
"require": "./lib/cjs/pool.js",
"types": "./lib/types/pool.d.ts"
},
"./references": {
"source": "./references.ts",
"import": "./lib/esm/references.js",
"require": "./lib/cjs/references.js",
"types": "./lib/types/references.d.ts"
},
"./nip04": {
"source": "./nip04.ts",
"import": "./lib/esm/nip04.js",
"require": "./lib/cjs/nip04.js",
"types": "./lib/types/nip04.d.ts"
},
"./nip05": {
"source": "./nip05.ts",
"import": "./lib/esm/nip05.js",
"require": "./lib/cjs/nip05.js",
"types": "./lib/types/nip05.d.ts"
},
"./nip06": {
"source": "./nip06.ts",
"import": "./lib/esm/nip06.js",
"require": "./lib/cjs/nip06.js",
"types": "./lib/types/nip06.d.ts"
@@ -89,146 +103,175 @@
"types": "./lib/types/nip07.d.ts"
},
"./nip10": {
"source": "./nip10.ts",
"import": "./lib/esm/nip10.js",
"require": "./lib/cjs/nip10.js",
"types": "./lib/types/nip10.d.ts"
},
"./nip11": {
"source": "./nip11.ts",
"import": "./lib/esm/nip11.js",
"require": "./lib/cjs/nip11.js",
"types": "./lib/types/nip11.d.ts"
},
"./nip13": {
"source": "./nip13.ts",
"import": "./lib/esm/nip13.js",
"require": "./lib/cjs/nip13.js",
"types": "./lib/types/nip13.d.ts"
},
"./nip17": {
"source": "./nip17.ts",
"import": "./lib/esm/nip17.js",
"require": "./lib/cjs/nip17.js",
"types": "./lib/types/nip17.d.ts"
},
"./nip18": {
"source": "./nip18.ts",
"import": "./lib/esm/nip18.js",
"require": "./lib/cjs/nip18.js",
"types": "./lib/types/nip18.d.ts"
},
"./nip19": {
"source": "./nip19.ts",
"import": "./lib/esm/nip19.js",
"require": "./lib/cjs/nip19.js",
"types": "./lib/types/nip19.d.ts"
},
"./nip21": {
"source": "./nip21.ts",
"import": "./lib/esm/nip21.js",
"require": "./lib/cjs/nip21.js",
"types": "./lib/types/nip21.d.ts"
},
"./nip25": {
"source": "./nip25.ts",
"import": "./lib/esm/nip25.js",
"require": "./lib/cjs/nip25.js",
"types": "./lib/types/nip25.d.ts"
},
"./nip27": {
"source": "./nip27.ts",
"import": "./lib/esm/nip27.js",
"require": "./lib/cjs/nip27.js",
"types": "./lib/types/nip27.d.ts"
},
"./nip28": {
"source": "./nip28.ts",
"import": "./lib/esm/nip28.js",
"require": "./lib/cjs/nip28.js",
"types": "./lib/types/nip28.d.ts"
},
"./nip29": {
"source": "./nip29.ts",
"import": "./lib/esm/nip29.js",
"require": "./lib/cjs/nip29.js",
"types": "./lib/types/nip29.d.ts"
},
"./nip30": {
"source": "./nip30.ts",
"import": "./lib/esm/nip30.js",
"require": "./lib/cjs/nip30.js",
"types": "./lib/types/nip30.d.ts"
},
"./nip39": {
"source": "./nip39.ts",
"import": "./lib/esm/nip39.js",
"require": "./lib/cjs/nip39.js",
"types": "./lib/types/nip39.d.ts"
},
"./nip42": {
"source": "./nip42.ts",
"import": "./lib/esm/nip42.js",
"require": "./lib/cjs/nip42.js",
"types": "./lib/types/nip42.d.ts"
},
"./nip44": {
"source": "./nip44.ts",
"import": "./lib/esm/nip44.js",
"require": "./lib/cjs/nip44.js",
"types": "./lib/types/nip44.d.ts"
},
"./nip46": {
"source": "./nip46.ts",
"import": "./lib/esm/nip46.js",
"require": "./lib/cjs/nip46.js",
"types": "./lib/types/nip46.d.ts"
},
"./nip49": {
"source": "./nip49.ts",
"import": "./lib/esm/nip49.js",
"require": "./lib/cjs/nip49.js",
"types": "./lib/types/nip49.d.ts"
},
"./nip54": {
"source": "./nip54.ts",
"import": "./lib/esm/nip54.js",
"require": "./lib/cjs/nip54.js",
"types": "./lib/types/nip54.d.ts"
},
"./nip57": {
"source": "./nip57.ts",
"import": "./lib/esm/nip57.js",
"require": "./lib/cjs/nip57.js",
"types": "./lib/types/nip57.d.ts"
},
"./nip59": {
"source": "./nip59.ts",
"import": "./lib/esm/nip59.js",
"require": "./lib/cjs/nip59.js",
"types": "./lib/types/nip59.d.ts"
},
"./nip58": {
"source": "./nip58.ts",
"import": "./lib/esm/nip58.js",
"require": "./lib/cjs/nip58.js",
"types": "./lib/types/nip58.d.ts"
},
"./nip75": {
"source": "./nip75.ts",
"import": "./lib/esm/nip75.js",
"require": "./lib/cjs/nip75.js",
"types": "./lib/types/nip75.d.ts"
},
"./nip94": {
"source": "./nip94.ts",
"import": "./lib/esm/nip94.js",
"require": "./lib/cjs/nip94.js",
"types": "./lib/types/nip94.d.ts"
},
"./nip98": {
"source": "./nip98.ts",
"import": "./lib/esm/nip98.js",
"require": "./lib/cjs/nip98.js",
"types": "./lib/types/nip98.d.ts"
},
"./nip99": {
"source": "./nip99.ts",
"import": "./lib/esm/nip99.js",
"require": "./lib/cjs/nip99.js",
"types": "./lib/types/nip99.d.ts"
},
"./nipb7": {
"source": "./nipb7.ts",
"import": "./lib/esm/nipb7.js",
"require": "./lib/cjs/nipb7.js",
"types": "./lib/types/nipb7.d.ts"
},
"./fakejson": {
"source": "./fakejson.ts",
"import": "./lib/esm/fakejson.js",
"require": "./lib/cjs/fakejson.js",
"types": "./lib/types/fakejson.d.ts"
},
"./signer": {
"source": "./signer.ts",
"import": "./lib/esm/signer.js",
"require": "./lib/cjs/signer.js",
"types": "./lib/types/signer.d.ts"
},
"./utils": {
"source": "./utils.ts",
"import": "./lib/esm/utils.js",
"require": "./lib/cjs/utils.js",
"types": "./lib/types/utils.d.ts"
@@ -236,12 +279,12 @@
},
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1",
"@noble/ciphers": "2.1.1",
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0",
"@scure/bip32": "2.0.1",
"@scure/bip39": "2.0.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {

View File

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, expect, test } from 'bun:test'
import { SimplePool, useWebSocketImplementation } from './pool.ts'
import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from './pure.ts'
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
import { hexToBytes } from '@noble/hashes/utils'
import { hexToBytes } from '@noble/hashes/utils.js'
useWebSocketImplementation(MockWebSocketClient)

View File

@@ -15,7 +15,7 @@ export function useWebSocketImplementation(websocketImplementation: any) {
export class SimplePool extends AbstractSimplePool {
constructor(options?: Pick<AbstractPoolConstructorOptions, 'enablePing' | 'enableReconnect'>) {
super({ verifyEvent, websocketImplementation: _WebSocket, ...options })
super({ verifyEvent, websocketImplementation: _WebSocket, maxWaitForConnection: 3000, ...options })
}
}

View File

@@ -11,7 +11,7 @@ import {
generateSecretKey,
} from './pure.ts'
import { ShortTextNote } from './kinds.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
test('private key generation', () => {
expect(bytesToHex(generateSecretKey())).toMatch(/[a-f0-9]{64}/)

12
pure.ts
View File

@@ -1,13 +1,13 @@
import { schnorr } from '@noble/curves/secp256k1'
import { bytesToHex } from '@noble/hashes/utils'
import { schnorr } from '@noble/curves/secp256k1.js'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core.ts'
import { sha256 } from '@noble/hashes/sha256'
import { sha256 } from '@noble/hashes/sha2.js'
import { utf8Encoder } from './utils.ts'
class JS implements Nostr {
generateSecretKey(): Uint8Array {
return schnorr.utils.randomPrivateKey()
return schnorr.utils.randomSecretKey()
}
getPublicKey(secretKey: Uint8Array): string {
return bytesToHex(schnorr.getPublicKey(secretKey))
@@ -16,7 +16,7 @@ class JS implements Nostr {
const event = t as VerifiedEvent
event.pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
event.id = getEventHash(event)
event.sig = bytesToHex(schnorr.sign(getEventHash(event), secretKey))
event.sig = bytesToHex(schnorr.sign(hexToBytes(getEventHash(event)), secretKey))
event[verifiedSymbol] = true
return event
}
@@ -30,7 +30,7 @@ class JS implements Nostr {
}
try {
const valid = schnorr.verify(event.sig, hash, event.pubkey)
const valid = schnorr.verify(hexToBytes(event.sig), hexToBytes(hash), hexToBytes(event.pubkey))
event[verifiedSymbol] = valid
return valid
} catch (err) {

View File

@@ -1,11 +1,11 @@
import { describe, test, expect } from 'bun:test'
import { buildEvent } from './test-helpers.ts'
import {
Queue,
insertEventIntoAscendingList,
insertEventIntoDescendingList,
binarySearch,
normalizeURL,
mergeReverseSortedLists,
} from './utils.ts'
import type { Event } from './core.ts'
@@ -220,48 +220,6 @@ describe('inserting into a asc sorted list of events', () => {
})
})
describe('enqueue a message into MessageQueue', () => {
test('enqueue into an empty queue', () => {
const queue = new Queue()
queue.enqueue('node1')
expect(queue.first!.value).toBe('node1')
})
test('enqueue into a non-empty queue', () => {
const queue = new Queue()
queue.enqueue('node1')
queue.enqueue('node3')
queue.enqueue('node2')
expect(queue.first!.value).toBe('node1')
expect(queue.last!.value).toBe('node2')
})
test('dequeue from an empty queue', () => {
const queue = new Queue()
const item1 = queue.dequeue()
expect(item1).toBe(null)
})
test('dequeue from a non-empty queue', () => {
const queue = new Queue()
queue.enqueue('node1')
queue.enqueue('node3')
queue.enqueue('node2')
const item1 = queue.dequeue()
expect(item1).toBe('node1')
const item2 = queue.dequeue()
expect(item2).toBe('node3')
})
test('dequeue more than in queue', () => {
const queue = new Queue()
queue.enqueue('node1')
queue.enqueue('node3')
const item1 = queue.dequeue()
expect(item1).toBe('node1')
const item2 = queue.dequeue()
expect(item2).toBe('node3')
const item3 = queue.dequeue()
expect(item3).toBe(null)
})
})
test('binary search', () => {
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('e' < b ? -1 : 'e' === b ? 0 : 1))).toEqual([3, true])
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('x' < b ? -1 : 'x' === b ? 0 : 1))).toEqual([4, false])
@@ -270,6 +228,94 @@ test('binary search', () => {
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('[' < b ? -1 : '[' === b ? 0 : 1))).toEqual([0, false])
})
describe('mergeReverseSortedLists', () => {
test('merge empty lists', () => {
const list1: Event[] = []
const list2: Event[] = []
expect(mergeReverseSortedLists(list1, list2)).toHaveLength(0)
})
test('merge list with empty list', () => {
const list1 = [buildEvent({ id: 'a', created_at: 30 }), buildEvent({ id: 'b', created_at: 20 })]
const list2: Event[] = []
const result = mergeReverseSortedLists(list1, list2)
expect(result).toHaveLength(2)
expect(result.map(e => e.id)).toEqual(['a', 'b'])
})
test('merge two simple lists', () => {
const list1 = [
buildEvent({ id: 'a', created_at: 30 }),
buildEvent({ id: 'b', created_at: 10 }),
buildEvent({ id: 'f', created_at: 3 }),
buildEvent({ id: 'g', created_at: 2 }),
]
const list2 = [
buildEvent({ id: 'c', created_at: 25 }),
buildEvent({ id: 'd', created_at: 5 }),
buildEvent({ id: 'e', created_at: 1 }),
]
const result = mergeReverseSortedLists(list1, list2)
expect(result.map(e => e.id)).toEqual(['a', 'c', 'b', 'd', 'f', 'g', 'e'])
})
test('merge lists with same timestamps', () => {
const list1 = [
buildEvent({ id: 'a', created_at: 30 }),
buildEvent({ id: 'b', created_at: 20 }),
buildEvent({ id: 'f', created_at: 10 }),
]
const list2 = [
buildEvent({ id: 'c', created_at: 30 }),
buildEvent({ id: 'd', created_at: 20 }),
buildEvent({ id: 'e', created_at: 20 }),
]
const result = mergeReverseSortedLists(list1, list2)
expect(result.map(e => e.id)).toEqual(['c', 'a', 'd', 'e', 'b', 'f'])
})
test('deduplicate events with same timestamp and id', () => {
const list1 = [
buildEvent({ id: 'a', created_at: 30 }),
buildEvent({ id: 'b', created_at: 20 }),
buildEvent({ id: 'b', created_at: 20 }),
buildEvent({ id: 'c', created_at: 20 }),
buildEvent({ id: 'd', created_at: 10 }),
]
const list2 = [
buildEvent({ id: 'a', created_at: 30 }),
buildEvent({ id: 'c', created_at: 20 }),
buildEvent({ id: 'b', created_at: 20 }),
buildEvent({ id: 'd', created_at: 10 }),
buildEvent({ id: 'e', created_at: 10 }),
buildEvent({ id: 'd', created_at: 10 }),
]
console.log('==================')
const result = mergeReverseSortedLists(list1, list2)
console.log(
'result:',
result.map(e => e.id),
)
expect(result.map(e => e.id)).toEqual(['a', 'c', 'b', 'd', 'e'])
})
test('merge when one list is completely before the other', () => {
const list1 = [buildEvent({ id: 'a', created_at: 50 }), buildEvent({ id: 'b', created_at: 40 })]
const list2 = [buildEvent({ id: 'c', created_at: 30 }), buildEvent({ id: 'd', created_at: 20 })]
const result = mergeReverseSortedLists(list1, list2)
expect(result).toHaveLength(4)
expect(result.map(e => e.id)).toEqual(['a', 'b', 'c', 'd'])
})
test('merge when one list is completely after the other', () => {
const list1 = [buildEvent({ id: 'a', created_at: 10 }), buildEvent({ id: 'b', created_at: 5 })]
const list2 = [buildEvent({ id: 'c', created_at: 30 }), buildEvent({ id: 'd', created_at: 20 })]
const result = mergeReverseSortedLists(list1, list2)
expect(result).toHaveLength(4)
expect(result.map(e => e.id)).toEqual(['c', 'd', 'a', 'b'])
})
})
describe('normalizeURL', () => {
test('normalizes wss:// URLs', () => {
expect(normalizeURL('wss://example.com')).toBe('wss://example.com/')

101
utils.ts
View File

@@ -1,9 +1,9 @@
import type { Event } from './core.ts'
import type { NostrEvent } from './core.ts'
export const utf8Decoder: TextDecoder = new TextDecoder('utf-8')
export const utf8Encoder: TextEncoder = new TextEncoder()
export { bytesToHex, hexToBytes } from '@noble/hashes/utils'
export { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
export function normalizeURL(url: string): string {
try {
@@ -22,7 +22,7 @@ export function normalizeURL(url: string): string {
}
}
export function insertEventIntoDescendingList(sortedArray: Event[], event: Event): Event[] {
export function insertEventIntoDescendingList(sortedArray: NostrEvent[], event: NostrEvent): NostrEvent[] {
const [idx, found] = binarySearch(sortedArray, b => {
if (event.id === b.id) return 0
if (event.created_at === b.created_at) return -1
@@ -34,7 +34,7 @@ export function insertEventIntoDescendingList(sortedArray: Event[], event: Event
return sortedArray
}
export function insertEventIntoAscendingList(sortedArray: Event[], event: Event): Event[] {
export function insertEventIntoAscendingList(sortedArray: NostrEvent[], event: NostrEvent): NostrEvent[] {
const [idx, found] = binarySearch(sortedArray, b => {
if (event.id === b.id) return 0
if (event.created_at === b.created_at) return -1
@@ -68,61 +68,58 @@ export function binarySearch<T>(arr: T[], compare: (b: T) => number): [number, b
return [start, false]
}
export class QueueNode<V> {
public value: V
public next: QueueNode<V> | null = null
public prev: QueueNode<V> | null = null
export function mergeReverseSortedLists(list1: NostrEvent[], list2: NostrEvent[]): NostrEvent[] {
const result: NostrEvent[] = new Array(list1.length + list2.length)
result.length = 0
let i1 = 0
let i2 = 0
let sameTimestampIds: string[] = []
constructor(message: V) {
this.value = message
}
}
export class Queue<V> {
public first: QueueNode<V> | null
public last: QueueNode<V> | null
constructor() {
this.first = null
this.last = null
}
enqueue(value: V): boolean {
const newNode = new QueueNode(value)
if (!this.last) {
// list is empty
this.first = newNode
this.last = newNode
} else if (this.last === this.first) {
// list has a single element
this.last = newNode
this.last.prev = this.first
this.first.next = newNode
while (i1 < list1.length && i2 < list2.length) {
let next: NostrEvent
if (list1[i1]?.created_at > list2[i2]?.created_at) {
next = list1[i1]
i1++
} else {
// list has elements, add as last
newNode.prev = this.last
this.last.next = newNode
this.last = newNode
}
return true
next = list2[i2]
i2++
}
dequeue(): V | null {
if (!this.first) return null
if (this.first === this.last) {
const target = this.first
this.first = null
this.last = null
return target.value
if (result.length > 0 && result[result.length - 1].created_at === next.created_at) {
if (sameTimestampIds.includes(next.id)) continue
} else {
sameTimestampIds.length = 0
}
const target = this.first
this.first = target.next
if (this.first) {
this.first.prev = null // fix: clean up prev pointer
result.push(next)
sameTimestampIds.push(next.id)
}
return target.value
while (i1 < list1.length) {
const next = list1[i1]
i1++
if (result.length > 0 && result[result.length - 1].created_at === next.created_at) {
if (sameTimestampIds.includes(next.id)) continue
} else {
sameTimestampIds.length = 0
}
result.push(next)
sameTimestampIds.push(next.id)
}
while (i2 < list2.length) {
const next = list2[i2]
i2++
if (result.length > 0 && result[result.length - 1].created_at === next.created_at) {
if (sameTimestampIds.includes(next.id)) continue
} else {
sameTimestampIds.length = 0
}
result.push(next)
sameTimestampIds.push(next.id)
}
return result
}

View File

@@ -1,4 +1,4 @@
import { bytesToHex } from '@noble/hashes/utils'
import { bytesToHex } from '@noble/hashes/utils.js'
import { Nostr as NostrWasm } from 'nostr-wasm'
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core.ts'