26 KiB
| name | description |
|---|---|
| welshman-app | Use this skill when working with @welshman/app: the instance-based client for building nostr applications — creating an App instance, the use() plugin registry, User & sessions, reactive data stores (profiles, follows, mutes, relay lists, handles, zappers), optimistic publishing with thunks, outbox-model requests, routing, web of trust, feeds, and search. |
welshman/app — Instance-Based Nostr App
Overview
@welshman/app is the high-level app layer of welshman. It ties util, net, store, domain, signer, and feeds together behind a single App instance. Everything — the event repository, connection pool, the signed-in user, and all features — hangs off that instance. There are no module-level globals: you create an app and reach everything through app.use(...).
Installation
npm install @welshman/app
# or
pnpm add @welshman/app
yarn add @welshman/app
Peer deps: svelte (4 or 5), all @welshman/* workspace packages, and @pomade/core.
Core mental model
- An app is an
Appinstance. It owns per-identity state (repository,pool,tracker,wrapManager), aconfig, and at most oneUser. Two apps never share data. - Features are plugins, resolved lazily and memoized via
app.use(SomeClass). Each plugin is constructed with the app and cached per app. Projection<T>is the universal accessor. It has.get()(sync snapshot) and.$(SvelteReadable). Bind.$in components; call.get()in callbacks/hot paths.- Reads are reactive and lazy-loading.
app.use(Profiles).one(pubkey)returns a store that fetches over the network (outbox model) and updates as events arrive. - Writes are optimistic. Publishing goes through thunks: the event hits the local repository immediately, signs lazily, and reports per-relay progress, with an abortable delay for soft-undo.
Creating an app
import {createApp} from "@welshman/app"
// Batteries-included: installs default policies (event ingestion, relay stats,
// gift-wrap unwrapping, NIP-42 auth-unless-blocked).
const app = createApp({
user, // optional User
config: {
dufflepudUrl: "https://dufflepud.example", // optional: batches NIP-05/zapper lookups
getDefaultRelays: () => [...],
getIndexerRelays: () => [...], // discovery relays for profiles/relay lists
getSearchRelays: () => [...], // NIP-50 search relays
},
})
// Bare app with NO side effects (tests, or custom policies):
import {App} from "@welshman/app"
const bare = new App()
// Always tear down when discarding an app (e.g. switching identities):
app.cleanup()
AppOptions is {user?, config?, getAdapter?, policies?}, AppConfig is the config field above, and AppPolicy is (app: IApp) => Unsubscriber.
IApp (what plugins/policies depend on): {user?, config, use, onCleanup, netContext, pool, tracker, repository, wrapManager}. A plugin registers teardown with app.onCleanup(unsubscriber); app.cleanup() runs them in reverse, then clears the pool, tracker, repository and wrap manager.
User & sessions
A User is {pubkey, signer}. A Session is a serializable {method, data} descriptor you persist; session handlers turn it back into a signer.
import {createApp, User, toSession, nip07} from "@welshman/app"
import {getNip07} from "@welshman/signer"
// Build a User from a live signer...
const user = await User.fromSigner(getNip07())
// ...or from a persisted session
const session = toSession(nip07, {}) // serializable, store this
localStorage.setItem("session", JSON.stringify(session))
const restored = await User.fromSession(JSON.parse(localStorage.getItem("session")!)) // User | undefined
const app = createApp({user: restored})
// Gate user-only actions (throws if no user):
const u = User.require(app)
await u.sign(stampedEvent)
await u.nip44EncryptToSelf(payload) // encrypt to self (private list entries)
Built-in session handlers (auto-registered): nip01 {secret}, nip07 {}, nip46 {clientSecret, signerPubkey, relays}, nip55 {pubkey, signer}, pomade {clientOptions, email}. Register custom ones with defineSessionHandler + registerSessionHandler.
nip55 additionally needs the Capacitor plugin passed to @welshman/signer once at startup, or building its signer throws "Nip55 is not enabled":
import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin"
import {setNip55Plugin} from "@welshman/signer"
setNip55Plugin(NostrSignerPlugin)
Data plugins (reactive collections)
All follow the same shape — get(key) (sync), one(key) (reactive, lazy-loads), load(key)/forceLoad(key) (promises), plus convenience accessors returning Projection. Resolve with app.use(...).
Every mutation method (create/update/follow/addRelay/setRelays/etc.) is async and returns a Command, not a Thunk — it builds the event but does not publish it. Call .publish() on the result to actually send it. See Commands below.
| Plugin | Data | Notable accessors |
|---|---|---|
Profiles |
kind-0 profiles | display(pk), update(fn) → Command; profileSearch |
FollowLists |
kind-3 follows | follow(pk, hint?, petname?), unfollow(pk), update(fn) → Command |
MuteLists |
kind-10000 mutes (private = encrypted) | mutePublicly(tag), mutePrivately(tag), unmute(v), setMutes(...) → Command |
PinLists |
kind-10001 pins | pin(tag), unpin(value) → Command |
RelayLists |
NIP-65 (kind 10002) | urls(pk), readUrls(pk), writeUrls(pk), addReadUrl/addWriteUrl, removeReadUrl/removeWriteUrl, setReadUrls/setWriteUrls → Command |
BlockedRelayLists |
kind-10006 | urls(pk), addUrl, removeUrl, setUrls → Command |
MessagingRelayLists |
kind-10050 (NIP-17 DM relays) | urls(pk), addUrl, removeUrl, setUrls → Command |
SearchRelayLists |
kind-10007 | urls(pk), addUrl, removeUrl, setUrls → Command |
BlossomServerLists |
kind-10063 media servers | urls(pk), addUrl, removeUrl, setUrls → Command |
FeedLists |
kind-10014 saved-feed lists | list accessors + update(fn) → Command |
RoomLists |
kind-10009 room lists | addRoom/removeRoom/addRelay/removeRelay/setRelays → Command |
Feeds |
kind-31890 saved feeds (keyed by address) | forAuthor(pk), loadForAuthor(pk), create(fields), update(addr, fn) → Command; makeFeedController(...) |
Pinboards |
kind-30067 pinboards (many per author, keyed by address) | forAuthor(pk), loadForAuthor(pk), create(fields), update(addr, fn) → Command |
Pins |
kind-39067 pins (keyed by address; each pin has its own d tag) |
forBoard(addr), forProfile(pk), loadForBoard(addr), loadForProfile(pk), create, update, addToBoard, removeFromBoard → Command |
Relays |
NIP-11 relay info (HTTP) | display(url), hasNip(url, n), hasNegentropy(url); relaySearch |
RelayManagement |
NIP-86 mgmt API | forUrl(url) → a ManagementApi client that signs auth as the app's user (role/member ops, ban/allow, …) |
RelayStats |
per-relay connection counters | get(url), getQuality(url) (0–1, drives router ranking) |
RelayRoles / RelayMemberLists / RoomPinLists |
relay-signed state, keyed per relay | relay-scoped collections (see RelaySignedDerivedPlugin) |
Handles |
NIP-05 (HTTP, batched) | forPubkey(pk), display(nip05), loadForPubkey(pk) |
Zappers |
LNURL zapper info (HTTP) | forPubkey(pk), validateZapReceipt(...), validateZapReceipts(...), validZapReceipts(...) |
Topics |
hashtags w/ counts | all, byName (Projections); topicSearch |
Reactions / Deletes |
kind-7 reactions and kind-5 deletes over the repository | reactive lookups |
Rooms |
NIP-29 rooms, keyed ${url}'${h} |
forRoom(url, h), forUrl(url), members(url, h), membershipStatus(...), pendingJoins(url, h?), createRoom/editRoom/deleteRoom/joinRoom/leaveRoom/addMember/removeMember(url, room, …) → Command |
Plaintext |
decrypted-content cache, keyed by ciphertext | ensure(ciphertext, decrypt), get(ciphertext) |
import {createApp, Profiles, RelayLists} from "@welshman/app"
const app = createApp({user})
// Reactive (Svelte): subscribe or use $ in a component
const profile$ = app.use(Profiles).one(pubkey) // Readable<Maybe<Profile>>, lazy-loads
const name$ = app.use(Profiles).display(pubkey).$ // Readable<string>
// Synchronous snapshot (no load)
const profileNow = app.use(Profiles).get(pubkey)
// Explicit load
await app.use(Profiles).load(pubkey)
// Relay selections (outbox model)
const writeRelays = app.use(RelayLists).writeUrls(pubkey).get() // string[]
// Mutations return a Command — build it, then decide how to publish it
const command = await app.use(RelayLists).addWriteUrl("wss://relay.example")
command.publish() // normal outbox/relays flow via Thunks
// or: command.publishToRelays(["wss://relay.example"]) // send straight to one relay
// Since these methods are async, `publish`/`publishToRelays` free functions avoid a double-await:
import {publish} from "@welshman/app"
await app.use(RelayLists).addWriteUrl("wss://relay.example").then(publish)
Publishing (optimistic thunks)
import {Thunks, Router} from "@welshman/app"
import {makeEvent, NOTE, userOutbox} from "@welshman/util"
// There's no dedicated outbox helper on Thunks — resolve write relays yourself via the
// Router's Resolver + the RelaySelection DSL (this is what Command.publish() does under the
// hood for every data-plugin mutation, whose `relays` come from the writer's own routes):
const thunk = app.use(Thunks).publish({
event: makeEvent(NOTE, {content: "hi"}),
relays: await app.use(Router).resolver.relays([userOutbox()]), // Promise<string[]>
delay: 3000, // abortable soft-undo window (ms)
})
// To specific relays:
app.use(Thunks).publish({event, relays: ["wss://relay.example"]})
// A thunk is a Svelte store with per-relay status:
thunk.subscribe(t => console.log(t.results))
thunk.abort() // effective only before `delay` elapses
await thunk.waitForCompletion()
thunk.getError() // string | undefined
app.use(Thunks).history // writable<Thunk[]> — optimistic log
app.use(Thunks).retry(thunk)
// Gift-wrapped (NIP-59): single recipient via `recipient`, or many via Wraps:
app.use(Thunks).publish({event, relays, recipient: theirPubkey})
const merged = await app.use(Wraps).publish({event: rumor, recipients: [a, b]})
// Proof of work (NIP-13):
app.use(Thunks).publish({event, relays, pow: 20})
ThunkOptions: {event, relays?, recipient?, delay?, pow?, ...PublishOptions} (app is injected). Incoming wraps addressed to the user are auto-unwrapped by the default appPolicyWraps.
Commands (deferred publishing)
Data-plugin mutation methods (create, update, follow, addRelay, setRelays, Rooms.*, …) don't publish — they build the EventTemplate and the relays it would go to, and hand back a Command for you to decide what to do with:
import type {Command} from "@welshman/app"
const command: Command = await app.use(FollowLists).follow(["p", otherPubkey])
command.app // the IApp it was built for
command.event // EventTemplate — unsigned, inspectable before publishing
command.relays // string[] — where publish() will send it
command.publish() // normal path: app.use(Thunks).publish({event, relays: command.relays})
command.publishToRelays(urls) // publish to a specific relay set instead of command.relays
This lets a caller preview/log a command, choose a different transport, or drop it entirely, instead of every plugin method publishing unconditionally. Wraps.publish is the one exception — it fans a single rumor out to a MergedThunk of per-recipient wraps (each with its own relays), which doesn't fit the one-event/one-relay-set Command shape, so it still publishes directly.
publish/publishToRelays are also exported as free functions (e.g. (command) => command.publish(), (urls) => (command) => command.publishToRelays(urls)) so you can chain straight off the mutation method's promise instead of double-awaiting:
import {publish, publishToRelays} from "@welshman/app"
await app.use(FollowLists).follow(["p", otherPubkey]).then(publish)
await app.use(Rooms).leave(relayUrl, roomMeta).then(publish)
await app.use(Rooms).join(relayUrl, roomMeta).then(publishToRelays([relayUrl]))
Requests & sync
import {Network, Sync} from "@welshman/app"
const net = app.use(Network)
const events = await net.load({filters: [{kinds: [1], authors: [pk]}], relays})
await net.request({filters, relays, autoClose: true})
// Outbox-model author load (resolves the author's write relays automatically).
// loadUsingOutbox returns the newest matching event; loadAllUsingOutbox returns them all.
const profileEvent = await net.loadUsingOutbox(pk, {kinds: [0]})
const allFeeds = await net.loadAllUsingOutbox(pk, {kinds: [31890]})
// A loader with different batching, still bound to this app's net context:
const slowLoad = net.makeLoader({delay: 500, timeout: 5000, threshold: 0.5})
// Negentropy-aware reconciliation (falls back to request/publish when unsupported):
await app.use(Sync).pull({relays, filters: [{authors: [pk]}]})
await app.use(Sync).push({relays, filters: [{authors: [pk]}]})
Querying the repository (Events)
Network fetches; Events reads what's already local. Every method binds this app's repository
and tracker and returns a Projection — .get() for a snapshot, .$ to subscribe — so there's no
get/derive pair to keep in sync.
import {Events} from "@welshman/app"
const events = app.use(Events)
events.byId(filters).$ // Map<id, TrustedEvent>
events.all(filters).$ // repository order
events.asc(filters).$ // oldest first
events.desc(filters).$ // newest first
events.one(idOrAddress, hints) // one event, loaded on first read if missing
events.isDeleted(event).$
// Scoped to a relay, via the tracker
events.byIdForUrl(url, filters).$
events.forUrl(url, filters).$
events.byIdByUrl(filters).$ // Map<url, Map<id, TrustedEvent>>
events.relaySignedForUrl(url, filters).$ // only what the relay itself signed
relaySignedForUrl is the loose counterpart to RelaySignedDerivedPlugin — relay-generated kinds
mean nothing from another author, so anything not signed by the relay's NIP-11 self is dropped.
Routing & tags
app.use(Router) turns the declarative RelaySelection DSL (from @welshman/util) into scored relay urls. It exposes a Resolver (router.resolver) plus a resolve(selections) shortcut. That same resolver is injected into every @welshman/domain kind by app.use(Domain), so writers/readers route through it too.
import {Router} from "@welshman/app"
import {userOutbox, outbox, seen, relay, addMinimalFallbacks} from "@welshman/util"
const router = app.use(Router) // per-app; NOT Router.get()
// resolver.relays(...) -> Promise<string[]>; resolver.relay(...) -> Promise<string | undefined>
const writeRelays = await router.resolver.relays([userOutbox()])
const hint = await router.resolver.relay([seen({id: event.id})])
// resolve(...) -> Promise<RelayScenario>; then tune fallbacks/limit and read urls
const relays = (await router.resolve([userOutbox()])).policy(addMinimalFallbacks).limit(8).getUrls()
// DSL selectors: userInbox/userOutbox/userMessaging, inbox(pk)/outbox(pk)/messaging(pk),
// inboxes(pks), eventInbox(ref)/eventOutbox(ref), seen(ref), relay(url)/relays(urls),
// indexers(), searchRelays() — each returns a RelaySelection (relays/inboxes return arrays).
Event tagging (reply/quote/reaction threading, p-tags, zap splits) now lives on the domain writers — writer.tagPubkey(pk), writer.addQuote(event), writer.addZapSplit(pk), and kind-specific setters like NoteWriter.setParent(parentEvent) — not on a separate Tags plugin. See the welshman-domain skill.
Router is the one ResolveRoute implementation in the stack. It resolves each route against the app:
- inbox / outbox —
app.use(RelayLists).load(pubkey)thenreadUrls()/writeUrls()(NIP-65, kind 10002). - messaging —
app.use(MessagingRelayLists).load(pubkey)(kind 10050). - eventInbox / eventOutbox — a known
ref.pubkeyroutes directly; otherwiseref.idis looked up in the repository to find the author.ref.relaysare always included. - seen —
app.tracker.getRelays(ref.id), or for a replaceablerefthe tracker entry of the event at its address, plusref.relays. - index / search —
app.config.getIndexerRelays?.()/getSearchRelays?.().
When app.user is undefined, user* routes resolve to no relays rather than throwing.
Router also satisfies @welshman/feeds' FeedRouter interface, which is how app.use(Feeds).makeFeedController(...) routes a feed's filters.
Relay quality
The resolver ranks relays by app.use(RelayStats).getQuality(url), 0–1:
| Score | Condition |
|---|---|
0 |
not a relay url, blocked by the user's kind-10006 list, or recently error-prone (any error in the last minute, >3 in an hour, >10 in a day) |
1 |
already in the pool |
0.9 |
connected at some point before |
0.8 |
a normal wss:// url with no history |
0.7 |
an IP, local, onion, or plain-ws:// url with no history |
A relay scoring 0 is dropped from the scenario's result entirely rather than deprioritized, so a scenario can come back empty even though its selections resolved to urls.
The DSL constructors, RelayScenario scoring and the fallback policies are documented in the welshman-util skill.
Web of trust
Built from the public p tags on follow (kind 3) and mute (kind 10000) lists as they land in the repository. Every read is a Projection (.get() / .$), and reads about a pubkey take a WotScope:
WotScope.Global— counts every list in the repository.WotScope.Follows— counts only lists published by the user's own follows, i.e. the pubkey as this user sees it. With no signed-in user it falls back to global.
import {Wot, WotScope} from "@welshman/app"
const wot = app.use(Wot)
wot.follows(pk).get() // string[] — who pk follows
wot.mutes(pk).get() // string[] — who pk mutes
wot.followers(pk, WotScope.Follows).get() // string[]
wot.muters(pk, WotScope.Follows).get() // string[]
wot.score(pk, WotScope.Follows).get() // number — followers − muters, within scope
wot.network(pk).get() // follows-of-follows (minus direct follows)
wot.scores(WotScope.Follows).get() // Map<pubkey, score> — the whole picture at once
Use scores(scope) when ranking a list (search results, a WoT range); it walks the graph once instead of once per pubkey.
Feeds & search
import {makeIntersectionFeed, makeScopeFeed, makeKindFeed, Scope} from "@welshman/feeds"
import {get} from "svelte/store"
const controller = app.use(Feeds).makeFeedController({
feed: makeIntersectionFeed(makeScopeFeed(Scope.Follows), makeKindFeed(1)),
onEvent: event => {/* render */},
})
await controller.load(50) // scopes (Self/Follows/Network/Followers) resolved via Wot
// Search lives on the collection that owns the data. There is no Searches plugin.
const search = get(app.use(Profiles).profileSearch)
const pubkeys = search.searchValues("alice") // also fires a NIP-50 network search; ranked by WoT
// also: app.use(Topics).topicSearch, app.use(Relays).relaySearch
// createSearch(options, {...}) builds a custom index over anything else
Plugin architecture (for extending)
Base classes in plugins/base.ts:
DerivedPlugin<T>— collection derived from repository events (the repo is the single source of truth). Pass{filters, eventToItem, getKey, loadOptions?}; implementfetch. This is the dominant pattern. Gives youindex/all(Projections),get(key),one(key),load/forceLoad, andproject(key, read).RelayScopedDerivedPlugin<T>— the same, keyed per relay via the tracker (getKey(item, url)), so the same addressable coordinate on two relays stays two entries.RelaySignedDerivedPlugin(inplugins/relays.ts) narrows it further to events signed by the relay's own NIP-11selfkey, which is whatRelayRoles,RelayMemberListsandRoomPinListsuse.LoadableMapPlugin<T>— owns its ownMap, lazily fetches over HTTP (e.g.Relays,Handles,Zappers). Implementfetch.MapPlugin<T>— owns its ownMap, no network (e.g.RelayStats,Plaintext).
Decode events with the app-configured @welshman/domain reader (app.use(Domain).reader(Kind)) as eventToItem, and mutate through app.use(Domain).writer(Kind, reader?) + app.use(Domain).command(writer):
import {DerivedPlugin, Network, Domain, User, type IApp} from "@welshman/app"
import {SOME_KIND} from "@welshman/util"
import {SomeKind, SomeKindReader, SomeKindWriter} from "@welshman/domain"
export class Somethings extends DerivedPlugin<SomeKindReader> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [SOME_KIND]}],
eventToItem: app.use(Domain).reader(SomeKind), // async: validates kind + parses
getKey: item => item.author(),
})
}
fetch = (pk: string, hints: string[] = []) =>
this.app.use(Network).loadUsingOutbox(pk, {kinds: [SOME_KIND]}, hints)
// Build a writer (optionally seeded from the current reader for edits), mutate it, then
// wrap it in a Command via Domain.command — the caller decides when/how to publish.
update = async (fn: (writer: SomeKindWriter) => void) => {
const user = User.require(this.app)
const writer = this.app.use(Domain).writer(SomeKind, await this.forceLoad(user.pubkey))
fn(writer)
return this.app.use(Domain).command(writer)
}
}
const things = app.use(Somethings) // lazily constructed + memoized
Caching/backoff for load come from makeLoadItem (@welshman/store); default staleness window is 1 hour; forceLoad bypasses it.
Policies & logging
Side effects live in AppPolicys ((app) => Unsubscriber), run at construction, cleaned up by cleanup().
defaultAppPolicies=[appPolicyIngest, appPolicyRelayStats, appPolicyWraps, appPolicyCacheDecrypt, appPolicyLogSignerMethods, appPolicyAuthUnlessBlocked].- Auth builders:
makeAppPolicyAuth(shouldAuth),appPolicyAuthAlways,appPolicyAuthNever,appPolicyAuthUnlessBlocked. appPolicyCacheDecryptandappPolicyLogSignerMethodsboth layer onto the user's signer viaUser.wrapSigner— the first caches decryptions intoapp.use(Plaintext), the second records signer calls intoapp.use(Logger)(read them fromapp.use(Logger).messages).
// Opt out of a default, or add your own:
import {App, defaultAppPolicies, appPolicyAuthNever, appPolicyIngest} from "@welshman/app"
const app = new App({user, policies: [appPolicyIngest, appPolicyAuthNever]})
Gotchas & tips
use()is memoized per app.app.use(Profiles)always returns the same instance for a given app. Cheap to call repeatedly.ProjectionvsReadable. Convenience accessors (display,urls,score, …) return aProjection— use.$for the store,.get()for a snapshot.one(key)returns a plainReadable(and triggers a load on subscribe).get(key)does not load;one(key)/load(key)do. Usegetfor a pure cache read.- Most loads use the outbox model, which needs the author's relay list.
loadUsingOutbox(and therefore mostfetchmethods) first loads NIP-65 relays for the author. createAppvsnew App.createAppinstalls default policies;new Appinstalls none. In tests prefernew App(no background subscriptions) unless you need ingestion.- Pass the
usertocreateApp/new App, don't assignapp.userafterwards. Policies run once, at construction.appPolicyCacheDecryptandappPolicyLogSignerMethodsbail out immediately when there is no user, so a user attached later gets no decrypt caching and no signer log. To switch identities, build a new app andcleanup()the old one. - Call
cleanup()when discarding an app to close sockets and free the repository/tracker/wrap state.
Old API → new API
| Old (global) | New (instance-based) |
|---|---|
addSession(...) / pubkey.get() |
User.fromSession(...) + createApp({user}); app.user?.pubkey |
deriveProfile(pk) |
app.use(Profiles).one(pk) |
deriveProfileDisplay(pk) |
app.use(Profiles).display(pk).$ |
publishThunk({...}) |
app.use(Thunks).publish({...}) (resolve outbox relays via await app.use(Router).resolver.relays([userOutbox()])) |
follow(tag) / mute(tag) |
app.use(FollowLists).follow(tag).then(publish) / app.use(MuteLists).mutePublicly(tag).then(publish), which return a Command |
load({...}) / request({...}) |
app.use(Network).load({...}) / request({...}) |
Router.get().FromUser() / router.Event(e) |
app.use(Router).resolver + the RelaySelection DSL (resolver.relays([userOutbox()]), resolver.relay([seen(e)])) |
app.use(Tags).tagEventForReply(e) |
domain writer tagging (NoteWriter.setParent(e), writer.tagPubkey/addQuote/addZapSplit) |
relays / handles / zappers stores |
app.use(Relays) / Handles / Zappers |
app.use(Searches).profileSearch |
app.use(Profiles).profileSearch (likewise Topics.topicSearch, Relays.relaySearch) |
wot.graph / wot.wotScore(a, b) |
app.use(Wot).scores(WotScope.Follows) / .score(pk, scope) |
RelayLists.addRelay(url, mode) |
RelayLists.addReadUrl(url) / addWriteUrl(url) |
Related skills
welshman-store— theRepositoryand Svelte-store primitives this layer builds on.welshman-domain— theKind/reader/writer model behindapp.use(Domain)(event decoding + publishing).welshman-util— theRelaySelectionDSL,ResolverandRelayScenariothatapp.use(Router)dereferences.welshman-net— request/publish/sockets behindapp.use(Network).welshman-signer— signers and login methods used byUser/sessions.welshman-feeds— feed construction used byapp.use(Feeds).