Split router out into its own library

This commit is contained in:
Jon Staab
2025-04-23 13:34:04 -07:00
parent 489a307a47
commit 2996e25359
42 changed files with 604 additions and 295 deletions
+6 -1
View File
@@ -10,7 +10,12 @@ export type CachedLoaderOptions<T> = {
subscribers?: Subscriber<T>[]
}
export const makeCachedLoader = <T>({name, load, indexStore, subscribers = []}: CachedLoaderOptions<T>) => {
export const makeCachedLoader = <T>({
name,
load,
indexStore,
subscribers = [],
}: CachedLoaderOptions<T>) => {
const pending = new Map<string, Promise<T | void>>()
const loadAttempts = new Map<string, number>()
+1 -1
View File
@@ -1,9 +1,9 @@
import {get} from "svelte/store"
import {addToListPublicly, removeFromList, makeList, FOLLOWS, MUTES, PINS} from "@welshman/util"
import {Router, addMaximalFallbacks} from "@welshman/router"
import {userFollows, userMutes, userPins} from "./user.js"
import {nip44EncryptToSelf} from "./session.js"
import {publishThunk} from "./thunk.js"
import {Router, addMaximalFallbacks} from "./router.js"
export const unfollow = async (value: string) => {
const list = get(userFollows) || makeList({kind: FOLLOWS})
+2 -20
View File
@@ -1,32 +1,14 @@
import {throttle} from "@welshman/lib"
import {verifyEvent, isEphemeralKind, isDVMKind} from "@welshman/util"
import {Repository, LocalRelay} from "@welshman/relay"
import {Pool, Tracker, SocketEvent, isRelayEvent} from "@welshman/net"
import {custom} from "@welshman/store"
import {loadRelay, trackRelayStats} from "./relays.js"
import {Tracker} from "@welshman/net"
export const repository = Repository.getSingleton()
export const repository = Repository.get()
export const relay = new LocalRelay(repository)
export const tracker = new Tracker()
Pool.getSingleton().subscribe(socket => {
loadRelay(socket.url)
trackRelayStats(socket)
socket.on(SocketEvent.Receive, message => {
if (isRelayEvent(message)) {
const event = message[2]
if (!isEphemeralKind(event.kind) && !isDVMKind(event.kind) && verifyEvent(event)) {
tracker.track(event.id, socket.url)
repository.publish(event)
}
}
})
})
// Adapt above objects to stores
export const makeRepositoryStore = ({throttle: t = 300}: {throttle?: number} = {}) =>
+3 -3
View File
@@ -2,10 +2,10 @@ import {nthEq, partition, race, now} from "@welshman/lib"
import {createEvent, getPubkeyTagValues, TrustedEvent} from "@welshman/util"
import {request, Tracker} from "@welshman/net"
import {Scope, FeedController, RequestOpts, FeedOptions, DVMOpts, Feed} from "@welshman/feeds"
import {Router, addMinimalFallbacks, getFilterSelections} from "@welshman/router"
import {makeDvmRequest} from "@welshman/dvm"
import {makeSecret, Nip01Signer} from "@welshman/signer"
import {pubkey, signer} from "./session.js"
import {Router, addMinimalFallbacks, getFilterSelections} from "./router.js"
import {loadRelaySelections} from "./relaySelections.js"
import {wotGraph, maxWot, getFollows, getNetwork, getFollowers} from "./wot.js"
import {repository} from "./core.js"
@@ -14,10 +14,10 @@ export type FeedRequestHandlerOptions = {
signal?: AbortSignal
}
export const makeFeedRequestHandler = ({signal}: FeedRequestHandlerOptions) =>
export const makeFeedRequestHandler =
({signal}: FeedRequestHandlerOptions) =>
async ({filters = [{}], relays = [], onEvent}: RequestOpts) => {
const tracker = new Tracker()
const requestOptions = {}
if (relays.length > 0) {
await request({tracker, signal, relays, filters, onEvent, autoClose: true})
+1 -1
View File
@@ -19,5 +19,5 @@ export const {
name: "follows",
store: follows,
getKey: follows => follows.event.pubkey,
load: makeOutboxLoader(FOLLOWS)
load: makeOutboxLoader(FOLLOWS),
})
+51 -1
View File
@@ -12,7 +12,6 @@ export * from "./profiles.js"
export * from "./pins.js"
export * from "./relays.js"
export * from "./relaySelections.js"
export * from "./router.js"
export * from "./search.js"
export * from "./session.js"
export * from "./storage.js"
@@ -25,3 +24,54 @@ export * from "./user.js"
export * from "./util.js"
export * from "./wot.js"
export * from "./zappers.js"
import {sortBy, throttleWithValue, tryCatch} from "@welshman/lib"
import {verifyEvent, isEphemeralKind, isDVMKind} from "@welshman/util"
import {routerContext} from "@welshman/router"
import {Pool, SocketEvent, isRelayEvent} from "@welshman/net"
import {pubkey} from "./session.js"
import {repository, tracker} from "./core.js"
import {getPubkeyRelays} from "./relaySelections.js"
import {Relay, relays, loadRelay, trackRelayStats, getRelayQuality} from "./relays.js"
// Sync relays with our database
Pool.get().subscribe(socket => {
loadRelay(socket.url)
trackRelayStats(socket)
socket.on(SocketEvent.Receive, message => {
if (isRelayEvent(message)) {
const event = message[2]
if (!isEphemeralKind(event.kind) && !isDVMKind(event.kind) && verifyEvent(event)) {
tracker.track(event.id, socket.url)
repository.publish(event)
}
}
})
})
// Configure the router
const _relayGetter = (fn?: (relay: Relay) => any) =>
throttleWithValue(200, () => {
let _relays = relays.get()
if (fn) {
_relays = _relays.filter(fn)
}
return sortBy(r => -getRelayQuality(r.url), _relays)
.slice(0, 5)
.map(r => r.url)
})
routerContext.getUserPubkey = () => pubkey.get()
routerContext.getPubkeyRelays = getPubkeyRelays
routerContext.getRelayQuality = getRelayQuality
routerContext.getDefaultRelays = _relayGetter()
routerContext.getIndexerRelays = _relayGetter()
routerContext.getSearchRelays = _relayGetter(r =>
tryCatch(() => r.profile?.supported_nips?.includes(50)),
)
+1 -1
View File
@@ -25,5 +25,5 @@ export const {
name: "mutes",
store: mutes,
getKey: mute => mute.event.pubkey,
load: makeOutboxLoader(MUTES)
load: makeOutboxLoader(MUTES),
})
+1 -1
View File
@@ -19,5 +19,5 @@ export const {
name: "pins",
store: pins,
getKey: pins => pins.event.pubkey,
load: makeOutboxLoader(PINS)
load: makeOutboxLoader(PINS),
})
+3 -1
View File
@@ -30,5 +30,7 @@ export const displayProfileByPubkey = (pubkey: string | undefined) =>
export const deriveProfileDisplay = (pubkey: string | undefined, relays: string[] = []) =>
pubkey
? derived(deriveProfile(pubkey, relays), $profile => displayProfile($profile, displayPubkey(pubkey)))
? derived(deriveProfile(pubkey, relays), $profile =>
displayProfile($profile, displayPubkey(pubkey)),
)
: readable("")
+21 -6
View File
@@ -9,11 +9,11 @@ import {
getRelayTags,
getRelayTagValues,
} from "@welshman/util"
import {TrustedEvent, Filter, PublishedList, List} from "@welshman/util"
import {request, load} from "@welshman/net"
import {TrustedEvent, PublishedList, List} from "@welshman/util"
import {request} from "@welshman/net"
import {deriveEventsMapped} from "@welshman/store"
import {Router, RelayMode} from "@welshman/router"
import {repository} from "./core.js"
import {Router} from "./router.js"
import {collection} from "./collection.js"
export const getRelayUrls = (list?: List): string[] =>
@@ -33,7 +33,6 @@ export const getWriteRelayUrls = (list?: List): string[] =>
.map((t: string[]) => normalizeRelayUrl(t[1])),
)
export type OutboxLoaderRequest = {
pubkey: string
relays: string[]
@@ -59,8 +58,8 @@ export const loadUsingOutbox = batcher(200, (requests: OutboxLoaderRequest[]) =>
return requests.map(always(promise))
})
export const makeOutboxLoader = (kind: number) =>
(pubkey: string, relays: string[]) => loadUsingOutbox({pubkey, relays, kind})
export const makeOutboxLoader = (kind: number) => (pubkey: string, relays: string[]) =>
loadUsingOutbox({pubkey, relays, kind})
export const relaySelections = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [RELAYS]}],
@@ -79,6 +78,22 @@ export const {
load: makeOutboxLoader(RELAYS),
})
export const getPubkeyRelays = (pubkey: string, mode?: string) => {
const $relaySelections = relaySelectionsByPubkey.get()
const $inboxSelections = inboxRelaySelectionsByPubkey.get()
switch (mode) {
case RelayMode.Read:
return getReadRelayUrls($relaySelections.get(pubkey))
case RelayMode.Write:
return getWriteRelayUrls($relaySelections.get(pubkey))
case RelayMode.Inbox:
return getRelayUrls($inboxSelections.get(pubkey))
default:
return getRelayUrls($relaySelections.get(pubkey))
}
}
export const inboxRelaySelections = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [INBOX_RELAYS]}],
itemToEvent: item => item.event,
+51 -3
View File
@@ -1,9 +1,29 @@
import {writable, derived} from "svelte/store"
import {withGetter} from "@welshman/store"
import {groupBy, indexBy, batch, now, uniq, batcher, postJson} from "@welshman/lib"
import {
groupBy,
indexBy,
batch,
now,
uniq,
batcher,
postJson,
ago,
DAY,
HOUR,
MINUTE,
} from "@welshman/lib"
import {RelayProfile} from "@welshman/util"
import {normalizeRelayUrl, displayRelayUrl, displayRelayProfile} from "@welshman/util"
import {Socket, SocketStatus, SocketEvent, ClientMessage, RelayMessage} from "@welshman/net"
import {
normalizeRelayUrl,
displayRelayUrl,
displayRelayProfile,
isOnionUrl,
isLocalUrl,
isIPAddress,
isRelayUrl,
} from "@welshman/util"
import {Pool, Socket, SocketStatus, SocketEvent, ClientMessage, RelayMessage} from "@welshman/net"
import {collection} from "./collection.js"
import {appContext} from "./context.js"
@@ -116,6 +136,34 @@ export const displayRelayByPubkey = (url: string) =>
export const deriveRelayDisplay = (url: string) =>
derived(deriveRelay(url), $relay => displayRelayProfile($relay?.profile, displayRelayUrl(url)))
export const getRelayQuality = (url: string) => {
const relay = relaysByUrl.get().get(url)
// Skip non-relays entirely
if (!isRelayUrl(url)) return 0
// If we have recent errors, skip it
if (relay?.stats) {
if (relay.stats.recent_errors.filter(n => n > ago(MINUTE)).length > 0) return 0
if (relay.stats.recent_errors.filter(n => n > ago(HOUR)).length > 3) return 0
if (relay.stats.recent_errors.filter(n => n > ago(DAY)).length > 10) return 0
}
// Prefer stuff we're connected to
if (Pool.get().has(url)) return 1
// Prefer stuff we've connected to in the past
if (relay?.stats) return 0.9
// If it's not weird url give it an ok score
if (!isIPAddress(url) && !isLocalUrl(url) && !isOnionUrl(url) && !url.startsWith("ws://")) {
return 0.8
}
// Default to a "meh" score
return 0.7
}
// Utilities for syncing stats from connections to relays
type RelayStatsUpdate = [string, (stats: RelayStats) => void]
-504
View File
@@ -1,504 +0,0 @@
import {
intersection,
mergeLeft,
first,
clamp,
sortBy,
shuffle,
pushToMapKey,
inc,
add,
ago,
take,
chunks,
MINUTE,
HOUR,
DAY,
} from "@welshman/lib"
import {
getFilterId,
isRelayUrl,
isOnionUrl,
isLocalUrl,
isIPAddress,
isShareableRelayUrl,
COMMENT,
PROFILE,
RELAYS,
INBOX_RELAYS,
FOLLOWS,
WRAP,
getReplyTags,
getCommentTags,
getPubkeyTagValues,
normalizeRelayUrl,
TrustedEvent,
Filter,
} from "@welshman/util"
import {Pool} from "@welshman/net"
import {pubkey} from "./session.js"
import {
relaySelectionsByPubkey,
inboxRelaySelectionsByPubkey,
getReadRelayUrls,
getWriteRelayUrls,
getRelayUrls,
} from "./relaySelections.js"
import {relaysByUrl} from "./relays.js"
export const INDEXED_KINDS = [PROFILE, RELAYS, INBOX_RELAYS, FOLLOWS]
export type RelaysAndFilters = {
relays: string[]
filters: Filter[]
}
export enum RelayMode {
Read = "read",
Write = "write",
Inbox = "inbox",
}
export type RouterOptions = {
/**
* Retrieves the user's public key.
* @returns The user's public key as a string, or null if not available.
*/
getUserPubkey?: () => string | undefined
/**
* Retrieves relays for the specified public key and mode.
* @param pubkey - The public key to retrieve relays for.
* @param mode - The relay mode (optional). May be "read", "write", or "inbox".
* @returns An array of relay URLs as strings.
*/
getPubkeyRelays?: (pubkey: string, mode?: RelayMode) => string[]
/**
* 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 quality of the specified relay.
* @param url - The URL of the relay to retrieve quality for.
* @returns The quality of the relay as a number between 0 and 1 inclusive.
*/
getRelayQuality?: (url: string) => number
/**
* 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[]
}
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
// Default router options
export const getRelayQuality = (url: string) => {
const relay = relaysByUrl.get().get(url)
// Skip non-relays entirely
if (!isRelayUrl(url)) return 0
// If we have recent errors, skip it
if (relay?.stats) {
if (relay.stats.recent_errors.filter(n => n > ago(MINUTE)).length > 0) return 0
if (relay.stats.recent_errors.filter(n => n > ago(HOUR)).length > 3) return 0
if (relay.stats.recent_errors.filter(n => n > ago(DAY)).length > 10) return 0
}
// Prefer stuff we're connected to
if (Pool.getSingleton().has(url)) return 1
// Prefer stuff we've connected to in the past
if (relay?.stats) return 0.9
// If it's not weird url give it an ok score
if (!isIPAddress(url) && !isLocalUrl(url) && !isOnionUrl(url) && !url.startsWith("ws://")) {
return 0.8
}
// Default to a "meh" score
return 0.7
}
export const getPubkeyRelays = (pubkey: string, mode?: string) => {
const $relaySelections = relaySelectionsByPubkey.get()
const $inboxSelections = inboxRelaySelectionsByPubkey.get()
switch (mode) {
case RelayMode.Read:
return getReadRelayUrls($relaySelections.get(pubkey))
case RelayMode.Write:
return getWriteRelayUrls($relaySelections.get(pubkey))
case RelayMode.Inbox:
return getRelayUrls($inboxSelections.get(pubkey))
default:
return getRelayUrls($relaySelections.get(pubkey))
}
}
export const routerContext: RouterOptions = {
getRelayQuality,
getPubkeyRelays,
getDefaultRelays: () => ["wss://relay.damus.io/", "wss://nos.lol/"],
getIndexerRelays: () => ["wss://purplepag.es/", "wss://relay.nostr.band/"],
getSearchRelays: () => ["wss://relay.nostr.band/", "wss://nostr.wine/"],
getUserPubkey: () => pubkey.get(),
getLimit: () => 3,
}
// Router class
export class Router {
readonly options: RouterOptions
static configure(options: RouterOptions) {
Object.assign(routerContext, options)
}
static get() {
return new Router(routerContext)
}
constructor(options: RouterOptions) {
this.options = mergeLeft(options, routerContext)
}
// Utilities derived from options
getRelaysForPubkey = (pubkey: string, mode?: RelayMode) =>
this.options.getPubkeyRelays?.(pubkey, mode) || []
getRelaysForPubkeys = (pubkeys: string[], mode?: RelayMode) =>
pubkeys.map(pubkey => this.getRelaysForPubkey(pubkey, mode))
getRelaysForUser = (mode?: RelayMode) => {
const pubkey = this.options.getUserPubkey?.()
return pubkey ? this.getRelaysForPubkey(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))
UserInbox = () => this.FromRelays(this.getRelaysForUser(RelayMode.Inbox))
ForPubkey = (pubkey: string) => this.FromRelays(this.getRelaysForPubkey(pubkey, RelayMode.Read))
FromPubkey = (pubkey: string) => this.FromRelays(this.getRelaysForPubkey(pubkey, RelayMode.Write))
PubkeyInbox = (pubkey: string) =>
this.FromRelays(this.getRelaysForPubkey(pubkey, RelayMode.Inbox))
ForPubkeys = (pubkeys: string[]) => this.merge(pubkeys.map(pubkey => this.ForPubkey(pubkey)))
FromPubkeys = (pubkeys: string[]) => this.merge(pubkeys.map(pubkey => this.FromPubkey(pubkey)))
PubkeyInboxes = (pubkeys: string[]) => this.merge(pubkeys.map(pubkey => this.PubkeyInbox(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)
}
EventAncestors = (event: TrustedEvent, type: "mentions" | "replies" | "roots") => {
const ancestorTags =
event.kind === COMMENT ? getCommentTags(event.tags) : getReplyTags(event.tags)
const tags: string[][] = (ancestorTags as any)[type] || []
return this.scenario(
tags.flatMap(([_, value, relay, pubkey]) => {
const selections = [makeSelection(this.ForUser().getUrls(), 0.5)]
if (pubkey) {
selections.push(makeSelection(this.FromPubkey(pubkey).getUrls()))
}
if (relay) {
selections.push(makeSelection([relay], 0.9))
}
return selections
}),
)
}
EventMentions = (event: TrustedEvent) => this.EventAncestors(event, "mentions")
EventParents = (event: TrustedEvent) => this.EventAncestors(event, "replies")
EventRoots = (event: TrustedEvent) => this.EventAncestors(event, "roots")
PublishEvent = (event: TrustedEvent) => {
const pubkeys = getPubkeyTagValues(event.tags)
return this.merge([
this.FromPubkey(event.pubkey),
...pubkeys.map(pubkey => this.ForPubkey(pubkey).weight(0.5)),
])
}
}
// 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 quality = this.router.options.getRelayQuality?.(relay)
const weight = relayWeights.get(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 ? -(quality * inc(Math.log(weight)) * Math.random()) : 0
}
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())
}
// Infer relay selections from filters
type FilterScenario = {filter: Filter; scenario: RouterScenario}
type FilterSelectionRule = (filter: Filter) => FilterScenario[]
export const getFilterSelectionsForSearch = (filter: Filter) => {
if (!filter.search) return []
const relays = routerContext.getSearchRelays?.() || []
return [{filter, scenario: Router.get().FromRelays(relays).weight(10)}]
}
export const getFilterSelectionsForWraps = (filter: Filter) => {
if (!filter.kinds?.includes(WRAP) || filter.authors) return []
return [
{
filter: {...filter, kinds: [WRAP]},
scenario: Router.get().UserInbox(),
},
]
}
export const getFilterSelectionsForIndexedKinds = (filter: Filter) => {
const kinds = intersection(INDEXED_KINDS, filter.kinds || [])
if (kinds.length === 0) return []
const relays = routerContext.getIndexerRelays?.() || []
return [
{
filter: {...filter, kinds},
scenario: Router.get().FromRelays(relays),
},
]
}
export const getFilterSelectionsForAuthors = (filter: Filter) => {
if (!filter.authors) return []
const chunkCount = clamp([1, 30], Math.round(filter.authors.length / 30))
return chunks(chunkCount, filter.authors).map(authors => ({
filter: {...filter, authors},
scenario: Router.get().FromPubkeys(authors),
}))
}
export const getFilterSelectionsForUser = (filter: Filter) => [
{filter, scenario: Router.get().ForUser().weight(0.2)},
]
export const defaultFilterSelectionRules = [
getFilterSelectionsForSearch,
getFilterSelectionsForWraps,
getFilterSelectionsForIndexedKinds,
getFilterSelectionsForAuthors,
getFilterSelectionsForUser,
]
export const getFilterSelections = (
filters: Filter[],
rules: FilterSelectionRule[] = defaultFilterSelectionRules,
): RelaysAndFilters[] => {
const filtersById = new Map<string, Filter>()
const scenariosById = new Map<string, RouterScenario[]>()
for (const filter of filters) {
for (const filterScenario of rules.flatMap(rule => rule(filter))) {
const id = getFilterId(filterScenario.filter)
filtersById.set(id, filterScenario.filter)
pushToMapKey(scenariosById, id, filterScenario.scenario)
}
}
const result = []
for (const [id, filter] of filtersById.entries()) {
const scenario = Router.get()
.merge(scenariosById.get(id) || [])
.policy(addMinimalFallbacks)
result.push({filters: [filter], relays: scenario.getUrls()})
}
return result
}
+1 -1
View File
@@ -5,11 +5,11 @@ import {dec, sortBy} from "@welshman/lib"
import {PROFILE, PublishedProfile} from "@welshman/util"
import {load} from "@welshman/net"
import {throttled} from "@welshman/store"
import {Router} from "@welshman/router"
import {wotGraph} from "./wot.js"
import {profiles} from "./profiles.js"
import {topics, Topic} from "./topics.js"
import {relays, Relay} from "./relays.js"
import {Router} from "./router.js"
import {handlesByNip05} from "./handles.js"
export type SearchOptions<V, T> = {
+52 -25
View File
@@ -1,14 +1,21 @@
import {derived} from "svelte/store"
import {cached, hash, omit, equals, assoc} from "@welshman/lib"
import {withGetter, synced} from "@welshman/store"
import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer, getPubkey} from "@welshman/signer"
import {
Nip46Broker,
Nip46Signer,
Nip07Signer,
Nip01Signer,
Nip55Signer,
getPubkey,
} from "@welshman/signer"
export enum SessionMethod {
Nip01 = 'nip01',
Nip07 = 'nip07',
Nip46 = 'nip46',
Nip55 = 'nip55',
Pubkey = 'pubkey',
Nip01 = "nip01",
Nip07 = "nip07",
Nip46 = "nip46",
Nip55 = "nip55",
Pubkey = "pubkey",
}
export type SessionNip01 = {
@@ -77,7 +84,7 @@ export const updateSession = (pubkey: string, f: (session: Session) => Session)
putSession(f(getSession(pubkey)))
export const dropSession = (_pubkey: string) => {
pubkey.update($pubkey => $pubkey === _pubkey ? undefined : $pubkey)
pubkey.update($pubkey => ($pubkey === _pubkey ? undefined : $pubkey))
sessions.update($sessions => omit([_pubkey], $sessions))
}
@@ -88,37 +95,57 @@ export const clearSessions = () => {
// Session factories
export const makeNip01Session = (secret: string): SessionNip01 =>
({method: SessionMethod.Nip01, secret, pubkey: getPubkey(secret)})
export const makeNip01Session = (secret: string): SessionNip01 => ({
method: SessionMethod.Nip01,
secret,
pubkey: getPubkey(secret),
})
export const makeNip07Session = (pubkey: string): SessionNip07 =>
({method: SessionMethod.Nip07, pubkey})
export const makeNip07Session = (pubkey: string): SessionNip07 => ({
method: SessionMethod.Nip07,
pubkey,
})
export const makeNip46Session = (pubkey: string, clientSecret: string, signerPubkey: string, relays: string[]): SessionNip46 =>
({method: SessionMethod.Nip46, pubkey, secret: clientSecret, handler: {pubkey: signerPubkey, relays}})
export const makeNip46Session = (
pubkey: string,
clientSecret: string,
signerPubkey: string,
relays: string[],
): SessionNip46 => ({
method: SessionMethod.Nip46,
pubkey,
secret: clientSecret,
handler: {pubkey: signerPubkey, relays},
})
export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 =>
({method: SessionMethod.Nip55, pubkey, signer})
export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 => ({
method: SessionMethod.Nip55,
pubkey,
signer,
})
export const makePubkeySession = (pubkey: string): SessionPubkey =>
({method: SessionMethod.Pubkey, pubkey})
export const makePubkeySession = (pubkey: string): SessionPubkey => ({
method: SessionMethod.Pubkey,
pubkey,
})
// Login utilities
export const loginWithNip01 = (secret: string) =>
addSession(makeNip01Session(secret))
export const loginWithNip01 = (secret: string) => addSession(makeNip01Session(secret))
export const loginWithNip07 = (pubkey: string) =>
addSession(makeNip07Session(pubkey))
export const loginWithNip07 = (pubkey: string) => addSession(makeNip07Session(pubkey))
export const loginWithNip46 = (pubkey: string, clientSecret: string, signerPubkey: string, relays: string[]) =>
addSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays))
export const loginWithNip46 = (
pubkey: string,
clientSecret: string,
signerPubkey: string,
relays: string[],
) => addSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays))
export const loginWithNip55 = (pubkey: string, signer: string) =>
addSession(makeNip55Session(pubkey, signer))
export const loginWithPubkey = (pubkey: string) =>
addSession(makePubkeySession(pubkey))
export const loginWithPubkey = (pubkey: string) => addSession(makePubkeySession(pubkey))
// Other stuff
-1
View File
@@ -120,4 +120,3 @@ export const clearStorage = async () => {
db = undefined // force initStorage to run again in tests
}
}
+33 -30
View File
@@ -10,7 +10,7 @@ import {
getListTags,
TrustedEvent,
} from "@welshman/util"
import {throttled, withGetter, WritableWithGetter} from "@welshman/store"
import {throttled, withGetter} from "@welshman/store"
import {Tracker} from "@welshman/net"
import {Repository, RepositoryUpdate} from "@welshman/relay"
import {getAll, bulkPut, bulkDelete} from "./storage.js"
@@ -123,8 +123,7 @@ export class PlaintextStorageAdapter {
const interval = setInterval(() => {
bulkPut(
this.options.name,
Object.entries(plaintext.get())
.map(([key, value]) => ({key, value})),
Object.entries(plaintext.get()).map(([key, value]) => ({key, value})),
)
}, 10_000)
@@ -156,14 +155,16 @@ export class TrackerStorageAdapter {
const onUpdate = throttle(3000, async () => {
await bulkPut(
this.options.name,
Array.from(this.options.tracker.relaysById.entries())
.map(([id, relays]) => ({id, relays: Array.from(relays)}))
Array.from(this.options.tracker.relaysById.entries()).map(([id, relays]) => ({
id,
relays: Array.from(relays),
})),
)
})
this.options.tracker.on('update', onUpdate)
this.options.tracker.on("update", onUpdate)
return () => this.options.tracker.off('update', onUpdate)
return () => this.options.tracker.off("update", onUpdate)
}
}
@@ -224,30 +225,32 @@ export class EventsStorageAdapter {
}
export const defaultStorageAdapters = {
relays: new RelaysStorageAdapter({name: 'relays'}),
handles: new HandlesStorageAdapter({name: 'handles'}),
zappers: new ZappersStorageAdapter({name: 'zappers'}),
freshness: new FreshnessStorageAdapter({name: 'freshness'}),
plaintext: new PlaintextStorageAdapter({name: 'plaintext'}),
tracker: new TrackerStorageAdapter({name: 'tracker', tracker}),
events: new EventsStorageAdapter(call(() => {
const userFollowPubkeys = withGetter(
derived(userFollows, l => new Set(getPubkeyTagValues(getListTags(l)))),
)
relays: new RelaysStorageAdapter({name: "relays"}),
handles: new HandlesStorageAdapter({name: "handles"}),
zappers: new ZappersStorageAdapter({name: "zappers"}),
freshness: new FreshnessStorageAdapter({name: "freshness"}),
plaintext: new PlaintextStorageAdapter({name: "plaintext"}),
tracker: new TrackerStorageAdapter({name: "tracker", tracker}),
events: new EventsStorageAdapter(
call(() => {
const userFollowPubkeys = withGetter(
derived(userFollows, l => new Set(getPubkeyTagValues(getListTags(l)))),
)
return {
repository,
name: 'events',
limit: 10_000,
rankEvent: (e: TrustedEvent) => {
const $sessions = sessions.get()
const metaKinds = [PROFILE, FOLLOWS, MUTES, RELAYS, INBOX_RELAYS]
return {
repository,
name: "events",
limit: 10_000,
rankEvent: (e: TrustedEvent) => {
const $sessions = sessions.get()
const metaKinds = [PROFILE, FOLLOWS, MUTES, RELAYS, INBOX_RELAYS]
if ($sessions[e.pubkey] || e.tags.some(t => $sessions[t[1]])) return 1
if (metaKinds.includes(e.kind) && userFollowPubkeys.get()?.has(e.pubkey)) return 1
if ($sessions[e.pubkey] || e.tags.some(t => $sessions[t[1]])) return 1
if (metaKinds.includes(e.kind) && userFollowPubkeys.get()?.has(e.pubkey)) return 1
return 0
},
}
})),
return 0
},
}
}),
),
}
+1 -6
View File
@@ -1,11 +1,6 @@
import type {Filter} from "@welshman/util"
import {isSignedEvent, SignedEvent} from "@welshman/util"
import {
push as basePush,
pull as basePull,
publishOne,
requestOne,
} from "@welshman/net"
import {push as basePush, pull as basePull, publishOne, requestOne} from "@welshman/net"
import {repository} from "./core.js"
import {relaysByUrl} from "./relays.js"
+1 -1
View File
@@ -7,9 +7,9 @@ import {
isReplaceableKind,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Router} from "@welshman/router"
import {displayProfileByPubkey} from "./profiles.js"
import {pubkey} from "./session.js"
import {Router} from "./router.js"
export const tagZapSplit = (pubkey: string, split = 1) => [
"zap",
+12 -14
View File
@@ -1,14 +1,10 @@
import type {Subscriber} from "svelte/store"
import {Writable, Readable, writable, derived, get} from "svelte/store"
import {writable, get} from "svelte/store"
import {
Deferred,
fromPairs,
TaskQueue,
ifLet,
dissoc,
remove,
identity,
uniq,
defer,
sleep,
assoc,
@@ -30,7 +26,7 @@ import {
isUnwrappedEvent,
isSignedEvent,
} from "@welshman/util"
import {publish, AdapterContext, PublishStatus, PublishOptions, PublishStatusByRelay} from "@welshman/net"
import {publish, PublishStatus, PublishOptions, PublishStatusByRelay} from "@welshman/net"
import {repository, tracker} from "./core.js"
import {pubkey, getSession, getSigner} from "./session.js"
@@ -52,7 +48,7 @@ export const prepEvent = (event: ThunkEvent) => {
return event as TrustedEvent
}
export type ThunkOptions = Omit<PublishOptions, 'event'> & {
export type ThunkOptions = Omit<PublishOptions, "event"> & {
event: ThunkEvent
delay?: number
}
@@ -173,7 +169,7 @@ export class Thunk {
this.options.onComplete?.()
this._subs = []
},
})
}),
)
}
@@ -198,7 +194,6 @@ export class MergedThunk {
constructor(readonly thunks: Thunk[]) {
const {Aborted, Failure, Timeout, Pending, Success} = PublishStatus
const relays = new Set(thunks.flatMap(thunk => Object.keys(thunk.options.relays)))
const statusMaps = thunks.map(thunk => thunk.status)
for (const thunk of thunks) {
this.controller.signal.addEventListener("abort", () => thunk.controller.abort())
@@ -246,8 +241,7 @@ export class MergedThunk {
export type AbstractThunk = Thunk | MergedThunk
export const isThunk = (thunk: AbstractThunk): thunk is Thunk =>
thunk instanceof Thunk
export const isThunk = (thunk: AbstractThunk): thunk is Thunk => thunk instanceof Thunk
export const isMergedThunk = (thunk: AbstractThunk): thunk is MergedThunk =>
thunk instanceof MergedThunk
@@ -261,18 +255,22 @@ export const thunkUrlsWithStatus = (thunk: AbstractThunk, status: PublishStatus)
export const thunkCompleteUrls = (thunk: AbstractThunk) => {
const incompleteStatuses = [PublishStatus.Sending, PublishStatus.Pending]
return Object.entries(thunk.status).filter(([_, s]) => !incompleteStatuses.includes(s)).map(nth(1))
return Object.entries(thunk.status)
.filter(([_, s]) => !incompleteStatuses.includes(s))
.map(nth(1))
}
export const thunkIncompleteUrls = (thunk: AbstractThunk) => {
const incompleteStatuses = [PublishStatus.Sending, PublishStatus.Pending]
return Object.entries(thunk.status).filter(([_, s]) => incompleteStatuses.includes(s)).map(nth(1))
return Object.entries(thunk.status)
.filter(([_, s]) => incompleteStatuses.includes(s))
.map(nth(1))
}
export const thunkIsComplete = (thunk: AbstractThunk) => thunkCompleteUrls(thunk).length > 0
export function* walkThunks(thunks: (AbstractThunk)[]): Iterable<Thunk> {
export function* walkThunks(thunks: AbstractThunk[]): Iterable<Thunk> {
for (const thunk of thunks) {
if (thunk instanceof MergedThunk) {
yield* walkThunks(thunk.thunks)