Update skills

This commit is contained in:
Jon Staab 2026-09-14 10:17:19 -07:00
parent 0ffeccb372
commit 40889a91cf
11 changed files with 1024 additions and 517 deletions

View file

@ -1,279 +1,460 @@
---
name: welshman-app
description: "Use this skill when working with @welshman/app: the App instance and its plugins, sessions and login, publishing via Commands and Thunks, app policies, WoT, feeds, sync, 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 — The App Instance and its Plugins
# welshman/app — Instance-Based Nostr App
`@welshman/app` composes `net`, `store`, `domain`, `signer`, and `feeds` into an application
framework built around a single `App` object.
## 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
```bash
npm i @welshman/app
npm install @welshman/app
# or
pnpm add @welshman/app
yarn add @welshman/app
```
## The App
Peer deps: `svelte` (4 or 5), all `@welshman/*` workspace packages, and `@pomade/core`.
## Core mental model
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.
## Creating an app
```typescript
import {App, createApp, User} from "@welshman/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: await User.fromSigner(signer), // omit for a signed-out app
user, // optional User
config: {
dufflepudUrl: "https://dufflepud.example.com",
getDefaultRelays: () => ["wss://relay.example.com"],
getIndexerRelays: () => ["wss://indexer.example.com"],
getSearchRelays: () => ["wss://search.example.com"],
dufflepudUrl: "https://dufflepud.example", // optional: batches NIP-05/zapper lookups
getDefaultRelays: () => [...],
getIndexerRelays: () => [...], // discovery relays for profiles/relay lists
getSearchRelays: () => [...], // NIP-50 search relays
},
getAdapter, // optional: custom net adapters (tests, mocks)
policies, // optional: overrides the defaults
})
// 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()
```
An `App` owns everything scoped to one identity:
`AppOptions` is `{user?, config?, getAdapter?, policies?}`, `AppConfig` is the `config` field above, and `AppPolicy` is `(app: IApp) => Unsubscriber`.
| Property | What it is |
|---|---|
| `app.user` | the signed-in `User`, or `undefined` |
| `app.config` | the `AppConfig` above |
| `app.repository` | this identity's event store |
| `app.tracker` | which relays each event was seen on |
| `app.pool` | socket pool |
| `app.wrapManager` | NIP-59 gift wrap bookkeeping |
| `app.netContext` | `{pool, repository, getAdapter}` for the net layer |
| `app.use(Plugin)` | resolve a per-app plugin singleton |
| `app.cleanup()` | run policy teardown and clear pool/tracker/repository |
`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.
`createApp` is `new App` plus `defaultAppPolicies`. Use `new App({policies: [...]})` for a bare app.
## User & sessions
**An app is scoped to one identity.** To log in, build a *new* app and `cleanup()` the old one —
never attach a user to an existing app. That's what keeps one account's data out of another's
repository.
## Plugins
`app.use(Ctor)` constructs the plugin on first use and memoizes it per app, so calling it inline
is cheap and idiomatic:
A `User` is `{pubkey, signer}`. A `Session` is a serializable `{method, data}` descriptor you persist; session handlers turn it back into a signer.
```typescript
app.use(Profiles).load(pubkey)
app.use(RelayLists).writeUrls(pubkey).get()
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)
```
### Plugin base classes
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`.
| Base | Shape |
|---|---|
| `MapPlugin<T>` | a plain keyed map of non-event data (relay stats, NIP-11 info) |
| `LoadableMapPlugin<T>` | a `MapPlugin` that knows how to `fetch(key)` from the network |
| `DerivedPlugin<T>` | a keyed collection **derived from the repository** — the repository is the source of truth, never a duplicated map |
| `RelayScopedDerivedPlugin<T>` | keyed by `getKey(item, url)` per relay, for data that only means something relative to a relay |
| `RelaySignedDerivedPlugin<T>` | the same, but only accepts events authored by the relay's NIP-11 `self` pubkey (NIP-29 room state, relay membership/roles) |
`nip55` additionally needs the Capacitor plugin passed to `@welshman/signer` once at startup, or building its signer throws `"Nip55 is not enabled"`:
Derived plugins expose:
```ts
import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin"
import {setNip55Plugin} from "@welshman/signer"
- `index` — `Projection<ItemsByKey<T>>`
- `all` — `Projection<T[]>`
- `one(key)` — a store for a single key, loading it on first subscribe
- `get(key)` — synchronous snapshot
- `load(key)` / `forceLoad(key)` — network fetch (cached / uncached)
- `project(key, read)` — a `Projection` derived from one key
setNip55Plugin(NostrSignerPlugin)
```
A **`Projection<T>` is `{get(): T, $: Readable<T>}`** — bind `.$` in markup, call `.get()` in
callbacks and hot paths. Build new ones with `projection(store)` or `projectFrom(source, read)`.
## Data plugins (reactive collections)
### Available plugins
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(...)`.
**Core:** `Network`, `Router`, `Domain`, `Thunks`, `Sync`, `Logger`, `Plaintext`
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()` (or `.publishAsRelay(url)`) on the result to actually send it. See [Commands](#commands-deferred-publishing) below.
**Relays:** `Relays` (NIP-11), `RelayStats`, `RelayManagement` (NIP-86), `RelayLists`,
`BlockedRelayLists`, `SearchRelayLists`, `MessagingRelayLists`, `BlossomServerLists`
**People:** `Profiles`, `FollowLists`, `MuteLists`, `Handles`, `Zappers`, `Wot`, `Topics`
**Content:** `Reactions`, `Deletes`, `Pins`, `Pinboards`, `Feeds`, `FeedLists`, `Wraps`
**NIP-29 / membership:** `Rooms`, `RoomLists`, `RoomPinLists`, `RelayMemberLists`, `RelayRoles`
## Sessions and login
A `Session` is `{method, ...data}`, serializable so you can persist it. Handlers convert one into
a signer: `nip01`, `nip07`, `nip46`, `nip55`, `pomade`, plus `registerSessionHandler` for your own.
| 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 (`forUrl(url).signEvent(event)`, role/member ops, …) |
| `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` (`Projection`s); `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)` |
```typescript
import {User, createApp, nip07, toSession} from "@welshman/app"
const session = toSession(nip07, {pubkey})
const user = await User.fromSession(session) // undefined if the handler can't build a signer
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.publishAsRelay("wss://relay.example") // sign + send straight to one relay (NIP-86 style)
// Since these methods are async, `publish`/`publishAsRelay` free functions avoid a double-await:
import {publish} from "@welshman/app"
await app.use(RelayLists).addWriteUrl("wss://relay.example").then(publish)
```
`User` wraps a signer and pubkey:
- `User.fromSigner(signer)` / `User.fromSession(session)`
- `User.require(app)` — the signed-in user or **throws**; use on paths that require login
- `user.sign(event)`, `user.wrapSigner(fn)`
Persist the `Session`, not the `User` — rebuild the user on startup and construct the app with it.
## Publishing
Two layers, and you usually want the first.
### Commands
A `Command` owns a rendered event plus the relays routing resolved for it:
## Publishing (optimistic thunks)
```typescript
import {Domain, publish} from "@welshman/app"
import {Note} from "@welshman/domain"
import {Thunks, Router} from "@welshman/app"
import {makeEvent, NOTE, userOutbox} from "@welshman/util"
const writer = app.use(Domain).writer(Note).setContent("hello")
const command = await app.use(Domain).command(writer)
command.publish() // to the resolved relays
command.publishToRelays(urls) // to specific relays
command.publishAsRelay(url) // signed by the relay itself (NIP-86)
```
Plugin mutators already return a `Command`, so `.then(publish)` is the common shape:
```typescript
await app.use(FollowLists).follow(["p", pubkey]).then(publish)
await app.use(RelayLists).addWriteUrl(url).then(publish)
```
Free-function forms exist for pipelines: `publish`, `publishToRelays(urls)`,
`publishAsRelay(url)`, `signAsRelay(url)`.
### Thunks
`app.use(Thunks).publish({event, relays, delay})` publishes optimistically: the event lands in the
local repository immediately, so the UI updates before the network settles. The returned `Thunk`
is a store you can render:
```typescript
const thunk = app.use(Thunks).publish({event, relays})
thunk.getUrlsWithStatus(PublishStatus.Success)
thunk.getFailedUrls()
thunk.isComplete()
await thunk.waitForError() // "" when everything succeeded
await thunk.waitForCompletion()
```
`app.use(Thunks).history` is a writable of every thunk this app has published — useful for a
"sending" indicator or deciding which relays the user has actually written to.
## Requests
```typescript
const network = app.use(Network)
network.load({relays, filters}) // batched, deduped, shared loader
network.request({relays, filters, onEvent})
network.publish({event, relays})
network.loadUsingOutbox(pubkey, filter) // newest matching event from the author's write relays
network.loadAllUsingOutbox(pubkey, filter) // every matching event
```
Prefer a plugin's `one(key)` / `load(key)` when one exists — they handle outbox routing and
caching for you. The bare `load`/`request`/`publish` from `@welshman/net` need an explicit
`context`; `Network` supplies `app.netContext`.
## Relay selection
Routing is the `RelaySelection` DSL from `@welshman/util`, resolved by `app.use(Router)`:
```typescript
import {outbox, inbox, seen, userOutbox, indexers, relay, relays} from "@welshman/util"
const scenario = await app.use(Router).resolve([userOutbox(), outbox(pubkey)])
const urls = scenario.getUrls()
// single best relay for a route
const hint = await app.use(Router).resolver.relay([outbox(event.pubkey)])
```
Selections are weighted (`outbox(pubkey, 2)`), and resolution is **async** — it may need to load
the target's relay list first.
## App policies
An `AppPolicy` is `(app) => Unsubscriber`, applied once at construction and torn down by
`cleanup()`. Policies own everything that subscribes or wires components together, keeping the
data classes free of side effects.
Built-ins: `appPolicyIngest`, `appPolicyRelayStats`, `appPolicyWraps`, `appPolicyCacheDecrypt`,
`appPolicyLogSignerMethods`, plus auth: `appPolicyAuthNever`, `appPolicyAuthAlways`,
`appPolicyAuthUnlessBlocked`, and `makeAppPolicyAuth(shouldAuth)` for a custom predicate.
```typescript
const app = createApp({
user,
policies: [...defaultAppPolicies, appPolicyAuthUnlessBlocked, myPolicy],
// 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)
})
const myPolicy: AppPolicy = app => {
const unsubscribe = on(app.repository, "update", handleUpdate)
// To specific relays:
app.use(Thunks).publish({event, relays: ["wss://relay.example"]})
return unsubscribe
}
// 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})
```
**Ordering gotcha:** policies run in the `App` constructor. If a policy module imports something
that transitively imports your app module, construct the app lazily (on first access) so every
policy has registered by the time it's built.
`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:
```typescript
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
command.publishAsRelay(url) // NIP-86: the relay signs the event with its own key
// (signevent), then publish the relay-signed event back to `url`
command.signAsRelay(url) // just the NIP-86 signevent step (returns {result, error})
```
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`/`publishAsRelay`/`signAsRelay` are also exported as free functions (e.g. `(command) => command.publish()`, `(url) => (command) => command.publishAsRelay(url)`) so you can chain straight off the mutation method's promise instead of double-awaiting:
```typescript
import {publish, publishAsRelay} 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(publishAsRelay(relayUrl))
```
## 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).
// 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.
```typescript
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.
```typescript
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)` then `readUrls()` / `writeUrls()` (NIP-65, kind 10002).
- **messaging** — `app.use(MessagingRelayLists).load(pubkey)` (kind 10050).
- **eventInbox / eventOutbox** — a known `ref.pubkey` routes directly; otherwise `ref.id` is looked up in the repository to find the author. `ref.relays` are always included.
- **seen** — `app.tracker.getRelays(ref.id)`, or for a replaceable `ref` the tracker entry of the event at its address, plus `ref.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.
```typescript
import {Wot, WotScope} from "@welshman/app"
const wot = app.use(Wot)
wot.follows(pubkey).get()
wot.followers(pubkey).get()
wot.network(pubkey).$ // follows-of-follows
wot.followsWhoFollow(pubkey, target).$
wot.wotScore(pubkey, target).$
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
```
## Feeds and sync
Use `scores(scope)` when ranking a list (search results, a WoT range); it walks the graph once instead of once per pubkey.
## Feeds & search
```typescript
app.use(Feeds).makeFeedController({feed, onEvent, ...})
app.use(Feeds).getPubkeysForScope(scope)
app.use(Feeds).forAuthor(pubkey).$
import {makeIntersectionFeed, makeScopeFeed, makeKindFeed, Scope} from "@welshman/feeds"
import {get} from "svelte/store"
app.use(Sync).pull({relays, filters}) // negentropy: fetch what we're missing
app.use(Sync).push({relays, filters}) // publish what the relay is missing
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
```
## Using welshman stores outside Svelte
## Plugin architecture (for extending)
Projections and plugin stores implement the Svelte store contract — `subscribe(cb) → unsubscribe`,
firing synchronously with the current value — so they adapt to any reactive framework with a small
hook. Only the `svelte/store` *types* are needed, not the runtime.
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?}`; implement `fetch`. This is the dominant pattern. Gives you `index`/`all` (`Projection`s), `get(key)`, `one(key)`, `load`/`forceLoad`, and `project(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` (in `plugins/relays.ts`) narrows it further to events signed by the relay's own NIP-11 `self` key, which is what `RelayRoles`, `RelayMemberLists` and `RoomPinLists` use.
- **`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`).
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)`:
```typescript
// React
const useStore = <T>(store: Readable<T>): T => {
const [value, setValue] = useState<T>(() => get(store))
import {DerivedPlugin, Network, Domain, User, type IApp} from "@welshman/app"
import {SOME_KIND} from "@welshman/util"
import {SomeKind, SomeKindReader, SomeKindWriter} from "@welshman/domain"
useEffect(() => store.subscribe(setValue), [store])
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(),
})
}
return value
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
```
For a `Projection`, subscribe to `.$` and read `.get()` for a synchronous snapshot.
Caching/backoff for `load` come from `makeLoadItem` (`@welshman/store`); default staleness window is 1 hour; `forceLoad` bypasses it.
## Policies & logging
Side effects live in `AppPolicy`s (`(app) => Unsubscriber`), run at construction, cleaned up by `cleanup()`.
- `defaultAppPolicies` = `[appPolicyIngest, appPolicyRelayStats, appPolicyWraps, appPolicyCacheDecrypt, appPolicyLogSignerMethods, appPolicyAuthUnlessBlocked]`.
- Auth builders: `makeAppPolicyAuth(shouldAuth)`, `appPolicyAuthAlways`, `appPolicyAuthNever`, `appPolicyAuthUnlessBlocked`.
- `appPolicyCacheDecrypt` and `appPolicyLogSignerMethods` both layer onto the user's signer via `User.wrapSigner` — the first caches decryptions into `app.use(Plaintext)`, the second records signer calls into `app.use(Logger)` (read them from `app.use(Logger).messages`).
```typescript
// 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.
- **`Projection` vs `Readable`.** Convenience accessors (`display`, `urls`, `score`, …) 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.
- **Pass the `user` to `createApp`/`new App`, don't assign `app.user` afterwards.** Policies run once, at construction. `appPolicyCacheDecrypt` and `appPolicyLogSignerMethods` bail 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 and `cleanup()` 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`](#commands-deferred-publishing) |
| `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-domain` — the readers/writers every plugin decodes events with
- `welshman-net` — sockets, adapters, request/publish lifecycle, auth
- `welshman-store` — the repository and the derive helpers plugins are built on
- `welshman-signer` — signer implementations behind `User`
- `welshman-util` — kinds, filters, tag specs, and the `RelaySelection` DSL
- `welshman-store` — the `Repository` and Svelte-store primitives this layer builds on.
- `welshman-domain` — the `Kind`/reader/writer model behind `app.use(Domain)` (event decoding + publishing).
- `welshman-util` — the `RelaySelection` DSL, `Resolver` and `RelayScenario` that `app.use(Router)` dereferences.
- `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)`.

View file

@ -36,12 +36,14 @@ yarn add @welshman/content
| `Text` | `string` | Plain text |
| `Newline` | `string` | One or more `\n` characters |
| `Topic` | `string` | Hashtag text without the `#`; numeric-only tags are skipped |
| `Command` | `{ command: string, pubkey?: string }` | A NIP-CD invocation (`/kick`, `/kick@npub1…`), only ever at the start of the content; `pubkey` is the executor the qualifier names |
| `Link` | `{ url: URL, meta: Record<string, string> }` | URLs with any scheme (http, https, ftp, ws, wss, etc.) and bare domains without a protocol; `meta` is populated from `imeta` tags or URL hash params |
| `LinkGrid` | `{ links: ParsedLinkValue[] }` | Produced by `reduceLinks`; a collection of adjacent block links |
| `Profile` | `ProfilePointer` (`{ pubkey, relays? }`) | nostr:npub / nostr:nprofile / @nostr:npub / @nostr:nprofile references (the `nostr:` prefix is required) |
| `Event` | `EventPointer` (`{ id, relays?, author?, kind? }`) | note / nevent references |
| `Address` | `AddressPointer` (`{ identifier, pubkey, kind, relays? }`) | naddr references |
| `Emoji` | `{ name: string, url?: string }` | `:shortcode:` — `url` resolved from `emoji` tags |
| `Room` | `ParsedRoomValue` (`{ url, room }`) | A NIP-29 room reference written as `relay.example.com'roomid` (an apostrophe or `’` between host and room id). The url is normalized to include a protocol and trailing slash; possessives like `example.com's` are skipped |
| `Code` | `string` | Backtick inline code or triple-backtick blocks |
| `Cashu` | `string` | cashu: token strings |
| `Invoice` | `string` | Bare lightning invoice string (without `lightning:` prefix); the `lightning:` prefix is in `raw` |
@ -55,9 +57,10 @@ Every `Parsed` element also has a `raw: string` field holding the original match
All guards narrow the union type:
```
isAddress isCashu isCode isEllipsis isEmail
isEmoji isEvent isImage isInvoice isLink
isLinkGrid isNewline isProfile isText isTopic
isAddress isCashu isCode isCommand isEllipsis
isEmail isEmoji isEvent isImage isInvoice
isLink isLinkGrid isNewline isProfile isRoom
isText isTopic
```
`isImage(parsed)` — special guard: true only for `ParsedLink` elements whose URL ends in `.jpg/.jpeg/.png/.gif/.webp`.
@ -83,7 +86,11 @@ isLinkGrid isNewline isProfile isText isTopic
| `renderEntity(entity)` | `entity.slice(0, 16) + "…"` | Display text for entity links |
| `createElement(tag)` | `document.createElement(tag)` | DOM element factory; override for SSR/non-browser |
Individual per-type render helpers are also exported (`renderText`, `renderLink`, `renderProfile`, `renderEvent`, `renderAddress`, `renderTopic`, `renderEmoji`, `renderCode`, `renderCashu`, `renderInvoice`, `renderEmail`, `renderNewline`, `renderEllipsis`, `renderOne`, `renderMany`).
Individual per-type render helpers are also exported (`renderText`, `renderLink`, `renderProfile`, `renderEvent`, `renderAddress`, `renderRoom`, `renderTopic`, `renderEmoji`, `renderCode`, `renderCommand`, `renderCashu`, `renderInvoice`, `renderEmail`, `renderNewline`, `renderEllipsis`, `renderOne`, `renderMany`), along with the default option bags `textRenderOptions` and `htmlRenderOptions`.
### Extending the parser
`parsers` is the ordered array of individual parsers (`parseCommand`, `parseNewline`, `parseLegacyMention`, `parseTopic`, `parseCodeBlock`, `parseCodeInline`, `parseAddress`, `parseProfile`, `parseEmoji`, `parseEvent`, `parseCashu`, `parseInvoice`, `parseEmail`, `parseRoom`, `parseLink`), and `parseNext(raw, context)` runs them in order at the current position. Each takes `(text, context: ParseContext)` and returns a `Parsed` or nothing. Order matters — `parseRoom` runs before `parseLink` so a `host'room` reference isn't swallowed as a bare-domain link.
## Common Patterns
@ -206,4 +213,7 @@ const emojiElements = parsed.filter(isEmoji)
- **`LinkGrid` is not rendered by default renderers**: `renderOne` has no case for `ParsedType.LinkGrid`. You must handle it yourself when building a custom UI (e.g. render each `value.links` entry as an image or card grid).
- **Legacy mentions** (`#[0]`, `#[1]`) are parsed automatically from the `tags` array and emitted as `ParsedProfile` or `ParsedEvent` elements.
- **Numeric hashtags are skipped**: `#42` will not produce a `Topic` element.
- **A `Command` is syntax, not a promise that anyone answers to it**: the parser has no idea which NIP-CD definitions exist, so `/nonsense` parses as a `Command` too. Match `value.command` (and `value.pubkey`, when the invocation names an executor) against the definitions you have, and render the element's `raw` as text when nothing matches.
- **Only the start of the content is an invocation**: a slash later in the text is punctuation or part of a path, so `look in /etc/passwd` produces no `Command`.
- **`ParsedRoom` is emitted by `renderOne` as plain text** (`r.addText(p.raw)`) rather than a link, because the renderer has no way to know a room's display name. Handle `isRoom` yourself when building a UI, the same as `isLinkGrid`.
- **Email matching** strips a leading `mailto:` — the resulting `ParsedEmail.value` is always the bare address string.

View file

@ -41,6 +41,7 @@ import "@welshman/editor/index.css"
| `BreakOrSubmit` | Keyboard handler: `Mod-Enter` always submits; `Enter` submits only when `aggressive: true` (chat-style); `Shift-Enter` inserts a hard break. |
| `CodeInline` | Inline `code` node with backtick input/paste rules. |
| `WordCount` | Extension that tracks `editor.storage.wordCount.words` and `editor.storage.wordCount.chars` on every document update. |
| `CommandExtension` | Inline atom node (`name: "command"`) holding a slash-command invocation — `{command, pubkey?}` attributes. `renderText` emits the canonical invocation via `renderCommandInvocation` from `@welshman/util`, so an executor that knows nothing about this client can read it back out of the note. |
### Node Views
@ -57,8 +58,9 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele
| Export | Description |
|--------|-------------|
| `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char`, `name`, `editor`, `search`, and `select`. Optional: `updateSignal`, `createSuggestion`. |
| `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`. Optional: `updateSignal`, `createSuggestion`. |
| `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char`, `name`, `editor`, `search`, and `select`. |
| `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`. |
| `CommandSuggestion` | Pre-configured `TippySuggestion` for `/`-triggered slash commands. Requires `editor`, `search`, and `getAttributes(value) => CommandAttributes \| undefined`. Sets `showOnEmpty: true` (a bare `/` lists what's available) and `allow: ({range}) => range.from === 1` (an invocation is only valid at the very start of the content). |
| `DefaultSuggestionsWrapper` | Default dropdown renderer used by `TippySuggestion`. Implements `ISuggestionsWrapper`; replace to use a framework component. |
**`TippySuggestion` options:**
@ -71,7 +73,11 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele
| `search` | yes | `(term: string) => string[]` — returns item values matching the query |
| `select` | yes | `(value: string, props) => void` — called when the user picks an item; call `props.command({...attrs})` to insert the node |
| `updateSignal` | no | A Svelte `Readable` store; when it emits, the suggestion list re-renders (use for async/reactive search results) |
| `allowCreate` | no | Let the user commit the raw term as an item (default `false`) |
| `showOnEmpty` | no | Show the whole list before anything is typed (default `false`). Right for a small closed set the user browses; wrong for profiles, where a bare trigger matches everything |
| `allow` | no | `({state, range}) => boolean` — narrow where the suggestion can fire, on top of the schema check |
| `createSuggestion` | no | `(value: string) => Element` — renders a custom DOM element for each dropdown item |
| `createSuggestionsWrapper` | no | `(target, props) => ISuggestionsWrapper` — swap the dropdown for a framework component |
`MentionSuggestion` is a pre-wired `TippySuggestion` for nprofile nodes. It handles `select` internally (encodes the pubkey as an nprofile with relay hints from `getRelays`) so you only need to supply `editor`, `search`, and `getRelays`.
@ -81,7 +87,6 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele
|--------|--------|
| `Editor` | `@tiptap/core` — the editor instance class |
| `NodeViewProps` | `@tiptap/core` — prop type for node view factories (Tiptap's type) |
| `NodeViewRendererProps` | `@tiptap/core` — alternate props type used in `Node.create({ addNodeView })` |
| `UploadTask` | `nostr-editor` — shape of an in-progress or completed file upload |
| `FileAttributes` | `nostr-editor` — `{ file: File, … }` passed to the `upload` callback |
| `editorProps` | `nostr-editor` — base ProseMirror `editorProps` used by nostr-editor; pass directly to `new Editor({ editorProps })` |
@ -138,7 +143,7 @@ import {get, writable} from "svelte/store"
import {Node, Extension, mergeAttributes} from "@tiptap/core"
import {Plugin, PluginKey} from "@tiptap/pm/state"
import type {NodeViewRendererProps} from "@tiptap/core"
import {Profiles, Router, createSearch} from "@welshman/app"
import {Profiles, Router} from "@welshman/app"
import {outbox} from "@welshman/util"
import {
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
@ -187,11 +192,8 @@ export const makeEditor = ({
charCount?: ReturnType<typeof writable<number>>
submit: () => void
}) => {
const profileSearch = createSearch(get(profiles), {
onSearch: searchProfiles,
getValue: (p: any) => p.event.pubkey,
fuseOptions: {keys: ["nip05", "name", "display_name"], threshold: 0.3},
})
// The Profiles plugin maintains a ready-made fuzzy search over known profiles
const profileSearch = get(app.use(Profiles).profileSearch)
const editor = new Editor({
content,
@ -232,7 +234,7 @@ export const makeEditor = ({
addNodeView: () => ({node}: NodeViewRendererProps) => {
const dom = document.createElement("span")
dom.classList.add("mention")
const unsub = deriveProfileDisplay(node.attrs.pubkey)
const unsub = app.use(Profiles).display(node.attrs.pubkey).$
.subscribe($d => { dom.textContent = "@" + $d })
return {
dom, destroy: unsub,
@ -246,7 +248,8 @@ export const makeEditor = ({
MentionSuggestion({
editor: (this as any).editor,
search: term => profileSearch.searchValues(term),
getRelays: pubkey => Router.get().FromPubkeys([pubkey]).getUrls(),
getRelays: async pubkey =>
(await app.use(Router).resolve([outbox(pubkey)])).getUrls(),
createSuggestion: pubkey => {
const el = document.createElement("span")
el.textContent = pubkey.slice(0, 12) + "…"
@ -324,7 +327,7 @@ const onSubmit = (editor: Editor) => {
## Integration Notes
- **`@welshman/app`** — `app.use(Profiles).profileSearch` and `app.use(Profiles).display(pubkey)` are the typical sources for mention autocomplete data and display names.
- **`@welshman/app`** — relay hints for nprofile bech32 strings come from `app.use(Router).resolve([outbox(pubkey)])`.
- **`@welshman/app`** — `app.use(Router).resolve([outbox(pubkey)])` provides the relay hints encoded into nprofile bech32 strings.
- **`@welshman/util`** — `fromNostrURI` is used internally by `EventNodeView` to strip the `nostr:` scheme before displaying.
- **`nostr-editor`** — `WelshmanExtension` extends `NostrExtension` from this package. Storage at `editor.storage.nostr` (including `getEditorTags()`) is provided by `nostr-editor`, not welshman itself.
- **`@tiptap/core`** — `Editor`, `NodeViewProps`, and all extension primitives come from Tiptap. Welshman does not re-export every Tiptap helper; import additional ones directly from `@tiptap/core` as needed.

View file

@ -115,11 +115,12 @@ class FeedCompiler {
}
type FeedCompilerOptions = {
router: FeedRouter // REQUIRED — resolves relay selections
getPubkeysForScope: (scope: Scope) => string[]
getPubkeysForWOTRange: (min: number, max: number) => string[]
signer?: ISigner
signal?: AbortSignal
context?: AdapterContext
context?: AdapterContext // net context: {pool, repository, getAdapter?}
}
```
@ -147,6 +148,32 @@ type FeedControllerOptions = FeedCompilerOptions & {
}
```
### Routing (`FeedRouter`)
`@welshman/feeds` has no way to turn a pubkey into relay urls, so it declares the capability as an interface and the caller supplies it.
```typescript
import type {RelaySelection, RelayScenario} from '@welshman/util'
export interface FeedRouter {
resolve(selections: RelaySelection[]): Promise<RelayScenario>
}
// Decide which relays serve which filters under the outbox model
getFilterSelections(filters: Filter[], router: FeedRouter): Promise<RelaysAndFilters[]>
// RelaysAndFilters = {relays: string[]; filters: Filter[]}
```
`getFilterSelections` applies one rule per outbox source: a `search` filter goes to `searchRelays(10)`; a gift-wrap filter with no `authors` goes to `userMessaging()`; a filter with `authors` is chunked and sent to each author's `outbox()`; and everything additionally gets a low-weight `userInbox(0.2)` pass. Each group resolves with `addMinimalFallbacks`.
`@welshman/app`'s `Router` plugin implements `FeedRouter`, and `app.use(Feeds).makeFeedController(...)` supplies it (along with `getPubkeysForScope`, `getPubkeysForWOTRange`, the signer, and the app's net context) so you only pass `feed` and your callbacks. The `RelaySelection` DSL itself is documented in the `welshman-util` skill.
### Display & validation helpers
`display*` functions render a feed definition as human-readable text — `displayFeed(feed)` dispatches on type, with `displayAuthorFeed`, `displayKindFeed`, `displayScopeFeed`, `displayTagFeed`, … underneath, plus `displayFeeds(feeds)` for a list.
`validate*` functions throw on a malformed feed tuple — `validateFeed(feed)` dispatches, with `validateAuthorFeed`, `validateDVMFeed`, `validateFeedArgs`, `validateTagFeedMapping`, … underneath. Run `validateFeed` on anything decoded from a kind-31890 event before compiling it.
## Common Patterns
### 1. Simple author + kind feed
@ -156,6 +183,7 @@ import { FeedController, makeIntersectionFeed, makeAuthorFeed, makeKindFeed } fr
import { Scope } from '@welshman/feeds'
const controller = new FeedController({
router, // a FeedRouter — e.g. app.use(Router)
feed: makeIntersectionFeed(
makeAuthorFeed("pubkey1", "pubkey2"),
makeKindFeed(1),
@ -178,6 +206,7 @@ import {
} from '@welshman/feeds'
const controller = new FeedController({
router,
feed: makeIntersectionFeed(
makeScopeFeed(Scope.Follows),
makeWOTFeed({ min: 0.1 }),
@ -206,6 +235,7 @@ import {
// DVMItem.mappings controls how DVM result tags become sub-feeds
const controller = new FeedController({
router,
feed: makeIntersectionFeed(
makeDVMFeed({
kind: 5300,
@ -227,6 +257,7 @@ await controller.load(30)
import { FeedController, makeListFeed, makeKindFeed, makeUnionFeed, FeedType } from '@welshman/feeds'
const controller = new FeedController({
router,
feed: makeUnionFeed(
makeListFeed({
addresses: ["10003:pubkey:identifier"],
@ -255,6 +286,7 @@ const filters = [
const feed = feedFromFilters(filters)
const compiler = new FeedCompiler({
router,
getPubkeysForScope: () => [],
getPubkeysForWOTRange: () => [],
})
@ -285,11 +317,13 @@ console.log('Authors in feed:', [...authors])
- **`@welshman/util`** — `Filter`, `TrustedEvent`, and nostr primitives used throughout. `getIdFilters()` is used internally by the compiler for address feeds.
- **`@welshman/signer`** — `ISigner` interface, passed optionally through `FeedCompilerOptions` for DVM requests that require signing.
- **`@welshman/net`** — The `FeedController` delegates to `requestPage` for relay communication. The `FeedCompiler` delegates to `requestDVM` for DVM-based feeds. Neither accepts `request` or `requestDVM` as constructor options. `AdapterContext` from net is passed through `FeedCompilerOptions`.
- **`@welshman/app`** — Higher-level app packages typically wire up `getPubkeysForScope` and `getPubkeysForWOTRange` using their own follow/WOT stores, then construct `FeedController` instances from user-facing feed definitions.
- **`@welshman/app`** — `app.use(Feeds).makeFeedController({feed, onEvent, …})` supplies `router` (the `Router` plugin), `getPubkeysForScope`/`getPubkeysForWOTRange` (from `Wot`), the user's signer, and the app's `{pool, repository}` context. `Feeds` is also the kind-31890 saved-feed collection.
- **`Tracker`** — Optional deduplication helper (from `@welshman/net` or app layer). Pass a shared `Tracker` instance to avoid re-emitting events seen in other controllers.
## Gotchas & Tips
- **`router` is required.** `FeedCompilerOptions.router` has no default; a `FeedController` or `FeedCompiler` constructed without one will fail when it tries to resolve relays. In an app, go through `app.use(Feeds).makeFeedController(...)`.
- **Always use factory functions** (`makeAuthorFeed`, etc.) rather than constructing raw tuples — the tuple structure is internal and type safety depends on using factories.
- **`useWindowing: true`** is for relays that may return events out of chronological order. Do not use it for DVM/algorithmic feeds where order is part of the result.
- **`FeedController.load()` is stateful** — each call continues from where the last left off (pagination). Create a new controller to reset.

View file

@ -23,7 +23,7 @@ pnpm add @welshman/lib
|--------|-------------|
| `Deferred<T, E>` | Type: a `Promise<T>` with `.resolve(T)` and `.reject(E)` methods attached |
| `defer<T, E>()` | Creates a `Deferred<T, E>` — a promise with exposed `.resolve()` and `.reject()` |
| `makePromise<T, E>(executor)` | Creates a strongly-typed promise with typed error |
| `makePromise<T, E>(executor)` | Creates a strongly-typed promise with typed error (`CustomPromise<T, E>`) |
`E` defaults to `T` when omitted. `defer<void>()` for a signal-style deferred. `thunk.complete` in `@welshman/app` is a `Deferred<void>`.
@ -54,8 +54,8 @@ bus.emit('login', { pubkey: '...' })
| Export | Description |
|--------|-------------|
| `LRUCache<K, V>` | LRU cache; evicts least-recently-used entries when full |
| `simpleCache(getValue)` | Minimal memoization wrapper over an `LRUCache` with default settings |
| `cached(options)` | Memoizes a function with an LRU backing cache; exposes `.cache` and `.pop()` |
| `simpleCache(getValue)` | Minimal memoization wrapper with default settings |
```typescript
import { LRUCache, cached } from '@welshman/lib'
@ -125,6 +125,7 @@ displayDomain('relay.damus.io/path') // => 'relay.damus.io'
| `batch(t, fn)` | First call fires `fn([item])` immediately; subsequent calls within `t` ms are collected and `fn` is called with all accumulated items |
| `batcher(t, execute)` | Collects calls for `t` ms, then calls `execute` with all accumulated requests; each individual call returns a `Promise<U>` resolved with its result from the batch. Unlike `batch`, the first call is also deferred — nothing fires immediately. |
| `race(threshold, promises)` | Resolves when `threshold` fraction of promises complete |
| `makeQueue()` | Returns `(f: () => Promise<unknown>) => Promise<unknown>` — chains every call onto one serial promise, logging and swallowing rejections so a failure doesn't stall the chain |
### Timestamp / Time Constants
@ -160,6 +161,7 @@ displayDomain('relay.damus.io/path') // => 'relay.damus.io'
| `within([low, high], n)` | `n >= low && n <= high` (inclusive) |
| `clamp([min, max], n)` | Constrains `n` to the range |
| `round(precision, x)` | Rounds to `precision` decimal places |
| `toInt(x)` | `parseInt` that returns `undefined` instead of `NaN` — accepts `number \| string \| undefined` |
### Array / Sequence Utilities
@ -250,6 +252,8 @@ type MaybeStr = Maybe<string> // string | undefined
| `ifLet(x, f)` | Calls `f(x)` only if `x` is defined |
| `doLet(x, f)` | Calls `f(x)` and returns the result — scoped binding without a variable |
| `isDefined(x)` / `isUndefined(x)` / `assertDefined(x)` | `undefined` checks (not null) |
| `maybe(x?)` | Identity, typed `T \| undefined` — widens a value to `Maybe<T>` |
| `allPass(...preds)` / `somePass(...preds)` | Combine predicates into one `(x) => boolean` |
### Curried Collection Helpers
@ -388,7 +392,6 @@ const label = formatTimestampRelative(event.created_at) // "3 hours ago"
```typescript
import { on } from '@welshman/lib'
// Each App owns its repository.
const unsub = on(app.repository, 'update', updates => {
console.log('added', updates.flatMap(u => u.added).length, 'events')
})

View file

@ -1,11 +1,13 @@
---
name: welshman-net
description: "Use this skill when working with @welshman/net: relay connections, request/publish flows, auth, relay pool management, adapters, policies, or low-level nostr network I/O."
description: "Use this skill when working with @welshman/net: relay connections, request/publish flows, auth, relay pool management, adapters, socket policies, the Repository/Tracker/WrapManager stores, or low-level nostr network I/O."
---
# welshman/net — Relay Network Layer
`@welshman/net` is the core networking layer for welshman-based nostr apps. It manages WebSocket relay connections, subscriptions, event publishing, NIP-42 auth, and NIP-77 negentropy sync. It sits below `@welshman/app` (which owns an `App` instance and its plugins) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities.
`@welshman/net` is the core networking layer for welshman-based nostr apps. It manages WebSocket relay connections, subscriptions, event publishing, NIP-42 auth, and NIP-77 negentropy sync. It sits below `@welshman/app` (which owns instances of these primitives and wires them together) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities.
**There are no module-level singletons.** `Pool`, `Repository`, `Tracker` and `WrapManager` are plain classes you instantiate; the pool and repository a call should use are passed per call as a `context`. `Pool.get()`, `Repository.get()` and a mutable global `netContext` do not exist.
## Installation
@ -18,18 +20,31 @@ yarn add @welshman/net
## Key Exports
### Context
| Export | Description |
|--------|-------------|
| `NetContext` | `{pool?: Pool, repository?: Repository, getAdapter?: AdapterFactory}` — the instances a call should use |
| `AdapterContext` | `Partial<NetContext>` — what every `context` parameter accepts |
| `AdapterFactory` | `(url: string, context: NetContext) => AbstractAdapter \| undefined` |
Every entry point (`request`, `requestOne`, `publish`, `publishOne`, `makeLoader`, `diff`/`pull`/`push`) takes an optional `context`. There is no default: without `context.pool` a `wss://` url throws `"Unable to connect to relays without context.pool"`, and without `context.repository` `LOCAL_RELAY_URL` throws `"LOCAL_RELAY_URL cannot be used without context.repository"`. In an app, `app.netContext` is that object and `app.use(Network)` passes it for you.
### Pool & Sockets
| Export | Description |
|--------|-------------|
| `Pool` | Singleton connection pool; creates and manages `Socket` instances per relay URL |
| `Pool.get()` | Returns the singleton `Pool` instance |
| `pool.get(url)` | Gets or lazily creates a `Socket` for the given relay URL |
| `pool.remove(url)` | Removes and cleans up a socket |
| `pool.subscribe(cb)` | Fires `cb(socket)` each time a new socket is created; returns unsubscriber |
| `Pool` | Connection pool; creates and manages `Socket` instances per relay url. Construct with `new Pool()` |
| `pool.socketPolicies` | The policies applied to sockets this pool creates — copied from `defaultSocketPolicies` at construction |
| `pool.get(url)` | Gets or lazily creates a `Socket` for a (normalized) relay url |
| `pool.has(url)` | Whether a socket already exists for the url |
| `pool.remove(url)` | Cleans up the socket and forgets the url |
| `pool.clear()` | Removes every socket |
| `pool.subscribe(cb)` | Fires `cb(socket)` each time a new socket is created; returns an unsubscriber |
| `Socket` | WebSocket wrapper with status tracking, send queue, and auth state |
| `SocketStatus` | Enum: `Open`, `Opening`, `Closing`, `Closed`, `Error` |
| `SocketEvent` | Enum: `Status`, `Send`, `Sending`, `Receive`, `Receiving`, `Error` |
| `socket.open()` / `attemptToOpen()` / `close()` / `cleanup()` / `send(message)` | Connection and send control |
| `socket.auth` | `AuthState` instance for NIP-42 on this connection |
### Request
@ -39,20 +54,24 @@ yarn add @welshman/net
| `requestOne(options)` | Subscribe to a single relay; returns `Promise<TrustedEvent[]>` |
| `request(options)` | Subscribe to multiple relays in parallel; returns `Promise<TrustedEvent[]>` |
| `makeLoader(options)` | Creates a batching `load` function with configurable delay/timeout/threshold |
| `load(options)` | Pre-built loader: 200 ms batch delay, 3 s timeout, 0.5 threshold. Simpler than `request()` when you just want events — auto-closes after EOSE, timeout, or disconnect; resolves when half the relays' subscriptions have closed; returns a `Promise<TrustedEvent[]>`. When used with `@welshman/app`, received events auto-flow into the repository and tracker. |
| `load(options)` | Pre-built loader with a 30 ms batch delay, 3 s timeout and 0.5 threshold. It auto-closes after EOSE, timeout, or disconnect, and resolves when half the relays' subscriptions have closed. It carries no context, so it only works where no pool or repository is needed. |
`request` / `requestOne` options (key fields):
- `relay` / `relays` — relay URL(s)
`request` / `requestOne` options (`BaseRequestOptions`):
- `relay` / `relays` — relay url(s)
- `filters` — array of nostr `Filter` objects
- `autoClose?: boolean` — close subscription after EOSE or on socket disconnect
- `autoClose?: boolean` — close the subscription after EOSE or on socket disconnect
- `signal?: AbortSignal` — cancellation
- `tracker?: Tracker` — cross-relay deduplication (shared automatically by `request`)
- `context?: AdapterContext`
- `resubscribeAttempts?: number` — how many times to retry a subscription the relay CLOSEs (default `0`; each retry backs off `2 ** attempt` seconds)
- `isEventValid?: (event, url) => boolean` — signature check override; defaults to `verifyEvent`
- Callbacks: `onEvent(event, url)`, `onEose(url)`, `onClose()`, `onDisconnect(url)`, `onFiltered`, `onDuplicate`, `onDeleted`, `onInvalid`, `onClosed(reason, url)`
`request`-only options:
- `threshold?: number` — fraction of relays that must close before the promise resolves (default `1`)
`request`-only: `threshold?: number` — fraction of relays that must close before the promise resolves (default `1`).
Without `autoClose` or a `signal`, `requestOne` streams indefinitely — the returned promise only resolves if the relay sends CLOSED for all active subscription IDs. Default policies also re-send the REQ when sockets reconnect.
`makeLoader` options: `{delay, timeout?, threshold?, context?, isEventValid?}`. The returned `Loader` takes `{relays, filters, signal?, onEvent?, onDisconnect?, onEose?, onClose?}`.
Without `autoClose` or a `signal`, `requestOne` streams indefinitely. The returned promise only resolves if the relay sends CLOSED for all active subscription ids.
### Publish
@ -61,7 +80,7 @@ Without `autoClose` or a `signal`, `requestOne` streams indefinitely — the ret
| `publish(options)` | Publishes to multiple relays; resolves to `PublishResultsByRelay` |
| `publishOne(options)` | Publishes to a single relay; resolves to `PublishResult` |
| `PublishStatus` | Enum: `Sending`, `Pending`, `Success`, `Failure`, `Timeout`, `Aborted` |
| `PublishResult` | `{ relay: string, status: PublishStatus, detail: string }` |
| `PublishResult` | `{status: PublishStatus, detail: string, relay: string}` |
| `PublishResultsByRelay` | `Record<string, PublishResult>` |
`publish` options: `event`, `relays`, `timeout?` (default 10 s), `signal?`, `context?`, plus callbacks `onSuccess`, `onFailure`, `onPending`, `onTimeout`, `onAborted`, `onComplete`.
@ -73,78 +92,74 @@ Without `autoClose` or a `signal`, `requestOne` streams indefinitely — the ret
| `AuthState` | Manages auth state for one socket; available as `socket.auth` |
| `AuthStatus` | Enum: `None`, `Requested`, `PendingSignature`, `DeniedSignature`, `PendingResponse`, `Forbidden`, `Ok` |
| `AuthStateEvent.Status` | Emitted when auth status changes |
| `makeSocketPolicyAuth(options)` | Creates a socket policy that auto-handles auth challenges |
| `defaultSocketPolicies` | Mutable array of policies applied to every new socket |
| `makeSocketPolicyAuth(options)` | Creates a socket policy that auto-handles auth challenges. Options: `{sign, shouldAuth?}` |
### Policies
A `SocketPolicy` is `(socket: Socket) => Unsubscriber`, run once per socket at creation.
| Export | Description |
|--------|-------------|
| `socketPolicyPing` | Sends a PING frame every 30 s when the socket is open and idle, to keep the connection alive |
| `socketPolicyAuthBuffer` | Buffers outgoing messages during auth and replays after success |
| `socketPolicyAuthBuffer` | Buffers outgoing messages during auth and replays them once it completes |
| `socketPolicyConnectOnSend` | Auto-opens closed sockets when a message is queued |
| `socketPolicyCloseInactive` | Closes idle sockets after 30 s (when no pending work remains); if the socket closes with pending work it delays and reopens, replaying queued messages |
| `defaultSocketPolicies` | Array of the four above; passed to every socket created by `Pool` |
| `socketPolicyLifecycle` | Owns the socket's lifetime: closes it after 30 s idle with nothing pending; while work *is* pending, probes with a throwaway REQ when nothing has been received for a while and closes if the probe goes unanswered; on an unexpected close, reopens after a flap delay and replays pending messages, rewriting each REQ's filters with `catchUpFilter` so nothing is missed |
| `defaultSocketPolicies` | `[socketPolicyAuthBuffer, socketPolicyConnectOnSend, socketPolicyLifecycle]` |
A `SocketPolicy` is `(socket: Socket) => Unsubscriber`.
`defaultSocketPolicies` is a template. `new Pool()` copies it into `pool.socketPolicies`, so mutating the array after a pool exists does nothing for that pool; assign to `pool.socketPolicies` instead.
### Repository
| Export | Description |
|--------|-------------|
| `Repository` | In-memory indexed event store with delete/expiry support |
| `Repository.get()` | Returns the singleton instance |
| `repository.publish(event)` | Stores an event; returns `false` if duplicate/stale |
| `repository.query(filters, opts?)` | Returns matching `TrustedEvent[]` sorted by `created_at` desc |
| `Repository` | In-memory indexed event store with delete/expiry support. Construct with `new Repository()` |
| `repository.publish(event, {shouldNotify?})` | Stores an event; returns `false` if duplicate/stale |
| `repository.query(filters, {shouldSort?})` | Returns matching `TrustedEvent[]`, sorted by `created_at` desc unless disabled |
| `repository.getEvent(idOrAddress)` | Look up by id or NIP-01 address (`kind:pubkey:d`) |
| `repository.isDeleted(event)` | `true` if a kind-5 delete covers this event |
| `repository.dump()` | Returns all stored events as `TrustedEvent[]` |
| `repository.load(events)` | Bulk-replaces all stored events; emits a single `"update"` diff. Events with `event[verifiedSymbol] = true` skip signature re-verification. |
| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — conventional URL for the local repository |
| `RepositoryUpdate` | `{ added: TrustedEvent[], removed: Set<string> }` — payload of `"update"` events |
| `repository.hasEvent(event)` | Whether the event (or a newer replacement) is already stored |
| `repository.removeEvent(idOrAddress)` | Drops an event and unwinds its index entries |
| `repository.isDeleted(event)` | `true` if a kind-5 delete covers this event (`isDeletedById` / `isDeletedByAddress` check one path each) |
| `repository.isExpired(event)` | `true` past the event's NIP-40 `expiration` |
| `repository.dump()` | All stored events as `TrustedEvent[]` |
| `repository.load(events)` | Bulk-**replaces** all stored events; emits one `"update"` diff. Events with `event[verifiedSymbol] = true` skip signature re-verification |
| `repository.clear()` | Empties every index |
| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — conventional url for the local repository (also exported by `@welshman/util`) |
| `RepositoryUpdate` | `{added: TrustedEvent[], removed: Set<string>}` — payload of `"update"` events |
| `mergeRepositoryUpdates(updates)` | Merges an array of `RepositoryUpdate` objects into one |
Emits `"update"` with `RepositoryUpdate` (`{ added: TrustedEvent[], removed: Set<string> }`) on every change.
Emits `"update"` with a `RepositoryUpdate` on every change.
> **Prefer `LOCAL_RELAY_URL` over direct repository access.** Rather than calling `repository.query()` or `repository.publish()` directly, pass `LOCAL_RELAY_URL` as a relay URL to the standard `load()`, `request()`, and `publish()` functions. This keeps local reads/writes going through the same policy, deduplication, and tracking pipeline as remote relay operations. Direct repository access is appropriate only for bulk startup (`repository.load()`) and low-level introspection (`repository.getEvent()`, `repository.isDeleted()`).
> **Prefer `LOCAL_RELAY_URL` over direct repository access.** Rather than calling `repository.query()` or `repository.publish()` directly, pass `LOCAL_RELAY_URL` as a relay url to `load()`, `request()` and `publish()` (with the repository in `context`). Local reads and writes then go through the same policy, deduplication and tracking pipeline as remote ones. Reserve the direct API for bulk startup (`repository.load()`) and low-level introspection (`getEvent`, `isDeleted`, `dump`).
### Tracker
| Export | Description |
|--------|-------------|
| `Tracker` | Bidirectional map of `eventId ↔ Set<relayUrl>` |
| `tracker.track(eventId, relay)` | Records relay; returns `true` if the event was already seen |
| `tracker.getRelays(eventId)` | Set of relay URLs that have sent this event |
| `Tracker` | Bidirectional map of `eventId ↔ Set<relayUrl>` (`relaysById` / `idsByRelay`) |
| `tracker.track(eventId, relay)` | Records the relay; returns `true` if the event was already seen |
| `tracker.addRelay(id, relay)` / `removeRelay(id, relay)` | Explicit edge management |
| `tracker.hasRelay(id, relay)` | Membership check |
| `tracker.getRelays(eventId)` | Set of relay urls that have sent this event |
| `tracker.getIds(relay)` | Set of event ids seen from a relay |
| `tracker.copy(id1, id2)` | Copies relay associations from one id to another (used for gift wraps) |
| `tracker.load(relaysById)` | Bulk-replaces all relay mappings from a `Map<string, Set<string>>`; emits `"load"` |
| `tracker.clear()` | Removes all relay mappings; emits `"clear"` |
| `tracker.load(relaysById)` | Bulk-replaces all mappings from a `Map<string, Set<string>>`; emits `"load"` |
| `tracker.clear()` | Removes all mappings; emits `"clear"` |
### Adapters
| Export | Description |
|--------|-------------|
| `getAdapter(url, context?)` | Factory: returns `SocketAdapter`, `LocalAdapter`, or custom adapter |
| `getAdapter(url, context?)` | Factory: `context.getAdapter` first, then `LocalAdapter` for `LOCAL_RELAY_URL`, then `SocketAdapter` for relay urls |
| `SocketAdapter` | WebSocket relay adapter |
| `LocalAdapter` | In-memory relay adapter |
| `LocalAdapter` | In-memory adapter over a `Repository` |
| `MockAdapter` | Test adapter with manual send control |
| `AbstractAdapter` | Base class for custom adapters |
| `AdapterEvent.Receive` | Emitted when a relay message arrives |
### Context
| Export | Description |
|--------|-------------|
| `NetContext` | `{ pool?, repository?, getAdapter? }` |
Pass `context` to each `request`/`publish`/`load` call. An `App` from `@welshman/app` owns one per
instance (`app.netContext`), and `app.use(Network)` supplies it for you.
### Negentropy / Diff (NIP-77)
| Export | Description |
|--------|-------------|
| `diff(options)` | Compares local events against relays; returns `{ relay, have, need }[]` |
| `diff(options)` | Compares local events against relays; returns `{relay, have, need}[]` |
| `pull(options)` | Fetches events relays have that you don't |
| `push(options)` | Publishes events you have that relays don't |
| `Difference` | Low-level per-relay negentropy session |
@ -153,27 +168,43 @@ instance (`app.netContext`), and `app.use(Network)` supplies it for you.
| Export | Description |
|--------|-------------|
| `RelayMessageType` | Enum of relay→client message types |
| `ClientMessageType` | Enum of client→relay message types |
| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, etc. | Type guards for relay messages |
| `isClientReq()`, `isClientEvent()`, etc. | Type guards for client messages |
| `RelayMessageType` / `ClientMessageType` | Enums of relay→client and client→relay message types |
| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, `isRelayClosed()`, … | Type guards for relay messages |
| `isClientReq()`, `isClientEvent()`, `isClientClose()`, `isClientAuth()`, … | Type guards for client messages |
| `matchReason(prefix, reason)` / `RelayReasonPrefix` | Match a relay's machine-readable `OK`/`CLOSED` reason prefix (`auth-required:`, `restricted:`, …) |
### WrapManager
| Export | Description |
|--------|-------------|
| `WrapManager` | Tracks NIP-59 gift wrap → rumor relationships; stores decrypted rumors in the repository and copies relay tracking from the wrap to the rumor |
| `WrapManager` | Tracks NIP-59 gift wrap ↔ rumor relationships. `new WrapManager({tracker, repository})` |
| `wrapManager.add({wrap, rumor, recipient})` | Stores the rumor in the repository and copies the wrap's relay tracking onto it |
| `wrapManager.getRumor(wrapId)` / `getWraps(rumorId)` | Look up either direction |
| `wrapManager.remove(id)` / `removeByRumorId(id)` / `clear()` | Teardown |
| `wrapManager.dump()` / `load(wrapItems)` | Persist and restore (`WrapItem[]`) |
---
## Common Patterns
### Set up a context
```typescript
import {Pool, Repository} from '@welshman/net'
import type {NetContext} from '@welshman/net'
const pool = new Pool()
const repository = new Repository()
const context: NetContext = {pool, repository}
```
Pass `context` to every net call below.
### Connect to a relay and stream events
```typescript
import {Pool, SocketEvent, SocketStatus} from '@welshman/net'
import {SocketEvent, SocketStatus} from '@welshman/net'
const pool = Pool.get()
const socket = pool.get('wss://relay.example.com')
socket.on(SocketEvent.Status, (status: SocketStatus) => {
@ -187,10 +218,12 @@ socket.send(['REQ', 'my-sub', {kinds: [1], limit: 10}])
### Load events (one-shot, batched)
```typescript
import {load} from '@welshman/net'
import {makeLoader} from '@welshman/net'
// Bind a loader to the context once; concurrent calls within `delay` collapse
// into a single REQ per relay.
const load = makeLoader({delay: 30, timeout: 3000, threshold: 0.5, context})
// load() batches multiple concurrent calls within 200 ms into a single REQ per relay.
// It auto-closes after EOSE, timeout, or disconnect, and resolves at 50 % relay threshold.
const events = await load({
relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
filters: [{kinds: [0], authors: ['<pubkey>']}],
@ -203,14 +236,15 @@ const events = await load({
import {request} from '@welshman/net'
import {now} from '@welshman/lib'
// Without autoClose this will stream forever.
// The returned promise never settles unless all relays close the subscription.
// Without autoClose this streams forever; the returned promise never settles
// unless all relays close the subscription.
const ctrl = new AbortController()
request({
relays: ['wss://relay.example.com'],
filters: [{kinds: [1], since: now()}],
signal: ctrl.signal,
context,
onEvent: (event, url) => console.log(event.id, 'from', url),
})
@ -227,6 +261,7 @@ const results = await publish({
event: signedEvent,
relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
timeout: 5000,
context,
onSuccess: r => console.log('accepted by', r.relay),
onFailure: r => console.warn('rejected by', r.relay, r.detail),
})
@ -238,36 +273,40 @@ for (const [relay, result] of Object.entries(results)) {
}
```
### Enable NIP-42 auth globally
### Enable NIP-42 auth
Policies are per-pool. Set them before the pool creates any sockets, because a socket already in the pool keeps the policies it was built with.
```typescript
import {defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net'
import {Pool, defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net'
import type {StampedEvent} from '@welshman/util'
// Call once at app startup, before any sockets are opened.
defaultSocketPolicies.push(
const pool = new Pool()
pool.socketPolicies = [
...defaultSocketPolicies,
makeSocketPolicyAuth({
sign: (event: StampedEvent) => mySigner.sign(event),
shouldAuth: (socket) => true, // auth on every relay
shouldAuth: socket => true, // auth on every relay
}),
)
]
```
### Custom socket policies
A `SocketPolicy` is `(socket: Socket) => Unsubscriber`. It receives the socket when it is created, attaches listeners or patches socket methods, and returns a cleanup function. Push custom policies onto `defaultSocketPolicies` before any sockets are opened.
A policy receives the socket when it is created, attaches listeners or patches socket methods, and returns a cleanup function.
```typescript
import {writable} from 'svelte/store'
import {on} from '@welshman/lib'
import {defaultSocketPolicies, SocketEvent, isRelayEvent} from '@welshman/net'
import {SocketEvent, isRelayEvent} from '@welshman/net'
import type {Socket, RelayMessage} from '@welshman/net'
// Track how many events each relay has delivered this session
export const eventCountByRelay = writable<Record<string, number>>({})
const eventCountPolicy = (socket: Socket) => {
const unsub = on(socket, SocketEvent.Receive, (message: RelayMessage) => {
const eventCountPolicy = (socket: Socket) =>
on(socket, SocketEvent.Receive, (message: RelayMessage) => {
if (isRelayEvent(message)) {
eventCountByRelay.update(counts => ({
...counts,
@ -276,13 +315,10 @@ const eventCountPolicy = (socket: Socket) => {
}
})
return unsub // called when the socket is destroyed
}
defaultSocketPolicies.push(eventCountPolicy)
pool.socketPolicies = [...pool.socketPolicies, eventCountPolicy]
```
The same structure applies to more advanced patterns — patch `socket.open` to block connections, listen to `SocketEvent.Sending`/`SocketEvent.Receiving` to intercept messages before they are processed, or manipulate `socket._recvQueue` directly to suppress or replay messages.
The same structure covers more advanced patterns. Patch `socket.open` to block connections, or listen to `SocketEvent.Sending`/`SocketEvent.Receiving` to intercept messages before they are processed.
### Custom adapter (e.g. non-WebSocket backend)
@ -300,7 +336,8 @@ class MyAdapter extends AbstractAdapter {
get sockets() { return [] }
send(message: ClientMessage) {
// forward message to your backend; call this.emit(AdapterEvent.Receive, replyMsg, this.url) when data arrives
// forward to your backend; call
// this.emit(AdapterEvent.Receive, replyMsg, this.url) when data arrives
}
}
@ -309,17 +346,18 @@ request({
filters: [{kinds: [1]}],
autoClose: true,
context: {
getAdapter: (url) => url.startsWith('myscheme://') ? new MyAdapter(url) : undefined,
...context,
getAdapter: url => (url.startsWith('myscheme://') ? new MyAdapter(url) : undefined),
},
})
```
A `getAdapter` that returns `undefined` falls through to the built-in resolution, so you can special-case one scheme and leave the rest alone.
### Use LOCAL_RELAY_URL to read/write the local repository
Pass `LOCAL_RELAY_URL` as a relay to the standard net functions so local operations go through the same pipeline as remote ones (policies, deduplication, tracker):
```typescript
import {load, publish, request, LOCAL_RELAY_URL} from '@welshman/net'
import {publish, request, LOCAL_RELAY_URL} from '@welshman/net'
import {now} from '@welshman/lib'
// Read from the local repository the same way you'd read from a remote relay
@ -332,27 +370,24 @@ const events = await load({
await publish({
event: signedEvent,
relays: [LOCAL_RELAY_URL, 'wss://relay.example.com'],
context,
})
// Subscribe to new local events in real time
request({
relays: [LOCAL_RELAY_URL],
filters: [{kinds: [1], since: now()}],
onEvent: (event) => console.log('new local event', event.id),
context,
onEvent: event => console.log('new local event', event.id),
})
```
Direct `repository` API calls (`repository.load()`, `repository.getEvent()`, `repository.isDeleted()`, `repository.dump()`) are still appropriate for bulk startup and low-level introspection — but for routine reads and writes prefer `LOCAL_RELAY_URL`.
### Startup: bulk-load persisted events (skip re-verification)
```typescript
import {Repository} from '@welshman/net'
import {verifiedSymbol} from '@welshman/util'
import type {TrustedEvent} from '@welshman/util'
const repo = Repository.get()
// Mark events as already-verified so welshman skips signature checks
const storedEvents: TrustedEvent[] = await loadFromStorage()
for (const event of storedEvents) {
@ -360,23 +395,21 @@ for (const event of storedEvents) {
}
// Replaces all in-memory events in one pass; emits a single "update"
repo.load(storedEvents)
repository.load(storedEvents)
```
### Startup: bulk-load Tracker state
```typescript
// `app.tracker` — wired to that app's pool and repository
// Build the map from your stored relay<->event mappings
// Build the map from your stored relay <-> event mappings
const relaysById = new Map<string, Set<string>>()
for (const {id, relays} of storedTrackerItems) {
if (repo.getEvent(id)) { // skip orphaned entries
if (repository.getEvent(id)) { // skip orphaned entries
relaysById.set(id, new Set(relays))
}
}
// Takes Map<string, Set<string>> — same shape as tracker.relaysById
// Takes Map<string, Set<string>> — the same shape as tracker.relaysById
tracker.load(relaysById)
```
@ -384,7 +417,6 @@ tracker.load(relaysById)
```typescript
import {on, batch} from '@welshman/lib'
// `app.repository`, or `new Repository()` when using @welshman/net standalone
import type {RepositoryUpdate} from '@welshman/net'
import type {TrustedEvent} from '@welshman/util'
@ -415,28 +447,30 @@ on(
## Integration Notes
- **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, etc. All event objects flowing through `@welshman/net` are `TrustedEvent` (already verified).
- **`@welshman/lib`** — utility helpers (`Emitter`, `batcher`, `defer`, `on`, etc.) used internally; `Emitter` (from `@welshman/lib`) is the base class for `Tracker`, `Repository`, and `WrapManager`. `Socket`, `AuthState`, `AbstractAdapter`, and `Difference` extend node's built-in `EventEmitter` directly.
- **`@welshman/app`** — wraps `@welshman/net` in an `App` instance whose plugins own routing, collections, and publishing. Most app-level code should go through `app.use(Network)`; drop down to `@welshman/net` only for raw relay I/O or when building without an `App`.
- **`NetContext`** — passed explicitly per call. Each `App` owns its own pool and repository, which keeps one identity's data out of another's.
- **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, `normalizeRelayUrl`, etc. All event objects flowing through `@welshman/net` are `TrustedEvent`.
- **`@welshman/lib`** — utility helpers (`Emitter`, `batcher`, `defer`, `on`, …). `Emitter` is the base class for `Tracker`, `Repository` and `WrapManager`; `Socket`, `AuthState`, `AbstractAdapter` and `Difference` extend node's `EventEmitter` directly.
- **`@welshman/app`** — an `App` owns one `Pool`, `Repository`, `Tracker` and `WrapManager`, exposes them as `app.netContext`, and wraps the request/publish entry points as `app.use(Network)`. Most app-level code should go through that; drop to `@welshman/net` for raw relay I/O or non-Svelte clients.
---
## Gotchas & Tips
- **Use `LOCAL_RELAY_URL`, not direct repository calls, for routine reads/writes.** Passing `LOCAL_RELAY_URL` to `load()`, `publish()`, or `request()` routes through the normal net pipeline (policies, deduplication, tracker). Calling `repository.query()` / `repository.publish()` directly bypasses all of that. Reserve the direct API for bulk startup (`repository.load()`), introspection (`getEvent`, `isDeleted`, `dump`), and listening to `"update"` events.
- **Build one `{pool, repository}` per identity and thread it through.** Two contexts never share sockets or events.
- **`request()` without `autoClose` or `signal` never resolves.** Pass one when you want a one-shot fetch; use a loader for the common case.
- **Relay url normalization happens inside `pool.get(url)`** via `normalizeRelayUrl`. Pass raw urls everywhere.
- **`pool.get(url)` creates a fresh socket after `pool.remove(url)`.** `remove` forgets the url and cleans up the socket, policies included; call it only when you want the pool to forget the relay, not merely to disconnect.
- **`socketPolicyLifecycle` reopens a closed socket only when work is pending.** Opening a socket because something was queued is `socketPolicyConnectOnSend`'s job.
- **Subscriptions the relay CLOSEs are not retried by default.** Set `resubscribeAttempts` when a caller should survive a transient refusal (e.g. auth in flight).
- **`Tracker` is shared across relays in `request()`**, so `onDuplicate` fires for events received from more than one relay. That is cross-relay deduplication, not an error.
- **`Repository.publish()` returns `false` for stale replaceable events.** If a newer version is already stored, the older one is silently dropped.
- **`makeSocketPolicyAuth` requires a `sign` function** returning `Promise<SignedEvent>`. If the user cancels, throw or reject: `doAuth` catches it and transitions to `AuthStatus.DeniedSignature`, preventing a retry loop.
- **Each filter in `filters` generates a separate REQ** inside `requestOne`. For large filter arrays merge them with `unionFilters` from `@welshman/util` first.
- **`repository.load()` replaces, it does not append.** It clears the indexes then re-inserts, emitting a single batched `"update"`. Use `repository.publish(event)` for incremental updates.
- **`RepositoryUpdate.removed` is a `Set<string>`.** Iterate with `for...of` or `Array.from`. `batch()` from `@welshman/lib` hands your callback a `RepositoryUpdate[]` — merge them yourself or use `mergeRepositoryUpdates`.
- **`tracker.load()` takes `Map<string, Set<string>>`.** Load it after `repository.load()` so you can drop orphaned event ids.
- **`request()` without `autoClose` or `signal` never resolves.** Always pass `autoClose: true` or an `AbortSignal` when you just want a one-shot fetch. Use `load()` for the common case.
- **`load()` sets `autoClose: true` internally** and uses a 0.5 relay threshold; it resolves when half the relays' subscriptions have closed (typically after EOSE, timeout, or disconnect) — useful when some relays are slow or offline.
- **Relay URL normalization** happens inside `Pool.get(url)` via `normalizeRelayUrl`. Pass raw URLs everywhere; the pool handles canonicalization.
- **`defaultSocketPolicies` is mutable.** Push policies before any sockets are created. Sockets created before a policy is pushed will not have it applied.
- **`socketPolicyCloseInactive` only replays pending work on unexpected close.** It reopens and replays queued messages when a socket closes while work is pending — it does not proactively open sockets when new work is queued (that is `socketPolicyConnectOnSend`'s job). After `pool.remove(url)` the socket is cleaned up including its policy listeners, so `socketPolicyCloseInactive` can no longer reopen it.
- **`Pool.get(url)` lazily creates a new socket on every call after `pool.remove(url)`.** Calling `pool.remove(url)` forgets the URL and cleans up the socket — any subsequent `pool.get(url)` will construct a fresh socket. Call `pool.remove()` only when you want the pool to forget the URL entirely, not merely to disconnect temporarily.
- **`Tracker` is shared across relays in `request()`.** This means `onDuplicate` fires for events received from more than one relay — expected behavior for cross-relay deduplication.
- **`Repository.publish()` returns `false` for stale replaceable events.** If a newer version of a replaceable event is already stored, the older one is silently dropped.
- **`WrapManager` stores the decrypted rumor in the `Repository`** and copies relay tracking from the gift-wrap event id to the rumor id. Keep a reference to the `WrapManager` instance alongside your `Repository` and `Tracker` singletons.
- **`makeSocketPolicyAuth` requires a `sign` function** that returns a `Promise<SignedEvent>`. If the user cancels signing, have the `sign` function throw or reject; `doAuth` will catch the failure via `tryCatch` and automatically transition to `AuthStatus.DeniedSignature`, preventing infinite retry loops.
- **Each filter in `filters` array generates a separate REQ** inside `requestOne`. For large filter arrays consider merging them with `unionFilters` from `@welshman/util` before calling `request`.
- **`repository.load()` replaces all events, not appends.** It clears internal indexes first, then re-inserts every event. Emit a single batched `"update"` diff — do not call it repeatedly for incremental updates; use `repository.publish(event)` for that.
- **`RepositoryUpdate.removed` is `Set<string>`, not an array.** Iterate with `for...of` or `Array.from(removed)`. The `batch()` helper from `@welshman/lib` delivers updates as `RepositoryUpdate[]` to your flush callback — merge them yourself or use `mergeRepositoryUpdates`.
- **`tracker.load()` takes `Map<string, Set<string>>`** (the same type as `tracker.relaysById`). Load it after `repository.load()` so you can filter out orphaned event ids.
## Related skills
- `welshman-app` — the instance-based layer that owns these primitives (`app.netContext`, `app.use(Network)`, `app.use(Sync)`).
- `welshman-store` — Svelte derivations over a `Repository` and `Tracker`.
- `welshman-util` — the event, filter and relay-url types on the wire, and the routing DSL for choosing which urls to pass to these functions.

View file

@ -25,25 +25,34 @@ yarn add @welshman/signer
The common contract all signers implement.
```typescript
import type { ISigner, SignOptions, SignWithOptions } from '@welshman/signer'
import type {ISigner, SignOptions, EncryptionImplementation} from '@welshman/signer'
interface ISigner {
sign: (event: StampedEvent, options?: SignOptions) => Promise<SignedEvent>
sign: SignWithOptions
nip04: EncryptionImplementation
nip44: EncryptionImplementation
getPubkey: () => Promise<string>
nip04: {
encrypt: (pubkey: string, message: string) => Promise<string>
decrypt: (pubkey: string, message: string) => Promise<string>
}
nip44: {
encrypt: (pubkey: string, message: string) => Promise<string>
decrypt: (pubkey: string, message: string) => Promise<string>
}
cleanup?: () => Promise<void>
}
type SignOptions = { signal?: AbortSignal }
type SignOptions = {signal?: AbortSignal}
type Sign = (event: StampedEvent) => Promise<SignedEvent>
type SignWithOptions = (event: StampedEvent, options?: SignOptions) => Promise<SignedEvent>
type Encrypt = (pubkey: string, message: string) => Promise<string>
type Decrypt = (pubkey: string, message: string) => Promise<string>
type EncryptionImplementation = {encrypt: Encrypt; decrypt: Decrypt}
```
### Helpers & wrappers
| Export | Description |
|---|---|
| `decrypt(signer, pubkey, message)` | Picks nip04 or nip44 by sniffing the ciphertext for `?iv=` |
| `nip04` / `nip44` | The raw primitives (`encrypt`/`decrypt` taking an explicit secret; `nip04.detect(m)`; `nip44.getSharedSecret`, LRU-cached) |
| `WrappedSigner` | An `ISigner` that routes every method through a `SignerMethodWrapper`, and an `Emitter`. `@welshman/app` layers decrypt-caching and signer logging onto the user's signer this way, via `User.wrapSigner(wrap)` |
| `SignerMethodWrapper` | `<T>(method: string, thunk: () => Promise<T>, args: unknown[]) => Promise<T>` — `method` is `"sign"`, `"getPubkey"`, `"nip44.decrypt"`, …; `args` lets a wrapper key a cache off the call's arguments |
| `signWithOptions(promise, options)` | Races a signing promise against a 30 s timeout and the caller's `AbortSignal` |
### Nip01Signer (local keypair)
| Export | Description |
@ -73,13 +82,22 @@ type SignOptions = { signal?: AbortSignal }
### Nip55Signer (native mobile)
This package never imports `nostr-signer-capacitor-plugin` — it types the plugin structurally as `Nip55` and the app hands it over. Install it (`npm install nostr-signer-capacitor-plugin`) and register it once at startup:
```ts
import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin"
import {setNip55Plugin} from "@welshman/signer"
setNip55Plugin(NostrSignerPlugin)
```
| Export | Description |
|---|---|
| `getNip55()` | Returns `Promise<AppInfo[]>` — installed signing apps via Capacitor |
| `setNip55Plugin(plugin)` | Registers the Capacitor plugin. Until called, `Nip55Signer` operations throw `"Nip55 is not enabled"` |
| `getNip55Plugin()` | Returns the registered plugin, or `undefined` |
| `getNip55()` | Returns `Promise<Nip55AppInfo[]>` — installed signing apps, or `[]` when no plugin is registered |
| `new Nip55Signer(packageName, pubkey?)` | Communicates with the specified native app; pass saved pubkey to resume a session |
Requires the peer dependency: `npm install nostr-signer-capacitor-plugin`
### Nip59 (Gift Wrap)
| Export | Description |
@ -211,13 +229,13 @@ const plaintext = await signer.nip44.decrypt(theirPubkey, ciphertext)
- **`@welshman/util`** supplies `makeEvent`, `makeSecret`, `StampedEvent`, `SignedEvent`, and nostr kind constants (`NOTE`, `DIRECT_MESSAGE`, etc.) used in all examples above.
- **`@welshman/net`** and **`@welshman/app`** accept an `ISigner` wherever signing is needed (e.g. publishing events). Pass any concrete signer — they are interchangeable.
- **`@welshman/app`** wraps a signer in a `User` (`User.fromSigner(signer)` or `User.fromSession(session)`), passed to the app at construction: `createApp({user})`. Reach it again via `app.user?.signer`, or `User.require(app).signer` where a login is required.
- **`@welshman/app`** has no `signer` store. An identity is a `User` (`{pubkey, signer}`) hanging off the app: `User.fromSigner(signer)` / `User.fromSession(session)`, then `createApp({user})`. Reach it as `app.user?.signer`, and require it with `User.require(app)`.
- `Nip59` wraps events with an ephemeral `Nip01Signer` by default (per the NIP-59 spec), so callers do not need to supply a wrapper unless they want a custom one.
## Gotchas & Tips
- **`Nip07Signer` is browser-only.** Do not instantiate it in SSR or Node environments; always guard with `getNip07()` first.
- **`Nip55Signer` requires Capacitor.** It will not work in a plain browser build. Only use it in a Capacitor-wrapped mobile app after confirming `getNip55()` returns apps.
- **`Nip55Signer` requires Capacitor.** It will not work in a plain browser build. Only use it in a Capacitor-wrapped mobile app, after calling `setNip55Plugin(NostrSignerPlugin)` and confirming `getNip55()` returns apps. Without the plugin, `getNip55()` returns `[]` rather than throwing, so it doubles as the feature check.
- **`waitForNostrconnect` holds an open subscription.** Always pass an `AbortSignal` (e.g., from `new AbortController().signal`) so you can cancel if the user navigates away.
- **`makeSecret()`** (from `@welshman/util`) generates a cryptographically secure random hex private key. Use it for the `clientSecret` in NIP-46 — never reuse the user's actual private key as the client secret.
- **`nip59.wrap()` returns the gift-wrap `SignedEvent` directly** — the return value itself is the kind-1059 event to publish. There is no `.wrap` sub-property on the return value.

View file

@ -30,6 +30,29 @@ yarn add @welshman/store
| `deriveEventsDesc(eventsByIdStore)` | Takes a `Readable<Map<string, TrustedEvent>>` and returns events sorted descending by `created_at` |
| `makeDeriveEvent(options)` | Factory returning `(idOrAddress: string) => Readable<TrustedEvent \| undefined>` for single-event lookups |
| `deriveIsDeleted(repository, event)` | `Readable<boolean>` — tracks deletion status of an event |
| `deriveArray(itemsByIdStore)` | Turns any `Readable<Map<string, T>>` into `Readable<T[]>` |
| `getEventsById(options)` | The one-shot, non-reactive form of `deriveEventsById` |
### Relay-scoped event stores
These key events by the relays they were seen on, via a `Tracker`. Use them when the same event (or the same addressable coordinate) has to stay distinct per relay — NIP-29 rooms, relay membership, per-relay replaceables.
| Export | Description |
|---|---|
| `deriveEventsByIdByUrl(options)` | `Readable<Map<url, Map<id, TrustedEvent>>>` — every relay at once |
| `deriveEventsByIdForUrl(options)` | `Readable<Map<id, TrustedEvent>>` — one relay |
| `getEventsByIdByUrl` / `getEventsByIdForUrl` | Non-reactive equivalents |
| `deriveItemsByKeyByUrl<T>(options)` | The relay-scoped `deriveItemsByKey`: an event is keyed once per relay via `getKey(item, url)` |
Both relay-scoped option types extend `EventsByIdOptions` with `tracker: Tracker` (and `url: string` for the `ForUrl` variants). `ItemsByKeyByUrlOptions` adds two things over `ItemsByKeyOptions`:
```typescript
{
getKey: (item: T, url: string) => string | undefined // undefined excludes the item on that relay
revalidateOn?: Readable<unknown> // re-evaluate every key when this changes — for keys that
// depend on state settling later (e.g. a relay's NIP-11 self)
}
```
`deriveEventsById` / `deriveEvents` options (`EventsByIdOptions`):
```typescript
@ -55,13 +78,6 @@ const deriveEvent = makeDeriveEvent({ repository })
const eventStore = deriveEvent(someIdOrAddress) // Readable<TrustedEvent | undefined>
```
`deriveEventsAsc` / `deriveEventsDesc` take a map store, not an array store:
```typescript
// correct: pass the Readable<Map<string, TrustedEvent>> directly
const notesAsc = deriveEventsAsc(noteEventsById)
const notesDesc = deriveEventsDesc(noteEventsById)
```
### Indexed collections
| Export | Description |
@ -73,6 +89,17 @@ const notesDesc = deriveEventsDesc(noteEventsById)
| `makeLoadItem<T>(loadItem, getItem, options?)` | Cached async loader with staleness checks and exponential backoff |
| `makeForceLoadItem<T>(loadItem, getItem)` | Async loader that always fetches fresh data |
`makeLoadItem` options (`MakeLoadItemOptions`):
```typescript
{
timeout?: number // staleness window in SECONDS (default 3600)
maxSize?: number // LRU size for the fetched/attempt caches (default 10_000)
getFetched?: (key: string) => number // override where "last fetched" is stored
setFetched?: (key: string, ts: number) => void
getSource?: (...args: any[]) => string // how extra args key the backoff (default JSON.stringify)
}
```
`deriveItemsByKey` options:
```typescript
{
@ -88,7 +115,8 @@ const notesDesc = deriveEventsDesc(noteEventsById)
| Export | Description |
|---|---|
| `synced(config)` | Writable store that auto-persists to a `StorageProvider`; exposes a `.ready` promise |
| `synced(config)` | Writable store that auto-persists to a `StorageProvider`; exposes a `.ready` promise (type `Synced<T>`) |
| `sync(config)` | The lower-level primitive: binds an existing store to a `StorageProvider` |
| `localStorageProvider` | Built-in `StorageProvider` backed by `localStorage` |
`StorageProvider` interface:
@ -110,7 +138,16 @@ interface StorageProvider {
| Export | Description |
|---|---|
| `getter<T>(store, options?)` | Returns `() => T`; auto-switches from `get()` to a subscription when call frequency exceeds `threshold` (default 10/s) |
| `withGetter<T>(store)` | Adds a `.get()` method to a `Readable` or `Writable` store |
| `withGetter<T>(store)` | Adds a `.get()` method to a `Readable` or `Writable` store (`WritableWithGetter` / `ReadableWithGetter`) |
### Derivation helpers
| Export | Description |
|---|---|
| `memoized(store)` | Suppresses notifications when the derived value is deep-equal to the last one |
| `deriveDeduplicated(stores, read)` | Derives `read()` from `stores`, skipping updates whose result is deep-equal to the previous. The backbone of `Projection`s in `@welshman/app` |
| `deriveDeduplicatedByValue(stores, read)` | Same, comparing by value identity rather than deep equality |
| `merged(stores)` | `derived(stores, identity)` — combine several stores into one array store |
## Common Patterns
@ -138,21 +175,22 @@ notes.subscribe($notes => {
### 2. Profiles indexed by pubkey
```typescript
import { parseJson } from "@welshman/lib"
import { Repository } from "@welshman/net"
import { deriveItemsByKey, deriveItems, makeDeriveItem } from "@welshman/store"
import { PROFILE } from "@welshman/util"
import { Profile, type ProfileReader } from "@welshman/domain"
import { PROFILE, type TrustedEvent } from "@welshman/util"
const repository = new Repository()
// Decoding is @welshman/domain's job — configure the kind, then use its reader as eventToItem.
const readProfile = Profile.configure({}).reader
type Profile = { event: TrustedEvent; name?: string }
const profilesByPubkey = deriveItemsByKey<ProfileReader>({
const profilesByPubkey = deriveItemsByKey<Profile>({
repository,
filters: [{ kinds: [PROFILE] }],
eventToItem: event => readProfile(event).parse(),
getKey: profile => profile.author(),
// eventToItem decodes each event; here we parse the profile's JSON content.
// In a full @welshman/app setup use `app.use(Domain).reader(Profile)` instead.
eventToItem: event => ({ event, ...parseJson(event.content) }),
getKey: profile => profile.event.pubkey,
})
// All profiles as array
@ -163,7 +201,7 @@ const deriveProfile = makeDeriveItem(profilesByPubkey)
const aliceProfile = deriveProfile("alice-pubkey-hex")
aliceProfile.subscribe($profile => {
console.log($profile?.display())
console.log($profile?.name)
})
```
@ -228,11 +266,10 @@ const getBookmark = (pubkey: string) => getBookmarksByPubkey().get(pubkey)
This `getBookmark` function is the right shape to pass as `getItem` to `makeLoadItem`
(see Pattern 6).
### 6. Full reactive item chain: deriveItemsByKey → deriveItems → getter → makeLoadItem → makeDeriveItem
### 6. Full reactive item chain
This is the canonical pattern for domain objects derived from repository events with
on-demand network loading. `@welshman/app` packages it as `DerivedPlugin` — prefer subclassing
that over hand-rolling the chain unless you need something it doesn't cover.
on-demand network loading.
```typescript
import {
@ -243,8 +280,7 @@ import {
makeDeriveItem,
} from "@welshman/store"
import { Network, Router } from "@welshman/app"
import { outbox } from "@welshman/util"
import { relayTags, tagSpec, tagValue, tagValues } from "@welshman/util"
import { outbox, tagSpec, tagValue, tagValues } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util"
const BOOKMARK_KIND = 30003
@ -259,13 +295,13 @@ type Bookmark = {
const parseBookmark = (event: TrustedEvent): Bookmark => ({
pubkey: event.pubkey,
title: tagValue(tagSpec("title"), event.tags) ?? "Untitled",
urls: tagValues(relayTags("r"), event.tags),
urls: tagValues(tagSpec("r"), event.tags),
event,
})
// Step 1: Reactive Map<pubkey, Bookmark> — live-updates from repository
const bookmarksByPubkey = deriveItemsByKey<Bookmark>({
repository: app.repository,
repository,
filters: [{ kinds: [BOOKMARK_KIND] }],
getKey: b => b.pubkey,
eventToItem: parseBookmark,
@ -304,18 +340,18 @@ aliceBookmark.subscribe($b => console.log($b?.title))
## Integration Notes
- **`@welshman/net`** — provides `Repository` and `Tracker`. `Repository` is the event cache that feeds all store primitives in this package. Events flow from the network into the repository, which triggers store updates automatically.
- **`@welshman/util`** — provides `TrustedEvent`, `Filter`, and the tag specs (`tagValue`, `hexTags`, …) used when decoding events.
- **`@welshman/domain`** — the typed readers you normally pass as `eventToItem`, instead of writing a parser by hand.
- **`@welshman/util`** — provides `TrustedEvent`, `Filter`, and the tag/event helpers you use to decode events inside `eventToItem`. Higher-level typed decoders (profiles, lists, …) now live in `@welshman/domain` as async Reader classes (e.g. `Profile.configure(context).reader(event)`).
- **`@welshman/app`** — the high-level app layer re-exports and composes store utilities with pre-configured repositories, loaders, and context. If you are using `@welshman/app`, many of these stores are already wired up for you.
- Stores in this package are **framework-agnostic** at runtime (plain Svelte stores), so they work in SvelteKit SSR as well as browser-only Svelte apps. The `synced` store's `localStorageProvider` is browser-only — guard it with `if (browser)` in SvelteKit.
## Gotchas & Tips
- **`eventToItem` can return `null`/`undefined`** — returning a falsy value from `eventToItem` in `deriveItemsByKey` causes that event to be skipped. Use this to filter out malformed events (e.g. `event.tags.length > 1 ? readList(event) : null`).
- **`eventToItem` can return `null`/`undefined`** — returning a falsy value from `eventToItem` in `deriveItemsByKey` causes that event to be skipped. Use this to filter out malformed events (e.g. `event.tags.length > 1 ? {event, pubkeys: tagValues(hexTags("p"), event.tags)} : null`).
- **`synced` is async on first read** — the store emits `defaultValue` synchronously, then overwrites it once storage resolves. Always `await store.ready` before reading in server-side or initialization code where you need the persisted value.
- **`throttled(0, store)` is a no-op** — it returns the original store unchanged, so it is safe to call with a user-configurable delay that may be zero.
- **`makeDeriveItem` is a factory** — call it once to create the lookup function, then call the returned function with a key to get a per-key `Readable`. Do not call `deriveItemsByKey` inside a Svelte `$:` block repeatedly; derive once at module level and pass the store down.
- **`makeLoadItem` timeout is in seconds** — the `timeout` option is compared against `now()` from `@welshman/lib`, which returns Unix time in seconds. The default is `3600` (one hour). Use `{ timeout: 30 }` for a 30-second staleness window, not `30_000`.
- **`makeLoadItem` uses exponential backoff** — repeated calls for the same key that already has a fresh result (item exists AND was fetched within the timeout window) are returned from cache without re-fetching. If the timeout has elapsed, it will re-fetch even if a previous value exists. Use `makeForceLoadItem` when you explicitly need fresh data.
- **`makeLoadItem` timeout is in seconds**, because it is compared against `now()` from `@welshman/lib`. Write `{ timeout: 30 }` for a 30-second staleness window, not `30_000`.
- **`makeLoadItem` propagates errors.** A rejection from `loadItem` rejects the returned promise; only the in-flight bookkeeping is cleaned up in a `finally`. Callers that treat a failed load as "no data yet" must catch it themselves, which is why app plugins wrap `load` in `.catch(noop)` on read paths.
- **`makeLoadItem` backs off exponentially per source**, so repeat attempts against a key that is not resolving are throttled rather than retried on every call. Use `makeForceLoadItem` when you need a fetch regardless of the cache.
- **`deriveEventsAsc`/`deriveEventsDesc` take a map store** — both functions accept a `Readable<Map<string, TrustedEvent>>` (the output of `deriveEventsById`), not an array store. To sort an array store use `deriveItemsSorted`.
- **`getter` vs `withGetter`** — use `getter(store)` when you only need the accessor function; use `withGetter(store)` when you want to keep the full store API (`.subscribe`, `.set`, `.update`) plus `.get()` on the same object.

View file

@ -1,11 +1,11 @@
---
name: welshman-util
description: "Use this skill when working with @welshman/util: nostr event types, kinds, tags, filters, addresses, NIPs (42/86/98), profiles, relays, zaps, wallets, or any core nostr data structures."
description: "Use this skill when working with @welshman/util: nostr event types, kinds, tags, filters, addresses, keys, NIPs (13/42/86/98), relay urls, lightning, wallets, slash commands, or any core nostr data structure. Also covers relay selection — the RelaySelection routing DSL, Resolver, RelayScenario and fallback policies all live here (there is no @welshman/router package). (Profiles, lists, handlers, and rooms now live in @welshman/domain as Reader/Writer classes.)"
---
# welshman/util — Core Nostr Utilities
`@welshman/util` is the foundational layer of the welshman nostr stack, providing types, constants, and helpers for every nostr primitive: events, kinds, tags, filters, addresses, profiles, lists, zaps, relays, and Lightning wallet integration. Higher level welshman packages (`@welshman/net`, `@welshman/app`, `@welshman/store`, etc.) depend on the types and utilities defined here.
`@welshman/util` is the foundational layer of the welshman nostr stack, providing types, constants, and helpers for every nostr primitive: events, kinds, tags, filters, addresses, zaps, relays, and Lightning wallet integration. Higher level welshman packages (`@welshman/net`, `@welshman/app`, `@welshman/store`, etc.) depend on the types and utilities defined here.
## Installation
@ -44,15 +44,41 @@ yarn add @welshman/util
| `getIdOrAddress(event)` | Returns address string for replaceable events, id otherwise |
| `getIdAndAddress(event)` | Returns array with both id and address (if applicable) |
| `deduplicateEvents(events)` | Deduplicate by id or address |
| `compareEventsAsc(a, b)` / `compareEventsDesc(a, b)` | Canonical event order: `created_at`, then `id` to break a same-second tie |
| `sortEventsAsc(events)` / `sortEventsDesc(events)` | Sort an iterable of events by that order |
| `isEphemeral(event)` | True for ephemeral kinds (20000–29999) |
| `isReplaceable(event)` | True for plain or parameterized replaceable |
| `isPlainReplaceable(event)` | True for kinds 10000–19999 and metadata/contacts |
| `isParameterizedReplaceable(event)` | True for kinds 30000–39999 |
| `sortEventsAsc(events)` / `sortEventsDesc(events)` | Sort by `created_at` |
| `asEventTemplate`, `asStampedEvent`, `asOwnedEvent`, `asHashedEvent`, `asSignedEvent` | Narrow an event down to the fields of a given level |
NIP-10 and NIP-22 threading lives on the `Note` and `Comment` classes in `@welshman/domain`, not here. The free helpers `getAncestors`, `getParentIdOrAddr`, `isChildOf`, `getReplyTags` and `getCommentTags` do not exist in this package.
### Type Guards
`isEventTemplate`, `isStampedEvent`, `isOwnedEvent`, `isHashedEvent`, `isSignedEvent`
### Keys & event construction
| Export | Description |
|--------|-------------|
| `makeSecret()` | Cryptographically secure random hex private key |
| `getPubkey(secret)` | Derive the hex pubkey from a hex secret |
| `stamp(event, created_at?)` | `EventTemplate` → `StampedEvent` |
| `own(event, pubkey)` | `StampedEvent` → `OwnedEvent` |
| `hash(event)` / `getHash(event)` | `OwnedEvent` → `HashedEvent` / the id alone |
| `sign(event, secret)` / `getSig(event, secret)` | `HashedEvent` → `SignedEvent` / the sig alone |
| `prep(event, pubkey, created_at?)` | Stamp + own + hash in one step — an unsigned rumor |
### Proof of work (NIP-13)
| Export | Description |
|--------|-------------|
| `makePow(event, difficulty)` | Mine a `nonce` tag until the id has `difficulty` leading zero bits; returns a `ProofOfWork` |
| `getPow(event)` | Leading zero bits on an event's id |
| `estimateWork(difficulty)` / `benchmarkDifficulty` | Rough cost estimate for a difficulty target |
### Event Kinds (constants)
All constants are exported by name from `@welshman/util`.
@ -133,8 +159,22 @@ ROOM_ADD_MEMBER = 9000 ROOM_REMOVE_MEMBER = 9001
ROOM_ADD_PERM = 9003 ROOM_REMOVE_PERM = 9004
ROOM_DELETE_EVENT = 9005 ROOM_EDIT_STATUS = 9006
ROOM_CREATE_PERMISSION = 19004
ROOM_UPDATE_PINS = 9010 ROOM_PINS = 39005
RELAY_MEMBERS = 13534 RELAY_ADD_MEMBER = 8000 RELAY_REMOVE_MEMBER = 8001
RELAY_JOIN = 28934 RELAY_INVITE = 28935 RELAY_LEAVE = 28936
RELAY_ROLE = 33534
```
**Pinboards**
```
PINBOARD = 30067 PIN = 39067
```
**Slash commands**
```
COMMAND = 31992
```
**Replaceable lists (kinds 10000–10099)**
@ -190,7 +230,7 @@ ALERT_ANDROID = 32833 ALERT_IOS = 32834
**Zaps / wallet / Lightning**
```
ZAP_GOAL = 9041 ZAP_REQUEST = 9734 ZAP_RESPONSE = 9735
ZAP_GOAL = 9041 ZAP_REQUEST = 9734 ZAP_RECEIPT = 9735
WALLET_INFO = 13194 WALLET_REQUEST = 23194 WALLET_RESPONSE = 23195
LIGHTNING_PUB_RPC = 21000
OTS = 1040
@ -270,16 +310,20 @@ isDVMKind(kind) // 5000–7000
| Export | Description |
|--------|-------------|
| `tagSpec(keys, matchValue?, normalize?)` | Build a spec: which tag keys, plus optional validation/normalization |
| `hexTags(keys)` | Spec for 32-byte hex values (`e`, `p`, …) |
| `addressTags(keys)` | Spec for `kind:pubkey:d` addresses (`a`, `A`) |
| `kindTags(keys)` | Spec for kind numbers (`k`) |
| `topicTags(keys)` | Spec for topics, normalized (`t`) |
| `relayTags(keys)` | Spec for relay urls (`r`, `relay`) |
| `tagValue(spec, tags)` | Value (index 1) of the first matching tag |
| `tagValues(spec, tags)` | Values of all matching tags |
| `matchTag(spec, tags)` / `matchTags(spec, tags)` | The matching tag(s) themselves |
| `tagMatcher(spec)` | A `(tag) => boolean` predicate, for filtering in one pass |
| `tagSpec(keys, matchValue?, normalizeValue?)` | Build a `TagSpec` — `keys` is a string or string[]; optional value filter/normalizer |
| `hexTags(keys)` | Spec matching 32-byte hex values (`isHex32`) — e/p tags |
| `addressTags(keys)` | Spec matching replaceable addresses (`Address.isAddress`) — a tags |
| `relayTags(keys)` | Spec matching relay urls (`isRelayUrl`) — r/relay tags |
| `topicTags(keys)` | Spec that strips a leading `#` from values — t tags |
| `kindTags(keys)` | Spec whose values parse to `number` — k tags |
| `matchTags(spec, tags)` | All tags matching the spec — spec first, then the tags array |
| `matchTag(spec, tags)` | First tag matching the spec, or `undefined` |
| `tagValues(spec, tags)` | Values (index 1, normalized) of all matching tags; undefined dropped |
| `tagValue(spec, tags)` | Value of the first matching tag, or `undefined` |
| `tagMatcher(spec)` / `tagValueExtractor(spec)` | The raw `(tag)=>boolean` / `(tag)=>T` for a spec |
| `tagValueMatcher(spec, value)` | Predicate for one value, compared after normalization — matches however the tag spelled it |
The old `getTagValue`/`getPubkeyTagValues`/`getEventTags`/… accessors were removed — use the spec selectors above (e.g. `tagValues(hexTags("p"), tags)`, `tagValue(tagSpec("title"), tags)`).
### Filters
@ -307,27 +351,166 @@ isDVMKind(kind) // 5000–7000
| `Address.from(s, relays?)` | Parse from `kind:pubkey:identifier` string |
| `Address.fromNaddr(naddr)` | Parse from NIP-19 naddr |
| `Address.fromEvent(event, relays?)` | Create from addressable event |
| `address.toString()` | Serialize to `kind:pubkey:identifier` |
| `address.toNaddr()` | Serialize to NIP-19 naddr |
| `getAddress(event)` | Convenience: get address string from event |
### Relay
| Export | Description |
|--------|-------------|
| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — the conventional url for the in-memory repository |
| `isRelayUrl(url)` | Validate relay URL |
| `isShareableRelayUrl(url)` | True if valid relay URL and not a local address |
| `isOnionUrl(url)` | Tor address check |
| `isLocalUrl(url)` | Local address check |
| `isIPAddress(url)` | IP address check |
| `normalizeRelayUrl(url)` | Normalize to standard wss:// format |
| `normalizeRelayUrl(url)` | Normalize to standard wss:// format (passes `LOCAL_RELAY_URL` through unchanged) |
| `displayRelayUrl(url)` | Strip protocol and trailing slash |
### Zaps (NIP-57)
`RelayMode`, `RelayProfile` and `displayRelayProfile` are gone. Read/write intent is expressed by the NIP-65 `RelayList` reader/writer in `@welshman/domain` (`readUrls()`/`writeUrls()`, `addReadUrl`/`addWriteUrl`), and NIP-11 relay info by the `Relay` type in `@welshman/domain` plus `app.use(Relays)`.
### Relay Selection (routing DSL)
`RelaySelection.ts` holds the relay-routing DSL. A *relay selection* names a source, such as
"the author's outbox" or "the relays this event was seen on", rather than a list of urls. Turning
one into urls needs relay lists, a tracker and a repository, so producing and resolving selections
are separate steps: this package defines and scores them, and `@welshman/app`'s `Router` plugin
supplies the one `ResolveRoute` implementation that dereferences them (see the `welshman-app`
skill). `@welshman/domain` readers/writers/queries emit selections, and `@welshman/feeds` asks for
the capability as its `FeedRouter` interface.
**Route + selection types**
| Type | Description |
|------|-------------|
| `EventRef` | `{ id?, pubkey?, kind?, identifier?, relays? }` — all optional and additive. A known `pubkey` routes directly without finding the event; `id` (or `kind`+`pubkey`+`identifier`) lets the resolver look it up; `relays` are hints for that lookup and a last-resort fallback |
| `RelayRoute` | Discriminated union: `userInbox` / `userOutbox` / `userMessaging`, `pubkeyInbox` / `pubkeyOutbox` / `pubkeyMessaging` (`{pubkey}`), `eventInbox` / `eventOutbox` / `seen` (`{ref}`), `relay` (`{url}`), `index`, `search` |
| `RelaySelection` | `{ route: RelayRoute; weight: number }` |
**DSL constructors** (each defaults `weight = 1`)
| Export | Returns | Description |
|--------|---------|-------------|
| `inbox(pubkey, weight?)` | `RelaySelection` | that pubkey's read relays |
| `outbox(pubkey, weight?)` | `RelaySelection` | that pubkey's write relays |
| `messaging(pubkey, weight?)` | `RelaySelection` | that pubkey's NIP-17 messaging relays |
| `userInbox(weight?)` / `userOutbox(weight?)` / `userMessaging(weight?)` | `RelaySelection` | the current user's relays |
| `eventInbox(ref, weight?)` / `eventOutbox(ref, weight?)` | `RelaySelection` | the referenced event's author's relays |
| `seen(ref, weight?)` | `RelaySelection` | relays the event was found on (tracker + ref hints) |
| `relay(url, weight?)` | `RelaySelection` | a literal relay url (formerly `relayHint`) |
| `relays(urls, weight?)` | `RelaySelection[]` | one selection per url (formerly `relayHints`) |
| `inboxes(pubkeys, weight?)` | `RelaySelection[]` | `uniq(pubkeys).map(inbox)` — inbox per referenced pubkey |
| `indexers(weight?)` | `RelaySelection` | profile/relay-list index relays |
| `searchRelays(weight?)` | `RelaySelection` | full-text search relays |
Note `relays` and `inboxes` return **arrays** — spread them with `...` into a route list; every
other constructor returns a single `RelaySelection`.
**Resolved selections + fallback policies**
| Export | Description |
|--------|-------------|
| `getLnUrl(address)` | Convert lightning address or URL to LNURL; returns `undefined` if invalid |
| `getInvoiceAmount(bolt11)` | Extract millisatoshi amount from BOLT11 invoice |
| `hrpToMillisat(hrpString)` | Convert human-readable BTC amount to millisats (`bigint`) |
| `Selection` | `{ weight: number; relays: string[] }` — a concrete, resolved weighted relay set |
| `makeSelection(relays, weight?)` | Build a `Selection`, filtering `isRelayUrl` and normalizing each url |
| `FallbackPolicy` | `(count: number, limit: number) => number` — how many defaults to add |
| `addNoFallbacks` | Never add fallback relays (**the default**) |
| `addMinimalFallbacks` | Add one fallback only if nothing else was found |
| `addMaximalFallbacks` | Top up to the limit with fallbacks |
**`RelayScenario`** — scores and picks concrete relays from weighted `Selection`s:
```typescript
new RelayScenario(selections: Selection[], options?: RelayScenarioOptions)
// options: { policy?, limit?, allowLocal?, allowOnion?, allowInsecure?,
// getRelayQuality?, getDefaultRelays? }
scenario.limit(n) // chainable (returns a cloned scenario)
scenario.policy(fn) // chainable
scenario.allowLocal(bool) / allowOnion(bool) / allowInsecure(bool) // chainable
scenario.getUrls() // string[]
scenario.getUrl() // first of getUrls()
```
`getUrls()` drops onion, local and plain-`ws://` urls unless explicitly allowed, sums each url's
weight across selections, scores as `quality * (1 + log(weight))` times a random factor, takes the
best `limit` (default 3), then adds shuffled `getDefaultRelays()` urls per the fallback policy. The
log keeps a relay that many selections name from dominating, and the random factor lets lower-ranked
relays get picked occasionally.
**`Resolver`** — bundles a single route-resolution function with the scenario options to apply to
everything it produces:
```typescript
type ResolveRoute = (route: RelayRoute) => MaybeAsync<string[]>
new Resolver(routeResolver: ResolveRoute, options?: RelayScenarioOptions)
await resolver.scenario(selections) // Promise<RelayScenario> — resolves each route, builds a scenario
await resolver.relays(selections) // Promise<string[]> — scenario(...).getUrls()
await resolver.relay(selections) // Promise<string | undefined> — scenario(...).getUrl()
```
In an app, `@welshman/app`'s `Router` owns the `Resolver` (`app.use(Router).resolver`), built with
`getRelayQuality`/`getDefaultRelays` from app config, and injects it into every domain kind's
context. Domain readers/writers then call `def.context.resolver.scenario(...)` / `.relay(...)`.
```typescript
import {outbox, inboxes, relay, Resolver} from '@welshman/util'
// Declarative selections: author's write relays (weight 1) + each mentioned
// pubkey's read relays (weight 0.5) + an explicit relay hint.
const selections = [
outbox(authorPubkey),
...inboxes(mentionedPubkeys, 0.5),
relay('wss://relay.example.com'),
]
// A Resolver dereferences routes -> urls given some route resolver.
const resolver = new Resolver(resolveRoute, {limit: 5, getRelayQuality})
const urls = await resolver.relays(selections) // string[]
```
**Routing gotchas**
- **Resolution is async**, because resolving an outbox may have to load a NIP-65 list first.
- **A scenario that resolves to nothing yields an empty array.** Add `.policy(addMinimalFallbacks)` where an empty result would break the caller.
- **Weights express preference, not selection.** Naming the same url in ten selections does not make it ten times more likely. To force a relay, use `forceRoutes` (domain writers) or `setRoutes` (domain queries).
- **Two calls with identical selections can return different urls.** In tests, assert on membership rather than exact url lists, or inject a deterministic `getRelayQuality`.
- **A relay whose `getRelayQuality` is 0 is dropped entirely**, because the scenario filters on the score and `-0` is falsy. A scenario can come back empty even though its selections resolved to urls.
### Slash commands
`Command.ts` models NIP-89-style slash commands: `CommandArg`/`CommandArgType`/`COMMAND_ARG_TYPES`
(`pubkey`, `event`, `address`, `relay`, `number`, `bool`, `enum`, `word`, `text`) with
`validateCommandArgs`; `CommandScope`/`CommandScopeTarget` with `parseCommandScope`,
`renderCommandScope`, `matchesCommandScopes`, `commandScopeRelays`, `commandScopesToFilter`; and
the invocation grammar — `parseCommandInvocation`, `renderCommandInvocation`, `bindCommandArgs`,
`parseCommandArgs`, `getActiveCommandArgIndex`. Kind `COMMAND` is 31992. `@welshman/editor`'s
`CommandExtension`/`CommandSuggestion` render and autocomplete these in the composer.
### Lightning (NIP-57 support)
| Export | Description |
|--------|-------------|
| `getLnUrl(address)` | Convert a lud16 address, HTTPS URL, or existing `lnurl1…` to an LNURL; `undefined` if invalid |
| `getInvoiceAmount(bolt11)` | Extract the millisatoshi amount from a BOLT11 invoice |
| `hrpToMillisat(hrpString)` | Convert a human-readable BTC amount to millisats (`bigint`) |
| `toMsats(sats)` / `fromMsats(msats)` | Unit conversion |
The `Zapper` and `Zap` types moved to `@welshman/domain` (`other/Zapper.ts`), alongside the `ZapRequest`/`ZapReceipt`/`ZapGoal` kinds. Receipt validation is `app.use(Zappers).validateZapReceipt(...)`.
### NIP-05 handles
| Export | Description |
|--------|-------------|
| `Handle` | `{ nip05, pubkey?, nip46?, relays? }` |
| `queryProfile(nip05)` | Resolve a NIP-05 identifier via `/.well-known/nostr.json`; `undefined` on failure |
| `displayNip05(nip05)` / `displayHandle(handle)` | Drop a leading `_@` for display |
### Pubkey
`Pubkey` wraps a hex pubkey plus relay hints. `Pubkey.from(entity, relays?)` accepts hex, `npub…` or `nprofile…`; instances expose `toString()`, `toNpub()`, `toNprofile()`.
### Wallet
@ -357,17 +540,11 @@ makeHttpAuthHeader(event: SignedEvent): string // Returns "Nostr <base64>"
```typescript
sendManagementRequest(url: string, request: ManagementRequest, authEvent: SignedEvent): Promise<ManagementResponse>
// ManagementResponse = { result?: any; error?: string }
// ManagementMethod enum covers: BanPubkey, AllowPubkey, BanEvent, AllowEvent, etc.
```
### Handlers (NIP-89)
Requests are built by `make*` factories rather than an enum: `makeBanPubkey`, `makeAllowPubkey`, `makeBanEvent`, `makeAllowEvent`, `makeCreateRole`/`makeEditRole`/`makeDeleteRole`, `makeAssignRole`/`makeUnassignRole`, `makeAssignMethod`/`makeUnassignMethod`, `makeCreateClaim`/`makeDeleteClaim`/`makeListClaims`, `makeChangeRelayName`/`Description`/`Icon`, `makeAllowKind`/`makeDisallowKind`, `makeBlockIp`/`makeUnblockIp`, `makeSignEvent`, `makeSupportedMethods`, and the matching `makeList*` readers.
```typescript
readHandlers(event: TrustedEvent): Handler[]
getHandlerKey(handler: Handler): string // "kind:address" format
getHandlerAddress(event: TrustedEvent): string | undefined
displayHandler(handler?: Handler, fallback?: string): string
```
`ManagementApi` is a client class that pairs a relay url with a `ManagementSign` function so you don't have to build the NIP-98 auth event per call. `app.use(RelayManagement).forUrl(url)` returns one bound to the app's user.
### Links
@ -439,7 +616,7 @@ for (const event of storedEvents) {
event[verifiedSymbol] = true
}
repository.load(storedEvents)
app.repository.load(storedEvents)
```
Only do this for events you persisted yourself after they were validated. Never set
@ -448,14 +625,25 @@ Only do this for events you persisted yourself after they were validated. Never
### Working with tags
```typescript
import {tagValue, tagValues, hexTags, relayTags, tagSpec, topicTags} from '@welshman/util'
import {
tagSpec,
hexTags,
topicTags,
relayTags,
tagValue,
tagValues,
} from '@welshman/util'
// Specs come first, then the tags array. The spec says which keys to match and how to
// validate the value, so malformed tags are skipped rather than silently returned.
const title = tagValue(tagSpec('title'), event.tags) // string | undefined
const urls = tagValues(relayTags('r'), event.tags) // string[], valid relay urls only
const ids = tagValues(hexTags(['e', 'a']), event.tags) // string[], 32-byte hex only
const topics = tagValues(topicTags('t'), event.tags) // string[], normalized
// A selector takes a spec FIRST, then the tags array
const title = tagValue(tagSpec('title'), event.tags) // string | undefined
const urls = tagValues(tagSpec('r'), event.tags) // string[]
// Multiple keys at once
const ids = tagValues(tagSpec(['e', 'a']), event.tags) // string[]
const mentions = tagValues(hexTags('p'), event.tags) // string[]
const topics = tagValues(topicTags('t'), event.tags) // string[] ("#x" -> "x")
const relays = tagValues(relayTags(['r', 'relay']), event.tags)
```
### Matching and building filters
@ -564,9 +752,9 @@ await fetch('https://api.example.com/upload', {
- **`@welshman/net`** — uses `TrustedEvent`, `Filter`, `SignedEvent` from this package as the wire types for relay connections and subscriptions.
- **`@welshman/store`** — provides Svelte stores over repositories built on `TrustedEvent`; relies on `isReplaceable`, `getAddress`, etc. for deduplication.
- **`@welshman/app`** — high-level application layer; wraps net/store/domain and resolves this package's `RelaySelection` DSL through `app.use(Router)`.
- **`@welshman/domain`** — builds its typed readers on the tag specs (`tagValue`, `hexTags`, `addressTags`) defined here.
- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here, and supplies the encryption used when writing encrypted list kinds.
- **`@welshman/app`** — high-level application layer; composes net/store/domain and uses the lightning helpers from this package (profile/list/handler/room helpers now live in `@welshman/domain`).
- **`@welshman/app`'s `Router` plugin** — dereferences the `RelaySelection` DSL defined here, and injects its `Resolver` into every `@welshman/domain` kind.
- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here; signers also provide the `nip44` encrypt/decrypt functions used by `@welshman/domain` list writers to encrypt private (NIP-44) tags.
---
@ -576,16 +764,23 @@ await fetch('https://api.example.com/upload', {
- **Replaceable event identity**: Use `getIdOrAddress` rather than `event.id` when referencing events that may be addressable — the address string is stable across updates, the id is not.
- **`validateZapReceipt` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result. For a reactive list of a parent's valid zaps, use `app.use(Zappers).validZapReceipts(receipts, parent)`, which re-validates as each recipient's zapper loads.
- **`app.use(Zappers).validateZapReceipt` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result. For a reactive list of a parent's valid zaps use `validZapReceipts(receipts, parent)`, which re-validates as each recipient's zapper loads.
- **`getLnUrl` handles three input forms**: bare lightning address (`user@domain`), full HTTPS URL, or already-encoded `lnurl1...`. Returns `undefined` for anything else.
- **`normalizeTopic` is not exported.** `Topics.ts` isn't re-exported from the index; use `topicTags("t")` to get normalized topic values off an event's tags.
- **`normalizeRelayUrl` vs `displayRelayUrl`**: Use `normalizeRelayUrl` before storing or comparing relay URLs. Use `displayRelayUrl` only for human-readable display (strips protocol/trailing slash).
- **`Address.isAddress`** checks the `kind:pubkey:identifier` format only, not naddr. To validate an naddr string, use `Address.fromNaddr` inside a try/catch.
- **`getTagValue` / `getTagValues` argument order**: the type(s) come **first**, the tags array comes **second** — `getTagValue('title', event.tags)`. This is the opposite of the specialized helpers like `getEventTags(tags)` which take only the tags array. Mixing up the order produces no TypeScript error but silently returns `undefined` or `[]`.
- **Tag selector argument order**: the spec comes **first**, the tags array **second** — `tagValue(tagSpec('title'), event.tags)`, `tagValues(hexTags('p'), event.tags)`. Mixing up the order produces no TypeScript error but silently returns `undefined` or `[]`.
- **`verifiedSymbol` is a Symbol key**: you must import `verifiedSymbol` from `@welshman/util` and use it as a computed property key — `event[verifiedSymbol] = true`. You cannot use a string key. The symbol is re-exported from `nostr-tools/pure`, so it is the same identity as the one used internally by `verifyEvent`.
---
## Related skills
- **`welshman-app`** (welshman-app skill) — the `Router` plugin that dereferences the routing DSL above, plus `RelayStats.getQuality` for the scoring input.
- **`@welshman/domain`** (welshman-domain skill) — Profiles, lists, handlers, rooms, and event routing moved out of `@welshman/util` and now live here. The old free functions (`readProfile`/`makeProfile`, `readList`/`makeList`, `PublishedProfile`/`PublishedList`, `Encryptable`, the handler/room helpers, …) were replaced by configurable `KindFactory` bundles: `Kind.configure(context).reader(event)` returns an async Reader that decodes the event, and `.writer(reader?)` builds/edits one. Private (NIP-44) list tags are handled inside the list Reader/Writer, so `Encryptable`/`DecryptedEvent` no longer exist.

View file

@ -11,14 +11,14 @@ Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](http
| Package | Description |
|---|---|
| `@welshman/util` | Core nostr types, event helpers, filters, tag specs, NIPs, and the `RelaySelection` routing DSL |
| `@welshman/util` | Core nostr types, event helpers, filters, NIP implementations, and the relay-selection routing DSL |
| `@welshman/lib` | General-purpose utilities: LRU cache, event emitter, deferred promises, task queue |
| `@welshman/net` | Relay connections, request/publish lifecycle, and auth handling |
| `@welshman/domain` | A typed Reader/Writer pair per event kind, so you never hand-parse tags |
| `@welshman/store` | Svelte stores and a Repository for indexing/querying nostr events client-side |
| `@welshman/net` | Relay connections, request/publish lifecycle, auth, and the `Repository`/`Tracker`/`WrapManager` stores |
| `@welshman/store` | Svelte store primitives over a `Repository` — live event and domain-object collections, cached loaders, persistence |
| `@welshman/signer` | Signing and login methods: NIP-01 (privkey), NIP-07 (extension), NIP-46 (bunker), NIP-55 (app), NIP-59 (gift wrap) |
| `@welshman/domain` | Typed Reader/Writer classes per event kind (profiles, notes, lists, rooms, relay management) that parse events, build templates, and emit relay routing |
| `@welshman/feeds` | Dynamic feed construction, filtering, and composition |
| `@welshman/app` | The `App` instance and its plugin registry, composing net, store, domain, signer, and feeds into a full application framework |
| `@welshman/app` | Instance-based application framework: an `App` composes net, store, signer, feeds, and domain, exposing data modules via `app.use(...)` |
| `@welshman/content` | Parser and renderer for nostr note content (links, mentions, media, custom formatting) |
| `@welshman/editor` | Batteries-included Svelte rich-text editor component with mention and embed support |
@ -27,43 +27,28 @@ Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](http
Packages are layered so lower-level ones have no welshman dependencies:
- **Foundational** (no welshman deps): `@welshman/lib`, `@welshman/util`
- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/store`, `@welshman/signer`, `@welshman/domain`
- **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/app`
- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/store`, `@welshman/signer`
- **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/domain`
- **Application** (composes everything above): `@welshman/app`
- **UI-focused** (largely independent, UI rendering concerns): `@welshman/content`, `@welshman/editor`
For deep-dives on any package, load the `welshman-<name>` skill (e.g. `welshman-net`, `welshman-app`, `welshman-domain`).
For deep-dives on any package, load the `welshman-<name>` skill (e.g. `welshman-net`, `welshman-app`, `welshman-domain`, `welshman-signer`).
## The App instance
Relay selection spans two packages. The `RelaySelection` DSL, `Resolver` and `RelayScenario` are in `@welshman/util` (`welshman-util` skill); the `Router` plugin that dereferences them is in `@welshman/app` (`welshman-app` skill). There is no `@welshman/router` package.
Everything in the framework hangs off one `App`. An app owns the primitives a single identity
needs — repository, socket pool, tracker, wrap manager — so data never bleeds across sessions.
## Getting started
```typescript
import {createApp, Network, Profiles, User} from "@welshman/app"
Install only what you need:
// `createApp` = `new App` plus the default policies (ingest, relay stats, gift-wrap unwrapping)
const app = createApp({
user: await User.fromSigner(signer), // omit for a signed-out app
config: {
getDefaultRelays: () => ["wss://relay.example.com"],
getIndexerRelays: () => ["wss://indexer.example.com"],
},
})
```bash
# Full application framework (includes app, net, store, signer, feeds, domain)
npm i @welshman/app
app.use(Profiles).load(pubkey) // plugins are per-app singletons, constructed on demand
app.use(Network).load({relays, filters})
# Or assemble manually for more control
npm i @welshman/util @welshman/net @welshman/signer
```
Three rules follow from this design:
1. **`app.use(Plugin)` is memoized and cheap** — call it inline rather than caching the result.
2. **An app is scoped to one identity.** Logging in means building a *new* app and calling
`cleanup()` on the old one, not attaching a user to the existing one.
3. **Side effects live in policies**, not in the data classes. An `AppPolicy` is
`(app) => Unsubscriber`, applied once at construction and torn down by `cleanup()`.
Svelte apps typically wrap `app` in a store so plugin reads re-subscribe when login swaps the
instance. That binding layer is app-specific and deliberately not part of welshman.
If you're building a conventional nostr web client, use `@welshman/app` for batteries-included functionality. For more advanced usage, use the lower-level modules without `app` for more control.
## Key nostr concepts
@ -79,67 +64,72 @@ instance. That binding layer is app-specific and deliberately not part of welshm
| Goal | Package(s) to use |
|---|---|
| Fetch notes from relays | `app.use(Network)`, or `@welshman/net` directly for low-level control |
| Select which relays to use | `RelaySelection` helpers in `@welshman/util` + `app.use(Router)` |
| Read or write a specific kind | `@welshman/domain` via `app.use(Domain)` |
| Sign and publish events | `@welshman/signer` + `Command` from `@welshman/app` |
| Build a feed UI | `@welshman/feeds` + `app.use(Feeds)` |
| Fetch notes from relays | `@welshman/net` (low-level) or `@welshman/app` (high-level) |
| Compose typed events (notes, profiles, lists) | `@welshman/domain` |
| Select which relays to read from / publish to | `@welshman/util` (routing DSL) + `@welshman/app` (Router plugin) |
| Sign and publish events | `@welshman/domain` + `@welshman/app`, or `@welshman/signer` + `@welshman/net` |
| Build a feed UI | `@welshman/feeds` + `@welshman/app` |
| Parse note text and media | `@welshman/content` |
| Embed a composer / editor | `@welshman/editor` |
| Cache nostr events client-side | `@welshman/store` + `app.repository` |
| Core event/filter/tag utilities | `@welshman/util` |
| Cache nostr events client-side | `@welshman/net` (`Repository`) + `@welshman/store` (reactive views over it) |
| Core event/filter utilities | `@welshman/util` |
| Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` |
## App example
### App Example
```typescript
import {createApp, Domain, Profiles, User} from "@welshman/app"
import {Note} from "@welshman/domain"
import {Nip07Signer} from "@welshman/signer"
import { Nip07Signer } from "@welshman/signer"
import { Note } from "@welshman/domain"
import { createApp, User, Domain, Profiles } from "@welshman/app"
// 1. Create an app instance. Each App owns its own repository, socket pool,
// tracker, and (optional) signing user, so data never leaks across identities.
// Pass the user at construction rather than assigning app.user afterwards.
const user = await User.fromSigner(new Nip07Signer())
// 1. Build an app for the signed-in user
const signer = new Nip07Signer()
const app = createApp({
user: await User.fromSigner(signer),
user,
config: {
getDefaultRelays: () => ["wss://relay.example.com"],
getDefaultRelays: () => ["wss://relay.example.com", "wss://relay2.example.com"],
getIndexerRelays: () => ["wss://indexer.example.com"],
},
})
// 2. Read the user's profile (loads from their write relays if not cached)
const profile = await app.use(Profiles).load(app.user!.pubkey)
// 2. Hydrate the repository from storage and flush changes back to it.
// See the welshman-net skill for repository.load() and the "update" listener.
console.log("Hello,", profile?.display())
// 3. Load the user's profile through the Profiles data module
// (triggers a network fetch via the outbox model if not cached)
const profile = await app.use(Profiles).forceLoad(user.pubkey)
if (profile) console.log("Hello,", profile.display())
// 3. Publish a note — build a writer, wrap it in a command, publish it
// ...or subscribe reactively:
app.use(Profiles).one(user.pubkey).subscribe($profile => {
if ($profile) console.log("Profile:", $profile.display())
})
// 4. Compose and publish a note. Domain builds the event and resolves relays;
// the returned Command sends it through the publish pipeline.
const writer = app.use(Domain).writer(Note).setContent("Hello, Nostr!")
const command = await app.use(Domain).command(writer)
await command.publish().waitForError()
await command.publish()
```
Publishing goes through a `Command`, which owns the rendered event and its resolved relays:
`command.publish()`, `.publishToRelays(urls)`, or `.publishAsRelay(url)`. Plugin mutators
(`app.use(FollowLists).follow(...)`, `app.use(Rooms).joinRoom(...)`) already return a `Command`,
so `.then(publish)` is usually all you need.
## Lower-level example
The net layer takes an explicit context, so it can be used without an `App` at all.
### Lower-level Example
```typescript
import {AbstractAdapter, isClientEvent, publish, request} from "@welshman/net"
import type {ClientMessage, NetContext} from "@welshman/net"
import {call, sleep} from "@welshman/lib"
import {Nip01Signer} from "@welshman/signer"
import {makeEvent, NOTE} from "@welshman/util"
import { AbstractAdapter, ClientMessage, isClientEvent, publish, request } from '@welshman/net'
import type { NetContext } from '@welshman/net'
import { call, sleep } from '@welshman/lib'
import { Nip01Signer } from '@welshman/signer'
import { makeEvent, NOTE } from '@welshman/util'
const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
const RELAY_URL = "bogus.relay"
// An adapter for our relay url which just prints the content
// Create an adapter for our relay url which just prints the content
export class PrintAdapter extends AbstractAdapter {
get sockets() { return [] }
get urls() { return [] }
@ -151,29 +141,38 @@ export class PrintAdapter extends AbstractAdapter {
}
}
// Context is passed explicitly. An `App` supplies its own via `app.netContext`; here we build one.
// A net context that routes our relay url to the custom adapter. Context is
// passed per call now — there is no module-level singleton.
const context: NetContext = {
getAdapter: (url: string) => (url === RELAY_URL ? new PrintAdapter() : undefined),
getAdapter: (url: string) => {
if (url === RELAY_URL) {
return new PrintAdapter()
}
},
}
// Loop, sending off pings every so often
call(async () => {
while (true) {
await sleep(1000)
const ping = await pingSigner.sign(makeEvent(NOTE, {content: "ping"}))
const ping = await pingSigner.sign(
makeEvent(NOTE, {content: 'ping'})
)
await publish({event: ping, relays: [RELAY_URL], context})
}
})
// Meanwhile, listen for pings and quote-note with a pong
call(async () => {
request({
relays: [RELAY_URL],
filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}],
context,
filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}],
onEvent: async (ping, url) => {
const pong = await pongSigner.sign(
makeEvent(NOTE, {content: "pong", tags: [["q", ping.id, RELAY_URL, ping.pubkey]]}),
makeEvent(NOTE, {content: 'pong', tags: [["q", ping.id, RELAY_URL, ping.pubkey]]})
)
await publish({event: pong, relays: [RELAY_URL], context})

View file

@ -5,67 +5,61 @@
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman/SKILL.md",
"computedHash": "586c6b142324e0a9043e7af16c662cee5f114d649367baba088282cc0de12734"
"computedHash": "e5b2fe539d3e53081ca2c95765fa6206debe8ab12d6915b9494f5f773869a894"
},
"welshman-app": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-app/SKILL.md",
"computedHash": "764a3bed16678b18e3935fee6069ceed965004ce4e624ae1a7edadfca6708ca1"
"computedHash": "5246d8285dbac8e63fbd73d01fd79ffdcd70a60017f34b54ee7bc899b7db7c17"
},
"welshman-content": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-content/SKILL.md",
"computedHash": "8ad4b3646e781d124c5332565cc4e0333664fcbcb5dccafc70b1d21266bcafd8"
"computedHash": "ed729978596d99e5e83ad284f064f6d37af1588887ce26921818278bde1e7ec5"
},
"welshman-editor": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-editor/SKILL.md",
"computedHash": "dbc39e6506231d1071b75453a78d99bb90017c17a57fd087c659a1daae335536"
"computedHash": "46047b6821d4f96c5bea11f34043261844970f6095927fb5c299b9b2b2e0d12f"
},
"welshman-feeds": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-feeds/SKILL.md",
"computedHash": "148bd556a0a08b9dc07b959c7ecd8b3259058dd2b3a3b2a2f2171c1cc669b25c"
"computedHash": "edf3cf2dc14d70aeffdf7bce30ed160b35c9cffd772750b312dc06688309ca56"
},
"welshman-lib": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-lib/SKILL.md",
"computedHash": "16cf693a002d2e781c085a38c3b43f93c25ab5a0f43f9404c3fa2ead39139b50"
"computedHash": "807f8caaa7272cb6216290e0c8a13f67cb037f19657530a61f903df1715e48fd"
},
"welshman-net": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-net/SKILL.md",
"computedHash": "234d48ff9ebea01919011db7f471bf997574424c313090bcfaf440762f6e2284"
},
"welshman-router": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-router/SKILL.md",
"computedHash": "f37e0c08fa32b577786f33fa21462fabf325eee27d32bad87269466e94d7dd72"
"computedHash": "38e33629e441bc82294c66b8bfc429d4c46e8f4a8e0bed388c4358531a47cf19"
},
"welshman-signer": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-signer/SKILL.md",
"computedHash": "a62c6d5211b904e4edb992ad2003765ab9956694bf1c30f3b4179c3d1d159a40"
"computedHash": "bd9a80597f1f96c98b01ad8bd6fa0869fcc8554a6bd5ee13f9a7f01ef39151a8"
},
"welshman-store": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-store/SKILL.md",
"computedHash": "2079746f8b5ac0b8ef3ba49afc31a120f9a3a17671b6e2512e8481c5d9979a5c"
"computedHash": "94e3b912bbb521b8abeae58436170cf3afc382b575f7484805359d1f1563a6da"
},
"welshman-util": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-util/SKILL.md",
"computedHash": "2d4c06937e9417c72347f1a54ae5c3688fbaa001a4f1c2a094f79f6637a99f33"
"computedHash": "3b3a5fcf5fa143118e34828dab4b09f3f7b1065cfb78891c1ebe4bdc244ec56a"
}
}
}