Routing & Tags
The Router
app.use(Router) is a per-app Router wired to this app's data. It is the single source for relay selection — there is no global Router.get(); one router belongs to each app.
The app wires it up with:
- user pubkey from
app.user - read/write relays per pubkey from
RelayLists(and NIP-17 messaging relays fromMessagingRelayLists) - relay quality from
RelayStats - default / indexer / search relays from
AppConfig
Routing is declarative. Rather than build a relay set imperatively, code produces a list of RelaySelections — descriptions of sources ("the author's outbox", "this pubkey's inbox", "the relays this event was seen on") — and the router resolves them into concrete urls. The Router exposes two members:
const router = app.use(Router)
router.resolver // a @welshman/util Resolver: dereferences routes -> urls
router.resolve(selections) // = router.resolver.scenario(selections) -> Promise<RelayScenario>resolver is a Resolver (from @welshman/util) built with the app's getRelayQuality (from RelayStats) and getDefaultRelays (from AppConfig). It is the same resolver injected into every domain kind via app.use(Domain), so a writer's routing and the router's own routing agree.
resolveRoute(route) is the underlying function that dereferences a single RelayRoute:
userInbox/userOutbox/userMessaging→ the signed-in user's read / write / NIP-17 relays.pubkeyInbox/pubkeyOutbox/pubkeyMessaging→ that pubkey's relays (viaRelayLists/MessagingRelayLists).eventInbox/eventOutbox→ resolve the referenced event's author, then its relays, merged with anyref.relayshints.seen→ the tracker's relays for the event id (or, for a replaceable ref, the resolved address's id), plusref.relayshints.index→AppConfig.getIndexerRelays();search→AppConfig.getSearchRelays().relay→ the literalroute.url.
The RelaySelection DSL
@welshman/util exports a small set of constructors that build RelaySelections (each pairs a RelayRoute with a weight, default 1). Most return a single selection; relays, inboxes return an array (spread them with ...):
import {
inbox, outbox, messaging,
userInbox, userOutbox, userMessaging,
eventInbox, eventOutbox, seen,
relay, relays, inboxes,
indexers, searchRelays,
} from "@welshman/util"
inbox(pubkey) // that pubkey's read relays (deliver here so they receive)
outbox(pubkey) // that pubkey's write relays (their events live here)
messaging(pubkey) // that pubkey's NIP-17 relays
userOutbox() // the current user's write relays
eventOutbox(ref) // the author of a referenced event
seen(ref) // relays a given event was found on
relay(url) // a literal relay url
relays(urls) // one selection per url -> RelaySelection[]
inboxes(pubkeys, 0.5) // one weighted inbox per pubkey -> RelaySelection[]
indexers() // profile/relay-list index relays
searchRelays() // full-text search relaysrelay / relays replace the old relayHint / relayHints. Resolve a selection list to a scored scenario, then to urls:
import {addMinimalFallbacks} from "@welshman/util"
const scenario = await app.use(Router).resolve([
userOutbox(),
...inboxes(mentionedPubkeys, 0.5),
])
const urls = scenario.policy(addMinimalFallbacks).limit(8).getUrls()
const hint = scenario.getUrl()A RelayScenario is chainable — .limit(n), .policy(fn), .allowLocal(bool), .allowOnion(bool), .allowInsecure(bool) — and terminates in getUrls() / getUrl(). It accumulates weight per relay, filters onion/local/insecure relays unless allowed, scores by relay quality (with noise), takes the best limit, then tops up from the default relays per the fallback policy (addNoFallbacks / addMinimalFallbacks / addMaximalFallbacks).
Routes from domain writers
You rarely assemble selections by hand. Each @welshman/domain kind knows how to route itself. A writer computes its RelaySelections in renderRoutes() (a protected override point); scenario() resolves them through the injected resolver and relays() returns the final urls:
const writer = app.use(Domain).writer(Note).setContent("gm")
await writer.scenario() // RelayScenario — resolved & scored
await writer.relays() // string[] — final urlsThe default EventWriter.renderRoutes() is the author's outbox plus every p-tagged pubkey's inbox:
return [userOutbox(), ...inboxes(tagValues(hexTags("p"), await this.renderTags()), 0.5)]Individual kinds override it. For example FollowListWriter / MuteListWriter route only to [userOutbox()] (their p-tags are data, not recipients); RelayListWriter adds indexers() and notifies every relay added to or removed from the list; DeleteWriter adds a seen(...) selection for each deleted event.
Routing is a writer concern — readers are getter-only and do not compute routes. Where to fetch a kind's events is decided by the request/loader layer, not the reader.
forcedRelays
Some events must publish to explicit relays, bypassing outbox/inbox routing. When a writer's forcedRelays is non-empty, scenario() publishes only there (relays(forcedRelays)):
writer.forceRelays("wss://relay.example.com/") // publish only here
writer.setRoom("wss://rooms.example.com/", roomId) // forcedRelays + an "h" tagNIP-29 room ops and relay-management ops set requiresRelays = true, so their validate() throws unless forcedRelays is set (via setRoom or forceRelays).
Relay quality
app.use(RelayStats) collects per-relay connection statistics (open/close/publish/request/event counts, timestamps, recent errors) and exposes a quality score the router uses to rank relays.
const stats = app.use(RelayStats)
stats.one(url) // Readable<Maybe<RelayStatsItem>>
stats.getQuality(url) // number in [0, 1] — 0 for blocked/error-prone relaysStats are populated automatically by the appPolicyRelayStats default policy. getQuality returns 0 for non-relay URLs, relays in the user's blocked list, or error-prone relays, and higher scores for relays that are connected or have been seen before.
Tags & relay hints
Domain writers build their own tags, including relay hints. A hint is a deferred Hint object occupying the relay-hint slot of a tag; renderTags() dereferences it to a single url through the resolver (unresolved views render it as ""). The shared helpers on EventWriter are:
const writer = app.use(Domain).writer(Note)
writer.tagPubkey(pubkey, petname?) // ["p", pubkey, hint(outbox(pubkey)), petname]
writer.addQuote(event, relay?) // ["q", id, relay ?? hint(outbox(pubkey)), pubkey]
writer.addZapSplit(pubkey, split?) // ["zap", pubkey, hint(outbox(pubkey)), split]Kind-specific tagging lives on the concrete writer — e.g. NoteWriter.setParent(event) does NIP-10 reply threading (p-tags the parent's participants, then e/a-tags the parent and thread root with markers and relay hints).
A typical reply, published through the domain:
import {Note} from "@welshman/domain"
const writer = app.use(Domain)
.writer(Note)
.setContent("well said")
.setParent(parentEvent)
// render + wrap in a Command, which carries the relays it resolved
const command = await app.use(Domain).command(writer)
await command.publish()app.use(Domain).command(writer) requires a signed-in user, calls writer.render() (which renders the event and resolves its relays), and returns a Command you can .publish(). It replaces the old Router.commandFromBuilder(builder).