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
+42
View File
@@ -5,6 +5,18 @@ The `Tools` module provides a comprehensive collection of utility functions for
## Basic functional programming utilities
```typescript
// Null or undefined
export type Nil = null | undefined;
// Check whether something is null or undefined
export declare const isNil: <T>(x: T, ...args: unknown[]) => boolean;
// Check whether something is not null or undefined
export declare const isNotNil: <T>(x: T, ...args: unknown[]) => x is (T & null) | (T & {}) | (T & undefined);
// Assert that a nullable type is not null or undefined
export declare const assertNotNil: <T>(x: T, ...args: unknown[]) => NonNullable<T>;
// Function that does nothing and returns undefined
export declare const noop: (...args: unknown[]) => undefined;
@@ -120,6 +132,33 @@ export declare const ago: (unit: number, count?: number) => number;
// Converts seconds to milliseconds
export declare const ms: (seconds: number) => number;
// Converts seconds to date
export declare const secondsToDate: (seconds: number) => Date;
// Converts date object to seconds
export declare const dateToSeconds: (date: Date) => number;
// Creates a local date from a date string
export declare const createLocalDate: (dateString: any, timezone?: string) => Date;
/** Formatter for date+time */
export declare const dateTimeFormatter: Intl.DateTimeFormat;
// Formats seconds as a datetime
export declare const formatTimestamp: (seconds: number) => string;
/** Formatter for date */
export declare const dateFormatter: Intl.DateTimeFormat;
// Formats seconds as a date
export declare const formatTimestampAsDate: (ts: number) => string;
/** Formatter for time */
export declare const timeFormatter: Intl.DateTimeFormat;
// Formats seconds as a time
export declare const formatTimestampAsTime: (ts: number) => string;
// Formats seconds as a relative date (x minutes ago)
export declare const formatTimestampRelative: (ts: number) => string;
```
## Sequences
@@ -232,6 +271,9 @@ export declare const toIterable: (x: any) => any;
// Ensures value is array by wrapping if needed
export declare const ensurePlural: <T>(x: T | T[]) => T[];
// Ensures values are not undefined
export declare const removeNil: <T>(xs: T[]) => (T & {})[];
```
## Objects
+1
View File
@@ -25,6 +25,7 @@
"@welshman/feeds": "workspace:*",
"@welshman/lib": "workspace:*",
"@welshman/relay": "workspace:*",
"@welshman/router": "workspace:*",
"@welshman/net": "workspace:*",
"@welshman/signer": "workspace:*",
"@welshman/store": "workspace:*",
+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]
+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)
+1
View File
@@ -7,6 +7,7 @@
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
+2 -10
View File
@@ -1,4 +1,4 @@
import {Emitter, now} from "@welshman/lib"
import {now} from "@welshman/lib"
import {TrustedEvent, SignedEvent, Filter} from "@welshman/util"
import {request, publish, AdapterContext} from "@welshman/net"
@@ -13,15 +13,7 @@ export type DVMRequestOptions = {
}
export const requestDvmResponse = (options: DVMRequestOptions) => {
const {
event,
relays,
context,
timeout = 30_000,
autoClose = true,
onResult,
onProgress,
} = options
const {event, relays, context, timeout = 30_000, autoClose = true, onResult, onProgress} = options
const kind = event.kind + 1000
const kinds = onProgress ? [kind, 7000] : [kind]
const filters: Filter[] = [{kinds, since: now() - 60, "#e": [event.id]}]
+1
View File
@@ -20,6 +20,7 @@
},
"dependencies": {
"@welshman/lib": "workspace:*",
"@welshman/net": "workspace:*",
"@welshman/util": "workspace:*"
},
"devDependencies": {
+110 -3
View File
@@ -1,6 +1,5 @@
import {bech32, utf8} from "@scure/base"
type Obj<T = any> = Record<string, T>
// ----------------------------------------------------------------------------
@@ -224,6 +223,12 @@ export const QUARTER = 90 * DAY
/** One year in seconds (approximate) */
export const YEAR = 365 * DAY
/** User's default locale */
export const LOCALE = new Intl.DateTimeFormat().resolvedOptions().locale
/** User's default timezone */
export const TIMEZONE = new Date().toString().match(/GMT[^\s]+/)![0]
/**
* Multiplies time unit by count
* @param unit - Time unit in seconds
@@ -250,6 +255,97 @@ export const ago = (unit: number, count = 1) => now() - int(unit, count)
*/
export const ms = (seconds: number) => seconds * 1000
/**
* Converts seconds to date
* @param seconds - Time in seconds
* @returns Date object
*/
export const secondsToDate = (seconds: number) => new Date(seconds * 1000)
/**
* Converts date object to seconds
* @param date - Date object
* @returns timestamp in seconds
*/
export const dateToSeconds = (date: Date) => Math.round(date.valueOf() / 1000)
/**
* Creates a local date from a date string
* @param dateString - date string
* @param timezone - timezone string
* @returns timezone-aware Date object
*/
export const createLocalDate = (dateString: any, timezone = TIMEZONE) =>
new Date(`${dateString} ${timezone}`)
/** Formatter for date+time */
export const dateTimeFormatter = new Intl.DateTimeFormat(LOCALE, {
dateStyle: "short",
timeStyle: "short",
})
/**
* Formats seconds as a datetime
* @param seconds - timestamp in seconds
* @returns datetime string
*/
export const formatTimestamp = (seconds: number) => dateTimeFormatter.format(secondsToDate(seconds))
/** Formatter for date */
export const dateFormatter = new Intl.DateTimeFormat(LOCALE, {
year: "numeric",
month: "long",
day: "numeric",
})
/**
* Formats seconds as a date
* @param seconds - timestamp in seconds
* @returns date string
*/
export const formatTimestampAsDate = (ts: number) => dateFormatter.format(secondsToDate(ts))
/** Formatter for time */
export const timeFormatter = new Intl.DateTimeFormat(LOCALE, {
timeStyle: "short",
})
/**
* Formats seconds as a time
* @param seconds - timestamp in seconds
* @returns time string
*/
export const formatTimestampAsTime = (ts: number) => timeFormatter.format(secondsToDate(ts))
/**
* Formats seconds as a relative date (x minutes ago)
* @param seconds - timestamp in seconds
* @returns relative date string
*/
export const formatTimestampRelative = (ts: number) => {
let unit
let delta = now() - ts
if (delta < int(MINUTE)) {
unit = "second"
} else if (delta < int(HOUR)) {
unit = "minute"
delta = Math.round(delta / int(MINUTE))
} else if (delta < int(DAY, 2)) {
unit = "hour"
delta = Math.round(delta / int(HOUR))
} else {
unit = "day"
delta = Math.round(delta / int(DAY))
}
const locale = new Intl.RelativeTimeFormat().resolvedOptions().locale
const formatter = new Intl.RelativeTimeFormat(locale, {
numeric: "auto",
})
return formatter.format(-delta, unit as Intl.RelativeTimeFormatUnit)
}
// ----------------------------------------------------------------------------
// Sequences
// ----------------------------------------------------------------------------
@@ -360,6 +456,14 @@ export const difference = <T>(a: T[], b: T[]) => {
*/
export const remove = <T>(a: T, xs: T[]) => xs.filter(x => x !== a)
/**
* Removes element at index
* @param i - Index to remove
* @param xs - Source array
* @returns New array with element removed
*/
export const removeAt = <T>(i: number, xs: T[]) => [...xs.slice(0, i), ...xs.slice(i + 1)]
/**
* Returns elements from second array not present in first
* @param a - Array of elements to exclude
@@ -602,7 +706,10 @@ export const chunks = <T>(n: number, xs: T[]) => {
export const splitAt = <T>(n: number, xs: T[]) => [xs.slice(0, n), xs.slice(n)]
/** Inserts element into array at index */
export const insert = <T>(n: number, x: T, xs: T[]) => [...xs.slice(0, n), x, ...xs.slice(n)]
export const insertAt = <T>(n: number, x: T, xs: T[]) => [...xs.slice(0, n), x, ...xs.slice(n)]
/** Replaces array element at index */
export const replaceAt = <T>(n: number, x: T, xs: T[]) => [...xs.slice(0, n), x, ...xs.slice(n + 1)]
/** Returns random element from array */
export const choice = <T>(xs: T[]): T => xs[Math.floor(xs.length * Math.random())]
@@ -926,7 +1033,7 @@ export const poll = ({interval = 300, condition, signal}: PollOptions) =>
}
}, interval)
signal.addEventListener('abort', () => {
signal.addEventListener("abort", () => {
resolve()
clearInterval(int)
})
+3 -3
View File
@@ -17,7 +17,7 @@ describe("requestOne", () => {
it("everything basically works", async () => {
let id
const sendSpy = vi.fn(m => {
if (m[0] === 'REQ') {
if (m[0] === "REQ") {
id = m[1]
}
})
@@ -87,13 +87,13 @@ describe("request", () => {
it("everything basically works", async () => {
let id1, id2
const send1Spy = vi.fn(m => {
if (m[0] === 'REQ') {
if (m[0] === "REQ") {
id1 = m[1]
}
})
const adapter1 = new MockAdapter("1", send1Spy)
const send2Spy = vi.fn(m => {
if (m[0] === 'REQ') {
if (m[0] === "REQ") {
id2 = m[1]
}
})
+1
View File
@@ -23,6 +23,7 @@
"@welshman/lib": "workspace:*",
"@welshman/relay": "workspace:*",
"@welshman/util": "workspace:*",
"events": "^3.3.0",
"isomorphic-ws": "^5.0.0"
},
"devDependencies": {
+3 -3
View File
@@ -1,5 +1,5 @@
import {Repository} from "@welshman/relay"
import {verifyEvent, TrustedEvent, SignedEvent} from "@welshman/util"
import {verifyEvent, TrustedEvent} from "@welshman/util"
import {AbstractAdapter} from "./adapter.js"
import {Pool} from "./pool.js"
@@ -12,8 +12,8 @@ export type NetContext = {
}
export const netContext: NetContext = {
pool: Pool.getSingleton(),
repository: Repository.getSingleton(),
pool: Pool.get(),
repository: Repository.get(),
isEventValid: (event, url) => verifyEvent(event),
isEventDeleted: (event, url) => netContext.repository.isDeleted(event),
}
+12 -11
View File
@@ -200,17 +200,18 @@ export const pull = async ({context, ...options}: PullOptions) => {
await Promise.all(
Array.from(idsByRelay.entries()).map(([relay, allIds]) => {
return Promise.all(
chunk(500, allIds).map(ids =>
new Promise<void>(resolve =>
requestOne({
relay,
context,
filters: [{ids}],
autoClose: true,
onClose: resolve,
onEvent: event => result.push(event as SignedEvent),
})
)
chunk(500, allIds).map(
ids =>
new Promise<void>(resolve =>
requestOne({
relay,
context,
filters: [{ids}],
autoClose: true,
onClose: resolve,
onEvent: event => result.push(event as SignedEvent),
}),
),
),
)
}),
+1 -1
View File
@@ -25,7 +25,7 @@ export class Pool {
_data = new Map<string, Socket>()
_subs: PoolSubscription[] = []
static getSingleton() {
static get() {
if (!poolSingleton) {
poolSingleton = new Pool()
}
+18 -23
View File
@@ -1,8 +1,6 @@
import {EventEmitter} from "events"
import {on, fromPairs, sleep, yieldThread} from "@welshman/lib"
import {SignedEvent} from "@welshman/util"
import {RelayMessage, ClientMessageType, isRelayOk} from "./message.js"
import {AbstractAdapter, AdapterEvent, AdapterContext, getAdapter} from "./adapter.js"
import {AdapterEvent, AdapterContext, getAdapter} from "./adapter.js"
export enum PublishStatus {
Sending = "publish:status:sending",
@@ -46,28 +44,25 @@ export const publishOne = (options: PublishOneOptions) =>
resolve(status)
}
adapter.on(
AdapterEvent.Receive,
(message: RelayMessage, url: string) => {
if (isRelayOk(message)) {
const [_, id, ok, detail] = message
adapter.on(AdapterEvent.Receive, (message: RelayMessage, url: string) => {
if (isRelayOk(message)) {
const [_, id, ok, detail] = message
if (id !== options.event.id) return
if (id !== options.event.id) return
if (ok) {
status = PublishStatus.Success
options.onSuccess?.(detail)
} else {
status = PublishStatus.Failure
options.onFailure?.(detail)
}
cleanup()
if (ok) {
status = PublishStatus.Success
options.onSuccess?.(detail)
} else {
status = PublishStatus.Failure
options.onFailure?.(detail)
}
},
)
options.signal?.addEventListener('abort', () => {
cleanup()
}
})
options.signal?.addEventListener("abort", () => {
if (status === PublishStatus.Pending) {
status = PublishStatus.Aborted
options.onAborted?.()
@@ -149,8 +144,8 @@ export const publish = async (options: PublishOptions) => {
options.onComplete?.()
}
},
})
)
}),
),
)
return status
+52 -45
View File
@@ -1,5 +1,16 @@
import {EventEmitter} from "events"
import {on, uniq, lt, flatten, addToMapKey, defer, Deferred, call, randomId, yieldThread, pushToMapKey, batcher} from "@welshman/lib"
import {
on,
uniq,
lt,
flatten,
addToMapKey,
defer,
Deferred,
call,
randomId,
pushToMapKey,
batcher,
} from "@welshman/lib"
import {
Filter,
getAddress,
@@ -9,9 +20,8 @@ import {
getFilterResultCardinality,
} from "@welshman/util"
import {RelayMessage, ClientMessageType, isRelayEvent, isRelayEose} from "./message.js"
import {getAdapter, AdapterContext, AbstractAdapter, AdapterEvent} from "./adapter.js"
import {getAdapter, AdapterContext, AdapterEvent} from "./adapter.js"
import {SocketEvent, SocketStatus} from "./socket.js"
import {Unsubscriber} from "./util.js"
import {netContext} from "./context.js"
import {Tracker} from "./tracker.js"
@@ -160,7 +170,6 @@ export const request = async (options: RequestOptions) => {
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[]>[] = []
if (relays.size !== options.relays.length) {
console.warn("Non-unique relays passed to request")
@@ -181,14 +190,13 @@ export const request = async (options: RequestOptions) => {
options.onClose?.()
ctrl.abort()
}
}
})
)
)
},
}),
),
),
)
}
export type LoaderOptions = {
delay: number
timeout?: number
@@ -251,7 +259,7 @@ export const makeLoader = (options: LoaderOptions) =>
resultsByRequest.set(request, defer())
// Propagate abort when all requests have been closed for a given relay
request.signal?.addEventListener('abort', () => close(relay, request))
request.signal?.addEventListener("abort", () => close(relay, request))
}
}
@@ -268,47 +276,46 @@ export const makeLoader = (options: LoaderOptions) =>
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))
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))
// 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)
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])
// 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)
}
if (eventsByRequest.get(request)?.length === cardinality) {
close(relay, request)
}
}
}
},
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)),
})
}
)
}
},
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 allRequests.map(r => resultsByRequest.get(r)!)
})
+1 -1
View File
@@ -44,7 +44,7 @@ export class Repository<E extends HashedEvent = TrustedEvent> extends Emitter {
eventsByKind = new Map<number, E[]>()
deletes = new Map<string, number>()
static getSingleton() {
static get() {
if (!repositorySingleton) {
repositorySingleton = new Repository()
}
+2
View File
@@ -0,0 +1,2 @@
build
__tests__
+29
View File
@@ -0,0 +1,29 @@
# @welshman/content [![version](https://badgen.net/npm/v/@welshman/content)](https://npmjs.com/package/@welshman/content)
Utilities for parsing and rendering note content. Customizable via RenderOptions.
```typescript
import {parse, render} from '@welshman/content'
const content = "Hello<br>from https://coracle.tools! <script>alert('evil')</script>"
const parsed = parse({content, tags: []})
// [
// { type: 'text', value: 'Hello<br>from ', raw: 'Hello<br>from ' },
// {
// type: 'link',
// value: { url: URL, isMedia: false },
// raw: 'https://coracle.tools'
// },
// {
// type: 'text',
// value: "! <script>alert('evil')</script>",
// raw: "! <script>alert('evil')</script>"
// }
// ]
const result = renderAsText(parsed)
// => Hello&lt;br&gt;from https://coracle.tools/! &lt;script&gt;alert('evil')&lt;/script&gt;
const result = renderAsHtml(parsed)
// => Hello&lt;br&gt;from <a href="https://coracle.tools/" target="_blank">coracle.tools/</a>! &lt;script&gt;alert('evil')&lt;/script&gt;
```
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@welshman/router",
"version": "0.1.0",
"author": "hodlbod",
"license": "MIT",
"description": "A collection of utilities for nostr relay selection.",
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm run clean && pnpm run compile --force",
"clean": "rimraf ./dist",
"compile": "tsc -b tsconfig.build.json",
"prepublishOnly": "pnpm run build"
},
"dependencies": {
"@welshman/lib": "workspace:*",
"@welshman/util": "workspace:*"
},
"devDependencies": {
"rimraf": "~6.0.0",
"typescript": "~5.8.0"
}
}
@@ -8,19 +8,14 @@ import {
pushToMapKey,
inc,
add,
ago,
take,
chunks,
MINUTE,
HOUR,
DAY,
} from "@welshman/lib"
import {
getFilterId,
isRelayUrl,
isOnionUrl,
isLocalUrl,
isIPAddress,
isShareableRelayUrl,
COMMENT,
PROFILE,
@@ -35,16 +30,6 @@ import {
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]
@@ -127,64 +112,10 @@ export const addMinimalFallbacks = (count: number, limit: number) => (count > 0
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 const routerContext: RouterOptions = {}
export class Router {
readonly options: RouterOptions
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": [
"src/**/*"
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
+3
View File
@@ -0,0 +1,3 @@
{
"entryPoints": ["src/index.ts"]
}
+9 -6
View File
@@ -1,7 +1,7 @@
import {verifiedSymbol, verifyEvent as verifyEventPure} from "nostr-tools/pure"
import {setNostrWasm, verifyEvent as verifyEventWasm} from "nostr-tools/wasm"
import {initNostrWasm} from "nostr-wasm"
import {mapVals, noop, first, pick, now} from "@welshman/lib"
import {mapVals, first, pick, now} from "@welshman/lib"
import {getReplyTagValues, getCommentTagValues} from "./Tags.js"
import {getAddress, Address} from "./Address.js"
import {
@@ -65,16 +65,19 @@ export const verifyEvent = (() => {
let verify = verifyEventPure
if (typeof WebAssembly === "object") {
initNostrWasm()
.then(nostrWasm => {
initNostrWasm().then(
nostrWasm => {
setNostrWasm(nostrWasm)
verify = verifyEventWasm
}, e => {
},
e => {
console.warn(e)
})
},
)
}
return (event: TrustedEvent) => Boolean(event.sig && (event[verifiedSymbol] || verify(event as SignedEvent)))
return (event: TrustedEvent) =>
Boolean(event.sig && (event[verifiedSymbol] || verify(event as SignedEvent)))
})()
export const isEventTemplate = (e: EventTemplate): e is EventTemplate =>
+1 -1
View File
@@ -34,7 +34,7 @@ export const isRelayUrl = (url: string) => {
if (url.match(/\\.*\./)) return false
// Skip non-localhost urls without a dot
if (!url.match(/\./) && !url.includes('localhost')) return false
if (!url.match(/\./) && !url.includes("localhost")) return false
try {
new URL(url)
+25
View File
@@ -74,6 +74,9 @@ importers:
'@welshman/relay':
specifier: workspace:*
version: link:../relay
'@welshman/router':
specifier: workspace:*
version: link:../router
'@welshman/signer':
specifier: workspace:*
version: link:../signer
@@ -213,6 +216,9 @@ importers:
'@welshman/lib':
specifier: workspace:*
version: link:../lib
'@welshman/net':
specifier: workspace:*
version: link:../net
'@welshman/util':
specifier: workspace:*
version: link:../util
@@ -254,6 +260,9 @@ importers:
'@welshman/util':
specifier: workspace:*
version: link:../util
events:
specifier: ^3.3.0
version: 3.3.0
isomorphic-ws:
specifier: ^5.0.0
version: 5.0.0(ws@8.18.1)
@@ -281,6 +290,22 @@ importers:
specifier: ~5.8.0
version: 5.8.2
packages/router:
dependencies:
'@welshman/lib':
specifier: workspace:*
version: link:../lib
'@welshman/util':
specifier: workspace:*
version: link:../util
devDependencies:
rimraf:
specifier: ~6.0.0
version: 6.0.1
typescript:
specifier: ~5.8.0
version: 5.8.2
packages/signer:
dependencies:
'@noble/curves':