Change requests from classes to functions

This commit is contained in:
Jon Staab
2025-04-10 10:38:47 -07:00
parent 0c43bf199f
commit 989fc74374
8 changed files with 336 additions and 342 deletions
+12 -17
View File
@@ -1,6 +1,6 @@
import {nthEq, partition, race, now} from "@welshman/lib" import {nthEq, partition, race, now} from "@welshman/lib"
import {createEvent, getPubkeyTagValues} from "@welshman/util" import {createEvent, getPubkeyTagValues} from "@welshman/util"
import {MultiRequest, Tracker, RequestEvent, request} from "@welshman/net" import {request, Tracker} from "@welshman/net"
import {Scope, FeedController, RequestOpts, FeedOptions, DVMOpts, Feed} from "@welshman/feeds" import {Scope, FeedController, RequestOpts, FeedOptions, DVMOpts, Feed} from "@welshman/feeds"
import {makeDvmRequest, DVMEvent} from "@welshman/dvm" import {makeDvmRequest, DVMEvent} from "@welshman/dvm"
import {makeSecret, Nip01Signer} from "@welshman/signer" import {makeSecret, Nip01Signer} from "@welshman/signer"
@@ -27,13 +27,17 @@ export const makeFeedRequestHandler = ({signal}: FeedRequestHandlerOptions) =>
req.on(RequestEvent.Close, resolve) req.on(RequestEvent.Close, resolve)
}) })
} else { } else {
const requests: MultiRequest[] = [] const promises: Promise<TrustedEvent>[][] = []
const [withSearch, withoutSearch] = partition(f => Boolean(f.search), filters) const [withSearch, withoutSearch] = partition(f => Boolean(f.search), filters)
if (withSearch.length > 0) { if (withSearch.length > 0) {
requests.push( promises.push(
request({ request({
tracker, signal, autoClose: true, signal,
tracker,
onEvent
threshold: 0.1,
autoClose: true,
filters: withSearch, filters: withSearch,
relays: Router.get().Search().getUrls(), relays: Router.get().Search().getUrls(),
}), }),
@@ -41,25 +45,16 @@ export const makeFeedRequestHandler = ({signal}: FeedRequestHandlerOptions) =>
} }
if (withoutSearch.length > 0) { if (withoutSearch.length > 0) {
requests.push( promises.push(
...getFilterSelections(filters).flatMap(options => ...getFilterSelections(filters).flatMap(({relays, filters}) =>
request({tracker, signal, autoClose: true, ...options}), request({tracker, signal, onEvent, relays, filters, threshold: 0.8, autoClose: true}),
), ),
) )
} }
// Break out selections by relay so we can complete early after a certain number // Break out selections by relay so we can complete early after a certain number
// of requests complete for faster load times // of requests complete for faster load times
await race( await race(withSearch.length > 0 ? 0.1 : 0.8, promises)
withSearch.length > 0 ? 0.1 : 0.8,
requests.map(
req =>
new Promise(resolve => {
req.on(RequestEvent.Event, onEvent)
req.on(RequestEvent.Close, resolve)
}),
),
)
// Wait until after we've queried the network to access our local cache. This results in less // Wait until after we've queried the network to access our local cache. This results in less
// snappy response times, but is necessary to prevent stale stuff that the user has already seen // snappy response times, but is necessary to prevent stale stuff that the user has already seen
+2 -5
View File
@@ -4,9 +4,8 @@ import {
push as basePush, push as basePush,
pull as basePull, pull as basePull,
PublishEvent, PublishEvent,
RequestEvent,
SinglePublish, SinglePublish,
SingleRequest, requestOne,
} from "@welshman/net" } from "@welshman/net"
import {repository} from "./core.js" import {repository} from "./core.js"
import {relaysByUrl} from "./relays.js" import {relaysByUrl} from "./relays.js"
@@ -35,9 +34,7 @@ export const pull = async ({relays, filters}: AppSyncOpts) => {
relays.map(async relay => { relays.map(async relay => {
await (hasNegentropy(relay) await (hasNegentropy(relay)
? basePull({filters, events, relays: [relay]}) ? basePull({filters, events, relays: [relay]})
: new Promise<void>(resolve => { : requestOne({filters, relay, autoClose: true}))
new SingleRequest({filters, relay, autoClose: true}).on(RequestEvent.Close, resolve)
}))
}), }),
) )
} }
+8 -5
View File
@@ -1,7 +1,7 @@
import {now} from "@welshman/lib" import {now} from "@welshman/lib"
import {Nip01Signer} from "@welshman/signer" import {Nip01Signer} from "@welshman/signer"
import {TrustedEvent, StampedEvent, Filter} from "@welshman/util" import {TrustedEvent, StampedEvent, Filter} from "@welshman/util"
import {MultiRequest, MultiPublish, PublishEvent, RequestEvent, AdapterContext} from "@welshman/net" import {request, MultiPublish, PublishEvent, AdapterContext} from "@welshman/net"
export type DVMHandler = { export type DVMHandler = {
stop?: () => void stop?: () => void
@@ -50,10 +50,13 @@ export class DVM {
filter["#p"] = [pubkey] filter["#p"] = [pubkey]
} }
const req = new MultiRequest({relays, filters: [filter], context}) request({
relays,
req.on(RequestEvent.Event, this.onEvent) filters: [filter],
req.on(RequestEvent.Close, resolve) context,
onClose: resolve,
onEvent: this.onEvent,
})
}) })
} }
} }
+24 -17
View File
@@ -1,6 +1,6 @@
import {Emitter, now} from "@welshman/lib" import {Emitter, now} from "@welshman/lib"
import {TrustedEvent, SignedEvent, Filter} from "@welshman/util" import {TrustedEvent, SignedEvent, Filter} from "@welshman/util"
import {MultiRequest, MultiPublish, RequestEvent, AdapterContext} from "@welshman/net" import {request, MultiPublish, AdapterContext} from "@welshman/net"
export enum DVMEvent { export enum DVMEvent {
Progress = "progress", Progress = "progress",
@@ -17,13 +17,12 @@ export type DVMRequestOptions = {
} }
export type DVMRequest = { export type DVMRequest = {
request: DVMRequestOptions options: DVMRequestOptions
emitter: Emitter emitter: Emitter
sub: MultiRequest
pub: MultiPublish pub: MultiPublish
} }
export const makeDvmRequest = (request: DVMRequestOptions) => { export const makeDvmRequest = (options: DVMRequestOptions) => {
const emitter = new Emitter() const emitter = new Emitter()
const { const {
event, event,
@@ -32,25 +31,33 @@ export const makeDvmRequest = (request: DVMRequestOptions) => {
timeout = 30_000, timeout = 30_000,
autoClose = true, autoClose = true,
reportProgress = true, reportProgress = true,
} = request } = options
const kind = event.kind + 1000 const kind = event.kind + 1000
const kinds = reportProgress ? [kind, 7000] : [kind] const kinds = reportProgress ? [kind, 7000] : [kind]
const filters: Filter[] = [{kinds, since: now() - 60, "#e": [event.id]}] const filters: Filter[] = [{kinds, since: now() - 60, "#e": [event.id]}]
const abortController = new AbortController()
const signal = AbortSignal.any([abortController.signal, AbortSignal.timeout(timeout)])
const sub = new MultiRequest({relays, filters, timeout, context}) request({
const pub = new MultiPublish({relays, event, timeout, context}) signal,
relays,
filters,
context,
onEvent: (event: TrustedEvent, url: string) => {
if (event.kind === 7000) {
emitter.emit(DVMEvent.Progress, url, event)
} else {
emitter.emit(DVMEvent.Result, url, event)
sub.on(RequestEvent.Event, (event: TrustedEvent, url: string) => { if (autoClose) {
if (event.kind === 7000) { abortController.abort()
emitter.emit(DVMEvent.Progress, url, event) }
} else {
emitter.emit(DVMEvent.Result, url, event)
if (autoClose) {
sub.close()
} }
} },
}) })
return {request, emitter, sub, pub} as DVMRequest const pub = new MultiPublish({relays, event, timeout, context})
return {options, emitter, pub} as DVMRequest
} }
+37 -44
View File
@@ -3,9 +3,9 @@ import {Nip01Signer} from "@welshman/signer"
import {makeEvent} from "@welshman/util" import {makeEvent} from "@welshman/util"
import {ClientMessageType} from "../src/message" import {ClientMessageType} from "../src/message"
import {MockAdapter} from "../src/adapter" import {MockAdapter} from "../src/adapter"
import {SingleRequest, MultiRequest, RequestEvent} from "../src/request" import {requestOne, request} from "../src/request"
describe("SingleRequest", () => { describe("requestOne", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers() vi.useFakeTimers()
}) })
@@ -17,12 +17,6 @@ describe("SingleRequest", () => {
it("everything basically works", async () => { it("everything basically works", async () => {
const sendSpy = vi.fn() const sendSpy = vi.fn()
const adapter = new MockAdapter("1", sendSpy) const adapter = new MockAdapter("1", sendSpy)
const req = new SingleRequest({
relay: "whatever",
filters: [{kinds: [1]}],
context: {getAdapter: () => adapter},
})
const duplicateSpy = vi.fn() const duplicateSpy = vi.fn()
const invalidSpy = vi.fn() const invalidSpy = vi.fn()
const filteredSpy = vi.fn() const filteredSpy = vi.fn()
@@ -30,18 +24,21 @@ describe("SingleRequest", () => {
const eoseSpy = vi.fn() const eoseSpy = vi.fn()
const closeSpy = vi.fn() const closeSpy = vi.fn()
req.on(RequestEvent.Duplicate, duplicateSpy) requestOne({
req.on(RequestEvent.Invalid, invalidSpy) relay: "whatever",
req.on(RequestEvent.Filtered, filteredSpy) filters: [{kinds: [1]}],
req.on(RequestEvent.Event, eventSpy) context: {getAdapter: () => adapter},
req.on(RequestEvent.Eose, eoseSpy) onDuplicate: duplicateSpy,
req.on(RequestEvent.Close, closeSpy) onInvalid: invalidSpy,
onFiltered: filteredSpy,
onEvent: eventSpy,
onEose: eoseSpy,
onClose: closeSpy,
})
await vi.runAllTimersAsync() await vi.runAllTimersAsync()
const id = Array.from(req._ids)[0] expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Req, expect.any(String), {kinds: [1]}])
expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Req, id, {kinds: [1]}])
const signer = Nip01Signer.ephemeral() const signer = Nip01Signer.ephemeral()
const event1 = await signer.sign(makeEvent(1)) const event1 = await signer.sign(makeEvent(1))
@@ -71,7 +68,7 @@ describe("SingleRequest", () => {
}) })
}) })
describe("MultiRequest", () => { describe("request", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers() vi.useFakeTimers()
}) })
@@ -85,14 +82,6 @@ describe("MultiRequest", () => {
const adapter1 = new MockAdapter("1", send1Spy) const adapter1 = new MockAdapter("1", send1Spy)
const send2Spy = vi.fn() const send2Spy = vi.fn()
const adapter2 = new MockAdapter("2", send2Spy) const adapter2 = new MockAdapter("2", send2Spy)
const req = new MultiRequest({
relays: ["1", "2"],
filters: [{kinds: [1]}],
context: {
getAdapter: (url: string) => (url === "1" ? adapter1 : adapter2),
},
})
const duplicateSpy = vi.fn() const duplicateSpy = vi.fn()
const invalidSpy = vi.fn() const invalidSpy = vi.fn()
const filteredSpy = vi.fn() const filteredSpy = vi.fn()
@@ -100,20 +89,24 @@ describe("MultiRequest", () => {
const eoseSpy = vi.fn() const eoseSpy = vi.fn()
const closeSpy = vi.fn() const closeSpy = vi.fn()
req.on(RequestEvent.Duplicate, duplicateSpy) request({
req.on(RequestEvent.Invalid, invalidSpy) relays: ["1", "2"],
req.on(RequestEvent.Filtered, filteredSpy) filters: [{kinds: [1]}],
req.on(RequestEvent.Event, eventSpy) context: {
req.on(RequestEvent.Eose, eoseSpy) getAdapter: (url: string) => (url === "1" ? adapter1 : adapter2),
req.on(RequestEvent.Close, closeSpy) },
onDuplicate: duplicateSpy,
onInvalid: invalidSpy,
onFiltered: filteredSpy,
onEvent: eventSpy,
onEose: eoseSpy,
onClose: closeSpy,
})
await vi.runAllTimersAsync() await vi.runAllTimersAsync()
const id1 = Array.from(req._children[0]._ids)[0] expect(send1Spy).toHaveBeenCalledTimes(1)
const id2 = Array.from(req._children[1]._ids)[0] expect(send2Spy).toHaveBeenCalledTimes(1)
expect(send1Spy).toHaveBeenCalledWith([ClientMessageType.Req, id1, {kinds: [1]}])
expect(send2Spy).toHaveBeenCalledWith([ClientMessageType.Req, id2, {kinds: [1]}])
const signer = Nip01Signer.ephemeral() const signer = Nip01Signer.ephemeral()
const event1 = await signer.sign(makeEvent(1)) const event1 = await signer.sign(makeEvent(1))
@@ -121,11 +114,11 @@ describe("MultiRequest", () => {
const event3 = makeEvent(1) const event3 = makeEvent(1)
const event4 = await signer.sign(makeEvent(1)) const event4 = await signer.sign(makeEvent(1))
adapter1.receive(["EVENT", id1, event1]) adapter1.receive(["EVENT", expect.any(String), event1])
adapter1.receive(["EVENT", id1, event2]) adapter1.receive(["EVENT", expect.any(String), event2])
adapter1.receive(["EVENT", id1, event3]) adapter1.receive(["EVENT", expect.any(String), event3])
adapter2.receive(["EVENT", id2, event1]) adapter2.receive(["EVENT", expect.any(String), event1])
adapter2.receive(["EVENT", id2, event4]) adapter2.receive(["EVENT", expect.any(String), event4])
await vi.runAllTimersAsync() await vi.runAllTimersAsync()
@@ -135,8 +128,8 @@ describe("MultiRequest", () => {
expect(eventSpy).toHaveBeenCalledWith(event1, "1") expect(eventSpy).toHaveBeenCalledWith(event1, "1")
expect(eoseSpy).toHaveBeenCalledTimes(0) expect(eoseSpy).toHaveBeenCalledTimes(0)
adapter1.receive(["EOSE", id1]) adapter1.receive(["EOSE", expect.any(String)])
adapter2.receive(["EOSE", id2]) adapter2.receive(["EOSE", expect.any(String)])
expect(eoseSpy).toHaveBeenCalledTimes(2) expect(eoseSpy).toHaveBeenCalledTimes(2)
+13 -9
View File
@@ -10,7 +10,7 @@ import {
} from "./message.js" } from "./message.js"
import {getAdapter, AdapterContext, AbstractAdapter, AdapterEvent} from "./adapter.js" import {getAdapter, AdapterContext, AbstractAdapter, AdapterEvent} from "./adapter.js"
import {Negentropy, NegentropyStorageVector} from "./negentropy.js" import {Negentropy, NegentropyStorageVector} from "./negentropy.js"
import {SingleRequest, RequestEvent} from "./request.js" import {requestOne} from "./request.js"
import {MultiPublish, PublishEvent} from "./publish.js" import {MultiPublish, PublishEvent} from "./publish.js"
export enum DifferenceEvent { export enum DifferenceEvent {
@@ -200,14 +200,18 @@ export const pull = async ({context, ...options}: PullOptions) => {
await Promise.all( await Promise.all(
Array.from(idsByRelay.entries()).map(([relay, allIds]) => { Array.from(idsByRelay.entries()).map(([relay, allIds]) => {
return Promise.all( return Promise.all(
chunk(500, allIds).map(ids => { chunk(500, allIds).map(ids =>
return new Promise<void>(resolve => { new Promise<void>(resolve =>
const req = new SingleRequest({relay, context, filters: [{ids}], autoClose: true}) requestOne({
relay,
req.on(RequestEvent.Close, resolve) context,
req.on(RequestEvent.Event, event => result.push(event as SignedEvent)) filters: [{ids}],
}) autoClose: true,
}), onClose: resolve,
onEvent: event => result.push(event as SignedEvent),
})
)
),
) )
}), }),
) )
+217 -227
View File
@@ -1,5 +1,5 @@
import {EventEmitter} from "events" import {EventEmitter} from "events"
import {on, call, randomId, yieldThread, pushToMapKey, batcher} from "@welshman/lib" import {on, flatten, addToMapKey, defer, Deferred, call, randomId, yieldThread, pushToMapKey, batcher} from "@welshman/lib"
import { import {
Filter, Filter,
unionFilters, unionFilters,
@@ -14,232 +14,165 @@ import {Unsubscriber} from "./util.js"
import {netContext} from "./context.js" import {netContext} from "./context.js"
import {Tracker} from "./tracker.js" import {Tracker} from "./tracker.js"
export enum RequestEvent { export type RequestOneOptions = {
Close = "request:event:close",
Disconnect = "request:event:disconnect",
Duplicate = "request:event:duplicate",
Eose = "request:event:eose",
Event = "request:event:event",
Filtered = "request:event:filtered",
Deleted = "request:event:deleted",
Invalid = "request:event:invalid",
}
// SingleRequest
export type SingleRequestEvents = {
[RequestEvent.Event]: (event: TrustedEvent) => void
[RequestEvent.Deleted]: (event: any) => void
[RequestEvent.Invalid]: (event: any) => void
[RequestEvent.Filtered]: (event: TrustedEvent) => void
[RequestEvent.Duplicate]: (event: TrustedEvent) => void
[RequestEvent.Disconnect]: () => void
[RequestEvent.Close]: () => void
[RequestEvent.Eose]: () => void
}
export type SingleRequestOptions = {
relay: string relay: string
filters: Filter[] filters: Filter[]
signal?: AbortSignal signal?: AbortSignal
context?: AdapterContext
timeout?: number
tracker?: Tracker tracker?: Tracker
context?: AdapterContext
autoClose?: boolean autoClose?: boolean
isEventValid?: (event: TrustedEvent, url: string) => boolean isEventValid?: (event: TrustedEvent, url: string) => boolean
isEventDeleted?: (event: TrustedEvent, url: string) => boolean isEventDeleted?: (event: TrustedEvent, url: string) => boolean
onEvent?: (event: TrustedEvent, url: string) => void
onDeleted?: (event: unknown, url: string) => void
onInvalid?: (event: unknown, url: string) => void
onFiltered?: (event: TrustedEvent, url: string) => void
onDuplicate?: (event: TrustedEvent, url: string) => void
onDisconnect?: (url: string) => void
onEose?: (url: string) => void
onClose?: () => void
} }
export class SingleRequest extends EventEmitter { export const requestOne = (options: RequestOneOptions) => {
_ids = new Set<string>() const ids = new Set<string>()
_eose = new Set<string>() const eose = new Set<string>()
_unsubscribers: Unsubscriber[] = [] const events: TrustedEvent[] = []
_adapter: AbstractAdapter const deferred = defer<TrustedEvent[]>()
_closed = false const tracker = options.tracker || new Tracker()
const adapter = getAdapter(options.relay, options.context)
const isEventValid = options.isEventValid || netContext.isEventValid
const isEventDeleted = options.isEventDeleted || netContext.isEventDeleted
constructor(readonly options: SingleRequestOptions) { let closed = false
super()
const tracker = options.tracker || new Tracker() const close = () => {
const isEventValid = options.isEventValid || netContext.isEventValid if (closed) return
const isEventDeleted = options.isEventDeleted || netContext.isEventDeleted
// Set up our adapter closed = true
this._adapter = getAdapter(this.options.relay, this.options.context)
// Listen for event/eose messages from the adapter for (const id of ids) {
this._unsubscribers.push( adapter.send(["CLOSE", id])
on(this._adapter, AdapterEvent.Receive, (message: RelayMessage, url: string) => { }
if (isRelayEvent(message)) {
const [_, id, event] = message
if (this._ids.has(id)) { options.onClose?.()
if (tracker.track(event.id, url)) { adapter.cleanup()
this.emit(RequestEvent.Duplicate, event) unsubscribers.map(call)
} else if (isEventDeleted(event, url)) { deferred.resolve(events)
this.emit(RequestEvent.Deleted, event) }
} else if (!isEventValid(event, url)) {
this.emit(RequestEvent.Invalid, event) const unsubscribers = [
} else if (!matchFilters(this.options.filters, event)) { on(adapter, AdapterEvent.Receive, (message: RelayMessage, url: string) => {
this.emit(RequestEvent.Filtered, event) if (isRelayEvent(message)) {
} else { const [_, id, event] = message
this.emit(RequestEvent.Event, event)
if (ids.has(id)) {
if (tracker.track(event.id, url)) {
options.onDuplicate?.(event, url)
} else if (isEventDeleted(event, url)) {
options.onDeleted?.(event, url)
} else if (!isEventValid(event, url)) {
options.onInvalid?.(event, url)
} else if (!matchFilters(options.filters, event)) {
options.onFiltered?.(event, url)
} else {
options.onEvent?.(event, url)
events.push(event)
}
}
}
if (isRelayEose(message)) {
const [_, id] = message
if (ids.has(id)) {
eose.add(id)
if (eose.size === ids.size) {
options.onEose?.(url)
if (options.autoClose) {
close()
} }
} }
} }
}
}),
]
if (isRelayEose(message)) { // Listen to disconnects from any sockets
const [_, id] = message for (const socket of adapter.sockets) {
unsubscribers.push(
on(socket, SocketEvent.Status, (status: SocketStatus) => {
if (![SocketStatus.Open, SocketStatus.Opening].includes(status)) {
options.onDisconnect?.(socket.url)
if (this._ids.has(id)) { if (options.autoClose) {
this._eose.add(id) close()
if (this._eose.size === this._ids.size) {
this.emit(RequestEvent.Eose)
if (this.options.autoClose) {
this.close()
}
}
} }
} }
}), }),
) )
// Listen to disconnects from any sockets
for (const socket of this._adapter.sockets) {
this._unsubscribers.push(
on(socket, SocketEvent.Status, (status: SocketStatus) => {
if (![SocketStatus.Open, SocketStatus.Opening].includes(status)) {
this.emit(RequestEvent.Disconnect)
if (this.options.autoClose) {
this.close()
}
}
}),
)
}
// Timeout our subscription
if (this.options.timeout || this.options.autoClose) {
setTimeout(() => this.close(), this.options.timeout || 10000)
}
// Handle abort signal
this.options.signal?.addEventListener("abort", () => this.close())
// Start asynchronously so the caller can set up listeners
yieldThread().then(() => {
for (const filter of this.options.filters) {
const id = `REQ-${randomId().slice(0, 8)}`
this._ids.add(id)
this._adapter.send([ClientMessageType.Req, id, filter])
}
})
} }
close() { // Handle abort signal
if (this._closed) return options.signal?.addEventListener("abort", close)
for (const id of this._ids) { // If we're auto-closing, make sure it happens even if the relay doesn't send an eose
this._adapter.send(["CLOSE", id]) // and the caller doesn't provide a signal, in order to avoid memory leaks
} if (options.autoClose && !options.signal) {
setTimeout(close, 30_000)
this._closed = true
this.emit(RequestEvent.Close)
this._adapter.cleanup()
this._unsubscribers.map(call)
this.removeAllListeners()
} }
for (const filter of options.filters) {
const id = `REQ-${randomId().slice(0, 8)}`
ids.add(id)
adapter.send([ClientMessageType.Req, id, filter])
}
return deferred
} }
// MultiRequest export type RequestOptions = Omit<RequestOneOptions, "relay"> & {
export type MultiRequestEvents = {
[RequestEvent.Event]: (event: TrustedEvent, url: string) => void
[RequestEvent.Deleted]: (event: TrustedEvent, url: string) => void
[RequestEvent.Invalid]: (event: TrustedEvent, url: string) => void
[RequestEvent.Filtered]: (event: TrustedEvent, url: string) => void
[RequestEvent.Duplicate]: (event: TrustedEvent, url: string) => void
[RequestEvent.Disconnect]: (url: string) => void
[RequestEvent.Eose]: (url: string) => void
[RequestEvent.Close]: () => void
}
export type MultiRequestOptions = Omit<SingleRequestOptions, "relay"> & {
relays: string[] relays: string[]
threshold?: number threshold?: number
} }
export class MultiRequest extends EventEmitter { export const request = async (options: RequestOptions) => {
_children: SingleRequest[] = [] const closed = new Set<string>()
_closed = new Set<string>() const tracker = new Tracker()
const relays = new Set(options.relays)
const ctrl = new AbortController()
const signal = options.signal ? AbortSignal.any([options.signal, ctrl.signal]) : ctrl.signal
const threshold = options.threshold || 1
const promises: Promise<TrustedEvent[]>[] = []
constructor(options: MultiRequestOptions) { if (relays.size !== options.relays.length) {
super() console.warn("Non-unique relays passed to request")
const tracker = new Tracker()
const relays = new Set(options.relays)
const threshold = options.threshold || 1
if (relays.size !== options.relays.length) {
console.warn("Non-unique relays passed to MultiRequest")
}
for (const relay of relays) {
const req = new SingleRequest({relay, tracker, ...options})
req.on(RequestEvent.Event, (event: TrustedEvent) => {
this.emit(RequestEvent.Event, event, relay)
})
req.on(RequestEvent.Deleted, (event: TrustedEvent) => {
this.emit(RequestEvent.Deleted, event, relay)
})
req.on(RequestEvent.Invalid, (event: TrustedEvent) => {
this.emit(RequestEvent.Invalid, event, relay)
})
req.on(RequestEvent.Filtered, (event: TrustedEvent) => {
this.emit(RequestEvent.Filtered, event, relay)
})
req.on(RequestEvent.Duplicate, (event: TrustedEvent) => {
this.emit(RequestEvent.Duplicate, event, relay)
})
req.on(RequestEvent.Disconnect, () => {
this.emit(RequestEvent.Disconnect, relay)
})
req.on(RequestEvent.Eose, () => {
this.emit(RequestEvent.Eose, relay)
})
req.on(RequestEvent.Close, () => {
this._closed.add(relay)
if (this._closed.size >= relays.size * threshold) {
this.emit(RequestEvent.Close)
this.close()
}
})
this._children.push(req)
}
} }
close() { return flatten(
for (const child of this._children) { await Promise.all(
child.close() Array.from(relays).map(relay =>
} requestOne({
} ...options,
tracker,
signal,
relay,
onClose: () => {
closed.add(relay)
if (closed.size >= relays.size * threshold) {
options.onClose?.()
ctrl.abort()
}
}
})
)
)
)
} }
export const request = (options: MultiRequestOptions) => new MultiRequest(options)
export type LoaderOptions = { export type LoaderOptions = {
delay: number delay: number
@@ -253,61 +186,118 @@ export type LoaderOptions = {
export type LoadOptions = { export type LoadOptions = {
relays: string[] relays: string[]
filters: Filter[] filters: Filter[]
signal?: AbortSignal
onEvent?: (event: TrustedEvent, url: string) => void
onDisconnect?: (url: string) => void
onEose?: (url: string) => void
onClose?: () => void
} }
/** /**
* Creates a convenience function which returns a promise of events from a request. * Creates a convenience function which returns a promise of events from a request.
* It may return early if filter cardinality is known, and it delays requests by * It may return early if filter cardinality is known, and it delays requests in order
* 200 in order to implement batching * to implement batching
* @param options - MultiRequestOptions * @param options - LoaderOptions
* @returns - a promise containing an array of TrustedEvents * @returns - a load function
*/ */
export const makeLoader = (options: LoaderOptions) => export const makeLoader = (options: LoaderOptions) =>
batcher(options.delay, async (requests: LoadOptions[]) => { batcher(options.delay, async (allRequests: LoadOptions[]) => {
const filtersByRelay = new Map<string, Filter[]>() const resultsByRequest = new Map<LoadOptions, Deferred<TrustedEvent[]>>()
const eventsByRequest = new Map<LoadOptions, TrustedEvent[]>()
const requestsByRelay = new Map<string, LoadOptions[]>()
const controllersByRelay = new Map<string, AbortController>()
const signalsByRelay = new Map<string, AbortSignal>()
const closedRequestsByRelay = new Map<string, Set<LoadOptions>>()
const closedRelaysByRequest = new Map<LoadOptions, Set<string>>()
const relays = allRequests.flatMap(r => r.relays)
const threshold = options.threshold || 1
const tracker = new Tracker()
for (const {filters, relays} of requests) { const close = (relay: string, request: LoadOptions) => {
for (const relay of relays) { addToMapKey(closedRequestsByRelay, relay, request)
for (const filter of filters) { addToMapKey(closedRelaysByRequest, request, relay)
pushToMapKey(filtersByRelay, relay, filter)
} const closedRelays = closedRelaysByRequest.get(request)?.size || 0
if (closedRelays >= request.relays.length * threshold) {
request.onClose?.()
resultsByRequest.get(request)?.resolve(eventsByRequest.get(request) || [])
}
if (closedRequestsByRelay.get(relay)?.size === requestsByRelay.get(relay)?.length) {
controllersByRelay.get(relay)?.abort()
} }
} }
const tracker = new Tracker() for (const request of allRequests) {
const events: TrustedEvent[] = [] for (const relay of request.relays) {
pushToMapKey(requestsByRelay, relay, request)
resultsByRequest.set(request, defer())
}
await Promise.all( // Propagate abort when all requests have been closed for a given relay
Array.from(filtersByRelay).map( request.signal?.addEventListener('abort', () => {
async ([relay, unmergedFilters]) => for (const relay of request.relays) {
new Promise<void>(resolve => { close(relay, request)
const filters = unionFilters(unmergedFilters) }
const cardinality = })
filters.length === 1 ? getFilterResultCardinality(filters[0]) : undefined }
const req = new MultiRequest({
filters,
tracker,
relays: [relay],
autoClose: true,
...options,
})
let count = 0 // Create an abort controller for each relay
for (const relay of relays) {
const controller = new AbortController()
const signals = [controller.signal]
req.on(RequestEvent.Event, (event: TrustedEvent) => { if (options.timeout) {
events.push(event) signals.push(AbortSignal.timeout(options.timeout))
}
if (++count === cardinality) { controllersByRelay.set(relay, controller)
resolve() signalsByRelay.set(relay, AbortSignal.any(signals))
}
Array.from(requestsByRelay).forEach(
async ([relay, requests]) => {
// Union all filters for a given request and send them together
const filters = unionFilters(requests.flatMap(r => r.filters))
// Propagate events to caller, but only for requests that have not been aborted
const getOpenRequests = () =>
requests.filter(request => !closedRequestsByRelay.get(relay)?.has(request))
requestOne({
relay,
filters,
tracker,
autoClose: true,
signal: signalsByRelay.get(relay),
context: options.context,
isEventValid: options.isEventValid,
isEventDeleted: options.isEventDeleted,
onEvent: (event: TrustedEvent, url: string) => {
for (const request of getOpenRequests()) {
if (matchFilters(request.filters, event)) {
pushToMapKey(eventsByRequest, request, event)
request.onEvent?.(event, url)
// Calculate cardinality for unioned filters so that we can return early
if (request.filters.length === 1) {
const cardinality = getFilterResultCardinality(request.filters[0])
if (eventsByRequest.get(request)?.length === cardinality) {
close(relay, request)
}
}
} }
}) }
},
req.on(RequestEvent.Close, () => resolve()) onDisconnect: (url: string) => getOpenRequests().forEach(request => request.onDisconnect?.(url)),
}), onEose: (url: string) => getOpenRequests().forEach(request => request.onEose?.(url)),
), onClose: () => requests.forEach(request => close(relay, request)),
})
}
) )
return requests.map(r => events.filter(event => matchFilters(r.filters, event))) return allRequests.map(r => resultsByRequest.get(r))
}) })
export const load = makeLoader({delay: 200, timeout: 3000, threshold: 0.5}) export const load = makeLoader({delay: 200, timeout: 3000, threshold: 0.5})
+23 -18
View File
@@ -15,7 +15,7 @@ import {
StampedEvent, StampedEvent,
NOSTR_CONNECT, NOSTR_CONNECT,
} from "@welshman/util" } from "@welshman/util"
import {MultiRequest, MultiPublish, RequestEvent, AdapterContext} from "@welshman/net" import {MultiPublish, request, AdapterContext} from "@welshman/net"
import {ISigner, EncryptionImplementation, decrypt, hash, own} from "../util.js" import {ISigner, EncryptionImplementation, decrypt, hash, own} from "../util.js"
import {Nip01Signer} from "./nip01.js" import {Nip01Signer} from "./nip01.js"
@@ -97,7 +97,7 @@ const popupManager = (() => {
})() })()
export class Nip46Receiver extends Emitter { export class Nip46Receiver extends Emitter {
public sub?: MultiRequest public abortController?: AbortController
constructor( constructor(
public signer: ISigner, public signer: ISigner,
@@ -108,33 +108,38 @@ export class Nip46Receiver extends Emitter {
// start listening to the remote signer for incoming events // start listening to the remote signer for incoming events
// broadcast any event returned by the remote signer // broadcast any event returned by the remote signer
start = async () => { start = async () => {
if (this.sub) return if (this.abortController) return
this.abortController = new AbortController()
const {relays, context} = this.params const {relays, context} = this.params
const userPubkey = await this.signer.getPubkey() const userPubkey = await this.signer.getPubkey()
const filters = [{kinds: [NOSTR_CONNECT], "#p": [userPubkey]}] const filters = [{kinds: [NOSTR_CONNECT], "#p": [userPubkey]}]
this.sub = new MultiRequest({relays, filters, context}) request({
relays,
filters,
context,
signal: this.abortController.signal,
onEvent: async (event: TrustedEvent, url: string) => {
const json = await decrypt(this.signer, event.pubkey, event.content)
const response = tryCatch(() => JSON.parse(json)) || {}
this.sub.on(RequestEvent.Event, async (event: TrustedEvent, url: string) => { // Delay errors in case there's a zombie signer out there clogging things up
const json = await decrypt(this.signer, event.pubkey, event.content) if (response.error) {
const response = tryCatch(() => JSON.parse(json)) || {} await sleep(3000)
}
// Delay errors in case there's a zombie signer out there clogging things up this.emit(Nip46Event.Receive, {...response, url, event} as Nip46Response)
if (response.error) { },
await sleep(3000) onClose: () => {
} this.abortController = undefined
},
this.emit(Nip46Event.Receive, {...response, url, event} as Nip46Response)
})
this.sub.on(RequestEvent.Close, () => {
this.sub = undefined
}) })
} }
stop = () => { stop = () => {
this.sub?.close() this.abortController?.abort()
this.removeAllListeners() this.removeAllListeners()
} }
} }