Compare commits

...

16 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
11 changed files with 442 additions and 308 deletions

View File

@@ -11,6 +11,7 @@ import { normalizeURL } from './utils.ts'
import type { Event, EventTemplate, Nostr, VerifiedEvent } 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'
import { Relay } from './relay.ts'
export type SubCloser = { close: (reason?: string) => void } export type SubCloser = { close: (reason?: string) => void }
@@ -19,6 +20,16 @@ export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {
// in case that relay shouldn't be authenticated against // 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) // 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>) 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'> & { export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
@@ -40,6 +51,10 @@ export class AbstractSimplePool {
public enableReconnect: boolean public enableReconnect: boolean
public automaticallyAuth?: (relayURL: string) => null | ((event: EventTemplate) => Promise<VerifiedEvent>) public automaticallyAuth?: (relayURL: string) => null | ((event: EventTemplate) => Promise<VerifiedEvent>)
public trustedRelayURLs: Set<string> = new Set() 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 private _WebSocket?: typeof WebSocket
@@ -49,6 +64,10 @@ export class AbstractSimplePool {
this.enablePing = opts.enablePing this.enablePing = opts.enablePing
this.enableReconnect = opts.enableReconnect || false this.enableReconnect = opts.enableReconnect || false
this.automaticallyAuth = opts.automaticallyAuth this.automaticallyAuth = opts.automaticallyAuth
this.onRelayConnectionFailure = opts.onRelayConnectionFailure
this.onRelayConnectionSuccess = opts.onRelayConnectionSuccess
this.allowConnectingToRelay = opts.allowConnectingToRelay
this.maxWaitForConnection = opts.maxWaitForConnection || 3000
} }
async ensureRelay( async ensureRelay(
@@ -69,10 +88,8 @@ export class AbstractSimplePool {
enableReconnect: this.enableReconnect, enableReconnect: this.enableReconnect,
}) })
relay.onclose = () => { relay.onclose = () => {
if (relay && !relay.enableReconnect) {
this.relays.delete(url) this.relays.delete(url)
} }
}
this.relays.set(url, relay) this.relays.set(url, relay)
} }
@@ -83,10 +100,15 @@ export class AbstractSimplePool {
} }
} }
try {
await relay.connect({ await relay.connect({
timeout: params?.connectionTimeout, timeout: params?.connectionTimeout,
abort: params?.abort, abort: params?.abort,
}) })
} catch (err) {
this.relays.delete(url)
throw err
}
return relay return relay
} }
@@ -100,28 +122,22 @@ export class AbstractSimplePool {
subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser { subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
const request: { url: string; filter: Filter }[] = [] const request: { url: string; filter: Filter }[] = []
const uniqUrls: string[] = []
for (let i = 0; i < relays.length; i++) { for (let i = 0; i < relays.length; i++) {
const url = normalizeURL(relays[i]) const url = normalizeURL(relays[i])
if (!request.find(r => r.url === url)) { if (!request.find(r => r.url === url)) {
if (uniqUrls.indexOf(url) === -1) {
uniqUrls.push(url)
request.push({ url, filter: filter }) request.push({ url, filter: filter })
} }
} }
}
return this.subscribeMap(request, params) return this.subscribeMap(request, params)
} }
subscribeMany(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser { subscribeMany(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
const request: { url: string; filter: Filter }[] = [] return this.subscribe(relays, filter, params)
const uniqUrls: string[] = []
for (let i = 0; i < relays.length; i++) {
const url = normalizeURL(relays[i])
if (uniqUrls.indexOf(url) === -1) {
uniqUrls.push(url)
request.push({ url, filter: filter })
}
}
return this.subscribeMap(request, params)
} }
subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser { subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser {
@@ -181,17 +197,28 @@ 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(
groupedRequests.map(async ({ url, filters }, i) => { groupedRequests.map(async ({ url, filters }, i) => {
if (this.allowConnectingToRelay?.(url, ['read', filters]) === false) {
handleClose(i, 'connection skipped by allowConnectingToRelay')
return
}
let relay: AbstractRelay let relay: AbstractRelay
try { try {
relay = await this.ensureRelay(url, { relay = await this.ensureRelay(url, {
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined, connectionTimeout:
this.maxWaitForConnection < (params.maxWait || 0)
? Math.max(params.maxWait! * 0.8, params.maxWait! - 1000)
: this.maxWaitForConnection,
abort: params.abort, abort: params.abort,
}) })
} catch (err) { } catch (err) {
this.onRelayConnectionFailure?.(url)
handleClose(i, (err as any)?.message || String(err)) handleClose(i, (err as any)?.message || String(err))
return return
} }
this.onRelayConnectionSuccess?.(url)
let subscription = relay.subscribe(filters, { let subscription = relay.subscribe(filters, {
...params, ...params,
oneose: () => handleEose(i), oneose: () => handleEose(i),
@@ -256,13 +283,7 @@ export class AbstractSimplePool {
filter: Filter, filter: Filter,
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth'>, params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth'>,
): SubCloser { ): SubCloser {
const subcloser = this.subscribeMany(relays, filter, { return this.subscribeEose(relays, filter, params)
...params,
oneose() {
subcloser.close('closed automatically on eose')
},
})
return subcloser
} }
async querySync( async querySync(
@@ -298,7 +319,11 @@ export class AbstractSimplePool {
publish( publish(
relays: string[], relays: string[],
event: Event, event: Event,
options?: { onauth?: (evt: EventTemplate) => Promise<VerifiedEvent> }, params?: {
onauth?: (evt: EventTemplate) => Promise<VerifiedEvent>
maxWait?: number
abort?: AbortSignal
},
): Promise<string>[] { ): 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) {
@@ -306,12 +331,29 @@ export class AbstractSimplePool {
return Promise.reject('duplicate url') 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 return r
.publish(event) .publish(event)
.catch(async err => { .catch(async err => {
if (err instanceof Error && err.message.startsWith('auth-required: ') && options?.onauth) { if (err instanceof Error && err.message.startsWith('auth-required: ') && params?.onauth) {
await r.auth(options.onauth) await r.auth(params.onauth)
return r.publish(event) // retry return r.publish(event) // retry
} }
throw err throw err
@@ -341,4 +383,19 @@ export class AbstractSimplePool {
this.relays.forEach(conn => conn.close()) this.relays.forEach(conn => conn.close())
this.relays = new Map() 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 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 { normalizeURL } from './utils.ts'
import { makeAuthEvent } from './nip42.ts' import { makeAuthEvent } from './nip42.ts'
import { yieldThread } from './helpers.ts'
type RelayWebSocket = WebSocket & { type RelayWebSocket = WebSocket & {
ping?(): void ping?(): void
@@ -42,17 +41,17 @@ export class AbstractRelay {
public openSubs: Map<string, Subscription> = new Map() public openSubs: Map<string, Subscription> = new Map()
public enablePing: boolean | undefined public enablePing: boolean | undefined
public enableReconnect: boolean public enableReconnect: boolean
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 reconnectTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private pingIntervalHandle: ReturnType<typeof setInterval> | undefined private pingIntervalHandle: ReturnType<typeof setInterval> | undefined
private reconnectAttempts: number = 0 private reconnectAttempts: number = 0
private closedIntentionally: boolean = false private skipReconnection: 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: RelayWebSocket | undefined private ws: RelayWebSocket | undefined
private incomingMessageQueue = new Queue<string>()
private queueRunning = false
private challenge: string | undefined private challenge: string | undefined
private authPromise: Promise<string> | undefined private authPromise: Promise<string> | undefined
private serial: number = 0 private serial: number = 0
@@ -119,15 +118,12 @@ export class AbstractRelay {
this._connected = false this._connected = false
this.connectionPromise = undefined this.connectionPromise = undefined
this.idleSince = undefined
const wasIntentional = this.closedIntentionally if (this.enableReconnect && !this.skipReconnection) {
this.closedIntentionally = false // reset for next time
this.onclose?.()
if (this.enableReconnect && !wasIntentional) {
this.reconnect() this.reconnect()
} else { } else {
this.onclose?.()
this.closeAllSubscriptions(reason) this.closeAllSubscriptions(reason)
} }
} }
@@ -139,13 +135,15 @@ export class AbstractRelay {
this.challenge = undefined this.challenge = undefined
this.authPromise = undefined this.authPromise = undefined
this.skipReconnection = false
this.connectionPromise = new Promise((resolve, reject) => { this.connectionPromise = new Promise((resolve, reject) => {
if (opts?.timeout) { if (opts?.timeout) {
connectionTimeoutHandle = setTimeout(() => { connectionTimeoutHandle = setTimeout(() => {
reject('connection timed out') reject('connection timed out')
this.connectionPromise = undefined this.connectionPromise = undefined
this.skipReconnection = true
this.onclose?.() this.onclose?.()
this.closeAllSubscriptions('relay connection timed out') this.handleHardClose('relay connection timed out')
}, opts.timeout) }, opts.timeout)
} }
@@ -191,10 +189,13 @@ export class AbstractRelay {
resolve() resolve()
} }
this.ws.onerror = ev => { this.ws.onerror = () => {
clearTimeout(connectionTimeoutHandle) clearTimeout(connectionTimeoutHandle)
reject((ev as any).message || 'websocket error') reject('connection failed')
this.handleHardClose('relay connection errored') this.connectionPromise = undefined
this.skipReconnection = true
this.onclose?.()
this.handleHardClose('relay connection failed')
} }
this.ws.onclose = ev => { this.ws.onclose = ev => {
@@ -228,7 +229,7 @@ export class AbstractRelay {
const sub = this.subscribe( const sub = this.subscribe(
[{ ids: ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], limit: 0 }], [{ ids: ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], limit: 0 }],
{ {
label: 'forced-ping', label: '<forced-ping>',
oneose: () => { oneose: () => {
resolve(true) resolve(true)
sub.close() sub.close()
@@ -267,21 +268,126 @@ export class AbstractRelay {
} }
} }
private async runQueue() { public async send(message: string) {
this.queueRunning = true if (!this.connectionPromise) throw new SendingOnClosedConnection(message, this.url)
while (true) {
if (false === this.handleNext()) { this.connectionPromise.then(() => {
break this.ws?.send(message)
} })
await yieldThread()
}
this.queueRunning = false
} }
private handleNext(): undefined | false { public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> {
const json = this.incomingMessageQueue.dequeue() 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) { if (!json) {
return false return
} }
// shortcut EVENT sub // shortcut EVENT sub
@@ -379,118 +485,11 @@ export class AbstractRelay {
} }
} }
} catch (err) { } catch (err) {
const [_, __, event] = JSON.parse(json)
;(window as any).printer.maybe(event.pubkey, ':: caught err', event, this.url, err)
return 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()
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 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 { export class Subscription {
@@ -566,6 +565,11 @@ export class Subscription {
this.closed = true this.closed = true
} }
this.relay.openSubs.delete(this.id) 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) this.onclose?.(reason)
} }
} }

View File

@@ -1,37 +1,5 @@
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts' 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 => { export const alwaysTrue: Nostr['verifyEvent'] = (t: Event): t is VerifiedEvent => {
t[verifiedSymbol] = true t[verifiedSymbol] = true
return true return true

View File

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

View File

@@ -21,6 +21,23 @@ export function getPow(hex: string): number {
return count 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. * 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. * 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() 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 break
} }
} }
return event 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

@@ -85,7 +85,7 @@ test('parse content with hashtags and emoji shortcodes', () => {
['emoji', 'alpaca', 'https://example.com/alpaca.png'], ['emoji', 'alpaca', 'https://example.com/alpaca.png'],
], ],
content: 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, created_at: 1234567890,
pubkey: 'dummy', pubkey: 'dummy',
id: 'dummy', id: 'dummy',
@@ -105,6 +105,11 @@ test('parse content with hashtags and emoji shortcodes', () => {
{ type: 'relay', url: 'wss://alpaca.com/' }, { type: 'relay', url: 'wss://alpaca.com/' },
{ type: 'text', text: '! ' }, { type: 'text', text: '! ' },
{ type: 'emoji', shortcode: 'star', url: 'https://example.com/star.png' }, { 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)) { if (u === -1 || (h >= 0 && h < u)) {
// parse hashtag // 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 m = content.slice(h + 1, h + MAX_HASHTAG_LENGTH).match(noCharacter)
const end = m ? h + 1 + m.index! : max const end = m ? h + 1 + m.index! : max
yield { type: 'text', text: content.slice(prevIndex, h) } yield { type: 'text', text: content.slice(prevIndex, h) }

View File

@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "nostr-tools", "name": "nostr-tools",
"version": "2.22.0", "version": "2.23.0",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -16,71 +16,85 @@
"types": "./lib/types/index.d.ts", "types": "./lib/types/index.d.ts",
"exports": { "exports": {
".": { ".": {
"source": "./index.ts",
"import": "./lib/esm/index.js", "import": "./lib/esm/index.js",
"require": "./lib/cjs/index.js", "require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts" "types": "./lib/types/index.d.ts"
}, },
"./core": { "./core": {
"source": "./core.ts",
"import": "./lib/esm/core.js", "import": "./lib/esm/core.js",
"require": "./lib/cjs/core.js", "require": "./lib/cjs/core.js",
"types": "./lib/types/core.d.ts" "types": "./lib/types/core.d.ts"
}, },
"./pure": { "./pure": {
"source": "./pure.ts",
"import": "./lib/esm/pure.js", "import": "./lib/esm/pure.js",
"require": "./lib/cjs/pure.js", "require": "./lib/cjs/pure.js",
"types": "./lib/types/pure.d.ts" "types": "./lib/types/pure.d.ts"
}, },
"./wasm": { "./wasm": {
"source": "./wasm.ts",
"import": "./lib/esm/wasm.js", "import": "./lib/esm/wasm.js",
"require": "./lib/cjs/wasm.js", "require": "./lib/cjs/wasm.js",
"types": "./lib/types/wasm.d.ts" "types": "./lib/types/wasm.d.ts"
}, },
"./kinds": { "./kinds": {
"source": "./kinds.ts",
"import": "./lib/esm/kinds.js", "import": "./lib/esm/kinds.js",
"require": "./lib/cjs/kinds.js", "require": "./lib/cjs/kinds.js",
"types": "./lib/types/kinds.d.ts" "types": "./lib/types/kinds.d.ts"
}, },
"./filter": { "./filter": {
"source": "./filter.ts",
"import": "./lib/esm/filter.js", "import": "./lib/esm/filter.js",
"require": "./lib/cjs/filter.js", "require": "./lib/cjs/filter.js",
"types": "./lib/types/filter.d.ts" "types": "./lib/types/filter.d.ts"
}, },
"./abstract-relay": { "./abstract-relay": {
"source": "./abstract-relay.ts",
"import": "./lib/esm/abstract-relay.js", "import": "./lib/esm/abstract-relay.js",
"require": "./lib/cjs/abstract-relay.js", "require": "./lib/cjs/abstract-relay.js",
"types": "./lib/types/abstract-relay.d.ts" "types": "./lib/types/abstract-relay.d.ts"
}, },
"./relay": { "./relay": {
"source": "./relay.ts",
"import": "./lib/esm/relay.js", "import": "./lib/esm/relay.js",
"require": "./lib/cjs/relay.js", "require": "./lib/cjs/relay.js",
"types": "./lib/types/relay.d.ts" "types": "./lib/types/relay.d.ts"
}, },
"./abstract-pool": { "./abstract-pool": {
"source": "./abstract-pool.ts",
"import": "./lib/esm/abstract-pool.js", "import": "./lib/esm/abstract-pool.js",
"require": "./lib/cjs/abstract-pool.js", "require": "./lib/cjs/abstract-pool.js",
"types": "./lib/types/abstract-pool.d.ts" "types": "./lib/types/abstract-pool.d.ts"
}, },
"./pool": { "./pool": {
"source": "./pool.ts",
"import": "./lib/esm/pool.js", "import": "./lib/esm/pool.js",
"require": "./lib/cjs/pool.js", "require": "./lib/cjs/pool.js",
"types": "./lib/types/pool.d.ts" "types": "./lib/types/pool.d.ts"
}, },
"./references": { "./references": {
"source": "./references.ts",
"import": "./lib/esm/references.js", "import": "./lib/esm/references.js",
"require": "./lib/cjs/references.js", "require": "./lib/cjs/references.js",
"types": "./lib/types/references.d.ts" "types": "./lib/types/references.d.ts"
}, },
"./nip04": { "./nip04": {
"source": "./nip04.ts",
"import": "./lib/esm/nip04.js", "import": "./lib/esm/nip04.js",
"require": "./lib/cjs/nip04.js", "require": "./lib/cjs/nip04.js",
"types": "./lib/types/nip04.d.ts" "types": "./lib/types/nip04.d.ts"
}, },
"./nip05": { "./nip05": {
"source": "./nip05.ts",
"import": "./lib/esm/nip05.js", "import": "./lib/esm/nip05.js",
"require": "./lib/cjs/nip05.js", "require": "./lib/cjs/nip05.js",
"types": "./lib/types/nip05.d.ts" "types": "./lib/types/nip05.d.ts"
}, },
"./nip06": { "./nip06": {
"source": "./nip06.ts",
"import": "./lib/esm/nip06.js", "import": "./lib/esm/nip06.js",
"require": "./lib/cjs/nip06.js", "require": "./lib/cjs/nip06.js",
"types": "./lib/types/nip06.d.ts" "types": "./lib/types/nip06.d.ts"
@@ -89,146 +103,175 @@
"types": "./lib/types/nip07.d.ts" "types": "./lib/types/nip07.d.ts"
}, },
"./nip10": { "./nip10": {
"source": "./nip10.ts",
"import": "./lib/esm/nip10.js", "import": "./lib/esm/nip10.js",
"require": "./lib/cjs/nip10.js", "require": "./lib/cjs/nip10.js",
"types": "./lib/types/nip10.d.ts" "types": "./lib/types/nip10.d.ts"
}, },
"./nip11": { "./nip11": {
"source": "./nip11.ts",
"import": "./lib/esm/nip11.js", "import": "./lib/esm/nip11.js",
"require": "./lib/cjs/nip11.js", "require": "./lib/cjs/nip11.js",
"types": "./lib/types/nip11.d.ts" "types": "./lib/types/nip11.d.ts"
}, },
"./nip13": { "./nip13": {
"source": "./nip13.ts",
"import": "./lib/esm/nip13.js", "import": "./lib/esm/nip13.js",
"require": "./lib/cjs/nip13.js", "require": "./lib/cjs/nip13.js",
"types": "./lib/types/nip13.d.ts" "types": "./lib/types/nip13.d.ts"
}, },
"./nip17": { "./nip17": {
"source": "./nip17.ts",
"import": "./lib/esm/nip17.js", "import": "./lib/esm/nip17.js",
"require": "./lib/cjs/nip17.js", "require": "./lib/cjs/nip17.js",
"types": "./lib/types/nip17.d.ts" "types": "./lib/types/nip17.d.ts"
}, },
"./nip18": { "./nip18": {
"source": "./nip18.ts",
"import": "./lib/esm/nip18.js", "import": "./lib/esm/nip18.js",
"require": "./lib/cjs/nip18.js", "require": "./lib/cjs/nip18.js",
"types": "./lib/types/nip18.d.ts" "types": "./lib/types/nip18.d.ts"
}, },
"./nip19": { "./nip19": {
"source": "./nip19.ts",
"import": "./lib/esm/nip19.js", "import": "./lib/esm/nip19.js",
"require": "./lib/cjs/nip19.js", "require": "./lib/cjs/nip19.js",
"types": "./lib/types/nip19.d.ts" "types": "./lib/types/nip19.d.ts"
}, },
"./nip21": { "./nip21": {
"source": "./nip21.ts",
"import": "./lib/esm/nip21.js", "import": "./lib/esm/nip21.js",
"require": "./lib/cjs/nip21.js", "require": "./lib/cjs/nip21.js",
"types": "./lib/types/nip21.d.ts" "types": "./lib/types/nip21.d.ts"
}, },
"./nip25": { "./nip25": {
"source": "./nip25.ts",
"import": "./lib/esm/nip25.js", "import": "./lib/esm/nip25.js",
"require": "./lib/cjs/nip25.js", "require": "./lib/cjs/nip25.js",
"types": "./lib/types/nip25.d.ts" "types": "./lib/types/nip25.d.ts"
}, },
"./nip27": { "./nip27": {
"source": "./nip27.ts",
"import": "./lib/esm/nip27.js", "import": "./lib/esm/nip27.js",
"require": "./lib/cjs/nip27.js", "require": "./lib/cjs/nip27.js",
"types": "./lib/types/nip27.d.ts" "types": "./lib/types/nip27.d.ts"
}, },
"./nip28": { "./nip28": {
"source": "./nip28.ts",
"import": "./lib/esm/nip28.js", "import": "./lib/esm/nip28.js",
"require": "./lib/cjs/nip28.js", "require": "./lib/cjs/nip28.js",
"types": "./lib/types/nip28.d.ts" "types": "./lib/types/nip28.d.ts"
}, },
"./nip29": { "./nip29": {
"source": "./nip29.ts",
"import": "./lib/esm/nip29.js", "import": "./lib/esm/nip29.js",
"require": "./lib/cjs/nip29.js", "require": "./lib/cjs/nip29.js",
"types": "./lib/types/nip29.d.ts" "types": "./lib/types/nip29.d.ts"
}, },
"./nip30": { "./nip30": {
"source": "./nip30.ts",
"import": "./lib/esm/nip30.js", "import": "./lib/esm/nip30.js",
"require": "./lib/cjs/nip30.js", "require": "./lib/cjs/nip30.js",
"types": "./lib/types/nip30.d.ts" "types": "./lib/types/nip30.d.ts"
}, },
"./nip39": { "./nip39": {
"source": "./nip39.ts",
"import": "./lib/esm/nip39.js", "import": "./lib/esm/nip39.js",
"require": "./lib/cjs/nip39.js", "require": "./lib/cjs/nip39.js",
"types": "./lib/types/nip39.d.ts" "types": "./lib/types/nip39.d.ts"
}, },
"./nip42": { "./nip42": {
"source": "./nip42.ts",
"import": "./lib/esm/nip42.js", "import": "./lib/esm/nip42.js",
"require": "./lib/cjs/nip42.js", "require": "./lib/cjs/nip42.js",
"types": "./lib/types/nip42.d.ts" "types": "./lib/types/nip42.d.ts"
}, },
"./nip44": { "./nip44": {
"source": "./nip44.ts",
"import": "./lib/esm/nip44.js", "import": "./lib/esm/nip44.js",
"require": "./lib/cjs/nip44.js", "require": "./lib/cjs/nip44.js",
"types": "./lib/types/nip44.d.ts" "types": "./lib/types/nip44.d.ts"
}, },
"./nip46": { "./nip46": {
"source": "./nip46.ts",
"import": "./lib/esm/nip46.js", "import": "./lib/esm/nip46.js",
"require": "./lib/cjs/nip46.js", "require": "./lib/cjs/nip46.js",
"types": "./lib/types/nip46.d.ts" "types": "./lib/types/nip46.d.ts"
}, },
"./nip49": { "./nip49": {
"source": "./nip49.ts",
"import": "./lib/esm/nip49.js", "import": "./lib/esm/nip49.js",
"require": "./lib/cjs/nip49.js", "require": "./lib/cjs/nip49.js",
"types": "./lib/types/nip49.d.ts" "types": "./lib/types/nip49.d.ts"
}, },
"./nip54": { "./nip54": {
"source": "./nip54.ts",
"import": "./lib/esm/nip54.js", "import": "./lib/esm/nip54.js",
"require": "./lib/cjs/nip54.js", "require": "./lib/cjs/nip54.js",
"types": "./lib/types/nip54.d.ts" "types": "./lib/types/nip54.d.ts"
}, },
"./nip57": { "./nip57": {
"source": "./nip57.ts",
"import": "./lib/esm/nip57.js", "import": "./lib/esm/nip57.js",
"require": "./lib/cjs/nip57.js", "require": "./lib/cjs/nip57.js",
"types": "./lib/types/nip57.d.ts" "types": "./lib/types/nip57.d.ts"
}, },
"./nip59": { "./nip59": {
"source": "./nip59.ts",
"import": "./lib/esm/nip59.js", "import": "./lib/esm/nip59.js",
"require": "./lib/cjs/nip59.js", "require": "./lib/cjs/nip59.js",
"types": "./lib/types/nip59.d.ts" "types": "./lib/types/nip59.d.ts"
}, },
"./nip58": { "./nip58": {
"source": "./nip58.ts",
"import": "./lib/esm/nip58.js", "import": "./lib/esm/nip58.js",
"require": "./lib/cjs/nip58.js", "require": "./lib/cjs/nip58.js",
"types": "./lib/types/nip58.d.ts" "types": "./lib/types/nip58.d.ts"
}, },
"./nip75": { "./nip75": {
"source": "./nip75.ts",
"import": "./lib/esm/nip75.js", "import": "./lib/esm/nip75.js",
"require": "./lib/cjs/nip75.js", "require": "./lib/cjs/nip75.js",
"types": "./lib/types/nip75.d.ts" "types": "./lib/types/nip75.d.ts"
}, },
"./nip94": { "./nip94": {
"source": "./nip94.ts",
"import": "./lib/esm/nip94.js", "import": "./lib/esm/nip94.js",
"require": "./lib/cjs/nip94.js", "require": "./lib/cjs/nip94.js",
"types": "./lib/types/nip94.d.ts" "types": "./lib/types/nip94.d.ts"
}, },
"./nip98": { "./nip98": {
"source": "./nip98.ts",
"import": "./lib/esm/nip98.js", "import": "./lib/esm/nip98.js",
"require": "./lib/cjs/nip98.js", "require": "./lib/cjs/nip98.js",
"types": "./lib/types/nip98.d.ts" "types": "./lib/types/nip98.d.ts"
}, },
"./nip99": { "./nip99": {
"source": "./nip99.ts",
"import": "./lib/esm/nip99.js", "import": "./lib/esm/nip99.js",
"require": "./lib/cjs/nip99.js", "require": "./lib/cjs/nip99.js",
"types": "./lib/types/nip99.d.ts" "types": "./lib/types/nip99.d.ts"
}, },
"./nipb7": { "./nipb7": {
"source": "./nipb7.ts",
"import": "./lib/esm/nipb7.js", "import": "./lib/esm/nipb7.js",
"require": "./lib/cjs/nipb7.js", "require": "./lib/cjs/nipb7.js",
"types": "./lib/types/nipb7.d.ts" "types": "./lib/types/nipb7.d.ts"
}, },
"./fakejson": { "./fakejson": {
"source": "./fakejson.ts",
"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": { "./signer": {
"source": "./signer.ts",
"import": "./lib/esm/signer.js", "import": "./lib/esm/signer.js",
"require": "./lib/cjs/signer.js", "require": "./lib/cjs/signer.js",
"types": "./lib/types/signer.d.ts" "types": "./lib/types/signer.d.ts"
}, },
"./utils": { "./utils": {
"source": "./utils.ts",
"import": "./lib/esm/utils.js", "import": "./lib/esm/utils.js",
"require": "./lib/cjs/utils.js", "require": "./lib/cjs/utils.js",
"types": "./lib/types/utils.d.ts" "types": "./lib/types/utils.d.ts"

View File

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

View File

@@ -1,11 +1,11 @@
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 { import {
Queue,
insertEventIntoAscendingList, insertEventIntoAscendingList,
insertEventIntoDescendingList, insertEventIntoDescendingList,
binarySearch, binarySearch,
normalizeURL, normalizeURL,
mergeReverseSortedLists,
} from './utils.ts' } from './utils.ts'
import type { Event } from './core.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', () => { 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 => ('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]) 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]) 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', () => { describe('normalizeURL', () => {
test('normalizes wss:// URLs', () => { test('normalizes wss:// URLs', () => {
expect(normalizeURL('wss://example.com')).toBe('wss://example.com/') expect(normalizeURL('wss://example.com')).toBe('wss://example.com/')

View File

@@ -1,4 +1,4 @@
import type { Event } from './core.ts' import type { NostrEvent } 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()
@@ -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 => { const [idx, found] = binarySearch(sortedArray, b => {
if (event.id === b.id) return 0 if (event.id === b.id) return 0
if (event.created_at === b.created_at) return -1 if (event.created_at === b.created_at) return -1
@@ -34,7 +34,7 @@ export function insertEventIntoDescendingList(sortedArray: Event[], event: Event
return sortedArray return sortedArray
} }
export function insertEventIntoAscendingList(sortedArray: Event[], event: Event): Event[] { export function insertEventIntoAscendingList(sortedArray: NostrEvent[], event: NostrEvent): NostrEvent[] {
const [idx, found] = binarySearch(sortedArray, b => { const [idx, found] = binarySearch(sortedArray, b => {
if (event.id === b.id) return 0 if (event.id === b.id) return 0
if (event.created_at === b.created_at) return -1 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] return [start, false]
} }
export class QueueNode<V> { export function mergeReverseSortedLists(list1: NostrEvent[], list2: NostrEvent[]): NostrEvent[] {
public value: V const result: NostrEvent[] = new Array(list1.length + list2.length)
public next: QueueNode<V> | null = null result.length = 0
public prev: QueueNode<V> | null = null let i1 = 0
let i2 = 0
let sameTimestampIds: string[] = []
constructor(message: V) { while (i1 < list1.length && i2 < list2.length) {
this.value = message let next: NostrEvent
} if (list1[i1]?.created_at > list2[i2]?.created_at) {
} next = list1[i1]
i1++
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
} else { } else {
// list has elements, add as last next = list2[i2]
newNode.prev = this.last i2++
this.last.next = newNode
this.last = newNode
}
return true
} }
dequeue(): V | null { if (result.length > 0 && result[result.length - 1].created_at === next.created_at) {
if (!this.first) return null if (sameTimestampIds.includes(next.id)) continue
} else {
if (this.first === this.last) { sameTimestampIds.length = 0
const target = this.first
this.first = null
this.last = null
return target.value
} }
const target = this.first result.push(next)
this.first = target.next sameTimestampIds.push(next.id)
if (this.first) {
this.first.prev = null // fix: clean up prev pointer
} }
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
} }