Plugin Architecture
Every feature in @welshman/app is a plugin, a class constructed with a single IApp argument and resolved lazily through app.use(...). The data plugins are built on a few base classes in plugins/base.ts, and most of their accessors return a Projection from @welshman/store.
const profiles = app.use(Profiles) // new Profiles(app), memoized per appBase classes
| Base class | Source of truth | Loads from network? | Used for |
|---|---|---|---|
MapPlugin<T> | Its own Map | No | Local, non-event data (relay stats, plaintext) |
LoadableMapPlugin<T> | Its own Map | Yes (HTTP) | Relay NIP-11 info, NIP-05 handles, zappers |
DerivedPlugin<T> | The repository | Yes (events) | Data derived from nostr events (profiles, lists, …) |
RelayScopedDerivedPlugin<T> | The repository and its tracker | Yes (events) | Event data keyed per relay (NIP-29 rooms, relay roles) |
DerivedPlugin is the most common. It is a live view over the app's repository: stored events appear once its first query answers, and new ones stream in.
MapPlugin<T>
A reactive, keyed in-memory collection that owns its own Map.
class MapPlugin<T> {
index: Projection<ItemsByKey<T>> // the whole Map
all: Projection<T[]> // values
one: (key?: string, ...args: any[]) => Readable<Maybe<T>>
get(key: string): Maybe<T> // sync read
project<U>(key: string, read: (item: Maybe<T>) => U): Projection<U>
set(key: string, value: T): void
delete(key: string): void
clear(): void
onItem(subscriber: (key: string, value: Maybe<T>) => void): Unsubscriber
}set, delete and clear notify onItem subscribers, which can persist the collection to storage.
LoadableMapPlugin<T>
A MapPlugin that lazily fetches items. Subclasses implement fetch, and the base adds caching and backoff.
abstract class LoadableMapPlugin<T> extends MapPlugin<T> {
abstract fetch(key: string, ...args: any[]): Promise<unknown>
load(key: string, ...args: any[]): Promise<Maybe<T>> // cached + deduped + backoff
forceLoad(key: string, ...args: any[]): Promise<Maybe<T>> // bypass the cache
}Subscribing to one(key) triggers a lazy load. Caching, in-flight de-duplication, and exponential backoff come from makeLoadItem in @welshman/store (default staleness window: one hour).
DerivedPlugin<T>
A keyed collection derived from stored events. The repository is the only source of truth. The collection stays subscribed for the life of the app, so it holds the parsed items in memory and get(key) answers synchronously once its first query lands. That holds for a SqliteRepository too: first use reads every stored event the collection's filters match.
type DerivedPluginOptions<T> = {
filters: Filter[]
eventToItem: (event: TrustedEvent) => MaybeAsync<Maybe<T>>
getKey: (item: T) => string
loadOptions?: MakeLoadItemOptions
}
abstract class DerivedPlugin<T> {
index: ItemCollection<T>
all: Projection<T[]>
one: (key?: string, ...args: any[]) => Readable<Maybe<T>>
load(key: string, ...args: any[]): Promise<Maybe<T>>
forceLoad(key: string, ...args: any[]): Promise<Maybe<T>>
get(key: string): Maybe<T>
project<U>(key: string, read: (item: Maybe<T>) => U): Projection<U>
abstract fetch(key: string, ...args: any[]): Promise<unknown>
}index comes from @welshman/store's deriveItemsByKey({filters, eventToItem, getKey, repository: app.repository}). It is a Projection over the keyed map plus an ItemCollection's read(key). eventToItem may be async, for lists with encrypted entries.
RelayScopedDerivedPlugin<T>
A DerivedPlugin whose getKey(item, url) runs once per relay the event was seen on (via the tracker), so the same addressable coordinate on two relays stays two entries. getKey returns undefined to exclude an item on a relay. RelaySignedDerivedPlugin (in plugins/relays.ts) narrows it to events signed by the relay's own NIP-11 self key, and backs RelayRoles, RelayMemberLists and RoomPinLists.
Lifecycle of a DerivedPlugin read
- Read (cached):
get(key)(sync) orone(key)(reactive) returns whatever the collection already holds. - Lazy load: subscribing to
one(key)(or callingload(key)) triggersfetch(key). Caching skips recently-loaded keys; in-flight calls for the same key collapse; failures back off exponentially. - Decode: inbound events flow through
eventToItem. Async decoders resolve and update the index when ready. - Derive: convenience accessors (
display(...),urls(...), …) areproject(key, read)calls returning aProjection<U>.
forceLoad bypasses the cache and resolves to the freshly-read item. Both loaders read through index.read, which waits out the collection's seeding query and outstanding parses, so an event the load delivered is in the index before they report the item absent.
A collection of a replaceable kind the user owns one of delegates its update to app.use(Domain).update(Kind, this.forceLoad, fn), which seeds a writer with the user's own event, hands it to the caller's edit and returns the Command that publishes it.
Store-derived stores
A custom store derived straight from the repository uses the @welshman/store derivations with repository: app.repository. The Events plugin wraps the common cases as projections.
The Domain plugin
app.use(Domain) binds the app's dependencies to @welshman/domain kinds. Every kind in @welshman/domain is a KindFactory, a bundle of reader, writer, and query classes with no knowledge of any app. Domain hands each factory the app's resolver and signer, producing a ConfiguredKind that reads, builds, and queries events.
class Domain {
constructor(app: IApp)
configure(factory: KindFactory): ConfiguredKind // memoized per factory
reader(factory: KindFactory): (event) => Parsed<Reader>
writer(factory: KindFactory, reader?: Reader): Writer
query(factory: KindFactory): Query
command(writer: EventWriter): Promise<Command>
}configure injects the KindContext:
factory.configure({
resolver: app.use(Router).resolver,
get signer() { return app.user?.signer },
})The signer is a getter because policies replace it (via wrapSigner) after configuration. The resolver is shared with the Router plugin and is stable for the app's lifetime.
Reading — reader(factory)
reader(factory) returns an (event) => Parsed<Reader> function that builds the reader and runs parse(). Most kinds parse synchronously and return the reader directly. The private-tag lists and AppData decrypt with the app's signer and return a promise. Either way the function fits a DerivedPlugin's eventToItem, and a collection of a synchronous kind has its items in place before the first get():
import {Note, MuteList} from "@welshman/domain"
// as a DerivedPlugin decoder:
eventToItem: app.use(Domain).reader(Note)
// ad hoc, no IO — no await:
const note = app.use(Domain).reader(Note)(event)
note.content()
// ad hoc, decrypts private tags — the type requires the await:
const mutes = await app.use(Domain).reader(MuteList)(event)
mutes.pubkeys() // string[], public + private when decrypted
mutes.includes(pubkey) // booleanA reader exposes synchronous getters over the event (id(), author(), content(), tags(), createdAt(), address(), room()) plus per-kind accessors such as FollowListReader.pubkeys() and RelayListReader.readUrls().
Writing — writer(factory, reader?)
writer(factory) builds a fresh event writer. Passing an existing reader seeds an edit from that event's content and tags:
import {FollowList, Note} from "@welshman/domain"
// edit an existing follow list:
const writer = app.use(Domain)
.writer(FollowList, existingReader)
.follow(pubkey)
// or compose a new note:
const note = app.use(Domain)
.writer(Note)
.setContent("hello")
.setParent(parentEvent) // NIP-10 reply threadingEvery setter returns the writer, so setters chain. The shared setters, the per-kind setters, and the render methods are documented in Readers & Writers. render() returns the unsigned template and its target relays as {event, relays}.
Publishing — command(writer)
command(writer) requires a signed-in user, calls writer.render(), and wraps the result in a Command:
const command = await app.use(Domain).command(writer)A writer's relays come from the RelaySelections it routes to, resolved by the Router's resolver.
Writing your own plugin
A plugin is any class with the shape new (app: IApp) => T. A data collection extends one of the base classes, and a plugin that only adds behavior is a plain class:
import {DerivedPlugin, Domain, Network, type IApp} from "@welshman/app"
import {Something, SomethingReader} from "@welshman/domain"
import {SOME_KIND} from "@welshman/util"
export class Somethings extends DerivedPlugin<SomethingReader> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [SOME_KIND]}],
eventToItem: app.use(Domain).reader(Something),
getKey: item => item.author(),
})
}
fetch = (pubkey: string, relayHints: string[] = []) =>
this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [SOME_KIND]}, relayHints)
}
// usage
const things = app.use(Somethings)
const thing$ = things.one(pubkey) // loads over the outbox model on subscribe