rename client, update docs/skills
tests / tests (push) Failing after 5m4s

This commit is contained in:
2026-06-18 19:31:14 +00:00
parent dfeb7a747b
commit fe5c11b00f
92 changed files with 1811 additions and 5268 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ npx skills add coracle-social/welshman
| welshman-store | Svelte stores and Repository pattern |
| welshman-signer | Signing, login methods, encrypted events |
| welshman-feeds | Dynamic feed construction |
| welshman-app | High-level app-layer Svelte stores |
| welshman-app | Instance-based client: plugins, sessions, publishing, requests |
| welshman-content | Note content parsing and rendering |
| welshman-editor | Svelte rich-text editor component |
+238 -578
View File
@@ -1,11 +1,15 @@
---
name: welshman-app
description: "Use this skill when working with @welshman/app: high-level Svelte stores for nostr apps, session management, WoT (web of trust), making requests, publishing events, user data, or relay selection at the app layer."
description: "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 — Application Layer Stores
# welshman/app — Instance-Based Nostr App
`@welshman/app` is the top-level application framework in the welshman stack. It wires together `@welshman/net` (subscriptions/publishing), `@welshman/store` (reactive collections), `@welshman/router` (relay selection), and `@welshman/signer` (key management) into ready-to-use Svelte stores and high-level utilities. It powers production apps like Coracle and Flotilla.
## Overview
`@welshman/app` is the high-level app layer of welshman. It ties `util`, `net`, `store`, `router`, `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(...)`.
This is a redesign of the older global-singleton API. If you see code using `pubkey`, `deriveProfile`, `publishThunk`, `addSession`, or `Router.get()` as importable globals, that is the **old** API — it no longer exists. The current API is instance-based (see the migration table at the bottom).
## Installation
@@ -16,619 +20,275 @@ pnpm add @welshman/app
yarn add @welshman/app
```
## Key Exports
Peer deps: `svelte` (4 or 5), all `@welshman/*` workspace packages, and `@pomade/core`.
### Core Singletons
## Core mental model
| Export | Description |
|---|---|
| `repository` | Singleton `Repository` from `@welshman/net`; non-DVM, non-ephemeral events received from the pool are stored here. WRAP (NIP-59) events are handled separately via `unwrapAndStore` and require `shouldUnwrap` to be set to `true` to process. |
| `tracker` | Singleton `Tracker`; maps event IDs to the relays they were seen on |
1. **An app is an `App` instance.** It owns per-identity state (`repository`, `pool`, `tracker`, `wrapManager`), a `config`, and at most one `User`. Two apps never share data.
2. **Features are plugins**, resolved lazily and memoized via `app.use(SomeClass)`. Each plugin is constructed with the app and cached per app.
3. **`Projection<T>` is the universal accessor.** It has `.get()` (sync snapshot) and `.$` (Svelte `Readable`). Bind `.$` in components; call `.get()` in callbacks/hot paths.
4. **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.
5. **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.
### Session Management
| Export | Description |
|---|---|
| `pubkey` | `Writable<string \| undefined>` — active session's pubkey |
| `session` | `Readable<Session \| undefined>` — derived from `pubkey` + `sessions` |
| `sessions` | `Writable<Record<string, Session>>` — all loaded sessions |
| `signer` | `Readable<ISigner \| undefined>` — signer for the active session |
| `signerLog` | `WritableWithGetter<SignerLogEntry[]>` — writable store that the session layer appends signer-operation entries to (useful for UI feedback during remote signing) |
| `SessionMethod` | Enum: `Nip01`, `Nip07`, `Nip46`, `Nip55`, `Pomade`, `Pubkey`, `Anonymous` |
**Login functions** (all call `addSession` internally):
## Creating an app
```typescript
loginWithNip01(secret: string): void
loginWithNip07(pubkey: string): void
loginWithNip46(pubkey, clientSecret, signerPubkey, relays): void
loginWithNip55(pubkey: string, signerPackageName: string): void
loginWithPomade(pubkey, email, clientOptions): void
loginWithPubkey(pubkey: string): void // read-only
```
import {createApp} from "@welshman/app"
**Session utilities**:
```typescript
addSession(session: Session): void // add and activate
dropSession(pubkey: string): void // remove and clean up signer
getSession(pubkey: string): Session | undefined
updateSession(pubkey, fn): void
clearSessions(): void
nip46Perms: string // default NIP-46 permission string
```
**Gift wrap (NIP-59)**:
```typescript
shouldUnwrap: Writable<boolean> // must be true to process incoming wraps
wrapManager: WrapManager // tracks wrap↔rumor mappings
unwrapAndStore(wrap: SignedEvent): Promise<void>
```
### Publishing — Thunks
| Export | Description |
|---|---|
| `publishThunk(options: ThunkOptions): Thunk` | Create, enqueue, and optimistically publish an event |
| `Thunk` | Class representing a single in-flight publish |
| `MergedThunk` | Aggregates multiple `Thunk`s (used by `sendWrapped`) |
| `thunks` | `Writable<Thunk[]>` — all active thunks |
| `abortThunk(thunk)` | Abort all constituent thunks |
| `retryThunk(thunk)` | Re-publish with original options |
| `mergeThunks(thunks[])` | Combine into a `MergedThunk` |
**Thunk status helpers**:
```typescript
thunkIsComplete(thunk): boolean
getThunkError(thunk): string | undefined
getThunkUrlsWithStatus(statuses, thunk): string[]
getCompleteThunkUrls(thunk): string[]
getFailedThunkUrls(thunk): string[]
waitForThunkError(thunk): Promise<string>
waitForThunkCompletion(thunk): Promise<void>
```
`ThunkOptions`:
```typescript
type ThunkOptions = {
event: EventTemplate // unsigned — will be signed lazily
relays: string[]
recipient?: string // if set, event is NIP-59 gift-wrapped
delay?: number // ms to wait before sending (abort window)
pow?: number // proof-of-work difficulty target
timeout?: number // ms per relay before marking as timed out
// PublishOptions callbacks: onSuccess, onFailure, onPending, onTimeout, onAborted, onComplete
}
```
### Commands (Higher-level Thunk Factories)
Most return a `Thunk` (or `Promise<Thunk>`). They automatically load the relevant user list before modifying it. Exception: `manageRelay` returns `Promise<Response>` (an HTTP response from the NIP-86 management endpoint), not a Thunk.
| Export | Description |
|---|---|
| `setProfile(profile: Profile)` | Publish NIP-01 profile metadata |
| `follow(tag: string[])` | Add to NIP-02 follow list |
| `unfollow(value: string)` | Remove from follow list |
| `mutePublicly(tag)` | Add to public mute list |
| `mutePrivately(tag)` | Add to private (encrypted) mute list |
| `unmute(value)` | Remove from mute list |
| `setMutes({publicTags?, privateTags?})` | Replace entire mute list |
| `pin(tag)` / `unpin(value)` | Manage pin list |
| `addRelay(url, mode)` / `removeRelay(url, mode)` | NIP-65 relay list management |
| `setRelays(tags)` / `setReadRelays(urls)` / `setWriteRelays(urls)` | Bulk relay list updates |
| `addMessagingRelay(url)` / `removeMessagingRelay(url)` | NIP-17 messaging relay list |
| `addBlockedRelay(url)` / `removeBlockedRelay(url)` | Blocked relay list |
| `addSearchRelay(url)` / `removeSearchRelay(url)` | Search relay list |
| `sendWrapped({event, recipients, ...options})` | NIP-59 gift-wrap to multiple recipients |
| `manageRelay(url, request)` | NIP-86 relay management |
| `createRoom` / `editRoom` / `deleteRoom` / `joinRoom` / `leaveRoom` | NIP-29 group room management |
### Profiles
```typescript
profilesByPubkey: Readable<Map<string, Profile>>
profiles: Readable<Profile[]>
getProfile(pubkey: string): Profile | undefined
loadProfile(pubkey, relayHints?): Promise<void>
forceLoadProfile(pubkey, relayHints?): Promise<void>
deriveProfile(pubkey: string): Readable<Profile | undefined> // auto-loads
deriveProfileDisplay(pubkey: string): Readable<string> // display name with fallback
displayProfileByPubkey(pubkey: string): string // synchronous
```
### Follow / Mute / Pin Lists
Each list type follows the same pattern (`follow` shown, `mute` and `pin` are identical):
```typescript
followListsByPubkey: Readable<Map<string, List>>
followLists: Readable<List[]>
getFollowList(pubkey): List | undefined
loadFollowList(pubkey, relayHints?): Promise<void>
forceLoadFollowList(pubkey, relayHints?): Promise<void>
deriveFollowList(pubkey): Readable<List | undefined>
```
### Relay Lists
```typescript
// NIP-65 relay lists
relayListsByPubkey / relayLists / getRelayList / loadRelayList / deriveRelayList
// NIP-17 messaging relay lists
messagingRelayListsByPubkey / messagingRelayLists / getMessagingRelayList / loadMessagingRelayList / deriveMessagingRelayList
// Blocked relay lists
blockedRelayListsByPubkey / getBlockedRelayList / loadBlockedRelayList / deriveBlockedRelayList
// Search relay lists (internal only — not exported from @welshman/app)
// Use userSearchRelayList / loadUserSearchRelayList / forceLoadUserSearchRelayList from user.ts instead
```
### Outbox Loading
`makeOutboxLoader` creates a loader function for any event kind. It looks up the target pubkey's relay list (fetching it if needed), then fetches events from their write relays using the outbox model. This is the internal mechanism used by all built-in `loadX` helpers.
```typescript
import {makeOutboxLoader} from '@welshman/app'
// Signature: makeOutboxLoader(kind, filter?, limit?)
// Returns: (pubkey: string, relayHints?: string[]) => Promise<void>
// Results are stored in repository — read via the derived store/getter, not the return value.
// Loader for kind 1 notes (default limit = 1)
const loadNote = makeOutboxLoader(1)
await loadNote('target-pubkey')
// With extra filter constraints
const loadRecentNotes = makeOutboxLoader(1, {since: Math.floor(Date.now() / 1000) - 86400})
await loadRecentNotes('target-pubkey')
// Override the limit via the third positional argument (not inside the filter object)
const loadMany = makeOutboxLoader(1, {}, 20)
await loadMany('target-pubkey')
// With relay hints to seed the lookup
await loadNote('target-pubkey', ['wss://relay.damus.io/'])
```
**Relay URL helpers** (exported from index):
```typescript
getPubkeyRelays(pubkey: string, mode?: RelayMode): string[]
derivePubkeyRelays(pubkey: string, mode?: RelayMode): Readable<string[]>
```
### User Data Stores (current session)
These automatically derive from the active `pubkey` and trigger a load on first access:
```typescript
userProfile: Readable<Profile | undefined>
userFollowList: Readable<List | undefined>
userMuteList: Readable<List | undefined>
userPinList: Readable<List | undefined>
userRelayList: Readable<List | undefined>
userMessagingRelayList: Readable<List | undefined>
userSearchRelayList: Readable<List | undefined>
userBlockedRelayList: Readable<List | undefined>
userBlossomServerList: Readable<List | undefined>
```
Corresponding loaders (operate on the current session's pubkey):
```typescript
loadUserProfile(relays?)
forceLoadUserProfile(relays?)
loadUserFollowList / forceLoadUserFollowList
loadUserMuteList / forceLoadUserMuteList
loadUserPinList / forceLoadUserPinList
loadUserRelayList / forceLoadUserRelayList
loadUserMessagingRelayList / forceLoadUserMessagingRelayList
// ...etc for each list type
```
### Router
```typescript
import {Router, routerContext, addMaximalFallbacks, addMinimalFallbacks} from '@welshman/router'
// The index.ts wires up routerContext automatically:
// routerContext.getUserPubkey, getPubkeyRelays, getRelayQuality, getDefaultRelays, etc.
Router.get() // singleton with app-wired context
Router.get().FromUser() // relays to publish from the current user
Router.get().ForPubkey(pubkey) // relays to read a pubkey's events
Router.get().Event(event) // best relay for a specific event
Router.get().Index() // indexer/bootstrap relays
Router.get().FromRelays(urls) // relay set from explicit URLs
.policy(addMaximalFallbacks) // add fallback relays
.limit(8)
.getUrls() // string[]
.getUrl() // string | undefined (first)
```
`routerContext` settings (configure before using router):
```typescript
import {routerContext} from '@welshman/router' // from @welshman/router, not @welshman/app
routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"]
```
### Tag Utilities
```typescript
tagPubkey(pubkey: string): string[] // ["p", pubkey, relayHint, displayName]
tagEvent(event, url?, mark?): string[][] // e-tag (+ a-tag if replaceable)
tagEventPubkeys(event): string[][] // p-tags for all mentioned pubkeys (excl. self)
tagEventForQuote(event, relay?): string[] // q-tag
tagEventForReply(event, relay?): string[][] // full reply thread tags
tagEventForComment(event, relay?): string[][]// NIP-22 comment tags
tagEventForReaction(event, relay?): string[][]// reaction tags
tagZapSplit(pubkey, split?): string[] // zap tag
```
### Web of Trust (WoT)
```typescript
// Reactive stores
followersByPubkey: Readable<Map<string, Set<string>>>
mutersByPubkey: Readable<Map<string, Set<string>>>
wotGraph: Writable<Map<string, number>> // pubkey → score; rebuilt on follow/mute changes
maxWot: Readable<number>
// Synchronous getters
getFollows(pubkey): string[]
getMutes(pubkey): string[]
getFollowers(pubkey): string[]
getMuters(pubkey): string[]
getNetwork(pubkey): string[] // follows-of-follows (excludes direct follows)
getFollowsWhoFollow(pubkey, target): string[]
getFollowsWhoMute(pubkey, target): string[]
getWotScore(pubkey, target): number // follows-who-follow minus follows-who-mute
// Per-user reactive score
getUserWotScore(tpk: string): number
deriveUserWotScore(tpk: string): Readable<number>
```
### Handles & Zappers
```typescript
handlesByNip05: Writable<Map<string, Handle>>
deriveHandle(nip05: string): Readable<Handle | undefined> // auto-loads
loadHandle(nip05): Promise<void>
zappersByLnurl: Writable<Map<string, Zapper>>
deriveZapper(lnurl: string): Readable<Zapper | undefined> // auto-loads
loadZapper(lnurl): Promise<void>
```
### Feeds
```typescript
import {makeFeedController} from '@welshman/app'
import {makeKindFeed} from '@welshman/feeds'
// makeFeedController wraps FeedController with app-level scope/WoT helpers
const ctrl = makeFeedController({
feed: makeKindFeed(NOTE),
useWindowing: true,
signal: abortController.signal,
onEvent: (e) => { /* handle event */ },
onExhausted: () => { /* no more events */ },
// 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
},
})
ctrl.load(100)
abortController.abort()
// 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()
```
WoT-scoped feed helpers (passed automatically to `FeedController`):
`IApp` (what plugins/policies depend on): `{user?, config, use, netContext, pool, tracker, repository, wrapManager}`.
## 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.
```typescript
getPubkeysForScope(scope: string): string[] // Scope.Self|Follows|Network|Followers
getPubkeysForWOTRange(min: number, max: number): string[] // fractional of maxWot
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)
```
### Sync (Negentropy)
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`.
## 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(...)`.
| Plugin | Data | Notable accessors |
|---|---|---|
| `Profiles` | kind-0 profiles | `one(pk)`, `display(pk)`, `publish(profile)` |
| `FollowLists` | kind-3 follows | `one(pk)`, `follow(tag)`, `unfollow(value)` |
| `MuteLists` | kind-10000 mutes (private = encrypted) | `mutePublicly(tag)`, `mutePrivately(tag)`, `unmute(v)`, `setMutes(...)` |
| `PinLists` | kind-10001 pins | `pin(tag)`, `unpin(value)` |
| `RelayLists` | NIP-65 (kind 10002) | `urls(pk)`, `readUrls(pk)`, `writeUrls(pk)`, `addRelay(url, mode)`, `setWriteRelays(urls)` |
| `BlockedRelayLists` | kind-10006 | `urls(pk)`, `addRelay`, `removeRelay`, `setRelays` |
| `MessagingRelayLists` | kind-10050 (NIP-17 DM relays) | `urls(pk)`, `addRelay`, ... |
| `SearchRelayLists` | kind-10007 | `urls(pk)`, `addRelay`, ... |
| `Relays` | NIP-11 relay info (HTTP) | `one(url)`, `display(url)`, `hasNip(url, n)`, `hasNegentropy(url)` |
| `RelayManagement` | NIP-86 | `post(url, request)` |
| `Handles` | NIP-05 (HTTP, batched) | `forPubkey(pk)`, `display(nip05)`, `loadForPubkey(pk)` |
| `Zappers` | LNURL zapper info (HTTP) | `forPubkey(pk)`, `validateZapReceipt(...)`, `validZapReceipts(...)` |
| `BlossomServerLists` | kind-10063 media servers | `one(pk)`, `load(pk)` |
| `Topics` | hashtags w/ counts | `all`, `byName` (plain `Readable`s) |
| `Rooms` | NIP-29 groups | `create/edit/delete/join/leave/addMember/removeMember(url, room, ...)` |
| `Plaintext` | decrypted-content cache (own events) | `ensure(event)`, `get(id)` |
```typescript
import {pull, push, hasNegentropy} from '@welshman/app'
import {createApp, Profiles, RelayLists} from "@welshman/app"
const app = createApp({user})
// pull/push use negentropy if the relay supports it, falling back to plain requests
await pull({relays, filters})
await push({relays, filters})
hasNegentropy(url: string): boolean
// 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[]
await app.use(RelayLists).addRelay("wss://relay.example", RelayMode.Write)
```
### Application Context
## Publishing (optimistic thunks)
```typescript
import {appContext} from '@welshman/app'
import {Thunks} from "@welshman/app"
import {makeEvent, NOTE} from "@welshman/util"
appContext.dufflepudUrl = 'https://my-dufflepud.example.com'
```
[Dufflepud](https://github.com/coracle-social/dufflepud) is an optional proxy server for NIP-05 lookups, zapper resolution, relay metadata, and link previews. Not required but helps bypass CORS.
---
## Common Patterns
### Login and publish a note
```typescript
import {makeSecret} from '@welshman/util'
import {loginWithNip07, publishThunk, signer} from '@welshman/app'
import {Router} from '@welshman/router'
import {NOTE, makeEvent} from '@welshman/util'
import {Nip07Signer} from '@welshman/signer'
// NIP-07 login
const nip07 = new Nip07Signer()
const pubkey = await nip07.getPubkey()
loginWithNip07(pubkey)
// Publish with optimistic local update and 3s undo window
const thunk = publishThunk({
event: makeEvent(NOTE, {content: 'Hello Nostr!'}),
relays: Router.get().FromUser().getUrls(),
delay: 3000,
// To the user's write relays (resolved via the Router):
const thunk = app.use(Thunks).publishToOutbox({
event: makeEvent(NOTE, {content: "hi"}),
delay: 3000, // abortable soft-undo window (ms)
})
// Subscribe to per-relay status
thunk.subscribe($thunk => {
for (const [url, result] of Object.entries($thunk.results)) {
console.log(url, result.status, result.detail)
// 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`.
## Requests & sync
```typescript
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):
const profileEvent = await net.loadUsingOutbox(pk, {kinds: [0]})
// 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]}]})
```
## Routing & tags
```typescript
import {Router, Tags} from "@welshman/app"
import {addMinimalFallbacks} from "@welshman/router"
const router = app.use(Router) // per-app; NOT Router.get()
const relays = router.FromUser().policy(addMinimalFallbacks).limit(8).getUrls()
const hint = router.Event(event).getUrl()
// Scenes: FromUser(), FromPubkey(pk), FromRelays(urls), Event(e), EventRoots(e), Search()
const tags = app.use(Tags)
const replyTags = tags.tagEventForReply(parentEvent) // also: tagPubkey, tagEvent,
// tagEventForComment/Quote/Reaction, tagZapSplit
app.use(Thunks).publishToOutbox({event: makeEvent(NOTE, {content: "ok", tags: replyTags})})
```
Relay quality used by the router comes from `app.use(RelayStats).getQuality(url)` (01; 0 for blocked/error-prone relays).
## Web of trust
```typescript
const wot = app.use(Wot)
wot.graph.get() // Map<pubkey, score> (score = #roots following #roots muting)
wot.max.get() // highest score
wot.follows(pk).get() // string[]
wot.network(pk).get() // follows-of-follows (minus direct follows)
wot.followers(pk).get()
wot.wotScore(myPk, theirPk).get() // number (or .$ for reactive)
```
## Feeds & search
```typescript
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
const search = get(app.use(Searches).profileSearch)
const pubkeys = search.searchValues("alice") // also fires a NIP-50 network search; ranked by WoT
// also: app.use(Searches).topicSearch, relaySearch; createSearch(...) for custom indexes
```
## Plugin architecture (for extending)
Three 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}`; implement `fetch`. This is the dominant pattern.
- **`LoadableMapPlugin<T>`** — owns its own `Map`, lazily fetches over HTTP (e.g. `Relays`, `Handles`, `Zappers`). Implement `fetch`.
- **`MapPlugin<T>`** — owns its own `Map`, no network (e.g. `RelayStats`, `Plaintext`).
```typescript
import {DerivedPlugin, Network, type IApp} from "@welshman/app"
import {SOME_KIND, readSomething} from "@welshman/util"
export class Somethings extends DerivedPlugin<ReturnType<typeof readSomething>> {
constructor(app: IApp) {
super(app, {filters: [{kinds: [SOME_KIND]}], eventToItem: readSomething, getKey: i => i.event.pubkey})
}
})
// Soft-undo within delay window
setTimeout(() => thunk.controller.abort(), 1000)
// Wait for all relays to finish (thunk.complete is a Deferred<void>)
await thunk.complete
```
### Derive a reactive profile
```typescript
import {deriveProfile, deriveProfileDisplay} from '@welshman/app'
const targetPubkey = '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'
// Reactive store — loads the profile in the background on first subscribe
const profile = deriveProfile(targetPubkey)
// Reactive display name with npub fallback
const name = deriveProfileDisplay(targetPubkey)
// In Svelte
// $: displayName = $name
```
### Reply to an event
```typescript
import {publishThunk, tagEventForReply, tagPubkey, signer} from '@welshman/app'
import {Router} from '@welshman/router'
import {NOTE, makeEvent} from '@welshman/util'
import type {TrustedEvent} from '@welshman/util'
async function replyTo(parent: TrustedEvent, content: string) {
const tags = tagEventForReply(parent)
return publishThunk({
event: makeEvent(NOTE, {content, tags}),
relays: Router.get().PublishEvent(parent).getUrls(),
})
}
```
### Send a NIP-59 gift-wrapped DM
```typescript
import {sendWrapped} from '@welshman/app'
import {DIRECT_MESSAGE, makeEvent} from '@welshman/util'
const mergedThunk = await sendWrapped({
event: makeEvent(DIRECT_MESSAGE, {content: 'secret message'}),
recipients: [recipientPubkey],
})
// Monitor combined status
mergedThunk.subscribe($t => {
for (const [url, result] of Object.entries($t.results)) {
console.log(url, result.status)
}
})
```
### Follow/unfollow
```typescript
import {follow, unfollow} from '@welshman/app'
// tag format: ["p", pubkey] or ["p", pubkey, relayHint, petname]
await follow(["p", "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"])
await unfollow("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322")
```
### Web of Trust filtering
```typescript
import {deriveUserWotScore, getWotScore, wotGraph, maxWot} from '@welshman/app'
import {get} from 'svelte/store'
// Filter a list of pubkeys to those with positive WoT score
const $graph = get(wotGraph)
const trusted = pubkeys.filter(pk => ($graph.get(pk) ?? 0) > 0)
// Reactive score for a single user
const score = deriveUserWotScore(somePubkey)
// Normalize by max score (01 range)
const $max = get(maxWot)
const normalized = ($graph.get(somePubkey) ?? 0) / ($max || 1)
```
### Load a feed of notes
```typescript
import {makeFeedController, getPubkeysForScope} from '@welshman/app'
import {makeKindFeed} from '@welshman/feeds'
import {NOTE} from '@welshman/util'
const abort = new AbortController()
const ctrl = makeFeedController({
feed: makeKindFeed(NOTE),
useWindowing: true,
signal: abort.signal,
onEvent: event => console.log(event),
onExhausted: () => console.log('no more events'),
})
ctrl.load(50)
// cleanup
abort.abort()
```
---
## Integration Notes
- `@welshman/app` **re-exports nothing** from `@welshman/net`, `@welshman/router`, etc. Import those directly when you need low-level primitives (`load`, `request`, `publish`, `Router` scenarios beyond `FromUser`).
- The `index.ts` bootstrap code runs on import and automatically wires `routerContext` (pubkey relays, relay quality, default/indexer/search relays) and hooks `Pool` to store incoming events in `repository`. **Import `@welshman/app` early in your app entry point** so this runs before any requests. The canonical side-effect import pattern is:
```typescript
// app entry point — must be first, before any @welshman/net or @welshman/router imports
import "@welshman/app"
// Then optionally override defaults
import {routerContext} from "@welshman/router"
routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"]
```
- `repository` and `tracker` are singletons shared across the whole app. All subscriptions made through `@welshman/net` that pass through the pool will populate `repository` automatically.
- `Router` is imported from `@welshman/router` but `routerContext` is configured by `@welshman/app/index.ts`. Use `Router.get()` (not `new Router(...)`) to get the app-configured singleton.
- `deriveProfile`, `deriveFollowList`, etc. use `makeLoadItem` under the hood: they fire a network request on first subscribe if data is not already in the repository, then resolve immediately on subsequent subscribes.
- `userFollowList`, `userMuteList`, etc. are derived from `pubkey`. They automatically re-derive when the active session changes (multi-account support).
---
## Using Welshman Stores Outside Svelte
All welshman stores implement the Svelte store contract: a `subscribe(callback) → unsubscribe` method where the callback fires **synchronously** with the current value on first call, then again on every change. This makes them trivially adaptable to any reactive framework — no Svelte runtime required, only the type imports.
### React
```typescript
import {useState, useEffect} from 'react'
import type {Readable, Writable} from 'svelte/store'
// Returns the current store value; re-renders when it changes.
function useReadable<T>(store: Readable<T>): T {
const [value, setValue] = useState<T>(() => {
// subscribe fires synchronously — capture the initial value then unsub immediately
let initial!: T
store.subscribe(v => { initial = v })()
return initial
})
useEffect(() => store.subscribe(setValue), [store])
return value
fetch = (pk: string, hints: string[] = []) =>
this.app.use(Network).loadUsingOutbox(pk, {kinds: [SOME_KIND]}, hints)
}
// Returns [currentValue, setter] — setter calls store.set directly.
function useWritable<T>(store: Writable<T>): [T, (value: T) => void] {
return [useReadable(store), store.set]
}
const things = app.use(Somethings) // lazily constructed + memoized
```
Usage:
Caching/backoff for `load` come from `makeLoadItem` (`@welshman/store`); default staleness window is 1 hour; `forceLoad` bypasses it.
```tsx
import {userProfile, pubkey} from '@welshman/app'
## Policies & logging
function ProfileHeader() {
const profile = useReadable(userProfile)
const [currentPubkey, setPubkey] = useWritable(pubkey)
Side effects live in `AppPolicy`s (`(app) => Unsubscriber`), run at construction, cleaned up by `cleanup()`.
return <div>{profile?.name ?? currentPubkey}</div>
}
```
### SolidJS
- `defaultAppPolicies` = `[appPolicyIngest, appPolicyRelayStats, appPolicyWraps, appPolicyAuthUnlessBlocked]`.
- Auth builders: `makeAppPolicyAuth(shouldAuth)`, `appPolicyAuthAlways`, `appPolicyAuthNever`, `appPolicyAuthUnlessBlocked`.
- `makeAppPolicyLogger(onMessage)` forwards `LogMessage`s from the user's `LoggingSigner` (users created via `User.fromSigner`/`fromSession` are wrapped automatically).
```typescript
import {createSignal, onCleanup} from 'solid-js'
import type {Readable, Writable} from 'svelte/store'
// Returns a SolidJS accessor (getter function); updates reactively.
function useReadable<T>(store: Readable<T>): () => T {
let initial!: T
store.subscribe(v => { initial = v })() // sync capture then unsubscribe
const [value, setValue] = createSignal<T>(initial)
onCleanup(store.subscribe(v => setValue(() => v)))
return value
}
// Returns [accessor, setter].
function useWritable<T>(store: Writable<T>): [() => T, (value: T) => void] {
return [useReadable(store), store.set]
}
import {App, defaultAppPolicies, makeAppPolicyLogger} from "@welshman/app"
const app = new App({user, policies: [...defaultAppPolicies, makeAppPolicyLogger(console.log)]})
```
Usage:
## Gotchas & tips
```tsx
import {userProfile} from '@welshman/app'
- **No globals.** Don't reach for importable `pubkey`/`deriveProfile`/`publishThunk`/`Router.get()` — they don't exist. Create an `App` and use `app.use(...)`.
- **`use()` is memoized per app.** `app.use(Profiles)` always returns the same instance for a given app. Cheap to call repeatedly.
- **`Projection` vs `Readable`.** Convenience accessors (`display`, `urls`, `wotScore`, …) return a `Projection` — use `.$` for the store, `.get()` for a snapshot. `one(key)` returns a plain `Readable` (and triggers a load on subscribe).
- **`get(key)` does not load; `one(key)`/`load(key)` do.** Use `get` for a pure cache read.
- **Most loads use the outbox model**, which needs the author's relay list. `loadUsingOutbox` (and therefore most `fetch` methods) first loads NIP-65 relays for the author.
- **`createApp` vs `new App`.** `createApp` installs default policies; `new App` installs none. In tests prefer `new App` (no background subscriptions) unless you need ingestion.
- **Call `cleanup()`** when discarding an app to close sockets and free the repository/tracker/wrap state.
- **The core class is `App`** (constructed by the `createApp` factory), the interface plugins depend on is `IApp`, and the config/options/policy types are `AppConfig`/`AppOptions`/`AppPolicy`.
function ProfileHeader() {
const profile = useReadable(userProfile)
return <div>{profile()?.name}</div>
}
```
## Old API → new API
### Vue
| 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({...})` / `publishToOutbox({...})` |
| `follow(tag)` / `mute(tag)` | `app.use(FollowLists).follow(tag)` / `app.use(MuteLists).mutePublicly(tag)` |
| `load({...})` / `request({...})` | `app.use(Network).load({...})` / `request({...})` |
| `Router.get().FromUser()` | `app.use(Router).FromUser()` |
| `relays` / `handles` / `zappers` stores | `app.use(Relays)` / `Handles` / `Zappers` |
```typescript
import {ref, onUnmounted} from 'vue'
import type {Readable, Writable} from 'svelte/store'
## Related skills
function useReadable<T>(store: Readable<T>) {
let initial!: T
store.subscribe(v => { initial = v })()
const value = ref<T>(initial)
const unsub = store.subscribe(v => { value.value = v as any })
onUnmounted(unsub)
return value // use as a readonly ref
}
```
### Notes
- **No Svelte runtime needed.** Only `svelte/store` types are imported. The store objects themselves ship with `@welshman/app`.
- **Welshman stores with `.get()`** (created via `withGetter`) can be read synchronously without subscribing — useful in event handlers and callbacks outside any reactive context. Most writable stores in `@welshman/app` expose `.get()`.
- **`subscribe` always fires immediately.** Unlike many observable libraries, the initial emission is synchronous, so the `useState` / `createSignal` initial value is always populated on first render.
---
## Gotchas & Tips
- **Thunks sign lazily.** `publishThunk` returns synchronously and immediately writes an unsigned/hashed event to `repository` for optimistic UI. Actual signing happens in a background queue. Do not assume the event has an `id` suitable for embedding in other events until signing completes.
- **`delay` is an undo window, not a debounce.** The thunk starts the delay timer immediately; if not aborted before `delay` ms, it signs and publishes. Calling `thunk.controller.abort()` after the delay has elapsed does nothing.
- **`sendWrapped` uses `recipients`, not `pubkeys`.** The docs example uses `pubkeys` but the actual type is `recipients: string[]`.
- **Gift wrap processing requires opt-in.** Set `shouldUnwrap.set(true)` to enable automatic NIP-59 unwrapping of incoming `kind:1059` events. Without this, wrapped events are silently discarded.
- **`commands` force-load lists before modifying them.** `follow()`, `unfollow()`, etc. call `forceLoadUserFollowList` to ensure they have the latest list before adding/removing, preventing accidental list truncation. Do not call these in rapid succession without awaiting each one.
- **WoT graph is rebuilt at most once per second** (throttled). Do not expect `wotGraph` to reflect a `follow()` call immediately; subscribe to the store instead.
- **`routerContext.getDefaultRelays` is throttled** with a 200 ms window by default in `index.ts`. It returns up to the 5 highest-quality known relays. Override it before any relay connections if you want a fixed bootstrap list.
- **Multiple sessions are supported.** Call `loginWith*` multiple times to add sessions. Switch the active session with `pubkey.set(otherPubkey)`. Remove a session with `dropSession(pubkey)` — this also cleans up the cached signer.
- **Stores have `.get()` via `withGetter`.** `pubkey.get()`, `signer.get()`, `session.get()`, `signerLog.get()`, `shouldUnwrap.get()` all work without `get()` from `svelte/store`. Use this for synchronous reads outside of reactive contexts.
- **`appContext.dufflepudUrl` must be set before first handle/zapper load.** There is no lazy re-fetch; set it at app startup.
- `welshman-store` — the `Repository` and Svelte-store primitives this layer builds on.
- `welshman-router` — relay-selection strategies behind `app.use(Router)`.
- `welshman-net` — request/publish/sockets behind `app.use(Network)`.
- `welshman-signer` — signers and login methods used by `User`/sessions.
- `welshman-feeds` — feed construction used by `app.use(Feeds)`.