Add more stuff to client
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@welshman/app",
|
||||
"name": "@welshman/client",
|
||||
"version": "0.8.13",
|
||||
"author": "hodlbod",
|
||||
"license": "MIT",
|
||||
"description": "A collection of svelte stores for use in building nostr client applications.",
|
||||
"description": "A client god-object for building nostr applications",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/app/src/index.js",
|
||||
"types": "dist/app/src/index.d.ts",
|
||||
"main": "dist/client/src/index.js",
|
||||
"types": "dist/client/src/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {Maybe} from '@welshman/lib'
|
||||
import {Repository, AdapterFactory, NetContext, WrapManager, DiffOptions, PullOptions, PushOptions, RequestOptions, PublishOptions, LoaderOptions, Tracker, Pool, push, pull, diff, publish, request, makeLoader} from '@welshman/net'
|
||||
import {RelayStats} from './relayStats.js'
|
||||
import {User} from './user.js'
|
||||
|
||||
export type ClientOptions = {
|
||||
user?: User
|
||||
getAdapter?: AdapterFactory
|
||||
socketPolicies?: SocketPolicy[]
|
||||
}
|
||||
|
||||
export class Client {
|
||||
user?: User
|
||||
pool: Pool
|
||||
tracker: Tracker
|
||||
repository: Repository
|
||||
wrapManager: WrapManager
|
||||
netContext: NetContext
|
||||
relayStats: RelayStats
|
||||
|
||||
constructor(options: ClientOptions) {
|
||||
this.user = options.user
|
||||
this.pool = new Pool({
|
||||
makeSocket: (url: string) => {
|
||||
const socketPolicies = options.socketPolicies ?? defaultSocketPolicies
|
||||
|
||||
if (this.user) {
|
||||
socketPolicies = [...socketPolicies, this.user.makeSocketPolicyAuth()]
|
||||
}
|
||||
|
||||
return makeSocket(url, socketPolicies)
|
||||
},
|
||||
})
|
||||
this.tracker = new Tracker()
|
||||
this.repository = new Repository()
|
||||
this.wrapManager = new WrapManager({
|
||||
tracker: this.tracker,
|
||||
repository: this.repository,
|
||||
})
|
||||
this.netContext = {
|
||||
pool: this.pool,
|
||||
repository: this.repository,
|
||||
getAdapter: options.getAdapter,
|
||||
}
|
||||
this.load = this.makeLoader({
|
||||
delay: 200,
|
||||
timeout: 3000,
|
||||
threshold: 0.5,
|
||||
})
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.pool.clear()
|
||||
this.tracker.clear()
|
||||
this.repository.clear()
|
||||
this.wrapManager.clear()
|
||||
this.relayStats.cleanup()
|
||||
}
|
||||
|
||||
// Bind net utilities to client net context
|
||||
|
||||
request = (options: RequestOptions) => request({...context: this.netContext, ...options})
|
||||
|
||||
makeLoader = (options: LoaderOptions) => makeLoader({...context: this.netContext, ...options})
|
||||
|
||||
publish = (options: PublishOptions) => publish({...context: this.netContext, ...options})
|
||||
|
||||
diff = (options: DiffOptions) => diff({...context: this.netContext, ...options})
|
||||
|
||||
pull = (options: PullOptions) => pull({...context: this.netContext, ...options})
|
||||
|
||||
push = (options: PushOptions) => push({...context: this.netContext, ...options})
|
||||
|
||||
// Bind store utilities to client stuff
|
||||
|
||||
getEventsById = (options: Omit<EventsByIdOptions 'repository'>) =>
|
||||
getEventsById({repository: this.repository, ...options})
|
||||
|
||||
deriveEventsById = (options: Omit<EventsByIdOptions 'repository'>) =>
|
||||
deriveEventsById({repository: this.repository, ...options})
|
||||
|
||||
deriveEvents = (options: Omit<EventsByIdOptions 'repository'>) =>
|
||||
deriveEvents({repository: this.repository, ...options})
|
||||
|
||||
makeDeriveEvent = (options: Omit<EventOptions, 'repository'>) =>
|
||||
makeDeriveEvent({repository: this.repository, ...options})
|
||||
|
||||
getEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, 'tracker' | 'repository'>) =>
|
||||
getEventsByIdByUrl({tracker: this.tracker, repository: this.repository, ...options})
|
||||
|
||||
deriveEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, 'tracker' | 'repository'>) =>
|
||||
deriveEventsByIdByUrl({tracker: this.tracker, repository: this.repository, ...options})
|
||||
|
||||
getEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, 'tracker' | 'repository'>) =>
|
||||
getEventsByIdForUrl({tracker: this.tracker, repository: this.repository, ...options})
|
||||
|
||||
deriveEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, 'tracker' | 'repository'>)
|
||||
deriveEventsByIdForUrl({tracker: this.tracker, repository: this.repository, ...options})
|
||||
|
||||
deriveItemsByKey = <T>(options: Omit<ItemsByKeyOptions<T>, 'repository'>) =>
|
||||
deriveEventsByIdForUrl({repository: this.repository, ...options})
|
||||
|
||||
deriveIsDeleted = (event: TrustedEvent) => deriveIsDeleted(this.repository, event)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {Subscriber} from 'svelte/store'
|
||||
import {call} from '@welshman/lib'
|
||||
|
||||
export class ClientData<T> {
|
||||
selfSubscribers: Subscriber<ClientData<T>>[] = []
|
||||
clearSubscribers: Subscriber<ClientData<T>>[] = []
|
||||
itemSubscribers: Subscriber<T>[] = []
|
||||
data = new Map<string, T>()
|
||||
|
||||
// Svelte store-like methods
|
||||
|
||||
private notify = () => {
|
||||
for (const subscriber of this.selfSubscribers) {
|
||||
subscriber(this)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe = (subscriber: Subscriber<ClientData<T>>) => {
|
||||
subscriber(this)
|
||||
this.selfSubscribers.push(subscriber)
|
||||
|
||||
return () => {
|
||||
this.selfSubscribers.splice(this.selfSubscribers.indexOf(subscriber), 1)
|
||||
}
|
||||
}
|
||||
|
||||
derived = (key: string) => {
|
||||
return readable(this.get(key), set => {
|
||||
const subscribers = [
|
||||
this.onClear(() => set(undefined)),
|
||||
this.onItem(set)
|
||||
]
|
||||
|
||||
return () => subscribers.forEach(call)
|
||||
})
|
||||
}
|
||||
|
||||
// EventEmitter-like methods
|
||||
|
||||
private emitClear = () => {
|
||||
for (const subscriber of this.clearSubscribers) {
|
||||
subscriber(this)
|
||||
}
|
||||
|
||||
this.notify()
|
||||
}
|
||||
|
||||
onClear = (subscriber: Subscriber<T>) => {
|
||||
this.clearSubscribers.push(subscriber)
|
||||
|
||||
return () => {
|
||||
this.clearSubscribers.splice(this.clearSubscribers.indexOf(subscriber), 1)
|
||||
}
|
||||
}
|
||||
|
||||
private emitItem = (item: T) => {
|
||||
for (const subscriber of this.itemSubscribers) {
|
||||
subscriber(item)
|
||||
}
|
||||
|
||||
this.notify()
|
||||
}
|
||||
|
||||
onItem = (subscriber: Subscriber<T>) => {
|
||||
this.itemSubscribers.push(subscriber)
|
||||
|
||||
return () => {
|
||||
this.itemSubscribers.splice(this.itemSubscribers.indexOf(subscriber), 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Map-like methods
|
||||
|
||||
clear = () => {
|
||||
this.data.clear()
|
||||
this.emitClear()
|
||||
}
|
||||
|
||||
delete = (key: string) => {
|
||||
this.data.delete(key)
|
||||
this.emitItem(key, undefined)
|
||||
}
|
||||
|
||||
set = (key: string, value: T) => {
|
||||
this.data.set(key, value)
|
||||
this.emitItem(key, value)
|
||||
}
|
||||
|
||||
get = (key: string) => this.data.get(key)
|
||||
values = () => this.data.values()
|
||||
items = () => this.data.items()
|
||||
keys = () => this.data.keys()
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./thunk.ts"
|
||||
export * from "./client.ts"
|
||||
export * from "./relays.ts"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {chunk, first} from "@welshman/lib"
|
||||
import {
|
||||
RELAYS,
|
||||
asDecryptedEvent,
|
||||
readList,
|
||||
TrustedEvent,
|
||||
sortEventsDesc,
|
||||
getRelaysFromList,
|
||||
RelayMode,
|
||||
Filter,
|
||||
isPlainReplaceableKind,
|
||||
} from "@welshman/util"
|
||||
import {
|
||||
deriveItemsByKey,
|
||||
deriveItems,
|
||||
makeForceLoadItem,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
getter,
|
||||
} from "@welshman/store"
|
||||
import {load} from "@welshman/net"
|
||||
import {Router, addMinimalFallbacks} from "@welshman/router"
|
||||
import type {Client} from './client.ts'
|
||||
|
||||
export class RelayLists extends ClientData<RelayStatsItem> {
|
||||
constructor(readonly client: Client) {}
|
||||
|
||||
fetch = async (pubkey: string, relayHints: string[] = []) => {
|
||||
const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}]
|
||||
|
||||
await Promise.all([
|
||||
this.client.load({filters, relays: Router.get().FromRelays(relayHints).getUrls()}),
|
||||
this.client.load({filters, relays: Router.get().FromPubkey(pubkey).getUrls()}),
|
||||
this.client.load({filters, relays: Router.get().Index().getUrls()}),
|
||||
])
|
||||
}
|
||||
|
||||
export const relayListsByPubkey = deriveItemsByKey({
|
||||
repository,
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
filters: [{kinds: [RELAYS]}],
|
||||
getKey: relayList => relayList.event.pubkey,
|
||||
})
|
||||
|
||||
export const relayLists = deriveItems(relayListsByPubkey)
|
||||
|
||||
export const getRelayListsByPubkey = getter(relayListsByPubkey)
|
||||
|
||||
export const getRelayLists = getter(relayLists)
|
||||
|
||||
export const getRelayList = (pubkey: string) => getRelayListsByPubkey().get(pubkey)
|
||||
|
||||
export const forceLoadRelayList = makeForceLoadItem(fetchRelayList, getRelayList)
|
||||
|
||||
export const loadRelayList = makeLoadItem(fetchRelayList, getRelayList)
|
||||
|
||||
export const deriveRelayList = makeDeriveItem(relayListsByPubkey, loadRelayList)
|
||||
|
||||
// Outbox loader
|
||||
|
||||
export const loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => {
|
||||
const filters = [{...filter, kinds: [kind], authors: [pubkey]}]
|
||||
const writeRelays = getRelaysFromList(await loadRelayList(pubkey), RelayMode.Write)
|
||||
const allRelays = Router.get()
|
||||
.FromRelays(writeRelays)
|
||||
.policy(addMinimalFallbacks)
|
||||
.limit(8)
|
||||
.getUrls()
|
||||
|
||||
if (isPlainReplaceableKind(kind)) {
|
||||
filters[0].limit = 1
|
||||
}
|
||||
|
||||
for (const relays of chunk(2, allRelays)) {
|
||||
const events = await load({filters, relays})
|
||||
|
||||
if (events.length > 0) {
|
||||
return first(sortEventsDesc(events))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const makeOutboxLoader =
|
||||
(kind: number, filter: Filter = {}, limit = 1) =>
|
||||
async (pubkey: string, relayHints: string[] = []) => {
|
||||
const filters = [{...filter, kinds: [kind], authors: [pubkey]}]
|
||||
const relays = Router.get().FromRelays(relayHints).getUrls()
|
||||
|
||||
await Promise.all([load({filters, relays}), loadUsingOutbox(kind, pubkey, filter)])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import {writable, Subscriber} from "svelte/store"
|
||||
import {getter, makeDeriveItem} from "@welshman/store"
|
||||
import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
|
||||
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl, getRelaysFromList} from "@welshman/util"
|
||||
import {Socket, SocketStatus, SocketEvent, ClientMessage, RelayMessage} from "@welshman/net"
|
||||
import {getBlockedRelayList} from "./blockedRelayLists.js"
|
||||
import type {Client} from "./client.js"
|
||||
|
||||
|
||||
export type RelayStatsUpdate = [string, (stats: RelayStatsItem) => void]
|
||||
|
||||
export type RelayStatsItem = {
|
||||
url: string
|
||||
first_seen: number
|
||||
recent_errors: number[]
|
||||
open_count: number
|
||||
close_count: number
|
||||
publish_count: number
|
||||
request_count: number
|
||||
event_count: number
|
||||
last_open: number
|
||||
last_close: number
|
||||
last_error: number
|
||||
last_publish: number
|
||||
last_request: number
|
||||
last_event: number
|
||||
last_auth: number
|
||||
publish_success_count: number
|
||||
publish_failure_count: number
|
||||
eose_count: number
|
||||
notice_count: number
|
||||
}
|
||||
|
||||
export const makeRelayStatsItem = (url: string): RelayStatsItem => ({
|
||||
url,
|
||||
first_seen: now(),
|
||||
recent_errors: [],
|
||||
open_count: 0,
|
||||
close_count: 0,
|
||||
publish_count: 0,
|
||||
request_count: 0,
|
||||
event_count: 0,
|
||||
last_open: 0,
|
||||
last_close: 0,
|
||||
last_error: 0,
|
||||
last_publish: 0,
|
||||
last_request: 0,
|
||||
last_event: 0,
|
||||
last_auth: 0,
|
||||
publish_success_count: 0,
|
||||
publish_failure_count: 0,
|
||||
eose_count: 0,
|
||||
notice_count: 0,
|
||||
})
|
||||
|
||||
export class RelayStats extends ClientData<RelayStatsItem> {
|
||||
cleanup: Unsubscriber
|
||||
|
||||
constructor(readonly client: Client) {
|
||||
this.cleanup = client.pool.subscribe(socket => {
|
||||
socket.on(SocketEvent.Send, this.onSocketSend)
|
||||
socket.on(SocketEvent.Receive, this.onSocketReceive)
|
||||
socket.on(SocketEvent.Status, this.onSocketStatus)
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvent.Send, this.onSocketSend)
|
||||
socket.off(SocketEvent.Receive, this.onSocketReceive)
|
||||
socket.off(SocketEvent.Status, this.onSocketStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Utilities for syncing stats from connections to relays
|
||||
|
||||
private updateRelayStats = batch(150, (batched: RelayStatsUpdate[]) => {
|
||||
for (const [url, updates] of groupBy(([url]) => url, batched)) {
|
||||
const prev = this.get(url)
|
||||
const next = prev ? {...prev} : makeRelayStatsItem(url)
|
||||
|
||||
for (const [_, update] of updates) {
|
||||
update(next)
|
||||
}
|
||||
|
||||
this.set(url, next)
|
||||
}
|
||||
})
|
||||
|
||||
private onSocketSend = ([verb]: ClientMessage, url: string) => {
|
||||
if (verb === "REQ") {
|
||||
this.updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.request_count++
|
||||
stats.last_request = now()
|
||||
},
|
||||
])
|
||||
} else if (verb === "EVENT") {
|
||||
this.updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.publish_count++
|
||||
stats.last_publish = now()
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
|
||||
if (verb === "OK") {
|
||||
const [_, ok] = extra
|
||||
|
||||
this.updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
if (ok) {
|
||||
stats.publish_success_count++
|
||||
} else {
|
||||
stats.publish_failure_count++
|
||||
}
|
||||
},
|
||||
])
|
||||
} else if (verb === "AUTH") {
|
||||
this.updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.last_auth = now()
|
||||
},
|
||||
])
|
||||
} else if (verb === "EVENT") {
|
||||
this.updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.event_count++
|
||||
stats.last_event = now()
|
||||
},
|
||||
])
|
||||
} else if (verb === "EOSE") {
|
||||
this.updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.eose_count++
|
||||
},
|
||||
])
|
||||
} else if (verb === "NOTICE") {
|
||||
this.updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.notice_count++
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private onSocketStatus = (status: string, url: string) => {
|
||||
if (status === SocketStatus.Open) {
|
||||
this.updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.last_open = now()
|
||||
stats.open_count++
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (status === SocketStatus.Closed) {
|
||||
this.updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.last_close = now()
|
||||
stats.close_count++
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (status === SocketStatus.Error) {
|
||||
this.updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.last_error = now()
|
||||
stats.recent_errors = uniq(stats.recent_errors.concat(now())).slice(-10)
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import {
|
||||
nth,
|
||||
uniq,
|
||||
intersection,
|
||||
mergeLeft,
|
||||
first,
|
||||
clamp,
|
||||
sortBy,
|
||||
shuffle,
|
||||
pushToMapKey,
|
||||
inc,
|
||||
add,
|
||||
take,
|
||||
chunks,
|
||||
} from "@welshman/lib"
|
||||
import {
|
||||
getFilterId,
|
||||
isRelayUrl,
|
||||
isOnionUrl,
|
||||
isLocalUrl,
|
||||
isShareableRelayUrl,
|
||||
PROFILE,
|
||||
RELAYS,
|
||||
MESSAGING_RELAYS,
|
||||
FOLLOWS,
|
||||
WRAP,
|
||||
getPubkeyTagValues,
|
||||
normalizeRelayUrl,
|
||||
TrustedEvent,
|
||||
Filter,
|
||||
readList,
|
||||
getAncestorTags,
|
||||
asDecryptedEvent,
|
||||
getRelaysFromList,
|
||||
getPubkeyTags,
|
||||
RelayMode,
|
||||
} from "@welshman/util"
|
||||
import {Repository} from "@welshman/net"
|
||||
|
||||
export type RelaysAndFilters = {
|
||||
relays: string[]
|
||||
filters: Filter[]
|
||||
}
|
||||
|
||||
export type RouterOptions = {
|
||||
/**
|
||||
* Retrieves default relays, for use as fallbacks when no other relays can be selected.
|
||||
* @returns An array of relay URLs as strings.
|
||||
*/
|
||||
getDefaultRelays?: () => string[]
|
||||
|
||||
/**
|
||||
* Retrieves relays that index profiles and relay selections.
|
||||
* @returns An array of relay URLs as strings.
|
||||
*/
|
||||
getIndexerRelays?: () => string[]
|
||||
|
||||
/**
|
||||
* Retrieves relays likely to support NIP-50 search.
|
||||
* @returns An array of relay URLs as strings.
|
||||
*/
|
||||
getSearchRelays?: () => string[]
|
||||
|
||||
/**
|
||||
* Retrieves the limit setting, which is the maximum number of relays that should be
|
||||
* returned from getUrls and getSelections.
|
||||
* @returns The limit setting as a number.
|
||||
*/
|
||||
getLimit?: () => number
|
||||
}
|
||||
|
||||
export type Selection = {
|
||||
weight: number
|
||||
relays: string[]
|
||||
}
|
||||
|
||||
export const makeSelection = (relays: string[], weight = 1): Selection => ({
|
||||
relays: relays.filter(isRelayUrl).map(normalizeRelayUrl),
|
||||
weight,
|
||||
})
|
||||
|
||||
// Fallback policies
|
||||
|
||||
export type FallbackPolicy = (count: number, limit: number) => number
|
||||
|
||||
export const addNoFallbacks = (count: number, limit: number) => 0
|
||||
|
||||
export const addMinimalFallbacks = (count: number, limit: number) => (count > 0 ? 0 : 1)
|
||||
|
||||
export const addMaximalFallbacks = (count: number, limit: number) => limit - count
|
||||
|
||||
// Router class
|
||||
|
||||
export class Router {
|
||||
constructor(readonly client: Client, readonly options: RouterOptions) {}
|
||||
|
||||
// Utilities derived from options
|
||||
|
||||
getRelaysForPubkey = (pubkey: string, mode?: RelayMode) =>
|
||||
getRelaysFromList(this.client.relayList.get(pubkey), mode)
|
||||
|
||||
getRelaysForPubkeys = (pubkeys: string[], mode?: RelayMode) =>
|
||||
pubkeys.map(pubkey => this.getRelaysForPubkey(pubkey, mode))
|
||||
|
||||
getRelaysForUser = (mode?: RelayMode) =>
|
||||
this.getRelaysForPubkey(this.client.pubkey, mode)
|
||||
|
||||
// Utilities for creating scenarios
|
||||
|
||||
scenario = (selections: Selection[]) => new RouterScenario(this, selections)
|
||||
|
||||
merge = (scenarios: RouterScenario[]) =>
|
||||
this.scenario(scenarios.flatMap((scenario: RouterScenario) => scenario.selections))
|
||||
|
||||
// Routing scenarios
|
||||
|
||||
FromRelays = (relays: string[]) => this.scenario([makeSelection(relays)])
|
||||
|
||||
Search = () => this.FromRelays(this.options.getSearchRelays?.() || [])
|
||||
|
||||
Index = () => this.FromRelays(this.options.getIndexerRelays?.() || [])
|
||||
|
||||
Default = () => this.FromRelays(this.options.getDefaultRelays?.() || [])
|
||||
|
||||
ForUser = () => this.FromRelays(this.getRelaysForUser(RelayMode.Read))
|
||||
|
||||
FromUser = () => this.FromRelays(this.getRelaysForUser(RelayMode.Write))
|
||||
|
||||
MessagesForUser = () => this.FromRelays(this.getRelaysForUser(RelayMode.Messaging))
|
||||
|
||||
ForPubkey = (pubkey: string) => this.FromRelays(this.getRelaysForPubkey(pubkey, RelayMode.Read))
|
||||
|
||||
FromPubkey = (pubkey: string) => this.FromRelays(this.getRelaysForPubkey(pubkey, RelayMode.Write))
|
||||
|
||||
MessagesForPubkey = (pubkey: string) =>
|
||||
this.FromRelays(this.getRelaysForPubkey(pubkey, RelayMode.Messaging))
|
||||
|
||||
ForPubkeys = (pubkeys: string[]) => this.merge(pubkeys.map(pubkey => this.ForPubkey(pubkey)))
|
||||
|
||||
FromPubkeys = (pubkeys: string[]) => this.merge(pubkeys.map(pubkey => this.FromPubkey(pubkey)))
|
||||
|
||||
MessagesForPubkeys = (pubkeys: string[]) =>
|
||||
this.merge(pubkeys.map(pubkey => this.MessagesForPubkey(pubkey)))
|
||||
|
||||
Event = (event: TrustedEvent) =>
|
||||
this.FromRelays(this.getRelaysForPubkey(event.pubkey, RelayMode.Write))
|
||||
|
||||
Replies = (event: TrustedEvent) =>
|
||||
this.FromRelays(this.getRelaysForPubkey(event.pubkey, RelayMode.Read))
|
||||
|
||||
Quote = (event: TrustedEvent, value: string, relays: string[] = []) => {
|
||||
const tag = event.tags.find(t => t[1] === value)
|
||||
const scenarios = [
|
||||
this.FromRelays(relays),
|
||||
this.ForPubkey(event.pubkey),
|
||||
this.FromPubkey(event.pubkey),
|
||||
]
|
||||
|
||||
if (tag?.[2] && isShareableRelayUrl(tag[2])) {
|
||||
scenarios.push(this.FromRelays([tag[2]]))
|
||||
}
|
||||
|
||||
if (tag?.[3]?.length === 64) {
|
||||
scenarios.push(this.FromPubkeys([tag[3]]))
|
||||
}
|
||||
|
||||
return this.merge(scenarios)
|
||||
}
|
||||
|
||||
EventParents = (event: TrustedEvent) => {
|
||||
const {replies} = getAncestorTags(event)
|
||||
const mentions = getPubkeyTags(event.tags)
|
||||
const authors = replies.map(nth(3)).filter(p => p?.length === 64)
|
||||
const others = mentions.map(nth(1)).filter(p => p?.length === 64)
|
||||
const relays = uniq([...replies, ...mentions].map(nth(2)).filter(r => r && isRelayUrl(r)))
|
||||
|
||||
return this.merge([
|
||||
this.FromPubkeys(authors).weight(10),
|
||||
this.FromPubkeys(others),
|
||||
this.FromRelays(relays),
|
||||
])
|
||||
}
|
||||
|
||||
EventRoots = (event: TrustedEvent) => {
|
||||
const {roots} = getAncestorTags(event)
|
||||
const mentions = getPubkeyTags(event.tags)
|
||||
const authors = roots.map(nth(3)).filter(p => p?.length === 64)
|
||||
const others = mentions.map(nth(1)).filter(p => p?.length === 64)
|
||||
const relays = uniq([...roots, ...mentions].map(nth(2)).filter(r => r && isRelayUrl(r)))
|
||||
|
||||
return this.merge([
|
||||
this.FromPubkeys(authors).weight(10),
|
||||
this.FromPubkeys(others),
|
||||
this.FromRelays(relays),
|
||||
])
|
||||
}
|
||||
|
||||
PublishEvent = (event: TrustedEvent) => {
|
||||
const pubkeys = getPubkeyTagValues(event.tags)
|
||||
const scenarios = [
|
||||
this.FromPubkey(event.pubkey),
|
||||
...pubkeys.map(pubkey => this.ForPubkey(pubkey).weight(0.5)),
|
||||
]
|
||||
|
||||
// Override the limit to ensure deliverability even when lots of pubkeys are mentioned
|
||||
return this.merge(scenarios).limit(30)
|
||||
}
|
||||
}
|
||||
|
||||
// Router Scenario
|
||||
|
||||
export type RouterScenarioOptions = {
|
||||
policy?: FallbackPolicy
|
||||
limit?: number
|
||||
allowLocal?: boolean
|
||||
allowOnion?: boolean
|
||||
allowInsecure?: boolean
|
||||
}
|
||||
|
||||
export class RouterScenario {
|
||||
constructor(
|
||||
readonly router: Router,
|
||||
readonly selections: Selection[],
|
||||
readonly options: RouterScenarioOptions = {},
|
||||
) {}
|
||||
|
||||
clone = (options: RouterScenarioOptions) =>
|
||||
new RouterScenario(this.router, this.selections, {...this.options, ...options})
|
||||
|
||||
filter = (f: (selection: Selection) => boolean) =>
|
||||
new RouterScenario(
|
||||
this.router,
|
||||
this.selections.filter(selection => f(selection)),
|
||||
this.options,
|
||||
)
|
||||
|
||||
update = (f: (selection: Selection) => Selection) =>
|
||||
new RouterScenario(
|
||||
this.router,
|
||||
this.selections.map(selection => f(selection)),
|
||||
this.options,
|
||||
)
|
||||
|
||||
policy = (policy: FallbackPolicy) => this.clone({policy})
|
||||
|
||||
limit = (limit: number) => this.clone({limit})
|
||||
|
||||
allowLocal = (allowLocal: boolean) => this.clone({allowLocal})
|
||||
|
||||
allowOnion = (allowOnion: boolean) => this.clone({allowOnion})
|
||||
|
||||
allowInsecure = (allowInsecure: boolean) => this.clone({allowInsecure})
|
||||
|
||||
weight = (scale: number) =>
|
||||
this.update(selection => ({...selection, weight: selection.weight * scale}))
|
||||
|
||||
getPolicy = () => this.options.policy || addNoFallbacks
|
||||
|
||||
getLimit = () => this.options.limit || this.router.options.getLimit?.() || 3
|
||||
|
||||
getUrls = () => {
|
||||
const limit = this.getLimit()
|
||||
const fallbackPolicy = this.getPolicy()
|
||||
const relayWeights = new Map<string, number>()
|
||||
const {allowOnion, allowLocal, allowInsecure} = this.options
|
||||
|
||||
for (const {weight, relays} of this.selections) {
|
||||
for (const relay of relays) {
|
||||
if (!isRelayUrl(relay)) continue
|
||||
if (!allowOnion && isOnionUrl(relay)) continue
|
||||
if (!allowLocal && isLocalUrl(relay)) continue
|
||||
if (!allowInsecure && relay.startsWith("ws://") && !isOnionUrl(relay)) continue
|
||||
|
||||
relayWeights.set(relay, add(weight, relayWeights.get(relay)))
|
||||
}
|
||||
}
|
||||
|
||||
const scoreRelay = (relay: string) => {
|
||||
const weight = relayWeights.get(relay)!
|
||||
const quality = this.router.client.getRelayQuality(relay)
|
||||
|
||||
// Log the weight, since it's a straight count which ends up over-weighting hubs.
|
||||
// Also add some random noise so that we'll occasionally pick lower quality/less
|
||||
// popular relays.
|
||||
return -(quality * inc(Math.log(weight)) * Math.random())
|
||||
}
|
||||
|
||||
const relays = take(
|
||||
limit,
|
||||
sortBy(scoreRelay, Array.from(relayWeights.keys()).filter(scoreRelay)),
|
||||
)
|
||||
|
||||
const fallbacksNeeded = fallbackPolicy(relays.length, limit)
|
||||
const allFallbackRelays: string[] = this.router.options.getDefaultRelays?.() || []
|
||||
const fallbackRelays = shuffle(allFallbackRelays).slice(0, fallbacksNeeded)
|
||||
|
||||
for (const fallbackRelay of fallbackRelays) {
|
||||
relays.push(fallbackRelay)
|
||||
}
|
||||
|
||||
return relays
|
||||
}
|
||||
|
||||
getUrl = () => first(this.getUrls())
|
||||
}
|
||||
@@ -13,11 +13,14 @@ import {
|
||||
} from "@welshman/util"
|
||||
import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net"
|
||||
import {Nip01Signer, Nip59} from "@welshman/signer"
|
||||
import type {Client} from './client.js'
|
||||
import type {User} from './user.js'
|
||||
|
||||
export type ThunkOptions = Override<
|
||||
PublishOptions,
|
||||
{
|
||||
user: User
|
||||
client: Client
|
||||
event: EventTemplate
|
||||
recipient?: string
|
||||
delay?: number
|
||||
@@ -108,7 +111,7 @@ export class Thunk {
|
||||
}
|
||||
|
||||
// Send it off
|
||||
await this.user.publish({
|
||||
await this.options.client.publish({
|
||||
...this.options,
|
||||
event,
|
||||
onSuccess: (result: PublishResult) => {
|
||||
@@ -126,7 +129,7 @@ export class Thunk {
|
||||
onAborted: this._setAborted,
|
||||
onComplete: (result: PublishResult) => {
|
||||
if (result.status !== PublishStatus.Success) {
|
||||
this.options.user.tracker.removeRelay(event.id, result.relay)
|
||||
this.options.client.tracker.removeRelay(event.id, result.relay)
|
||||
}
|
||||
|
||||
this.options.onComplete?.(result)
|
||||
@@ -158,7 +161,7 @@ export class Thunk {
|
||||
})
|
||||
}
|
||||
|
||||
this.user.wrapManager.add({recipient, wrap: this.wrap, rumor: this.event})
|
||||
this.options.client.wrapManager.add({recipient, wrap: this.wrap, rumor: this.event})
|
||||
|
||||
return this._publish(this.wrap)
|
||||
}
|
||||
@@ -186,13 +189,13 @@ export class Thunk {
|
||||
// Update tracker and repository with the signed event since the id will have changed
|
||||
if (this.options.pow) {
|
||||
for (const url of this.options.relays) {
|
||||
this.options.user.tracker.removeRelay(this.event.id, url)
|
||||
this.options.user.tracker.track(signedEvent.id, url)
|
||||
this.options.client.tracker.removeRelay(this.event.id, url)
|
||||
this.options.client.tracker.track(signedEvent.id, url)
|
||||
}
|
||||
}
|
||||
|
||||
this.options.user.repository.removeEvent(this.event.id)
|
||||
this.options.user.repository.publish(signedEvent)
|
||||
this.options.client.repository.removeEvent(this.event.id)
|
||||
this.options.client.repository.publish(signedEvent)
|
||||
|
||||
return this._publish(signedEvent)
|
||||
} catch (e: any) {
|
||||
@@ -205,17 +208,17 @@ export class Thunk {
|
||||
thunkQueue.push(this)
|
||||
|
||||
for (const url of this.options.relays) {
|
||||
this.options.user.tracker.track(this.event.id, url)
|
||||
this.options.client.tracker.track(this.event.id, url)
|
||||
}
|
||||
|
||||
this.options.user.repository.publish(this.event)
|
||||
this.options.client.repository.publish(this.event)
|
||||
thunks.update($thunks => append(this, $thunks))
|
||||
|
||||
this.controller.signal.addEventListener("abort", () => {
|
||||
if (this.wrap) {
|
||||
this.user.wrapManager.remove(this.wrap.id)
|
||||
this.options.client.wrapManager.remove(this.wrap.id)
|
||||
} else {
|
||||
this.options.user.repository.removeEvent(this.event.id)
|
||||
this.options.client.repository.removeEvent(this.event.id)
|
||||
}
|
||||
|
||||
thunks.update($thunks => remove(this, $thunks))
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import {Maybe} from '@welshman/lib'
|
||||
import {Repository, AdapterFactory, NetContext, WrapManager, DiffOptions, PullOptions, PushOptions, RequestOptions, PublishOptions, LoaderOptions, Tracker, Pool, push, pull, diff, publish, request, makeLoader} from '@welshman/net'
|
||||
import {ISigner} from '@welshman/signer'
|
||||
|
||||
export type UserOptions = {
|
||||
shouldAuth?: (socket: Socket) => boolean
|
||||
}
|
||||
|
||||
export class User {
|
||||
constructor(
|
||||
readonly pubkey: string,
|
||||
readonly signer: ISigner,
|
||||
readonly options: UserOptions
|
||||
) {}
|
||||
|
||||
static async fromSigner(signer: ISigner, options: UserOptions) {
|
||||
const pubkey = await signer.getPubkey()
|
||||
|
||||
return new User(pubkey, signer, options)
|
||||
}
|
||||
|
||||
makeSocketPolicyAuth = () =>
|
||||
makeSocketPolicyAuth({
|
||||
sign: this.signer.sign,
|
||||
shouldAuth: this.options.shouldAuth,
|
||||
})
|
||||
}
|
||||
@@ -71,9 +71,7 @@ export class Repository extends Emitter {
|
||||
return Array.from(this.eventsById.values())
|
||||
}
|
||||
|
||||
load = (events: TrustedEvent[]) => {
|
||||
const stale = new Set(this.eventsById.keys())
|
||||
|
||||
clear = () => {
|
||||
this.eventsById.clear()
|
||||
this.eventsByAddress.clear()
|
||||
this.eventsByTag.clear()
|
||||
@@ -82,6 +80,13 @@ export class Repository extends Emitter {
|
||||
this.eventsByKind.clear()
|
||||
this.deletes.clear()
|
||||
this.expired.clear()
|
||||
this.emit("clear")
|
||||
}
|
||||
|
||||
load = (events: TrustedEvent[]) => {
|
||||
const stale = new Set(this.eventsById.keys())
|
||||
|
||||
this.clear()
|
||||
|
||||
const added = []
|
||||
|
||||
|
||||
@@ -41,10 +41,15 @@ export class WrapManager extends Emitter {
|
||||
|
||||
// Adding/importing
|
||||
|
||||
load = (wrapItems: WrapItem[]) => {
|
||||
clear = () => {
|
||||
this._wrapIndex.clear()
|
||||
this._rumorIndex.clear()
|
||||
this._recipientIndex.clear()
|
||||
this.emit("load")
|
||||
}
|
||||
|
||||
load = (wrapItems: WrapItem[]) => {
|
||||
this.clear()
|
||||
|
||||
for (const wrapItem of wrapItems) {
|
||||
this._add(wrapItem)
|
||||
|
||||
Reference in New Issue
Block a user