Update skills
This commit is contained in:
parent
0ffeccb372
commit
40889a91cf
11 changed files with 1024 additions and 517 deletions
|
|
@ -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)`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue